You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/words/words-provider.svelte.ts

2911 lines
92 KiB

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<WordsDocument> | undefined;
onSelectionChange: OnChangeFn<WordsSelection | null> | 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<WordsProvider>('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<HTMLElement | null>(null);
history = $state<WordsHistory>(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<WordsSelection | null>(null);
replacePreserveCase = $state(false);
currentMatchIndex = $state(-1);
slashMenuMounts = $state(0);
slashMenuActiveIndex = $state(0);
slashDismissedAnchorKey = $state<string | null>(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<number | undefined>(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<WordsStatus>(() => (this.isInvalid ? 'error' : 'idle'));
readonly selectedLink = $derived.by(() => getLinkAtSelection(this.history.present));
readonly selectionKind = $derived.by<WordsSelectionKind>(() => {
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<WordsTableCell | undefined>(() => {
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<readonly WordsTableCell[]>(() => {
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<readonly WordsTableCell[]>(() => {
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<WordsTableCellTone>(
() => this.currentTableCell?.tone ?? 'default'
);
readonly currentTableCellVerticalAlign = $derived.by<WordsTableCellVerticalAlign>(
() => 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<string | undefined> = 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<HTMLElement>) => {
// 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<HTMLElement>) => {
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<HTMLElement>) => {
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<void> {
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<HTMLElement>,
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<WordsSelectionRect | null>(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<WordsSelectionRect | null>(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<HTMLButtonElement>) => {
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<string | null>(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 };
}

Powered by TurnKey Linux.