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.
1636 lines
49 KiB
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'
|
|
);
|
|
}
|