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

1636 lines
49 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,
WordsSelectionRect,
WordsSelectionKind,
WordsStatus,
WordsToolbarOrientation
} from './types';
import {
actionFromBeforeInput,
actionFromPaste,
applyHistoryCommand,
applyWordsCommand,
beginWordsComposition,
createEmptyWordsDocument,
createWordsCompositionState,
createWordsHistory,
createWordsState,
encodeWordsDomSelectionTarget,
endWordsComposition,
findTextInDocument,
getTextEntries,
getLinkAtSelection,
isCollapsedSelection,
redoWords,
renderWordsDomHtml,
renderWordsPlainText,
restoreSelectionApiFromDomTarget,
sanitizeWordsUrl,
selectionApiToWordsSelection,
serializeWordsJson,
setWordsSelection,
undoWords,
updateWordsComposition,
type WordsCommand,
type WordsDocument,
type WordsExportFormat,
type WordsEditorState,
type WordsHistory,
type WordsImportFormat,
type WordsMark,
type WordsSelection
} 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;
}> {}
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 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 {}
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('');
currentMatchIndex = $state(-1);
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-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?.();
};
});
}
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
})
);
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(() => {
const index = this.selection?.anchor.path[0] ?? 0;
return this.document.children[index]?.type ?? 'paragraph';
});
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 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 currentTextAlign = $derived.by(() => {
const index = this.selection?.anchor.path[0] ?? 0;
const block = this.document.children[index];
return block && block.type !== 'list' ? (block.textAlign ?? 'left') : 'left';
});
readonly findMatches = $derived.by(() =>
this.findQuery ? findTextInDocument(this.document, this.findQuery) : []
);
readonly hasFindMatches = $derived.by(() => this.findMatches.length > 0);
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;
}
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
};
}
commit(reason: WordsCommitReason = 'programmatic', target?: HTMLElement) {
this.closeTypingBatch();
this.pendingCommitReason = reason;
void this.runtime.trigger('commit-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-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-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 || opts.replacement === undefined) return;
const command: WordsCommand = {
type: 'replaceAllText',
query: this.findQuery,
replacement: opts.replacement
};
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-clear'
: name === 'unlink'
? 'commit-unlink'
: 'commit-format';
void this.runtime.trigger(event, {
fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined
});
}
applyCommand(command: WordsCommand): boolean {
return this.applyCommandWithOptions(command);
}
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-link', {
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-unlink', {
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>) => {
this.focused = true;
this.ensureSelection();
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;
if (!related || !this.isInsideWordsTool(related)) {
this.syncSelectionFromDom({ source: 'blur' });
}
this.closeTypingBatch();
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 (!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 === '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 === '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;
}
if (!this.isEditable) return;
const target = e.target;
const targetElement =
target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!targetElement) 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;
}
this.applyCommandWithOptions(action.command, { batch: action.batch });
};
readonly onpaste = (e: ClipboardEvent & { currentTarget: HTMLElement }) => {
if (this.isDisabled || this.isReadonly) {
e.preventDefault();
return;
}
const action = actionFromPaste({
plainText: e.clipboardData?.getData('text/plain') ?? '',
html: e.clipboardData?.getData('text/html') ?? ''
});
e.preventDefault();
this.syncSelectionFromDom({ source: 'input' });
if (action.type === 'ignore') {
this.signalInvalidInput(action.reason, e.currentTarget);
return;
}
this.applyCommand(action.command);
};
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,
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,
currentTextAlign: this.currentTextAlign,
canUndo: this.canUndo,
canRedo: this.canRedo,
canUnlink: this.canUnlink,
findQuery: this.findQuery,
findMatches: this.findMatches,
currentMatchIndex: this.currentMatchIndex,
hasFindMatches: this.hasFindMatches,
focus: () => this.focus(),
selectAll: () => this.selectAll(),
selectBlock: () => this.selectBlock(),
runCommand: (command: WordsCommandName) => this.runCommandName(command),
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),
findNext: () => this.findNext(),
findPrev: () => this.findPrev(),
replaceAllText: (replacement: string) => this.replaceAllText(replacement)
}));
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-link-editor]'
);
}
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 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 });
};
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 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 '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-invalid-input', { 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,
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.bubbleMenu|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 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
});
};
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(() =>
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),
onpointerdown: this.onpointerdown,
onclick: this.onclick
} as const)
);
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 'align-left':
return provider.currentTextAlign === 'left';
case 'align-center':
return provider.currentTextAlign === 'center';
case 'align-right':
return provider.currentTextAlign === 'right';
case 'align-justify':
return 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 '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 === 'increase-indent' || command === 'decrease-indent') {
return this.provider.isReadonly || this.provider.currentBlock !== 'list';
}
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.linkEditor|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,
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),
findNext: () => this.provider.findNext(),
findPrev: () => this.provider.findPrev(),
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.findReplace|Find and replace'),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
'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)
);
}
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 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 isToggleCommand(command: WordsCommandName): boolean {
return (
command === 'bold' ||
command === 'italic' ||
command === 'underline' ||
command === 'strike' ||
command === 'code' ||
command === 'paragraph' ||
command === 'heading' ||
command === 'quote' ||
command === 'unordered-list' ||
command === 'ordered-list' ||
command === 'check-list'
);
}

Powered by TurnKey Linux.