import { tick, untrack } from 'svelte'; import { context, type WithRefOpts } from '../../provider'; import { boolToEmptyStrOrUndef } from '$adom'; import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$libs/reactive'; import { Soma } from '../../core/soma.svelte'; import type { OnChangeFn, SomaFocusEvent, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import { FieldProvider } from '../field'; import { wordsMorfo } from '../../../morfo/components/words'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { WordsBubbleMenuSide, WordsCommandName, WordsCommitReason, WordsOnUploadImage, WordsSelectionRect, WordsSelectionKind, WordsSlashCommandId, WordsSlashCommandItem, WordsStatus, WordsToolbarOrientation } from './types'; import { actionFromBeforeInput, actionFromPaste, applyHistoryCommand, applyWordsCommand, batchHistoryCommands, beginWordsComposition, createEmptyWordsDocument, createWordsCompositionState, createWordsHistory, createWordsState, comparePath, decodeWordsPath, encodeWordsDomSelectionTarget, endWordsComposition, findTextInDocument, getTextEntries, getLinkAtSelection, isCollapsedSelection, isFindQueryValid, normalizeRange, redoWords, renderWordsDomHtml, renderWordsPlainText, resolveTextNode, restoreSelectionApiFromDomTarget, sanitizeWordsUrl, selectionApiToWordsSelection, serializeWordsJson, setWordsSelection, undoWords, updateWordsComposition, type WordsCommand, type WordsDocument, type WordsExportFormat, type WordsEditorState, type WordsHistory, type WordsImageBlock, type WordsImportFormat, type WordsMark, type WordsPoint, type WordsTextMatch, type WordsSelection, type WordsTableCell, type WordsTableCellTone, type WordsTableCellVerticalAlign } from './engine'; interface WordsOpts extends WithRefOpts, StateProps<{ value: WordsDocument; selection: WordsSelection | null; }>, ActiveProps<{ contentId: string; placeholder: string | undefined; name: string | undefined; disabled: boolean; readonly: boolean; required: boolean; invalid: boolean; commitOnBlur: boolean; ariaLabel: string | undefined; onValueChange: OnChangeFn | undefined; onSelectionChange: OnChangeFn | undefined; onValueCommit: ((document: WordsDocument, reason: WordsCommitReason) => void) | undefined; onUploadImage: WordsOnUploadImage | undefined; }> {} interface WordsContentOpts extends WithRefOpts {} interface WordsPlaceholderOpts extends WithRefOpts {} interface WordsToolbarOpts extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {} interface WordsToolbarGroupOpts extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {} interface WordsBubbleMenuOpts extends WithRefOpts, ActiveProps<{ side: WordsBubbleMenuSide }> {} interface WordsSlashMenuOpts extends WithRefOpts {} interface WordsCommandButtonOpts extends WithRefOpts, ActiveProps<{ command: WordsCommandName; level: 1 | 2 | 3 | undefined; listKind: 'ordered' | 'unordered' | 'check' | undefined; }> {} interface WordsLinkEditorOpts extends WithRefOpts, ActiveProps<{ href: string; title: string | undefined; }> {} interface WordsFindReplaceOpts extends WithRefOpts {} interface WordsHiddenInputOpts extends WithRefOpts {} interface WordsStatusOpts extends WithRefOpts {} interface WordsDrawerOpts extends WithRefOpts, StateProps<{ open: boolean }>, ActiveProps<{ disabled: boolean }> {} type WordsSelectionSyncSource = | 'selectionchange' | 'pointer' | 'keyboard' | 'select' | 'input' | 'blur' | 'direct'; export class WordsProvider { readonly opts: WordsOpts; readonly soma: Soma; readonly runtime: SomaRuntime; readonly runtimePart: SomaRuntimePart; static readonly ctx = context('Words'); static get(): WordsProvider | undefined { return this.ctx.getOr(undefined) as WordsProvider | undefined; } static require(): WordsProvider { return this.ctx.get(); } static create(opts: WordsOpts) { return new WordsProvider(opts); } readonly field = FieldProvider.get(); contentRef = state(null); history = $state(createWordsHistory(createWordsState(createEmptyWordsDocument()))); composition = $state(createWordsCompositionState()); focused = $state(false); dirty = $state(false); isSelecting = $state(false); findQuery = $state(''); findCaseSensitive = $state(false); findWholeWord = $state(false); findRegex = $state(false); findSelectionScope = $state(null); replacePreserveCase = $state(false); currentMatchIndex = $state(-1); slashMenuMounts = $state(0); slashMenuActiveIndex = $state(0); slashDismissedAnchorKey = $state(null); /** * Atomic block selection — block index pointing to an image (or * any other contenteditable=false block we add later). Distinct * from the text selection because the caret can never live inside * an atomic block. Set by the eidos click handler when the user * clicks an image; cleared by any selectionchange / focus loss / * text-editing event. */ selectedBlockIndex = $state(undefined); private pendingCommitReason: WordsCommitReason = 'programmatic'; private typingBatchOpen = false; private constructor(opts: WordsOpts) { this.opts = opts; this.soma = Soma.require(); const initialState = createWordsState(opts.value.current, opts.selection.current); this.history = createWordsHistory(initialState); this.opts.value.current = initialState.document; this.opts.selection.current = initialState.selection; this.runtime = this.soma.runtime(wordsMorfo, { props: { focused: () => this.focused, empty: () => this.isEmpty, dirty: () => this.dirty, disabled: () => this.isDisabled, readonly: () => this.isReadonly, required: () => this.isRequired, invalid: () => this.isInvalid, composing: () => this.composition.active, selectionKind: () => this.selectionKind, currentBlock: () => this.currentBlock, editable: () => String(this.isEditable), placeholderHidden: () => !this.isEmpty, status: () => this.status }, parts: { content: () => this.opts.contentId.current || undefined }, events: { 'commit-save-content': () => { this.opts.onValueCommit.current?.(this.document, this.pendingCommitReason); } } }); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, context: WordsProvider.ctx, syncAttrs: true }); if (this.field) this.field.inputId.current = opts.contentId.current; $effect(() => { this.syncExternalState(this.opts.value.current, this.opts.selection.current); }); $effect(() => { const root = this.contentRef.current; if (!root) return; const doc = root.ownerDocument; const handleSelectionChange = () => { const selection = doc.getSelection(); if (!domSelectionTouchesRoot(selection, root)) return; this.syncSelectionFromDom({ source: 'selectionchange' }); }; return this.soma.dom.listen(doc, 'selectionchange', handleSelectionChange); }); $effect(() => { const root = this.contentRef.current; if (!root) return; const doc = root.ownerDocument; const handlePointerUp = () => { this.isSelecting = false; }; const handlePointerCancel = () => { this.isSelecting = false; }; const cleanupUp = this.soma.dom.listen(doc, 'pointerup', handlePointerUp); const cleanupCancel = this.soma.dom.listen(doc, 'pointercancel', handlePointerCancel); return () => { cleanupUp?.(); cleanupCancel?.(); }; }); $effect(() => { const context = this.slashMenuContext; const itemCount = this.slashMenuItems.length; if (!context) { this.slashMenuActiveIndex = 0; this.slashDismissedAnchorKey = null; return; } if (itemCount === 0) { this.slashMenuActiveIndex = 0; return; } if (this.slashMenuActiveIndex < 0 || this.slashMenuActiveIndex >= itemCount) { this.slashMenuActiveIndex = 0; } }); } readonly document = $derived.by(() => this.history.present.document); readonly selection = $derived.by(() => this.history.present.selection); readonly activeMarks = $derived.by(() => this.history.present.activeMarks); readonly plainText = $derived.by(() => renderWordsPlainText(this.document)); readonly html = $derived.by(() => renderWordsDomHtml(this.document, { matches: this.findMatches, activeMatchIndex: this.currentMatchIndex, selectedBlockIndex: this.selectedBlockIndex }) ); readonly isDisabled = $derived.by( () => this.opts.disabled.current || (this.field?.isDisabled ?? false) ); readonly isReadonly = $derived.by( () => this.opts.readonly.current || (this.field?.isReadonly ?? false) ); readonly isRequired = $derived.by( () => this.opts.required.current || (this.field?.isRequired ?? false) ); readonly isInvalid = $derived.by( () => this.opts.invalid.current || (this.field?.isInvalid ?? false) ); readonly isEditable = $derived.by(() => !this.isDisabled && !this.isReadonly); readonly isEmpty = $derived.by(() => this.plainText.trim().length === 0); readonly canUndo = $derived.by(() => this.history.past.length > 0); readonly canRedo = $derived.by(() => this.history.future.length > 0); readonly status = $derived.by(() => (this.isInvalid ? 'error' : 'idle')); readonly selectedLink = $derived.by(() => getLinkAtSelection(this.history.present)); readonly selectionKind = $derived.by(() => { if (!this.selection) return 'none'; return isCollapsedSelection(this.selection) ? 'collapsed' : 'range'; }); readonly currentBlock = $derived.by(() => { // Atomic block selection (image / etc) takes precedence over // text-selection-derived currentBlock — the caret can't live // inside an atomic block, so without this short-circuit the // drawer would never enter the atomic block's mode. if (this.selectedBlockIndex !== undefined) { const atomic = this.document.children[this.selectedBlockIndex]; if (atomic) return atomic.type; } const index = this.selection?.anchor.path[0] ?? 0; return this.document.children[index]?.type ?? 'paragraph'; }); /** * Mark an atomic block (image / etc) as visually selected. The * eidos render reads this via `selectedBlockIndex` to paint the * accent outline; the drawer picks it up via `currentBlock` to * enter the block's mode. Distinct from `selectBlock()` (which * extends the text selection to cover the current block's * contents). */ selectAtomicBlock(blockIndex: number): void { if (blockIndex < 0 || blockIndex >= this.document.children.length) return; this.selectedBlockIndex = blockIndex; // Focus the editor root so keyboard handlers (esc, delete) work // on the atomic block. const root = this.opts.ref.current; if (root && typeof (root as HTMLElement).focus === 'function') { (root as HTMLElement).focus({ preventScroll: true } as FocusOptions); } } clearSelectedBlock(): void { if (this.selectedBlockIndex !== undefined) this.selectedBlockIndex = undefined; } readonly currentHeadingLevel = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; return block?.type === 'heading' ? block.level : undefined; }); readonly currentCodeLanguage = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; return block?.type === 'code' ? block.language : undefined; }); readonly currentListKind = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; return block?.type === 'list' ? block.kind : undefined; }); readonly currentTableCell = $derived.by(() => { const path = this.selection?.anchor.path; if (!path) return undefined; const blockIndex = path[0] ?? -1; const rowIndex = path[1] ?? -1; const cellIndex = path[2] ?? -1; const block = this.document.children[blockIndex]; return block?.type === 'table' ? block.children[rowIndex]?.children[cellIndex] : undefined; }); readonly currentTableRowCells = $derived.by(() => { const path = this.selection?.anchor.path; if (!path) return []; const block = this.document.children[path[0] ?? -1]; return block?.type === 'table' ? (block.children[path[1] ?? -1]?.children ?? []) : []; }); readonly currentTableColumnCells = $derived.by(() => { const path = this.selection?.anchor.path; if (!path) return []; const block = this.document.children[path[0] ?? -1]; const cellIndex = path[2] ?? -1; return block?.type === 'table' ? block.children.map((row) => row.children[cellIndex]).filter(Boolean) : []; }); readonly currentTableStriped = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; return block?.type === 'table' && block.striped === true; }); readonly currentTableCompact = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; return block?.type === 'table' && block.compact === true; }); readonly currentTableCellTone = $derived.by( () => this.currentTableCell?.tone ?? 'default' ); readonly currentTableCellVerticalAlign = $derived.by( () => this.currentTableCell?.verticalAlign ?? 'top' ); readonly currentTableHeaderRowActive = $derived.by( () => this.currentTableRowCells.length > 0 && this.currentTableRowCells.every((cell) => cell.header === true) ); readonly currentTableHeaderColumnActive = $derived.by( () => this.currentTableColumnCells.length > 0 && this.currentTableColumnCells.every((cell) => cell.header === true) ); readonly currentTextAlign = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; if (block?.type === 'table') return this.currentTableCell?.textAlign ?? 'left'; return block && block.type !== 'list' && block.type !== 'code' && block.type !== 'image' ? (block.textAlign ?? 'left') : 'left'; }); readonly findQueryValid = $derived.by(() => isFindQueryValid(this.findQuery, { caseSensitive: this.findCaseSensitive, regex: this.findRegex }) ); readonly findInSelection = $derived.by(() => Boolean(this.findSelectionScope)); readonly canFindInSelection = $derived.by( () => this.selectionKind === 'range' || Boolean(this.findSelectionScope) ); readonly findMatches = $derived.by(() => { if (!this.findQuery || !this.findQueryValid) return []; const matches = findTextInDocument(this.document, this.findQuery, { caseSensitive: this.findCaseSensitive, wholeWord: this.findWholeWord, regex: this.findRegex }); const scope = this.findSelectionScope; return scope ? matches.filter((match) => matchIsInsideSelection(match, scope)) : matches; }); readonly hasFindMatches = $derived.by(() => this.findMatches.length > 0); readonly slashMenuAvailable = $derived.by(() => this.slashMenuMounts > 0); readonly slashMenuContext = $derived.by(() => this.resolveSlashMenuContext()); readonly slashMenuItems = $derived.by(() => filterSlashCommandItems(this.slashMenuContext?.query ?? '') ); readonly slashMenuOpen = $derived.by(() => { const context = this.slashMenuContext; return Boolean( context && this.slashMenuAvailable && this.isEditable && !this.composition.active && this.slashDismissedAnchorKey !== context.anchorKey && this.slashMenuItems.length > 0 ); }); readonly slashMenuQuery = $derived.by(() => this.slashMenuContext?.query ?? ''); readonly canUnlink = $derived.by( () => Boolean(this.selectedLink) && !this.isDisabled && !this.isReadonly ); readonly resolvedContentLabel: Active = readableActive(() => { if (this.field?.labelId.current) return undefined; return ( this.opts.ariaLabel.current || this.soma.langs.ts('#?components.words.content|Editor content') || undefined ); }); focus() { this.soma.dom.focus(this.contentRef.current, { preventScroll: true }); } setContentRef(el: HTMLElement | null) { this.contentRef.current = el; } syncSelectionFromDom(options: { source?: WordsSelectionSyncSource } = {}): boolean { const root = this.contentRef.current; if (!root) return false; const selection = root.ownerDocument.defaultView?.getSelection() ?? null; const next = selectionApiToWordsSelection(this.document, root, selection); if (!next) return false; if (this.shouldIgnoreTransientSelectionCollapse(next, root, options.source ?? 'direct')) { return false; } // Clear the atomic-block highlight only when the text selection // genuinely moved (the user clicked somewhere else / pressed an // arrow / typed). Synthetic selectionchange echoes that arrive // right after `selectAtomicBlock` (which doesn't actually move // the DOM caret) would otherwise wipe the highlight a tick // later, defeating the click. if (!sameWordsSelection(this.selection, next)) { this.clearSelectedBlock(); } this.updateSelection(next); return true; } restoreDomSelection(): boolean { const root = this.contentRef.current; const selection = this.selection; if (!root || !selection) return false; return restoreSelectionApiFromDomTarget( root, encodeWordsDomSelectionTarget(this.document, selection) ); } restoreModelSelection(selection: WordsSelection | null): boolean { if (!selection) return false; this.updateSelection(selection); return this.restoreDomSelection(); } getSelectionClientRect(): WordsSelectionRect | null { const root = this.contentRef.current; if (!root || this.selectionKind !== 'range') return null; const selection = root.ownerDocument.defaultView?.getSelection() ?? null; if (!domSelectionTouchesRoot(selection, root) || !selection?.rangeCount) return null; const range = selection.getRangeAt(0); const rect = firstUsableClientRect(range); if (!rect) return null; return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, width: rect.width, height: rect.height }; } getCaretClientRect(): WordsSelectionRect | null { const root = this.contentRef.current; if (!root || !this.selection || !isCollapsedSelection(this.selection)) return null; const selection = root.ownerDocument.defaultView?.getSelection() ?? null; if (!domSelectionTouchesRoot(selection, root) || !selection?.rangeCount) return null; const range = selection.getRangeAt(0); const rect = firstUsableClientRect(range) ?? range.getBoundingClientRect(); if (rect.width || rect.height || rect.left || rect.top) return wordsRectFromDomRect(rect); const fallback = root.getBoundingClientRect(); return wordsRectFromDomRect(fallback); } commit(reason: WordsCommitReason = 'programmatic', target?: HTMLElement) { this.closeTypingBatch(); this.pendingCommitReason = reason; void this.runtime.trigger('commit-save-content', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); } undo(target?: HTMLElement) { if (this.isDisabled || this.isReadonly || !this.canUndo) return; this.closeTypingBatch(); this.publishHistory(undoWords(this.history)); void this.runtime.trigger('commit-restore-history', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); } redo(target?: HTMLElement) { if (this.isDisabled || this.isReadonly || !this.canRedo) return; this.closeTypingBatch(); this.publishHistory(redoWords(this.history)); void this.runtime.trigger('commit-restore-history', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); } runCommandName( name: WordsCommandName, opts: { target?: HTMLElement; level?: 1 | 2 | 3; replacement?: string } = {} ) { if (name === 'undo') { this.undo(opts.target); return; } if (name === 'redo') { this.redo(opts.target); return; } if (name === 'commit') { this.commit('button', opts.target); return; } if (name === 'find-text') { return; } if (name === 'replace-all-text') { if (!this.findQuery || !this.findQueryValid || opts.replacement === undefined) return; if (!this.hasFindMatches) return; const options = { caseSensitive: this.findCaseSensitive, wholeWord: this.findWholeWord, regex: this.findRegex, preserveCase: this.replacePreserveCase }; const command: WordsCommand = this.findInSelection ? { type: 'replaceTextMatches', matches: this.findMatches, replacement: opts.replacement, options } : { type: 'replaceAllText', query: this.findQuery, replacement: opts.replacement, options }; const changed = this.applyCommand(command); if (!changed) return; this.currentMatchIndex = -1; void this.runtime.trigger('commit-set-replace', { fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined }); return; } const command = this.commandFromName(name, opts.level); if (!command) return; const changed = this.applyCommand(command); if (!changed) return; const event = name === 'clear-document' ? 'commit-reset-content' : name === 'unlink' ? 'commit-reset-link' : 'commit-set-format'; void this.runtime.trigger(event, { fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined }); } applyCommand(command: WordsCommand): boolean { return this.applyCommandWithOptions(command); } private applyNavigationCommand(command: WordsCommand): boolean { if (this.isDisabled || this.isReadonly) return false; this.ensureSelection(); const result = applyWordsCommand(this.history.present, command); if (!result.changed) return false; const documentChanged = result.state.document !== this.history.present.document; this.closeTypingBatch(); if (documentChanged) this.publishHistory(applyHistoryCommand(this.history, command)); else this.publishPresent(result.state, { documentChanged: false }); return true; } private applyCommandWithOptions( command: WordsCommand, options: { batch?: 'typing' } = {} ): boolean { if (this.isDisabled || this.isReadonly) return false; this.ensureSelection(); const next = options.batch === 'typing' ? this.typingHistoryCommand(command) : this.historyCommand(command); const changed = next !== this.history; this.publishHistory(next); this.typingBatchOpen = options.batch === 'typing' && changed; return changed; } insertLink(href: string, title?: string, target?: HTMLElement): boolean { const changed = this.applyCommandWithOptions({ type: 'insertLink', href, title }); if (!changed) { if (target) this.signalInvalidInput('invalid-link', target); return false; } void this.runtime.trigger('commit-set-link', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); return true; } setCodeLanguage(language?: string, target?: HTMLElement): boolean { const changed = this.applyCommandWithOptions({ type: 'setCodeLanguage', language }); if (!changed) return false; void this.runtime.trigger('commit-set-format', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); return true; } unlink(target?: HTMLElement): boolean { const changed = this.applyCommandWithOptions({ type: 'unlink' }); if (!changed) return false; void this.runtime.trigger('commit-reset-link', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); return true; } importContent(content: string, format: WordsImportFormat) { this.applyCommand({ type: 'importContent', content, format }); } exportContent(format: WordsExportFormat): string { const result = applyWordsCommand(this.history.present, { type: 'exportContent', format }); return result.exportedContent ?? ''; } readonly onfocus = (e: SomaFocusEvent) => { // Only fire the perceptual contact-focus on a real transition // (was-not-focused → focused). Re-focus from internal popovers // (bubble menu, link editor, find panel, slash menu, toolbar // dropdowns) does NOT count as a transition because `focused` // stays true across that bounce — see onblur below. Without this // guard every focus return inside a normal editing session // triggers sound + haptic + a visual stamp, fatiguing the user. const wasFocused = this.focused; this.focused = true; this.ensureSelection(); if (!wasFocused) { void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement }); } }; readonly onblur = (e: SomaFocusEvent) => { this.isSelecting = false; const related = e.relatedTarget as HTMLElement | null; const goingInternal = !!related && this.isInsideWordsTool(related); if (!goingInternal) { this.syncSelectionFromDom({ source: 'blur' }); } this.closeTypingBatch(); // Treat the Words component as a single focus scope: blur into an // internal tool (popover / bubble menu / link editor / find panel / // slash menu) keeps `focused` true, so the next focus-back to the // content is not a fresh transition and doesn't re-emit the // perceptual contact-focus event. if (!goingInternal) { this.focused = false; if (this.opts.commitOnBlur.current) this.commit('blur'); } }; readonly onkeydown = (e: SomaKeyboardEvent) => { this.isSelecting = false; const key = e.key.toLowerCase(); const mod = e.ctrlKey || e.metaKey; if (this.handleSlashMenuKeydown(e, key, mod)) return; if (!mod) { if (key === 'backspace') { e.preventDefault(); this.applyCommand({ type: 'deleteBackward' }); return; } if (key === 'delete') { e.preventDefault(); this.applyCommand({ type: 'deleteForward' }); return; } if (key === 'enter' && e.shiftKey) { e.preventDefault(); this.applyCommand({ type: 'insertLineBreak' }); return; } if (key === 'tab') { if (this.currentBlock === 'code') { e.preventDefault(); this.applyCommand( e.shiftKey ? { type: 'outdentCodeLine' } : { type: 'insertText', text: '\t' } ); return; } if (this.currentBlock === 'table' && this.isEditable) { e.preventDefault(); this.applyNavigationCommand({ type: 'moveTableCell', direction: e.shiftKey ? 'previous' : 'next' }); return; } if (this.currentBlock === 'list') { e.preventDefault(); if (e.shiftKey) { this.runCommandName('decrease-indent', { target: e.currentTarget as HTMLElement }); } else { this.runCommandName('increase-indent', { target: e.currentTarget as HTMLElement }); } } return; } return; } if (key === 'enter' && this.currentBlock === 'code') { e.preventDefault(); this.applyCommand({ type: 'exitCodeBlock' }); return; } if (key === 'enter' && this.currentBlock === 'list' && this.currentListKind === 'check') { e.preventDefault(); const changed = this.applyCommandWithOptions({ type: 'toggleCheckItem' }); if (changed) { void this.runtime.trigger('commit-set-check', { fallbackTarget: e.currentTarget as HTMLElement }); } return; } if (key === 'a') { e.preventDefault(); this.selectAll(); return; } if (key === 'b') { e.preventDefault(); this.runCommandName('bold', { target: e.currentTarget as HTMLElement }); return; } if (key === 'i') { e.preventDefault(); this.runCommandName('italic', { target: e.currentTarget as HTMLElement }); return; } if (key === 'u') { e.preventDefault(); this.runCommandName('underline', { target: e.currentTarget as HTMLElement }); return; } if (key === 'l') { e.preventDefault(); this.runCommandName('align-left', { target: e.currentTarget as HTMLElement }); return; } if (key === 'e') { e.preventDefault(); this.runCommandName('align-center', { target: e.currentTarget as HTMLElement }); return; } if (key === 'r') { e.preventDefault(); this.runCommandName('align-right', { target: e.currentTarget as HTMLElement }); return; } if (key === 'j') { e.preventDefault(); this.runCommandName('align-justify', { target: e.currentTarget as HTMLElement }); return; } if (key === 'z') { e.preventDefault(); if (e.shiftKey) this.redo(e.currentTarget as HTMLElement); else this.undo(e.currentTarget as HTMLElement); return; } if (key === 'y') { e.preventDefault(); this.redo(e.currentTarget as HTMLElement); } }; readonly onkeyup = (_e: KeyboardEvent) => { this.isSelecting = false; this.syncSelectionFromDom({ source: 'keyboard' }); }; readonly onpointerdown = (_e: PointerEvent) => { this.isSelecting = true; }; readonly onpointerup = (_e: PointerEvent) => { this.isSelecting = false; this.syncSelectionFromDom({ source: 'pointer' }); }; readonly onclick = (e: MouseEvent & { currentTarget: HTMLElement }) => { this.isSelecting = false; if (e.detail === 3) { this.selectBlock(); return; } const target = e.target; const targetElement = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; if (!targetElement) return; // Atomic-block click — figure[data-words-block='image'] (and any // future atomic block types). Don't preventDefault: the browser // will still resolve a caret-friendly text position; we just // flag the block as the visually-selected atomic so the drawer // can enter image mode. const atomicFigure = targetElement.closest( 'figure[data-words-block="image"][data-words-path]' ); if (atomicFigure && e.currentTarget.contains(atomicFigure)) { const path = decodeWordsPath(atomicFigure.getAttribute('data-words-path')); const blockIndex = path?.[0]; if (typeof blockIndex === 'number') { e.preventDefault(); this.selectAtomicBlock(blockIndex); return; } } if (!this.isEditable) return; const checkToggle = targetElement.closest('[data-words-check-toggle][data-words-path]'); if (checkToggle && e.currentTarget.contains(checkToggle)) { e.preventDefault(); const path = decodeWordsPath(checkToggle.getAttribute('data-words-path')); if (!path) return; const changed = this.applyCommandWithOptions({ type: 'toggleCheckItem', path }); if (!changed) return; void this.runtime.trigger('commit-set-check', { fallbackTarget: e.currentTarget as HTMLElement }); return; } const link = targetElement.closest('a[href]'); if (!link || !e.currentTarget.contains(link)) return; e.preventDefault(); this.syncSelectionFromDom({ source: 'pointer' }); }; readonly onselect = (_e: Event) => { this.syncSelectionFromDom({ source: 'select' }); }; readonly onbeforeinput = (e: InputEvent & { currentTarget: HTMLElement }) => { if (this.isDisabled || this.isReadonly) { e.preventDefault(); return; } const action = actionFromBeforeInput(e); if (action.type === 'ignore') { if (action.reason === 'composition') { e.preventDefault(); return; } if (action.reason === 'unsupported') return; e.preventDefault(); this.signalInvalidInput(action.reason, e.currentTarget); return; } e.preventDefault(); this.syncSelectionFromDom({ source: 'input' }); if (action.type === 'history') { if (action.direction === 'undo') this.undo(e.currentTarget); else this.redo(e.currentTarget); return; } if ( action.command.type === 'insertText' && action.command.text === ' ' && this.applyCommandWithOptions({ type: 'applyMarkdownShortcut' }, { batch: 'typing' }) ) { return; } this.applyCommandWithOptions(action.command, { batch: action.batch }); }; readonly onpaste = (e: ClipboardEvent & { currentTarget: HTMLElement }) => { if (this.isDisabled || this.isReadonly) { e.preventDefault(); return; } // Image files first — Windows / macOS / Chrome ship them under // clipboardData.files when the user copies an image from a // browser / file manager / screenshot tool. If at least one // image-typed file is present *and* the consumer has wired // onUploadImage, we own the paste and drop the regular text/html // processing entirely (mixing both produces awkward layouts). const imageFiles = collectImageFiles(e.clipboardData?.files); if (imageFiles.length > 0 && this.opts.onUploadImage.current) { e.preventDefault(); this.syncSelectionFromDom({ source: 'input' }); void this.insertImageFiles(imageFiles, e.currentTarget); return; } const plainText = e.clipboardData?.getData('text/plain') ?? ''; const html = e.clipboardData?.getData('text/html') ?? ''; const action = actionFromPaste({ plainText, html }); e.preventDefault(); this.syncSelectionFromDom({ source: 'input' }); const pastedUrl = normalizedSinglePastedUrl(plainText); if (pastedUrl && this.applyCommand({ type: 'insertLink', href: pastedUrl })) return; if (action.type === 'ignore') { this.signalInvalidInput(action.reason, e.currentTarget); return; } this.applyCommand(action.command); }; readonly ondragover = (e: DragEvent & { currentTarget: HTMLElement }) => { // Only intercept drags carrying image files; let everything else // (text drags, internal selection drags) follow the browser // default so the contenteditable's native drop handling works. if (this.isDisabled || this.isReadonly) return; if (!this.opts.onUploadImage.current) return; if (!dataTransferHasImageFiles(e.dataTransfer)) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }; readonly ondrop = (e: DragEvent & { currentTarget: HTMLElement }) => { if (this.isDisabled || this.isReadonly) return; if (!this.opts.onUploadImage.current) return; const imageFiles = collectImageFiles(e.dataTransfer?.files); if (imageFiles.length === 0) return; e.preventDefault(); this.syncSelectionFromDom({ source: 'input' }); void this.insertImageFiles(imageFiles, e.currentTarget); }; /** * Insert N image files: each gets a blob URL + status='pending' * inserted immediately; the consumer's `onUploadImage` callback * runs in parallel; as each resolves we rewrite the matching * placeholder block by `src` (which is unique because blob URLs are). * Failures flip the placeholder to status='error' and keep the blob * URL so the local preview survives. */ private async insertImageFiles(files: readonly File[], target: HTMLElement): Promise { const upload = this.opts.onUploadImage.current; if (!upload) return; const placeholders: { readonly blobUrl: string; readonly alt: string }[] = []; for (const file of files) { const node = this.opts.ref.current; const URLClass = node?.ownerDocument?.defaultView?.URL ?? globalThis.URL; const blobUrl = URLClass.createObjectURL(file); const alt = file.name.replace(/\.[^.]+$/, ''); placeholders.push({ blobUrl, alt }); this.applyCommand({ type: 'insertImage', src: blobUrl, alt, status: 'pending' }); } for (const { blobUrl, alt } of placeholders) { try { const file = files[placeholders.findIndex((p) => p.blobUrl === blobUrl)]; if (!file) continue; const result = await upload(file); this.replaceImageBlock(blobUrl, { src: result.url, alt: result.alt ?? alt }); } catch { this.markImageBlockError(blobUrl); } } // best-effort telemetry trigger; intentionally swallowed if the // runtime hasn't been started in the current scope (tests, SSR). try { void this.runtime.trigger('commit-save-content', { fallbackTarget: target }); } catch { /* noop */ } } /** * Replace the image block whose current `src` is `previousSrc`. Used * by the paste/drop upload flow to swap a blob URL for the final * uploaded URL once the consumer's promise resolves. */ private replaceImageBlock( previousSrc: string, patch: { readonly src: string; readonly alt?: string } ): void { const next = mapImageBlocksBySrc(this.document, previousSrc, (block) => ({ ...block, src: patch.src, ...(patch.alt !== undefined ? { alt: patch.alt } : {}), status: undefined })); if (next === this.document) return; this.applyCommand({ type: 'replaceDocument', document: next }); } /** Flip an image-block (matched by src) into status='error'. */ private markImageBlockError(src: string): void { const next = mapImageBlocksBySrc(this.document, src, (block) => ({ ...block, status: 'error' as const })); if (next === this.document) return; this.applyCommand({ type: 'replaceDocument', document: next }); } readonly oncompositionstart = (e: CompositionEvent) => { this.composition = beginWordsComposition(e.data ?? ''); }; readonly oncompositionupdate = (e: CompositionEvent) => { this.composition = updateWordsComposition(this.composition, e.data ?? ''); }; readonly oncompositionend = (e: CompositionEvent) => { const finalComposition = e.data && e.data !== this.composition.text ? updateWordsComposition(this.composition, e.data) : this.composition; const action = endWordsComposition(finalComposition); this.composition = createWordsCompositionState(); if (action.type === 'ignore') return; this.syncSelectionFromDom({ source: 'input' }); this.applyCommandWithOptions(action.command, { batch: 'typing' }); }; readonly snippetProps = $derived.by(() => ({ document: this.document, selection: this.selection, selectedBlockIndex: this.selectedBlockIndex, plainText: this.plainText, isEmpty: this.isEmpty, isFocused: this.focused, isDisabled: this.isDisabled, isReadonly: this.isReadonly, isInvalid: this.isInvalid, activeMarks: this.activeMarks, selectedLink: this.selectedLink, currentBlock: this.currentBlock, currentHeadingLevel: this.currentHeadingLevel, currentCodeLanguage: this.currentCodeLanguage, currentTextAlign: this.currentTextAlign, currentTableCellTone: this.currentTableCellTone, currentTableCellVerticalAlign: this.currentTableCellVerticalAlign, currentTableHeaderRowActive: this.currentTableHeaderRowActive, currentTableHeaderColumnActive: this.currentTableHeaderColumnActive, currentTableStriped: this.currentTableStriped, currentTableCompact: this.currentTableCompact, canUndo: this.canUndo, canRedo: this.canRedo, canUnlink: this.canUnlink, findQuery: this.findQuery, findCaseSensitive: this.findCaseSensitive, findWholeWord: this.findWholeWord, findRegex: this.findRegex, findInSelection: this.findInSelection, canFindInSelection: this.canFindInSelection, replacePreserveCase: this.replacePreserveCase, findQueryValid: this.findQueryValid, findMatches: this.findMatches, currentMatchIndex: this.currentMatchIndex, hasFindMatches: this.hasFindMatches, slashMenuOpen: this.slashMenuOpen, slashMenuQuery: this.slashMenuQuery, slashMenuItems: this.slashMenuItems, slashMenuActiveIndex: this.slashMenuActiveIndex, focus: () => this.focus(), selectAll: () => this.selectAll(), selectBlock: () => this.selectBlock(), selectAtomicBlock: (blockIndex: number) => this.selectAtomicBlock(blockIndex), clearSelectedBlock: () => this.clearSelectedBlock(), runCommand: (command: WordsCommandName) => this.runCommandName(command), setCodeLanguage: (language?: string) => this.setCodeLanguage(language), insertLink: (href: string, title?: string) => this.insertLink(href, title), unlink: () => this.unlink(), importContent: (content: string, format: WordsImportFormat) => this.importContent(content, format), exportContent: (format: WordsExportFormat) => this.exportContent(format), commit: (reason: WordsCommitReason = 'programmatic') => this.commit(reason), undo: () => this.undo(), redo: () => this.redo(), setFindQuery: (query: string) => this.setFindQuery(query), setFindCaseSensitive: (caseSensitive: boolean) => this.setFindCaseSensitive(caseSensitive), setFindWholeWord: (wholeWord: boolean) => this.setFindWholeWord(wholeWord), setFindRegex: (regex: boolean) => this.setFindRegex(regex), setFindInSelection: (selectionOnly: boolean) => this.setFindInSelection(selectionOnly), setReplacePreserveCase: (preserveCase: boolean) => this.setReplacePreserveCase(preserveCase), findNext: () => this.findNext(), findPrev: () => this.findPrev(), replaceCurrentText: (replacement: string) => this.replaceCurrentText(replacement), replaceAllText: (replacement: string) => this.replaceAllText(replacement), commitSlashCommand: (id?: WordsSlashCommandId) => this.commitSlashCommand(id), closeSlashMenu: () => this.closeSlashMenu() })); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, 'aria-required': this.isRequired || undefined, 'aria-invalid': this.isInvalid || undefined } as const) ); private updateSelection(selection: WordsSelection | null) { const result = setWordsSelection(this.history.present, selection); if (!result.changed) return; this.closeTypingBatch(); this.publishPresent(result.state, { documentChanged: false }); } private isInsideWordsTool(element: HTMLElement): boolean { return !!element.closest( '[data-words-bubble-menu], [data-words-toolbar], [data-words-find-replace], [data-words-heading-picker], [data-words-toolbar-family], [data-words-toolbar-family-panel], [data-words-code-language-picker], [data-words-code-language-panel], [data-words-link-editor], [data-words-slash-menu]' ); } private shouldIgnoreTransientSelectionCollapse( next: WordsSelection, root: HTMLElement, source: WordsSelectionSyncSource ): boolean { if (this.selectionKind !== 'range' || !isCollapsedSelection(next)) return false; if (source === 'selectionchange') return true; const active = root.ownerDocument.activeElement; if (!(active instanceof HTMLElement)) return false; if (active === root) return false; if (this.isInsideWordsTool(active)) return true; if (root.contains(active)) return false; return false; } private selectAll() { if (this.isDisabled) return; const entries = getTextEntries(this.document); const first = entries[0]; const last = entries.at(-1); if (!first || !last) return; this.closeTypingBatch(); this.updateSelection({ anchor: { path: first.path, offset: 0 }, focus: { path: last.path, offset: last.text.text.length } }); void tick().then(() => this.restoreDomSelection()); } private selectBlock() { if (this.isDisabled) return; const selection = this.selection; if (!selection) return; const blockIndex = selection.anchor.path[0] ?? -1; if (blockIndex < 0) return; const entries = getTextEntries(this.document).filter((entry) => entry.path[0] === blockIndex); if (!entries.length) return; const first = entries[0]; const last = entries.at(-1); if (!first || !last) return; this.closeTypingBatch(); this.updateSelection({ anchor: { path: first.path, offset: 0 }, focus: { path: last.path, offset: last.text.text.length } }); void tick().then(() => this.restoreDomSelection()); } readonly setFindQuery = (query: string) => { this.findQuery = query; this.currentMatchIndex = -1; }; readonly setFindCaseSensitive = (caseSensitive: boolean) => { this.findCaseSensitive = caseSensitive; this.currentMatchIndex = -1; }; readonly setFindWholeWord = (wholeWord: boolean) => { this.findWholeWord = wholeWord; this.currentMatchIndex = -1; }; readonly setFindRegex = (regex: boolean) => { this.findRegex = regex; this.currentMatchIndex = -1; }; readonly setFindInSelection = (selectionOnly: boolean) => { if (!selectionOnly) { this.findSelectionScope = null; this.currentMatchIndex = -1; return; } if (this.selectionKind !== 'range' || !this.selection) return; this.findSelectionScope = this.selection; this.currentMatchIndex = -1; }; readonly setReplacePreserveCase = (preserveCase: boolean) => { this.replacePreserveCase = preserveCase; }; readonly findNext = () => { if (!this.hasFindMatches) return; const nextIndex = this.currentMatchIndex + 1 < this.findMatches.length ? this.currentMatchIndex + 1 : 0; this.activateFindMatch(nextIndex); }; readonly findPrev = () => { if (!this.hasFindMatches) return; const nextIndex = this.currentMatchIndex > 0 ? this.currentMatchIndex - 1 : this.findMatches.length - 1; this.activateFindMatch(nextIndex); }; readonly replaceAllText = (replacement: string) => { this.runCommandName('replace-all-text', { replacement }); }; readonly replaceCurrentText = (replacement: string) => { if (!this.findQuery || !this.findQueryValid || !this.hasFindMatches) return; const matchIndex = this.currentMatchIndex >= 0 ? this.currentMatchIndex : 0; const match = this.findMatches[matchIndex]; if (!match) return; const changed = this.applyCommand({ type: 'replaceTextMatch', match, replacement, options: { caseSensitive: this.findCaseSensitive, wholeWord: this.findWholeWord, regex: this.findRegex, preserveCase: this.replacePreserveCase } }); if (!changed) return; const nextMatches = this.findMatches; if (nextMatches.length) this.activateFindMatch(Math.min(matchIndex, nextMatches.length - 1)); else this.currentMatchIndex = -1; void this.runtime.trigger('commit-set-replace', { fallbackTarget: this.opts.ref.current ?? undefined }); }; moveSlashMenuActive(delta: number): void { const count = this.slashMenuItems.length; if (!this.slashMenuOpen || count === 0) return; this.slashMenuActiveIndex = (this.slashMenuActiveIndex + delta + count) % count; } setSlashMenuActiveIndex(index: number): void { const count = this.slashMenuItems.length; if (count === 0) { this.slashMenuActiveIndex = 0; return; } this.slashMenuActiveIndex = Math.max(0, Math.min(index, count - 1)); } closeSlashMenu(): void { const context = this.slashMenuContext; if (!context) return; this.slashDismissedAnchorKey = context.anchorKey; this.slashMenuActiveIndex = 0; } mountSlashMenu(): void { this.slashMenuMounts += 1; } unmountSlashMenu(): void { this.slashMenuMounts = Math.max(0, this.slashMenuMounts - 1); if (this.slashMenuMounts === 0) this.slashMenuActiveIndex = 0; } commitSlashCommand(id?: WordsSlashCommandId, target?: HTMLElement): boolean { const context = this.slashMenuContext; if (!context || !this.slashMenuOpen) return false; const item = (id ? this.slashMenuItems.find((entry) => entry.id === id) : undefined) ?? this.slashMenuItems[this.slashMenuActiveIndex]; if (!item) return false; let command: WordsCommand | undefined; if (item.id === 'image') { // Image insert needs a runtime URL. The MVP uses the browser's // native prompt; consumers wanting a custom dialog can hide // the slash menu's 'image' entry (via `slashCommands` prop // override) and trigger `applyWordsCommand({ type: 'insertImage', // src, alt? })` from their own toolbar/dialog instead. const node = this.opts.ref.current; const win = node?.ownerDocument?.defaultView ?? undefined; const src = win?.prompt?.('Image URL:')?.trim(); if (!src) return false; const altRaw = win?.prompt?.('Alt text (optional):'); const alt = altRaw?.trim() ? altRaw.trim() : undefined; command = { type: 'insertImage', src, alt }; } else { command = slashCommandToWordsCommand(item); } if (!command) return false; const selected = setWordsSelection(this.history.present, { anchor: { path: context.path, offset: context.start }, focus: { path: context.path, offset: context.end } }); const scopedHistory = { ...this.history, present: selected.state }; const next = batchHistoryCommands(scopedHistory, [{ type: 'deleteRange' }, command]); if (next === scopedHistory) return false; this.closeTypingBatch(); this.slashDismissedAnchorKey = null; this.slashMenuActiveIndex = 0; this.publishHistory(next); void tick().then(() => this.restoreDomSelection()); void this.runtime.trigger('commit-set-slash', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); return true; } private handleSlashMenuKeydown( e: SomaKeyboardEvent, key: string, mod: boolean ): boolean { if (!this.slashMenuOpen || mod || e.altKey) return false; if (key === 'arrowdown') { e.preventDefault(); this.moveSlashMenuActive(1); return true; } if (key === 'arrowup') { e.preventDefault(); this.moveSlashMenuActive(-1); return true; } if (key === 'home') { e.preventDefault(); this.setSlashMenuActiveIndex(0); return true; } if (key === 'end') { e.preventDefault(); this.setSlashMenuActiveIndex(this.slashMenuItems.length - 1); return true; } if (key === 'enter' || key === 'tab') { e.preventDefault(); return this.commitSlashCommand(undefined, e.currentTarget as HTMLElement); } if (key === 'escape') { e.preventDefault(); this.closeSlashMenu(); return true; } return false; } private ensureSelection() { if (this.selection) return; const first = getTextEntries(this.document)[0]; if (!first) return; this.updateSelection({ anchor: { path: first.path, offset: 0 }, focus: { path: first.path, offset: 0 } }); } private activateFindMatch(index: number) { const match = this.findMatches[index]; if (!match) return; this.currentMatchIndex = index; this.updateSelection({ anchor: { path: match.path, offset: match.start }, focus: { path: match.path, offset: match.end } }); void tick().then(() => this.restoreDomSelection()); } private historyCommand(command: WordsCommand): WordsHistory { return applyHistoryCommand(this.history, command); } private typingHistoryCommand(command: WordsCommand): WordsHistory { if (!this.typingBatchOpen) return this.historyCommand(command); const result = applyWordsCommand(this.history.present, command); if (!result.changed) return this.history; return { ...this.history, present: result.state, future: [] }; } private closeTypingBatch() { this.typingBatchOpen = false; } private publishHistory(next: WordsHistory) { if (next === this.history) return; const documentChanged = next.present.document !== this.history.present.document; this.history = next; this.publishState(documentChanged); } private publishPresent(next: WordsEditorState, options: { documentChanged: boolean }) { this.history = { ...this.history, present: next }; this.publishState(options.documentChanged); } private publishState(documentChanged: boolean) { if (documentChanged) { this.dirty = true; this.opts.value.current = this.document; this.opts.onValueChange.current?.(this.document); } if (!sameWordsSelection(this.opts.selection.current, this.selection)) { this.opts.selection.current = this.selection; this.opts.onSelectionChange.current?.(this.selection); } } private syncExternalState( externalDocument: WordsDocument, externalSelection: WordsSelection | null ) { const current = this.history.present; if (!sameWordsDocument(externalDocument, current.document)) { const nextState = createWordsState(externalDocument, externalSelection); if (!sameWordsDocument(nextState.document, current.document)) { const limit = this.history.limit; untrack(() => { this.closeTypingBatch(); this.history = createWordsHistory(nextState, { limit }); this.dirty = false; this.writeExternalValue(nextState.document); this.writeExternalSelection(nextState.selection); }); return; } this.syncExternalSelection(nextState.selection); return; } this.syncExternalSelection(externalSelection); } private syncExternalSelection(externalSelection: WordsSelection | null) { const current = this.history.present; if (sameWordsSelection(externalSelection, current.selection)) return; const result = setWordsSelection(current, externalSelection); if (!result.changed) { untrack(() => { this.writeExternalSelection(result.state.selection); }); return; } untrack(() => { this.closeTypingBatch(); this.history = { ...this.history, present: result.state }; this.writeExternalSelection(result.state.selection); }); } private writeExternalValue(document: WordsDocument) { if (this.opts.value.current !== document) this.opts.value.current = document; } private writeExternalSelection(selection: WordsSelection | null) { if (!sameWordsSelection(this.opts.selection.current, selection)) { this.opts.selection.current = selection; } } private resolveSlashMenuContext(): { readonly path: readonly number[]; readonly start: number; readonly end: number; readonly query: string; readonly anchorKey: string; } | null { const selection = this.selection; if (!selection || !isCollapsedSelection(selection)) return null; if (this.currentBlock === 'code' || this.currentBlock === 'table') return null; const text = resolveTextNode(this.document, selection.anchor.path); if (!text) return null; const offset = Math.max(0, Math.min(selection.anchor.offset, text.text.length)); const before = text.text.slice(0, offset); const match = before.match(/(?:^|\s)\/([\p{L}\p{N}_-]{0,32})$/u); if (!match) return null; const query = match[1] ?? ''; const start = offset - query.length - 1; const pathKey = selection.anchor.path.join('.'); return { path: selection.anchor.path, start, end: offset, query, anchorKey: `${pathKey}:${start}` }; } private commandFromName(name: WordsCommandName, level?: 1 | 2 | 3): WordsCommand | undefined { switch (name) { case 'bold': case 'italic': case 'underline': case 'strike': case 'code': return { type: 'toggleMark', mark: name }; case 'paragraph': return { type: 'setBlock', block: 'paragraph' }; case 'heading': if (this.currentBlock === 'heading' && this.currentHeadingLevel === (level ?? 1)) { return { type: 'setBlock', block: 'paragraph' }; } return { type: 'setBlock', block: 'heading', level: level ?? 1 }; case 'quote': if (this.currentBlock === 'quote') return { type: 'setBlock', block: 'paragraph' }; return { type: 'setBlock', block: 'quote' }; case 'code-block': if (this.currentBlock === 'code') return { type: 'setBlock', block: 'paragraph' }; return { type: 'setBlock', block: 'code' }; case 'insert-table': return { type: 'insertTable' }; case 'insert-table-row': return { type: 'insertTableRow' }; case 'insert-table-column': return { type: 'insertTableColumn' }; case 'delete-table-row': return { type: 'deleteTableRow' }; case 'delete-table-column': return { type: 'deleteTableColumn' }; case 'toggle-table-header-row': return { type: 'toggleTableHeaderRow' }; case 'toggle-table-header-column': return { type: 'toggleTableHeaderColumn' }; case 'toggle-table-striped': return { type: 'toggleTableStriped' }; case 'toggle-table-compact': return { type: 'toggleTableCompact' }; case 'table-cell-tone-default': return { type: 'setTableCellTone', tone: 'default' }; case 'table-cell-tone-muted': return { type: 'setTableCellTone', tone: 'muted' }; case 'table-cell-tone-accent': return { type: 'setTableCellTone', tone: 'accent' }; case 'table-cell-vertical-top': return { type: 'setTableCellVerticalAlign', verticalAlign: 'top' }; case 'table-cell-vertical-middle': return { type: 'setTableCellVerticalAlign', verticalAlign: 'middle' }; case 'table-cell-vertical-bottom': return { type: 'setTableCellVerticalAlign', verticalAlign: 'bottom' }; case 'align-left': return { type: 'setTextAlign', align: 'left' }; case 'align-center': return { type: 'setTextAlign', align: 'center' }; case 'align-right': return { type: 'setTextAlign', align: 'right' }; case 'align-justify': return { type: 'setTextAlign', align: 'justify' }; case 'increase-indent': return { type: 'increaseIndent' }; case 'decrease-indent': return { type: 'decreaseIndent' }; case 'unordered-list': return { type: 'toggleList', kind: 'unordered' }; case 'ordered-list': return { type: 'toggleList', kind: 'ordered' }; case 'check-list': return { type: 'toggleList', kind: 'check' }; case 'unlink': return { type: 'unlink' }; case 'clear-formatting': return { type: 'clearFormatting' }; case 'clear-document': return { type: 'replaceDocument', document: createEmptyWordsDocument() }; default: return undefined; } } private signalInvalidInput(reason: string, target: HTMLElement) { this.soma.logger.warn('soma.words', `Ignored unsupported editor input: ${reason}`, { context: { reason } }); void this.runtime.trigger('signal-warn-invalid', { fallbackTarget: target }); } } export class WordsContentProvider { readonly opts: WordsContentOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsContentOpts) { return new WordsContentProvider(opts); } private constructor(opts: WordsContentOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('content', { id: opts.id, ref: opts.ref, owner: this, onRefChange: (el) => { this.provider.setContentRef(el); }, syncAttrs: true }); } readonly snippetProps = $derived.by(() => ({ document: this.provider.document, html: this.provider.html, selection: this.provider.selection })); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'textbox' as const, contenteditable: this.provider.isEditable ? 'true' : 'false', tabindex: this.provider.isDisabled ? undefined : 0, spellcheck: true, 'aria-multiline': 'true' as const, 'aria-label': this.provider.resolvedContentLabel.current, 'aria-labelledby': this.provider.field?.labelId.current || undefined, 'aria-describedby': [this.provider.field?.helperId.current, this.provider.field?.errorId.current] .filter(Boolean) .join(' ') || undefined, 'aria-readonly': this.provider.isReadonly || undefined, 'aria-disabled': this.provider.isDisabled || undefined, 'aria-invalid': this.provider.isInvalid || undefined, onfocus: this.provider.onfocus, onblur: this.provider.onblur, onkeydown: this.provider.onkeydown, onkeyup: this.provider.onkeyup, onpointerdown: this.provider.onpointerdown, onpointerup: this.provider.onpointerup, onclick: this.provider.onclick, onselect: this.provider.onselect, onbeforeinput: this.provider.onbeforeinput, onpaste: this.provider.onpaste, ondragover: this.provider.ondragover, ondrop: this.provider.ondrop, oncompositionstart: this.provider.oncompositionstart, oncompositionupdate: this.provider.oncompositionupdate, oncompositionend: this.provider.oncompositionend } as const) ); } export class WordsPlaceholderProvider { readonly opts: WordsPlaceholderOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsPlaceholderOpts) { return new WordsPlaceholderProvider(opts); } private constructor(opts: WordsPlaceholderOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('placeholder', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); } readonly isPresent = $derived.by(() => this.provider.isEmpty); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, 'aria-hidden': 'true' as const, 'data-hidden': boolToEmptyStrOrUndef(!this.isPresent), hidden: !this.isPresent || undefined } as const) ); } export class WordsToolbarProvider { readonly opts: WordsToolbarOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsToolbarOpts) { return new WordsToolbarProvider(opts); } private constructor(opts: WordsToolbarOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('toolbar', { id: opts.id, ref: opts.ref, owner: this, props: { orientation: () => this.opts.orientation.current, disabled: () => this.provider.isDisabled }, syncAttrs: true }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'toolbar' as const, 'aria-orientation': this.opts.orientation.current } as const) ); } export class WordsToolbarGroupProvider { readonly opts: WordsToolbarGroupOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsToolbarGroupOpts) { return new WordsToolbarGroupProvider(opts); } private constructor(opts: WordsToolbarGroupOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('toolbar-group', { id: opts.id, ref: opts.ref, owner: this, props: { orientation: () => this.opts.orientation.current }, syncAttrs: true }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'group' as const } as const) ); } export class WordsBubbleMenuProvider { readonly opts: WordsBubbleMenuOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; rect = $state(null); static create(opts: WordsBubbleMenuOpts) { return new WordsBubbleMenuProvider(opts); } private constructor(opts: WordsBubbleMenuOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('bubble-menu', { id: opts.id, ref: opts.ref, owner: this, props: { open: () => this.open, positioned: () => this.positioned, selectionKind: () => this.provider.selectionKind, side: () => this.opts.side.current, disabled: () => this.provider.isDisabled }, syncAttrs: true }); } readonly open = $derived.by( () => this.provider.selectionKind === 'range' && this.provider.isEditable && !this.provider.isSelecting ); readonly positioned = $derived.by(() => Boolean(this.rect)); readonly style = $derived.by(() => { const rect = this.rect; if (!rect) return undefined; return [ `--_words-bubble-x: ${rect.left + rect.width / 2}px`, `--_words-bubble-y: ${this.opts.side.current === 'bottom' ? rect.bottom : rect.top}px` ].join('; '); }); readonly snippetProps = $derived.by(() => ({ open: this.open, rect: this.rect, updatePosition: () => this.updatePosition() })); readonly onpointerdown = (e: PointerEvent) => { if (!this.open) return; e.preventDefault(); this.provider.restoreDomSelection(); }; readonly onpointerdowncapture = (e: PointerEvent) => { if (!this.open) return; e.preventDefault(); this.provider.restoreDomSelection(); }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'toolbar' as const, 'aria-orientation': 'horizontal' as const, 'aria-label': this.provider.soma.langs.ts('#?components.words.bubble-menu|Selection toolbar'), 'data-open': boolToEmptyStrOrUndef(this.open), 'data-positioned': boolToEmptyStrOrUndef(this.positioned), 'data-side': this.opts.side.current, style: this.style, hidden: !this.open || !this.positioned || undefined, onpointerdowncapture: this.onpointerdowncapture, onpointerdown: this.onpointerdown } as const) ); updatePosition(): boolean { if (!this.open) { this.rect = null; return false; } const rect = this.provider.getSelectionClientRect(); if (rect) this.rect = rect; return Boolean(this.rect); } } export class WordsSlashMenuProvider { readonly opts: WordsSlashMenuOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; rect = $state(null); static create(opts: WordsSlashMenuOpts) { return new WordsSlashMenuProvider(opts); } private constructor(opts: WordsSlashMenuOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.provider.mountSlashMenu(); this.runtimePart = this.provider.runtime.part('slash-menu', { id: opts.id, ref: opts.ref, owner: this, props: { open: () => this.open, query: () => this.provider.slashMenuQuery, activeIndex: () => this.provider.slashMenuActiveIndex, disabled: () => this.provider.isDisabled, readonly: () => this.provider.isReadonly }, syncAttrs: true }); } readonly open = $derived.by(() => this.provider.slashMenuOpen); readonly positioned = $derived.by(() => Boolean(this.rect)); readonly style = $derived.by(() => { const rect = this.rect; if (!rect) return undefined; return [ `--_words-slash-x: ${rect.left}px`, `--_words-slash-y: ${rect.bottom}px`, `--_words-slash-anchor-width: ${Math.max(rect.width, 1)}px` ].join('; '); }); readonly snippetProps = $derived.by(() => ({ open: this.open, query: this.provider.slashMenuQuery, rect: this.rect, activeIndex: this.provider.slashMenuActiveIndex, items: this.provider.slashMenuItems, disabled: this.provider.isDisabled, readonly: this.provider.isReadonly, updatePosition: () => this.updatePosition(), setActiveIndex: (index: number) => this.provider.setSlashMenuActiveIndex(index), moveActive: (delta: number) => this.provider.moveSlashMenuActive(delta), commit: (id?: WordsSlashCommandId) => this.provider.commitSlashCommand(id, this.opts.ref.current ?? undefined), close: () => this.provider.closeSlashMenu() })); readonly onpointerdown = (e: PointerEvent) => { if (!this.open) return; e.preventDefault(); this.provider.restoreDomSelection(); }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'listbox' as const, 'aria-label': this.provider.soma.langs.ts('#?components.words.slash-menu|Insert block'), 'data-open': boolToEmptyStrOrUndef(this.open), 'data-query': this.provider.slashMenuQuery, 'data-active-index': String(this.provider.slashMenuActiveIndex), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), 'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly), style: this.style, hidden: !this.open || !this.positioned || undefined, onpointerdown: this.onpointerdown } as const) ); updatePosition(): boolean { if (!this.open) { this.rect = null; return false; } const rect = this.provider.getCaretClientRect(); if (rect) this.rect = rect; return Boolean(this.rect); } dispose(): void { this.provider.unmountSlashMenu(); } } export class WordsCommandButtonProvider { readonly opts: WordsCommandButtonOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsCommandButtonOpts) { return new WordsCommandButtonProvider(opts); } private constructor(opts: WordsCommandButtonOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('command-button', { id: opts.id, ref: opts.ref, owner: this, states: { on: () => this.isActive }, props: { command: () => this.opts.command.current, active: () => this.isActive, disabled: () => this.isDisabled, toggle: () => isToggleCommand(this.opts.command.current) }, syncAttrs: true }); } readonly isActive = $derived.by(() => this.resolveActive()); readonly isDisabled = $derived.by(() => this.resolveDisabled()); readonly onclick = (e: SomaMouseEvent) => { if (this.isDisabled) return; if (this.provider.selectionKind === 'range') this.provider.restoreDomSelection(); else this.provider.syncSelectionFromDom(); this.provider.runCommandName(this.opts.command.current, { target: e.currentTarget as HTMLElement, level: this.opts.level.current }); void tick().then(() => { this.provider.focus(); this.provider.restoreDomSelection(); }); }; readonly onpointerdown = (e: PointerEvent) => { if (this.isDisabled) return; e.preventDefault(); if (this.provider.selectionKind === 'range') this.provider.restoreDomSelection(); else this.provider.syncSelectionFromDom(); }; readonly props = $derived.by(() => { const props = this.runtimePart.assert({ ...this.runtimePart.props, type: 'button' as const, disabled: this.isDisabled || undefined, 'aria-pressed': isToggleCommand(this.opts.command.current) ? this.isActive : undefined, 'data-command': this.opts.command.current, 'data-active': boolToEmptyStrOrUndef(this.isActive), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled) } as const); return { ...props, onpointerdown: this.onpointerdown, onclick: this.onclick }; }); private resolveActive(): boolean { const command = this.opts.command.current; const provider = this.provider; switch (command) { case 'bold': case 'italic': case 'underline': case 'strike': case 'code': return provider.activeMarks.includes(command as WordsMark); case 'paragraph': return provider.currentBlock === 'paragraph'; case 'heading': return ( provider.currentBlock === 'heading' && provider.currentHeadingLevel === (this.opts.level.current ?? 1) ); case 'quote': return provider.currentBlock === 'quote'; case 'code-block': return provider.currentBlock === 'code'; case 'align-left': return isTextAlignableBlock(provider.currentBlock) && provider.currentTextAlign === 'left'; case 'align-center': return ( isTextAlignableBlock(provider.currentBlock) && provider.currentTextAlign === 'center' ); case 'align-right': return isTextAlignableBlock(provider.currentBlock) && provider.currentTextAlign === 'right'; case 'align-justify': return ( isTextAlignableBlock(provider.currentBlock) && provider.currentTextAlign === 'justify' ); case 'unordered-list': return provider.currentBlock === 'list' && provider.currentListKind === 'unordered'; case 'ordered-list': return provider.currentBlock === 'list' && provider.currentListKind === 'ordered'; case 'check-list': return provider.currentBlock === 'list' && provider.currentListKind === 'check'; case 'toggle-table-header-row': return provider.currentTableHeaderRowActive; case 'toggle-table-header-column': return provider.currentTableHeaderColumnActive; case 'toggle-table-striped': return provider.currentTableStriped; case 'toggle-table-compact': return provider.currentTableCompact; case 'table-cell-tone-default': return provider.currentBlock === 'table' && provider.currentTableCellTone === 'default'; case 'table-cell-tone-muted': return provider.currentBlock === 'table' && provider.currentTableCellTone === 'muted'; case 'table-cell-tone-accent': return provider.currentBlock === 'table' && provider.currentTableCellTone === 'accent'; case 'table-cell-vertical-top': return ( provider.currentBlock === 'table' && provider.currentTableCellVerticalAlign === 'top' ); case 'table-cell-vertical-middle': return ( provider.currentBlock === 'table' && provider.currentTableCellVerticalAlign === 'middle' ); case 'table-cell-vertical-bottom': return ( provider.currentBlock === 'table' && provider.currentTableCellVerticalAlign === 'bottom' ); case 'unlink': return Boolean(provider.selectedLink); default: return false; } } private resolveDisabled(): boolean { const command = this.opts.command.current; if (this.provider.isDisabled) return true; if (command === 'undo') return this.provider.isReadonly || !this.provider.canUndo; if (command === 'redo') return this.provider.isReadonly || !this.provider.canRedo; if (command === 'unlink') return this.provider.isReadonly || !this.provider.canUnlink; if (command === 'commit') return false; if ( command === 'insert-table-row' || command === 'insert-table-column' || command === 'delete-table-row' || command === 'delete-table-column' || command === 'toggle-table-header-row' || command === 'toggle-table-header-column' || command === 'toggle-table-striped' || command === 'toggle-table-compact' || command === 'table-cell-tone-default' || command === 'table-cell-tone-muted' || command === 'table-cell-tone-accent' || command === 'table-cell-vertical-top' || command === 'table-cell-vertical-middle' || command === 'table-cell-vertical-bottom' ) { return this.provider.isReadonly || this.provider.currentBlock !== 'table'; } if (command === 'increase-indent' || command === 'decrease-indent') { return this.provider.isReadonly || this.provider.currentBlock !== 'list'; } if ( command === 'align-left' || command === 'align-center' || command === 'align-right' || command === 'align-justify' ) { return this.provider.isReadonly || !isTextAlignableBlock(this.provider.currentBlock); } return this.provider.isReadonly; } } export class WordsLinkEditorProvider { readonly opts: WordsLinkEditorOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; error = $state(null); static create(opts: WordsLinkEditorOpts) { return new WordsLinkEditorProvider(opts); } private constructor(opts: WordsLinkEditorOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('link-editor', { id: opts.id, ref: opts.ref, owner: this, props: { activeLink: () => Boolean(this.selectedLink), disabled: () => this.provider.isDisabled || this.provider.isReadonly, invalid: () => Boolean(this.error) }, syncAttrs: true }); } readonly selectedLink = $derived.by(() => this.provider.selectedLink); readonly canApply = $derived.by(() => !this.provider.isDisabled && !this.provider.isReadonly); readonly canUnlink = $derived.by(() => this.provider.canUnlink); readonly snippetProps = $derived.by(() => ({ selectedLink: this.selectedLink, href: this.opts.href.current, title: this.opts.title.current ?? '', error: this.error, canApply: this.canApply, canUnlink: this.canUnlink, disabled: this.provider.isDisabled, readonly: this.provider.isReadonly, captureSelection: () => this.captureSelection(), validate: (href?: string) => this.validate(href), apply: (href?: string, title?: string, target?: HTMLElement) => this.apply(href, title, target), unlink: (target?: HTMLElement) => this.unlink(target), clearError: () => this.clearError() })); readonly onsubmit = (e: SubmitEvent & { currentTarget: HTMLFormElement }) => { e.preventDefault(); const data = new FormData(e.currentTarget); const href = data.get('href'); const title = data.get('title'); this.apply( typeof href === 'string' ? href : undefined, typeof title === 'string' ? title : undefined, e.currentTarget ); }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, 'aria-label': this.provider.soma.langs.ts('#?components.words.link-editor|Edit link'), 'data-active-link': boolToEmptyStrOrUndef(Boolean(this.selectedLink)), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly), 'data-invalid': boolToEmptyStrOrUndef(Boolean(this.error)), onsubmit: this.onsubmit } as const) ); captureSelection(): boolean { return this.provider.syncSelectionFromDom(); } validate(href = this.opts.href.current): boolean { const trimmedHref = href.trim(); if (!trimmedHref) { this.error = 'Enter a link URL.'; return false; } if (!sanitizeWordsUrl(trimmedHref)) { this.error = 'Enter a valid http(s), mailto, tel or relative URL.'; return false; } this.error = null; return true; } apply(href = this.opts.href.current, title = this.opts.title.current, target?: HTMLElement) { const trimmedHref = href.trim(); const trimmedTitle = title?.trim() || undefined; if (!this.validate(trimmedHref)) return false; const changed = this.provider.insertLink(trimmedHref, trimmedTitle, target); this.error = changed ? null : 'Enter a valid http(s), mailto, tel or relative URL.'; return changed; } unlink(target = this.opts.ref.current ?? undefined): boolean { const changed = this.provider.unlink(target); if (changed) this.error = null; return changed; } clearError() { this.error = null; } } export class WordsFindReplaceProvider { readonly opts: WordsFindReplaceOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsFindReplaceOpts) { return new WordsFindReplaceProvider(opts); } private constructor(opts: WordsFindReplaceOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('find-replace', { id: opts.id, ref: opts.ref, owner: this, props: { disabled: () => this.provider.isDisabled, readonly: () => this.provider.isReadonly, hasMatches: () => this.provider.hasFindMatches }, syncAttrs: true }); } readonly snippetProps = $derived.by(() => ({ findQuery: this.provider.findQuery, findCaseSensitive: this.provider.findCaseSensitive, findWholeWord: this.provider.findWholeWord, findRegex: this.provider.findRegex, findInSelection: this.provider.findInSelection, canFindInSelection: this.provider.canFindInSelection, replacePreserveCase: this.provider.replacePreserveCase, findQueryValid: this.provider.findQueryValid, findMatches: this.provider.findMatches, currentMatchIndex: this.provider.currentMatchIndex, hasFindMatches: this.provider.hasFindMatches, disabled: this.provider.isDisabled, readonly: this.provider.isReadonly, setFindQuery: (query: string) => this.provider.setFindQuery(query), setFindCaseSensitive: (caseSensitive: boolean) => this.provider.setFindCaseSensitive(caseSensitive), setFindWholeWord: (wholeWord: boolean) => this.provider.setFindWholeWord(wholeWord), setFindRegex: (regex: boolean) => this.provider.setFindRegex(regex), setFindInSelection: (selectionOnly: boolean) => this.provider.setFindInSelection(selectionOnly), setReplacePreserveCase: (preserveCase: boolean) => this.provider.setReplacePreserveCase(preserveCase), findNext: () => this.provider.findNext(), findPrev: () => this.provider.findPrev(), replaceCurrentText: (replacement: string) => this.provider.replaceCurrentText(replacement), replaceAllText: (replacement: string) => this.provider.replaceAllText(replacement) })); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'search' as const, 'aria-label': this.provider.soma.langs.ts('#?components.words.find-replace|Find and replace'), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), 'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly), 'data-invalid': boolToEmptyStrOrUndef(!this.provider.findQueryValid), 'data-has-matches': boolToEmptyStrOrUndef(this.provider.hasFindMatches) } as const) ); } export class WordsHiddenInputProvider { readonly opts: WordsHiddenInputOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsHiddenInputOpts) { return new WordsHiddenInputProvider(opts); } private constructor(opts: WordsHiddenInputOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('hidden-input', { id: opts.id, ref: opts.ref, owner: this }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, type: 'hidden' as const, name: this.provider.opts.name.current, value: serializeWordsJson(this.provider.document), disabled: this.provider.isDisabled || undefined, 'aria-hidden': 'true' as const } as const) ); } export class WordsStatusProvider { readonly opts: WordsStatusOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsStatusOpts) { return new WordsStatusProvider(opts); } private constructor(opts: WordsStatusOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('status', { id: opts.id, ref: opts.ref, owner: this, props: { status: () => this.provider.status }, syncAttrs: true }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'status' as const, 'aria-live': 'polite' as const, 'data-status': this.provider.status } as const) ); } /** * Drawer — companion right-rail provider. * * Owns the open/collapsed state of the drawer and the derived `mode` * (the contextual panel that should be visible: default | format | * image | table | code | list). Eidos consumes both via the snippet * to render the shell + the per-mode body. * * Mode derivation lives here (not in eidos) so any other consumer * that wants to react to the contextual context (telemetry, future * mini-drawer overlays, etc.) reads the same source of truth. */ export type WordsDrawerMode = | 'default' | 'format' | 'block' | 'cell' | 'row' | 'table' | 'list-item' | 'list' | 'code' | 'image'; export class WordsDrawerProvider { readonly opts: WordsDrawerOpts; readonly provider: WordsProvider; readonly runtimePart: SomaRuntimePart; static create(opts: WordsDrawerOpts) { return new WordsDrawerProvider(opts); } private constructor(opts: WordsDrawerOpts) { this.opts = opts; this.provider = WordsProvider.require(); this.runtimePart = this.provider.runtime.part('drawer', { id: opts.id, ref: opts.ref, owner: this, props: { mode: () => this.mode, open: () => this.opts.open.current, disabled: () => this.opts.disabled.current || this.provider.isDisabled }, syncAttrs: true }); } /** * Hierarchical panel stack (most specific first). Built per * caret position so the drawer can render all relevant scopes * stacked — e.g. inside a table cell with text selected: * [format, cell, row, table] * The user can see + act on every layer in one surface instead * of guessing which popover/menu owns the action they need. */ readonly modes: readonly WordsDrawerMode[] = $derived.by(() => { const sel = this.provider.selection; const block = this.provider.currentBlock; // Atomic blocks (no inline format, no parent scopes) if (block === 'image') return ['image']; if (block === 'code') return ['code']; if (!sel) return ['default']; const stack: WordsDrawerMode[] = []; // Inline-text format is the most specific scope. Only meaningful // when there's a non-collapsed selection AND the current block // can host inline marks (excludes 'code' which is plain text). const isRange = sel.anchor.path[0] !== sel.focus.path[0] || sel.anchor.path[1] !== sel.focus.path[1] || sel.anchor.path[2] !== sel.focus.path[2] || sel.anchor.offset !== sel.focus.offset; if ( isRange && (block === 'paragraph' || block === 'heading' || block === 'quote' || block === 'list' || block === 'table') ) { stack.push('format'); } // Block-scope stack — from innermost container outward. if (block === 'table') { stack.push('cell', 'row', 'table'); } else if (block === 'list') { stack.push('list-item', 'list'); } else if (block === 'paragraph' || block === 'heading' || block === 'quote') { stack.push('block'); } return stack.length > 0 ? stack : ['default']; }); /** Top of the stack — convenience for consumers that just want the * most specific active scope. */ readonly mode: WordsDrawerMode = $derived.by(() => this.modes[0] ?? 'default'); /** * Toggle the drawer open/closed and emit the `commit-toggle-drawer` * event so sema / telemetry can react. Returns the new open state. */ toggle(target?: HTMLElement): boolean { const next = !this.opts.open.current; this.opts.open.current = next; try { void this.provider.runtime.trigger('commit-toggle-drawer', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); } catch { /* noop — runtime may not be wired (tests / SSR) */ } return next; } setOpen(open: boolean, target?: HTMLElement): void { if (this.opts.open.current === open) return; this.opts.open.current = open; try { void this.provider.runtime.trigger('commit-toggle-drawer', { fallbackTarget: target ?? this.opts.ref.current ?? undefined }); } catch { /* noop */ } } readonly snippetProps = $derived.by(() => ({ modes: this.modes, mode: this.mode, open: this.opts.open.current, disabled: this.opts.disabled.current || this.provider.isDisabled, // Forward the parent snippet so eidos panels can call runCommand, // read activeMarks, etc. without re-piping every action. snippet: this.provider.snippetProps, toggle: (target?: HTMLElement) => this.toggle(target), setOpen: (open: boolean, target?: HTMLElement) => this.setOpen(open, target) })); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'complementary' as const, 'data-words-drawer-mode': this.mode, 'data-open': this.opts.open.current ? '' : undefined, 'data-disabled': this.opts.disabled.current || this.provider.isDisabled ? '' : undefined } as const) ); } function firstUsableClientRect(range: Range): DOMRect | null { const rects = range.getClientRects(); for (const rect of rects) { if (rect.width || rect.height) return rect; } const rect = range.getBoundingClientRect(); return rect.width || rect.height ? rect : null; } function wordsRectFromDomRect(rect: DOMRect | DOMRectReadOnly): WordsSelectionRect { return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, width: rect.width, height: rect.height }; } function sameWordsSelection(a: WordsSelection | null, b: WordsSelection | null): boolean { if (a === b) return true; if (!a || !b) return false; return ( samePathParts(a.anchor.path, b.anchor.path) && a.anchor.offset === b.anchor.offset && samePathParts(a.focus.path, b.focus.path) && a.focus.offset === b.focus.offset ); } function sameWordsDocument(a: WordsDocument, b: WordsDocument): boolean { if (a.version !== b.version) return false; if (a.children.length !== b.children.length) return false; return serializeWordsJson(a) === serializeWordsJson(b); } function domSelectionTouchesRoot(selection: Selection | null, root: HTMLElement): boolean { const anchor = selection?.anchorNode; const focus = selection?.focusNode; return Boolean((anchor && root.contains(anchor)) || (focus && root.contains(focus))); } function samePathParts(a: readonly number[], b: readonly number[]): boolean { return a.length === b.length && a.every((part, index) => part === b[index]); } function matchIsInsideSelection(match: WordsTextMatch, selection: WordsSelection): boolean { const range = normalizeRange(selection); if (range.collapsed) return false; return ( compareWordsPoint({ path: match.path, offset: match.start }, range.start) >= 0 && compareWordsPoint({ path: match.path, offset: match.end }, range.end) <= 0 ); } function compareWordsPoint(a: WordsPoint, b: WordsPoint): -1 | 0 | 1 { const pathOrder = comparePath(a.path, b.path); if (pathOrder !== 0) return pathOrder; if (a.offset < b.offset) return -1; if (a.offset > b.offset) return 1; return 0; } function isToggleCommand(command: WordsCommandName): boolean { return ( command === 'bold' || command === 'italic' || command === 'underline' || command === 'strike' || command === 'code' || command === 'paragraph' || command === 'heading' || command === 'quote' || command === 'code-block' || command === 'unordered-list' || command === 'ordered-list' || command === 'check-list' || command === 'toggle-table-header-row' || command === 'toggle-table-header-column' || command === 'toggle-table-striped' || command === 'toggle-table-compact' ); } function isTextAlignableBlock(block: string): boolean { return block === 'paragraph' || block === 'heading' || block === 'quote' || block === 'table'; } function normalizedSinglePastedUrl(text: string): string | undefined { const normalized = text .replace(/\r\n?/g, '\n') .replace(/\u0000/g, '') .trim(); if (!normalized || /\s/.test(normalized)) return undefined; return sanitizeWordsUrl(normalized); } const DEFAULT_SLASH_COMMANDS = [ { id: 'paragraph', label: 'Paragraph', description: 'Plain text block', command: 'paragraph', keywords: ['text', 'body', 'normal'] }, { id: 'heading-1', label: 'Heading 1', description: 'Large section heading', command: 'heading', level: 1, keywords: ['h1', 'title'] }, { id: 'heading-2', label: 'Heading 2', description: 'Medium section heading', command: 'heading', level: 2, keywords: ['h2', 'subtitle'] }, { id: 'heading-3', label: 'Heading 3', description: 'Small section heading', command: 'heading', level: 3, keywords: ['h3'] }, { id: 'quote', label: 'Quote', description: 'Blockquote callout', command: 'quote', keywords: ['blockquote', 'cite'] }, { id: 'code-block', label: 'Code block', description: 'Preformatted code', command: 'code-block', keywords: ['code', 'pre', 'snippet'] }, { id: 'table', label: 'Table', description: '2 by 2 editable grid', command: 'insert-table', keywords: ['grid', 'cells', 'rows', 'columns'] }, { id: 'unordered-list', label: 'Bulleted list', description: 'List with bullets', command: 'unordered-list', listKind: 'unordered', keywords: ['ul', 'bullet', 'list'] }, { id: 'ordered-list', label: 'Numbered list', description: 'List with numbers', command: 'ordered-list', listKind: 'ordered', keywords: ['ol', 'number', 'list'] }, { id: 'check-list', label: 'Checklist', description: 'Track tasks inline', command: 'check-list', listKind: 'check', keywords: ['todo', 'task', 'check'] }, { id: 'image', label: 'Image', description: 'Insert an image from a URL', command: 'insert-image', keywords: ['img', 'picture', 'photo', 'figure'] } ] as const satisfies readonly WordsSlashCommandItem[]; function filterSlashCommandItems(query: string): readonly WordsSlashCommandItem[] { const normalized = query.trim().toLowerCase(); if (!normalized) return DEFAULT_SLASH_COMMANDS; return DEFAULT_SLASH_COMMANDS.filter((item) => { const haystack = [item.label, item.description, item.command, ...(item.keywords ?? [])] .join(' ') .toLowerCase(); return haystack.includes(normalized); }); } function slashCommandToWordsCommand(item: WordsSlashCommandItem): WordsCommand | undefined { switch (item.id) { case 'paragraph': return { type: 'setBlock', block: 'paragraph' }; case 'heading-1': case 'heading-2': case 'heading-3': return { type: 'setBlock', block: 'heading', level: item.level ?? 1 }; case 'quote': return { type: 'setBlock', block: 'quote' }; case 'code-block': return { type: 'setBlock', block: 'code' }; case 'table': return { type: 'insertTable' }; case 'unordered-list': return { type: 'toggleList', kind: 'unordered' }; case 'ordered-list': return { type: 'toggleList', kind: 'ordered' }; case 'check-list': return { type: 'toggleList', kind: 'check' }; case 'image': // Image needs a runtime prompt for the URL — handled imperatively // in commitSlashCommand. Returning undefined here lets that path // short-circuit if the prompt resolves to an empty URL. return undefined; } } // ── Image paste/drop helpers (F3.8) ───────────────────────────────────── function collectImageFiles(files: FileList | null | undefined): File[] { if (!files || files.length === 0) return []; const out: File[] = []; for (let i = 0; i < files.length; i++) { const f = files.item(i); if (f && f.type.startsWith('image/')) out.push(f); } return out; } function dataTransferHasImageFiles(dt: DataTransfer | null | undefined): boolean { if (!dt) return false; // `types` is the most reliable signal during dragover (files aren't // readable yet on dragover in most browsers — only on drop). The // 'Files' entry in `types` means at least one file is in the drag. if (Array.from(dt.types).includes('Files')) { // items[] is readable on dragover in Chromium and exposes kind+type // without violating the drop-target restriction. Use it when // present to filter to image-typed files specifically. if (dt.items && dt.items.length > 0) { for (let i = 0; i < dt.items.length; i++) { const item = dt.items[i]; if (item.kind === 'file' && item.type.startsWith('image/')) return true; } return false; } return true; } return false; } /** * Map all image blocks whose `src` matches `previousSrc` through * `update`, returning a new document. Returns the same reference if * no block matched (so callers can early-exit on no-op). */ function mapImageBlocksBySrc( document: WordsDocument, previousSrc: string, update: (block: WordsImageBlock) => WordsImageBlock ): WordsDocument { let changed = false; const children = document.children.map((block) => { if (block.type !== 'image' || block.src !== previousSrc) return block; const next = update(block); if (next !== block) changed = true; return next; }); if (!changed) return document; return { ...document, children }; }