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.
2911 lines
92 KiB
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 };
|
|
}
|