From cfcf59f2d9a9d8aa944833dc5587ed5ac582dfb2 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 21 Dec 2025 16:32:49 -0800 Subject: [PATCH] add entity mention extension with search autocomplete --- src/lib/components/edra/editor.css | 48 ++++ src/lib/components/edra/editor.ts | 12 + .../entity-mention/EntityMention.ts | 73 ++++++ .../entity-mention/EntityMentionList.svelte | 239 ++++++++++++++++++ .../edra/extensions/entity-mention/index.ts | 3 + .../extensions/entity-mention/suggestion.ts | 150 +++++++++++ 6 files changed, 525 insertions(+) create mode 100644 src/lib/components/edra/extensions/entity-mention/EntityMention.ts create mode 100644 src/lib/components/edra/extensions/entity-mention/EntityMentionList.svelte create mode 100644 src/lib/components/edra/extensions/entity-mention/index.ts create mode 100644 src/lib/components/edra/extensions/entity-mention/suggestion.ts diff --git a/src/lib/components/edra/editor.css b/src/lib/components/edra/editor.css index 33bfc0c5..d1dd03dd 100644 --- a/src/lib/components/edra/editor.css +++ b/src/lib/components/edra/editor.css @@ -491,3 +491,51 @@ input[type='checkbox'] { color: var(--color-destructive); border: 1px solid darkred; } + +/* Entity Mention Styles - .tiptap prefix for specificity over .tiptap a */ +.tiptap a.entity-mention, +.tiptap a.entity-mention:hover, +.tiptap a.entity-mention:visited { + padding: 2px 6px; + border-radius: 4px; + text-decoration: none; + font-weight: 500; + transition: background 0.15s, opacity 0.15s; + background: rgba(128, 128, 128, 0.2); + color: var(--text-primary); +} + +.tiptap a.entity-mention:hover { + opacity: 0.8; +} + +/* Element-specific mention colors */ +.tiptap a.entity-mention[data-element='wind'] { + background: rgba(48, 195, 114, 0.2); + color: #1dc688; +} + +.tiptap a.entity-mention[data-element='fire'] { + background: rgba(224, 85, 85, 0.2); + color: #ec5c5c; +} + +.tiptap a.entity-mention[data-element='water'] { + background: rgba(66, 165, 245, 0.2); + color: #42a5f5; +} + +.tiptap a.entity-mention[data-element='earth'] { + background: rgba(198, 134, 66, 0.2); + color: #c68642; +} + +.tiptap a.entity-mention[data-element='dark'] { + background: rgba(156, 39, 176, 0.2); + color: #ba68c8; +} + +.tiptap a.entity-mention[data-element='light'] { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; +} diff --git a/src/lib/components/edra/editor.ts b/src/lib/components/edra/editor.ts index b2a881ed..6d476949 100644 --- a/src/lib/components/edra/editor.ts +++ b/src/lib/components/edra/editor.ts @@ -14,6 +14,8 @@ import { Table, TableCell, TableRow, TableHeader } from './extensions/table/inde import { Placeholder } from '@tiptap/extensions'; import { Markdown } from '@tiptap/markdown'; import MathMatics from '@tiptap/extension-mathematics'; +import Youtube from '@tiptap/extension-youtube'; +import { EntityMention, createEntityMentionSuggestion } from './extensions/entity-mention/index.js'; import AutoJoiner from 'tiptap-extension-auto-joiner'; import 'katex/dist/katex.min.css'; @@ -116,6 +118,16 @@ export default ( TableRow, TableCell, Markdown, + Youtube.configure({ + inline: false, + modestBranding: true + }), + EntityMention.configure({ + HTMLAttributes: { + class: 'entity-mention' + }, + suggestion: createEntityMentionSuggestion() + }), ...(extensions ?? []) ], ...options diff --git a/src/lib/components/edra/extensions/entity-mention/EntityMention.ts b/src/lib/components/edra/extensions/entity-mention/EntityMention.ts new file mode 100644 index 00000000..70dcc2b6 --- /dev/null +++ b/src/lib/components/edra/extensions/entity-mention/EntityMention.ts @@ -0,0 +1,73 @@ +/** + * EntityMention Extension + * + * Extends Tiptap's Mention extension to handle game entity mentions + * (characters, weapons, summons). Renders as clickable links to gbf.wiki. + */ +import Mention from '@tiptap/extension-mention' +import { mergeAttributes } from '@tiptap/core' + +/** Element ID to slug mapping */ +const ELEMENT_SLUGS: Record = { + 0: 'null', + 1: 'wind', + 2: 'fire', + 3: 'water', + 4: 'earth', + 5: 'dark', + 6: 'light' +} + +/** + * Gets the element slug from various attribute formats + * Handles both legacy (object with slug) and new (numeric) formats + */ +function getElementSlug(element: unknown): string { + if (!element) return 'null' + + // Handle object format: { id: number, slug: string, ... } + if (typeof element === 'object' && element !== null && 'slug' in element) { + return (element as { slug: string }).slug + } + + // Handle numeric format + if (typeof element === 'number') { + return ELEMENT_SLUGS[element] ?? 'null' + } + + return 'null' +} + +export const EntityMention = Mention.extend({ + name: 'mention', + + renderHTML({ node, HTMLAttributes }) { + const id = node.attrs.id + + // Extract name - handle various formats from legacy data + const name = id?.name?.en ?? id?.granblue_en ?? 'Unknown' + + // Get element slug for styling + const elementSlug = getElementSlug(id?.element) + + // Get entity type for additional styling/tracking + const entityType = id?.type ?? id?.searchableType?.toLowerCase() ?? 'unknown' + + return [ + 'a', + mergeAttributes( + { + href: `https://gbf.wiki/${encodeURIComponent(name.replace(/ /g, '_'))}`, + target: '_blank', + rel: 'noopener noreferrer' + }, + { 'data-type': this.name }, + { 'data-element': elementSlug }, + { 'data-entity-type': entityType }, + this.options.HTMLAttributes, + HTMLAttributes + ), + name + ] + } +}) diff --git a/src/lib/components/edra/extensions/entity-mention/EntityMentionList.svelte b/src/lib/components/edra/extensions/entity-mention/EntityMentionList.svelte new file mode 100644 index 00000000..a4560654 --- /dev/null +++ b/src/lib/components/edra/extensions/entity-mention/EntityMentionList.svelte @@ -0,0 +1,239 @@ + + +
+ {#if items.length > 0} + {#each items as item, index} + + {/each} + {:else} +
+ {#if query.length < 2} + Type at least 2 characters to search + {:else} + No results found + {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/edra/extensions/entity-mention/index.ts b/src/lib/components/edra/extensions/entity-mention/index.ts new file mode 100644 index 00000000..85be9f39 --- /dev/null +++ b/src/lib/components/edra/extensions/entity-mention/index.ts @@ -0,0 +1,3 @@ +export { EntityMention } from './EntityMention.js' +export { createEntityMentionSuggestion } from './suggestion.js' +export type { EntityMentionData } from './EntityMentionList.svelte' diff --git a/src/lib/components/edra/extensions/entity-mention/suggestion.ts b/src/lib/components/edra/extensions/entity-mention/suggestion.ts new file mode 100644 index 00000000..8aaf5a7c --- /dev/null +++ b/src/lib/components/edra/extensions/entity-mention/suggestion.ts @@ -0,0 +1,150 @@ +/** + * Entity Mention Suggestion Configuration + * + * Configures the Tiptap suggestion plugin for entity mentions. + * Handles search API calls and renders the dropdown using Svelte. + */ +import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion' +import { searchAdapter } from '$lib/api/adapters/search.adapter' +import type { UnifiedSearchResult } from '$lib/api/adapters/search.adapter' +import { mount, unmount } from 'svelte' +import EntityMentionList from './EntityMentionList.svelte' +import type { EntityMentionData } from './EntityMentionList.svelte' + +interface MentionItem extends UnifiedSearchResult {} + +/** + * Creates the suggestion configuration for entity mentions + */ +export function createEntityMentionSuggestion(): Omit, 'editor'> { + return { + char: '@', + allowSpaces: false, + + items: async ({ query }): Promise => { + // Require at least 2 characters to search + if (query.length < 2) return [] + + try { + const response = await searchAdapter.searchAll({ + query, + per: 7 + }) + return response.results + } catch (error) { + console.error('Entity mention search failed:', error) + return [] + } + }, + + render: () => { + let container: HTMLElement | null = null + let component: ReturnType | null = null + let componentInstance: { onKeyDown: (event: KeyboardEvent) => boolean } | null = null + + return { + onStart: (props: SuggestionProps) => { + // Create container element + container = document.createElement('div') + container.className = 'entity-mention-popup' + document.body.appendChild(container) + + // Mount Svelte component + component = mount(EntityMentionList, { + target: container, + props: { + items: props.items, + command: (item: EntityMentionData) => { + props.command({ id: item }) + }, + query: props.query + } + }) + + // Store reference for keyboard handling + // The component exports onKeyDown + componentInstance = component as unknown as { onKeyDown: (event: KeyboardEvent) => boolean } + + // Position the popup + updatePosition(container, props.clientRect) + }, + + onUpdate: (props: SuggestionProps) => { + if (!component || !container) return + + // Update component props - Svelte 5 style + // We need to remount with new props since mount() doesn't return reactive props + unmount(component) + component = mount(EntityMentionList, { + target: container, + props: { + items: props.items, + command: (item: EntityMentionData) => { + props.command({ id: item }) + }, + query: props.query + } + }) + componentInstance = component as unknown as { onKeyDown: (event: KeyboardEvent) => boolean } + + // Update position + updatePosition(container, props.clientRect) + }, + + onKeyDown: (props: SuggestionKeyDownProps): boolean => { + if (props.event.key === 'Escape') { + return true + } + + // Delegate to component for arrow/enter handling + if (componentInstance?.onKeyDown) { + return componentInstance.onKeyDown(props.event) + } + + return false + }, + + onExit: () => { + if (component) { + unmount(component) + component = null + } + if (container) { + container.remove() + container = null + } + componentInstance = null + } + } + } + } +} + +/** + * Positions the popup near the cursor + */ +function updatePosition( + container: HTMLElement, + clientRect: (() => DOMRect | null) | null | undefined +) { + if (!clientRect) return + + const rect = clientRect() + if (!rect) return + + // Position below the cursor + const top = rect.bottom + 8 + const left = rect.left + + // Check if popup would go off-screen + const viewportHeight = window.innerHeight + const popupHeight = 280 // max-height from styles + + // If not enough space below, position above + const actualTop = top + popupHeight > viewportHeight ? rect.top - popupHeight - 8 : top + + container.style.position = 'fixed' + container.style.top = `${actualTop}px` + container.style.left = `${left}px` + container.style.zIndex = '1000' +}