import { html, TemplateResult, CSSResultArray, PropertyValues, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { EditorView } from 'prosemirror-view'; import { EditorState, Transaction, TextSelection } from 'prosemirror-state'; import { Node as PMNode } from 'prosemirror-model'; import NileElement from '../internal/nile-element'; import { styles } from './nile-wysiwyg-editor.css'; import { defaultSchema, createEditorState, computeToolbarState, htmlToDoc, htmlToSlice, docToHtml, docToText, docToMarkdown, markdownToHtml, docToJson, jsonToDoc, isDocEmpty, sanitizeHtml, getCommand, getMarkRange, flattenToSingleParagraph, imageUploadKey, findUploadPos, countWords, TaskItemView, ImageView, FigureView, EmbedView, StyleBlockView, MathView, isOfficeHtml, cleanOfficeHtml, toInlineStyledHtml, buildWordDocument, buildPrintDocument, isFormatPainterArmed, armFormatPainter, disarmFormatPainter, applyFormatPainter, getFindState, searchTr, findStepTr, clearFindTr, replaceActiveTr, replaceAllTr, } from './engine'; import type { ToolbarState } from './engine/selection-state'; import type { CommandAttrs, CommandName } from './engine/commands'; import { selectedImageInfo } from './engine/commands'; import type { MathRenderer } from './engine/plugins/math-view'; import type { SuggestionMatch } from './engine/plugins/suggestions'; import type { WysiwygChangeDetail, WysiwygCommandDetail, MentionsConfig, SnippetConfig, MergeFieldConfig, TemplateConfig, } from './types'; import { DEFAULT_TOOLBAR } from './types'; import { EMOJI } from './emoji'; import './nile-wysiwyg-toolbar/nile-wysiwyg-toolbar'; import './nile-wysiwyg-toolbar/nile-wysiwyg-toolbar-item'; import type { AuthoredToolbarItem } from './nile-wysiwyg-toolbar/nile-wysiwyg-toolbar'; import './ui/link-editor'; import './ui/bubble-menu'; import './ui/bubble-item'; import './ui/image-panel'; import './ui/table-menu'; import './ui/suggest-list'; import './ui/find-replace'; import './ui/source-view'; import './ui/embed-panel'; import './ui/bookmark-panel'; import './ui/code-block-menu'; import './ui/shortcut-help'; import './ui/image-menu'; import './ui/embed-menu'; import './ui/special-chars'; import './ui/emoji-picker'; import './ui/insert-menu'; import './ui/math-panel'; import './ui/markdown-panel'; import type { NileWysiwygLinkEditor } from './ui/link-editor'; import type { NileWysiwygBubbleMenu } from './ui/bubble-menu'; import type { NileWysiwygImagePanel } from './ui/image-panel'; import type { NileWysiwygTableMenu } from './ui/table-menu'; import type { NileWysiwygSuggestList, SuggestItem } from './ui/suggest-list'; import type { NileWysiwygFindBar } from './ui/find-replace'; import type { NileWysiwygSourceView } from './ui/source-view'; import type { NileWysiwygEmbedPanel } from './ui/embed-panel'; import type { NileWysiwygBookmarkPanel } from './ui/bookmark-panel'; import type { NileWysiwygCodeMenu } from './ui/code-block-menu'; import type { NileWysiwygShortcutHelp } from './ui/shortcut-help'; import type { NileWysiwygImageMenu } from './ui/image-menu'; import type { NileWysiwygEmbedMenu } from './ui/embed-menu'; import type { NileWysiwygSpecialChars } from './ui/special-chars'; import type { NileWysiwygEmojiPicker } from './ui/emoji-picker'; import type { NileWysiwygInsertMenu } from './ui/insert-menu'; import type { NileWysiwygMathPanel } from './ui/math-panel'; import type { NileWysiwygMarkdownPanel } from './ui/markdown-panel'; interface SlashItem extends SuggestItem { command: CommandName | 'openImage' | 'openEmbed' | 'openBookmark'; attrs?: CommandAttrs; } const SLASH_ITEMS: SlashItem[] = [ { key: 'heading1', label: 'Heading 1', hint: '#', command: 'heading', attrs: { level: 1 } }, { key: 'heading2', label: 'Heading 2', hint: '##', command: 'heading', attrs: { level: 2 } }, { key: 'heading3', label: 'Heading 3', hint: '###', command: 'heading', attrs: { level: 3 } }, { key: 'bulletList', label: 'Bulleted list', hint: '-', command: 'bulletList' }, { key: 'orderedList', label: 'Numbered list', hint: '1.', command: 'orderedList' }, { key: 'taskList', label: 'Checklist', hint: '[ ]', command: 'taskList' }, { key: 'blockquote', label: 'Blockquote', hint: '>', command: 'blockquote' }, { key: 'codeBlock', label: 'Code block', hint: '```', command: 'codeBlock' }, { key: 'table', label: 'Table', hint: '3×3', command: 'insertTable', attrs: { rows: 3, cols: 3 } }, { key: 'image', label: 'Image', command: 'openImage' }, { key: 'embed', label: 'Video embed', hint: 'YouTube…', command: 'openEmbed' }, { key: 'bookmark', label: 'Bookmark (anchor)', command: 'openBookmark' }, { key: 'horizontalRule', label: 'Divider', hint: '---', command: 'horizontalRule' }, ]; let uploadCounter = 0; function readFileAsDataURL(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } const CHANGE_DEBOUNCE_MS = 250; /** * WYSIWYG rich text editor powered by ProseMirror. * * Requires evergreen browsers: selection inside shadow DOM relies on * `Selection.getComposedRanges()` (Safari 17+, recent Firefox) or Chrome's * `shadowRoot.getSelection()`. * * @tag nile-wysiwyg-editor * * @event nile-change - Debounced content change. detail: { value, json, content } * @event nile-input - Every document change. detail: { value, json, content } * @event nile-focus - Editor gained focus. * @event nile-blur - Editor lost focus. * @event nile-selection-change - detail: ToolbarState snapshot. * @event nile-image-upload-request - Cancelable. detail: { file, insert(src, alt) }. * Call preventDefault() and use insert() to fully own the upload. * @event nile-image-upload-error - detail: { file, error } when uploadHandler rejects. * @event nile-mention-query - detail: { trigger, query, provide(items) } for async sources. * @event nile-mention-select - detail: { trigger, key, label } when a mention is inserted. * @event nile-word-count - detail: { words, characters } on every document change. * @event nile-autosave - detail: { value, json, content } on the autosave interval. * * @csspart base - Outer container. * @csspart toolbar - The toolbar element. * @csspart surface - The scrollable editing surface. */ @customElement('nile-wysiwyg-editor') export class NileWysiwygEditor extends NileElement { static formAssociated = true; /** Document content as HTML. Not reflected to the attribute. */ @property({ type: String }) value = ''; @property({ type: String }) placeholder = ''; @property({ type: Boolean, reflect: true }) disabled = false; @property({ type: Boolean, reflect: true }) readonly = false; /** Toolbar config string; `"none"` hides the toolbar. */ @property({ type: String }) toolbar = DEFAULT_TOOLBAR; /** * Toolbar built from authored `` light-DOM * children. When present it takes precedence over the `toolbar` string. */ @state() private authoredToolbar?: AuthoredToolbarItem[]; /** * Bubble menu built from authored `` light-DOM * children. When present it replaces the built-in bold/italic/underline/ * strike/link set. */ @state() private authoredBubble?: AuthoredToolbarItem[]; /** Watches authored toolbar/bubble children so edits re-render them. */ private toolbarObserver?: MutationObserver; /** Shows a balloon toolbar over text selections. */ @property({ type: Boolean, attribute: 'bubble-menu' }) bubbleMenu = false; /** Sanitizes incoming HTML (value, paste, insertHTML). Leave on. */ @property({ type: Boolean }) sanitize = true; /** Constrains content to a single paragraph. */ @property({ type: Boolean, attribute: 'singleline' }) singleLine = false; /** Form field name. */ @property({ type: String }) name = ''; @property({ type: Boolean, reflect: true }) required = false; /** * Async image upload handler. When set, pasted/dropped/picked image files * are uploaded through it (an "Uploading…" placeholder shows meanwhile) * and the returned src is inserted. */ @property({ attribute: false }) uploadHandler?: ( file: File ) => Promise<{ src: string; alt?: string }>; /** * Fallback when no uploadHandler is set and no nile-image-upload-request * listener takes over: embed image files as base64 data URLs. */ @property({ attribute: false }) allowBase64Images = true; /** * Shows an "Upload from computer" button in the Insert Image dialog. Off by * default (URL-only, like the RTE's default); files can always be added via * drag & drop or paste regardless. */ @property({ type: Boolean, attribute: 'upload-from-computer' }) uploadFromComputer = false; /** Max upload size (bytes) shown/enforced by the image dialog. Default 2 MB. */ @property({ type: Number, attribute: 'image-max-file-size' }) imageMaxFileSize = 2 * 1024 * 1024; /** * Mention config: trigger character → items, same shape and JSON attribute * format as nile-rich-text-editor. For async sources, configure the * trigger with an empty array and listen to nile-mention-query. */ @property({ attribute: 'mentions', converter: { fromAttribute: (value: string): MentionsConfig => { try { const parsed = JSON.parse(value); const out: MentionsConfig = {}; for (const trigger of Object.keys(parsed)) { const items = parsed[trigger]; if (Array.isArray(items)) { out[trigger] = items .filter(i => i && typeof i.key === 'string' && typeof i.label === 'string') .map(i => ({ key: i.key, label: i.label })); } } return out; } catch { return {}; } }, toAttribute: (value: MentionsConfig) => JSON.stringify(value), }, }) mentions: MentionsConfig = {}; /** Disables the "/" command menu. */ @property({ type: Boolean, attribute: 'no-slash-commands' }) noSlashCommands = false; /** Disables the ":" emoji picker. */ @property({ type: Boolean, attribute: 'no-emoji' }) noEmoji = false; /** Maximum character count; further input is rejected. 0 = unlimited. */ @property({ type: Number, attribute: 'maxlength' }) maxLength = 0; /** Always show the word/character counter footer (implied by maxlength). */ @property({ type: Boolean, attribute: 'show-counter' }) showCounter = false; /** Keeps the toolbar pinned to the viewport while scrolling long content. */ @property({ type: Boolean, attribute: 'sticky-toolbar', reflect: true }) stickyToolbar = false; /** Reusable snippets surfaced in the "/" menu (JSON attribute). */ @property({ attribute: 'snippets', converter: { fromAttribute: (value: string): SnippetConfig[] => { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter( s => s && typeof s.key === 'string' && typeof s.label === 'string' && typeof s.html === 'string' ) : []; } catch { return []; } }, toAttribute: (value: SnippetConfig[]) => JSON.stringify(value), }, }) snippets: SnippetConfig[] = []; /** Merge fields offered in the merge-field picker (JSON attribute). */ @property({ attribute: 'merge-fields', converter: { fromAttribute: (value: string): MergeFieldConfig[] => { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter(f => f && typeof f.id === 'string' && typeof f.label === 'string') : []; } catch { return []; } }, toAttribute: (value: MergeFieldConfig[]) => JSON.stringify(value), }, }) mergeFields: MergeFieldConfig[] = []; /** Content templates offered in the template picker (JSON attribute). */ @property({ attribute: 'templates', converter: { fromAttribute: (value: string): TemplateConfig[] => { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter(t => t && typeof t.label === 'string' && typeof t.html === 'string') : []; } catch { return []; } }, toAttribute: (value: TemplateConfig[]) => JSON.stringify(value), }, }) templates: TemplateConfig[] = []; /** * Renders a math node's LaTeX to HTML (KaTeX/MathJax/MathML). When unset, * the raw LaTeX is shown. Same trust level as uploadHandler — output is * inserted as HTML. */ @property({ attribute: false }) mathRenderer?: MathRenderer; /** Emit nile-autosave every N ms while the document has changed. 0 = off. */ @property({ type: Number, attribute: 'autosave-interval' }) autosaveInterval = 0; /** When on, every paste drops formatting and inserts plain text. */ @property({ type: Boolean, attribute: 'paste-as-plain-text', reflect: true }) pasteAsPlainText = false; /** Disables Markdown detection on plain-text pastes. */ @property({ type: Boolean, attribute: 'no-markdown-paste' }) noMarkdownPaste = false; /** Disables typography autocorrect (smart quotes, dashes, (c) → ©). */ @property({ type: Boolean, attribute: 'no-text-transform' }) noTextTransform = false; /** Expands the editor to fill the viewport. Esc exits. */ @property({ type: Boolean, reflect: true }) fullscreen = false; /** Outlines block boundaries (paragraphs, headings, lists…) while editing. */ @property({ type: Boolean, attribute: 'show-blocks', reflect: true }) showBlocks = false; @state() private toolbarState?: ToolbarState; @state() private hasFocus = false; @state() private sourceMode = false; @state() private counter = { words: 0, characters: 0 }; /** Which list the shared insert-menu is showing, if open. */ @state() private insertMenuMode: 'template' | 'merge' | null = null; @query('.nile-wysiwyg') private baseEl!: HTMLElement; @query('nile-wysiwyg-toolbar') private toolbarEl?: HTMLElement; @query('.nile-wysiwyg__surface') private surfaceEl!: HTMLElement; @query('nile-wysiwyg-link-editor') private linkEditorEl!: NileWysiwygLinkEditor; @query('nile-wysiwyg-bubble-menu') private bubbleMenuEl!: NileWysiwygBubbleMenu; @query('nile-wysiwyg-image-panel') private imagePanelEl!: NileWysiwygImagePanel; @query('nile-wysiwyg-table-menu') private tableMenuEl!: NileWysiwygTableMenu; @query('nile-wysiwyg-suggest-list') private suggestListEl!: NileWysiwygSuggestList; @query('nile-wysiwyg-find-bar') private findBarEl!: NileWysiwygFindBar; @query('nile-wysiwyg-source-view') private sourceViewEl?: NileWysiwygSourceView; @query('nile-wysiwyg-embed-panel') private embedPanelEl!: NileWysiwygEmbedPanel; @query('nile-wysiwyg-bookmark-panel') private bookmarkPanelEl!: NileWysiwygBookmarkPanel; @query('nile-wysiwyg-code-menu') private codeMenuEl!: NileWysiwygCodeMenu; @query('nile-wysiwyg-shortcut-help') private shortcutHelpEl!: NileWysiwygShortcutHelp; @query('nile-wysiwyg-image-menu') private imageMenuEl!: NileWysiwygImageMenu; @query('nile-wysiwyg-embed-menu') private embedMenuEl!: NileWysiwygEmbedMenu; @query('nile-wysiwyg-special-chars') private specialCharsEl!: NileWysiwygSpecialChars; @query('nile-wysiwyg-emoji-picker') private emojiPickerEl!: NileWysiwygEmojiPicker; @query('nile-wysiwyg-insert-menu') private insertMenuEl!: NileWysiwygInsertMenu; @query('nile-wysiwyg-math-panel') private mathPanelEl!: NileWysiwygMathPanel; @query('nile-wysiwyg-markdown-panel') private markdownPanelEl!: NileWysiwygMarkdownPanel; private view?: EditorView; private mentionMatch: SuggestionMatch | null = null; private slashMatch: SuggestionMatch | null = null; private emojiMatch: SuggestionMatch | null = null; private activeSuggestSource: 'mention' | 'slash' | 'emoji' | null = null; private suggestDismissed: string | null = null; private mentionQueryToken = ''; private autosaveTimer?: ReturnType; private lastAutosaveValue = ''; private internals?: ElementInternals; private defaultValue = ''; private syncingValue = false; private changeTimer?: ReturnType; private pendingChange = false; /** Body overflow value before fullscreen locked it; null = not locked. */ private previousBodyOverflow: string | null = null; public static get styles(): CSSResultArray { return [styles]; } constructor() { super(); if (typeof this.attachInternals === 'function') { this.internals = this.attachInternals(); } } connectedCallback(): void { super.connectedCallback(); // Remount after a disconnect/reconnect (e.g. host moved in the DOM). if (this.hasUpdated && !this.view) { this.updateComplete.then(() => this.mountView()); } // Close floating panels when the user clicks anywhere outside them. document.addEventListener('pointerdown', this.onOutsidePointerDown, true); // Format painter applies on pointer release so a drag-selection is painted // whole, not on the first mid-drag selection change. document.addEventListener('mouseup', this.onPainterPointerUp); // Discover an author-supplied toolbar / bubble menu (light-DOM // / children) and // keep them in sync as they change. this.syncAuthoredToolbar(); this.syncAuthoredBubble(); if (!this.toolbarObserver) { this.toolbarObserver = new MutationObserver(() => { this.syncAuthoredToolbar(); this.syncAuthoredBubble(); }); } this.toolbarObserver.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ['name', 'label', 'icon', 'divider', 'overflow'], }); } disconnectedCallback(): void { super.disconnectedCallback(); document.removeEventListener('pointerdown', this.onOutsidePointerDown, true); document.removeEventListener('mouseup', this.onPainterPointerUp); this.toolbarObserver?.disconnect(); if (this.changeTimer) clearTimeout(this.changeTimer); if (this.autosaveTimer) clearInterval(this.autosaveTimer); if (this.previousBodyOverflow !== null) { document.body.style.overflow = this.previousBodyOverflow; this.previousBodyOverflow = null; } this.view?.destroy(); this.view = undefined; } protected firstUpdated(): void { this.defaultValue = this.value; // Re-read now that light-DOM children are guaranteed parsed (the initial // connectedCallback read can run mid-parse before children exist). this.syncAuthoredToolbar(); this.syncAuthoredBubble(); this.mountView(); } /** * Reads `` children into a structured toolbar and * stores it on {@link authoredToolbar}. Absent items clear the override * (falling back to the `toolbar` string). No-ops when nothing changed to * avoid needless re-renders. */ private syncAuthoredToolbar(): void { const nodes = this.querySelectorAll('nile-wysiwyg-toolbar-item'); const next: AuthoredToolbarItem[] | undefined = nodes.length ? Array.from(nodes) .map(el => ({ name: el.hasAttribute('divider') ? '|' : el.getAttribute('name') || '', label: el.getAttribute('label') || undefined, icon: el.getAttribute('icon') || undefined, overflow: el.hasAttribute('overflow') || undefined, })) .filter(item => item.name) : undefined; if (JSON.stringify(next) !== JSON.stringify(this.authoredToolbar)) { this.authoredToolbar = next; } } /** * Reads `` children into a structured bubble-menu * item list stored on {@link authoredBubble}. Absent items clear the override * (falling back to the built-in default). No-ops when nothing changed. */ private syncAuthoredBubble(): void { const nodes = this.querySelectorAll('nile-wysiwyg-bubble-item'); const next: AuthoredToolbarItem[] | undefined = nodes.length ? Array.from(nodes) .map(el => ({ name: el.hasAttribute('divider') ? '|' : el.getAttribute('name') || '', label: el.getAttribute('label') || undefined, icon: el.getAttribute('icon') || undefined, })) .filter(item => item.name) : undefined; if (JSON.stringify(next) !== JSON.stringify(this.authoredBubble)) { this.authoredBubble = next; } } protected updated(changed: PropertyValues): void { if (changed.has('value') && this.view && !this.syncingValue) { const current = docToHtml(this.view.state.doc); if (current !== this.value) this.setContent(this.value); } this.syncingValue = false; if ((changed.has('disabled') || changed.has('readonly')) && this.view) { this.view.setProps({}); } if (changed.has('placeholder') && this.view) { // Decorations are recomputed on any transaction. this.view.dispatch(this.view.state.tr); } if (changed.has('required')) this.updateValidity(); if (changed.has('autosaveInterval')) this.restartAutosave(); if ( (changed.has('pasteAsPlainText') || changed.has('fullscreen') || changed.has('showBlocks')) && this.view ) { this.toolbarState = this.snapshotToolbar(this.view.state); } // Input rules are baked into the state at build time; rebuild in place // (same doc, fresh plugins) when the autocorrect toggle changes. if (changed.has('noTextTransform') && this.view && !changed.has('value')) { this.view.updateState(this.buildState(this.view.state.doc)); } // Lock page scrolling while fullscreen covers the viewport. if (changed.has('fullscreen')) { if (this.fullscreen) { if (this.previousBodyOverflow === null) { this.previousBodyOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; } } else if (this.previousBodyOverflow !== null) { document.body.style.overflow = this.previousBodyOverflow; this.previousBodyOverflow = null; } } } private restartAutosave(): void { if (this.autosaveTimer) clearInterval(this.autosaveTimer); this.autosaveTimer = undefined; if (this.autosaveInterval > 0) { this.lastAutosaveValue = this.value; this.autosaveTimer = setInterval(() => { if (!this.view || this.value === this.lastAutosaveValue) return; this.lastAutosaveValue = this.value; this.emit('nile-autosave', this.buildChangeDetail(this.view.state.doc)); }, this.autosaveInterval); } } // ------------------------------------------------------------------ view private buildState(doc?: PMNode): EditorState { return createEditorState({ schema: defaultSchema, doc, getPlaceholder: () => this.placeholder, singleLine: this.singleLine, onLinkShortcut: () => this.openLinkEditor(), onFindShortcut: () => this.openFindBar(), onShortcutHelp: () => this.openShortcutHelp(), onImageFiles: (files, pos) => { for (const file of files) this.insertImageFile(file, pos); }, mentions: { getTriggers: () => Object.keys(this.mentions), onChange: match => { this.mentionMatch = match; this.updateSuggestList(); }, onKeyDown: event => this.handleSuggestKey(event, 'mention'), }, slashCommands: { onChange: match => { this.slashMatch = match; this.updateSuggestList(); }, onKeyDown: event => this.handleSuggestKey(event, 'slash'), }, emoji: { onChange: match => { this.emojiMatch = match; this.updateSuggestList(); }, onKeyDown: event => this.handleSuggestKey(event, 'emoji'), }, getMaxLength: () => this.maxLength, getForcePlainText: () => this.pasteAsPlainText, getMarkdownPaste: () => !this.noMarkdownPaste, getSanitize: () => this.sanitize, textTransform: !this.noTextTransform, }); } /** * ToolbarState snapshot augmented with host-only flags the pure * computeToolbarState can't know about (e.g. the paste-as-plain-text toggle). */ private snapshotToolbar(state: EditorState): ToolbarState { return { ...computeToolbarState(state), pasteAsPlainText: this.pasteAsPlainText, fullscreen: this.fullscreen, showBlocks: this.showBlocks, formatPainter: isFormatPainterArmed(state), }; } private mountView(): void { if (this.view || !this.surfaceEl) return; let doc = this.value ? htmlToDoc(defaultSchema, this.value, this.sanitize) : undefined; if (doc && this.singleLine) doc = flattenToSingleParagraph(doc); this.view = new EditorView(this.surfaceEl, { state: this.buildState(doc), dispatchTransaction: tr => this.handleTransaction(tr), editable: () => !this.disabled && !this.readonly, transformPastedHTML: pasted => { // Word/Google Docs clipboard HTML gets normalized (real lists, no // mso noise) before the sanitizer clamps it to the schema. const cleaned = isOfficeHtml(pasted) ? cleanOfficeHtml(pasted) : pasted; return this.sanitize ? sanitizeHtml(cleaned) : cleaned; }, handleDOMEvents: { mousedown: () => { // Clicking into the document dismisses the never-focused popovers // (they take no focus, so blur can't close them). this.closeSpecialChars(false); this.closeInsertMenu(false); return false; }, focus: () => { this.hasFocus = true; this.updateOverlays(); this.emit('nile-focus'); return false; }, blur: (_view, event) => { // Focus moving into the editor's own chrome (language picker, // panels) is not a blur — closing overlays here would dismiss // the control the user just clicked. const related = (event as FocusEvent).relatedTarget as Node | null; if (related && (this.shadowRoot?.contains(related) || this.contains(related))) { return false; } this.hasFocus = false; this.flushChange(); this.updateOverlays(); this.closeSuggestList(); this.closeSpecialChars(false); this.emit('nile-blur'); return false; }, }, attributes: { role: 'textbox', 'aria-multiline': this.singleLine ? 'false' : 'true', }, nodeViews: { task_item: (node, view, getPos) => new TaskItemView(node, view, getPos as () => number | undefined), image: (node, view, getPos) => new ImageView(node, view, getPos as () => number | undefined), figure: (node, view, getPos) => new FigureView(node, view, getPos as () => number | undefined), embed: (node, view, getPos) => new EmbedView(node, view, getPos as () => number | undefined), styleBlock: node => new StyleBlockView(node), math: node => new MathView(node, this.mathRenderer), }, }); this.view.dom.addEventListener('mousedown', this.onPainterPointerDown); this.toolbarState = this.snapshotToolbar(this.view.state); this.counter = countWords(this.view.state.doc); this.syncFormValue(this.view.state.doc); } /** * True while a drag-selection is in progress with the format painter armed. * Suppresses the mid-drag auto-apply in {@link handleTransaction} so the * copied formatting lands on the final, complete selection (on mouse-up). */ private painterDragActive = false; private onPainterPointerDown = (): void => { if (this.view && isFormatPainterArmed(this.view.state)) { this.painterDragActive = true; } }; private onPainterPointerUp = (): void => { if (!this.painterDragActive) return; this.painterDragActive = false; // Defer a frame so ProseMirror has synced the final DOM selection. requestAnimationFrame(() => { if (!this.view) return; if (this.view.state.selection.empty || !isFormatPainterArmed(this.view.state)) return; const applyTr = applyFormatPainter(this.view.state); if (applyTr) this.view.dispatch(applyTr); }); }; private handleTransaction(tr: Transaction): void { if (!this.view) return; const newState = this.view.state.apply(tr); this.view.updateState(newState); // Format painter: once armed, the next non-empty selection the user makes // receives the copied formatting, then the painter disarms. While a pointer // drag is building the selection we defer to the mouse-up handler // (onPainterPointerUp) so the whole range is painted, not just the first // mid-drag character. Single-shot selections (double-click, Shift+End, // programmatic) still apply here. if ( tr.selectionSet && !newState.selection.empty && isFormatPainterArmed(newState) && !this.painterDragActive ) { const applyTr = applyFormatPainter(newState); if (applyTr) { this.view.dispatch(applyTr); return; } } this.toolbarState = this.snapshotToolbar(newState); this.emit('nile-selection-change', this.toolbarState); this.updateOverlays(); if (this.findBarEl?.open) { const find = getFindState(newState); this.findBarEl.matchCount = find.matches.length; this.findBarEl.activeIndex = find.activeIndex; } if (tr.docChanged) { const detail = this.buildChangeDetail(newState.doc); this.syncingValue = true; this.value = detail.value; this.syncFormValue(newState.doc); this.emit('nile-input', detail); this.counter = countWords(newState.doc); this.emit('nile-word-count', this.counter); this.scheduleChange(); } } private buildChangeDetail(doc: PMNode): WysiwygChangeDetail { const value = isDocEmpty(doc) ? '' : docToHtml(doc); return { value, json: docToJson(doc), content: value }; } private scheduleChange(): void { this.pendingChange = true; if (this.changeTimer) clearTimeout(this.changeTimer); this.changeTimer = setTimeout(() => this.flushChange(), CHANGE_DEBOUNCE_MS); } private flushChange(): void { if (!this.pendingChange || !this.view) return; this.pendingChange = false; if (this.changeTimer) clearTimeout(this.changeTimer); this.emit('nile-change', this.buildChangeDetail(this.view.state.doc)); } // ------------------------------------------------------------------ form private syncFormValue(doc: PMNode): void { if (!this.internals) return; const value = isDocEmpty(doc) ? '' : docToHtml(doc); this.internals.setFormValue(value === '' ? null : value); this.updateValidity(); } private updateValidity(): void { if (!this.internals) return; const empty = this.view ? isDocEmpty(this.view.state.doc) : this.value === ''; if (this.required && empty) { this.internals.setValidity( { valueMissing: true }, 'Please fill out this field.', this.surfaceEl ?? undefined ); } else { this.internals.setValidity({}); } } formResetCallback(): void { this.setContent(this.defaultValue); } formDisabledCallback(disabled: boolean): void { this.disabled = disabled; } public checkValidity(): boolean { return this.internals ? this.internals.checkValidity() : true; } public reportValidity(): boolean { return this.internals ? this.internals.reportValidity() : true; } // -------------------------------------------------------------- overlays /** Positions an overlay element near a doc position (default: selection). */ private positionOverlay( el: HTMLElement, placement: 'above' | 'below', pos?: number ): void { if (!this.view || !this.baseEl) return; const from = pos ?? this.view.state.selection.from; const coords = this.view.coordsAtPos(from); const baseRect = this.baseEl.getBoundingClientRect(); const left = Math.max( 4, Math.min(coords.left - baseRect.left, baseRect.width - el.offsetWidth - 4) ); el.style.left = `${left}px`; if (placement === 'above') { el.style.top = `${Math.max(4, coords.top - baseRect.top - el.offsetHeight - 8)}px`; } else { el.style.top = `${coords.bottom - baseRect.top + 6}px`; } } /** Positions a floating panel directly below a toolbar/anchor element. */ private positionOverlayBelowAnchor(el: HTMLElement, anchor: HTMLElement): void { if (!this.baseEl) return; const baseRect = this.baseEl.getBoundingClientRect(); const anchorRect = anchor.getBoundingClientRect(); const left = Math.max( 4, Math.min(anchorRect.left - baseRect.left, baseRect.width - el.offsetWidth - 4) ); // Clear any CSS-pinned `right` (e.g. the find bar) so `left` wins. el.style.right = 'auto'; el.style.left = `${left}px`; el.style.top = `${anchorRect.bottom - baseRect.top + 6}px`; } /** Finds a toolbar button by its aria-label, to anchor a panel below it. */ private toolbarButton(label: string): HTMLElement | null { return ( (this.toolbarEl?.shadowRoot?.querySelector( `button[aria-label="${label}"]` ) as HTMLElement | null) ?? null ); } private updateOverlays(): void { if (!this.bubbleMenuEl) return; const panelOpen = this.linkEditorEl?.open || this.imagePanelEl?.open || this.embedPanelEl?.open || this.bookmarkPanelEl?.open || this.emojiPickerEl?.open; const show = this.bubbleMenu && this.hasFocus && !!this.toolbarState && !this.toolbarState.selectionEmpty && !this.toolbarState.imageSelected && !this.toolbarState.embedSelected && !panelOpen; this.bubbleMenuEl.open = show; if (show) { this.bubbleMenuEl.toolbarState = this.toolbarState; this.bubbleMenuEl.updateComplete.then(() => this.positionOverlay(this.bubbleMenuEl, 'above') ); } if (this.tableMenuEl) { const showTable = this.hasFocus && !!this.toolbarState && this.toolbarState.inTable && !panelOpen; this.tableMenuEl.open = showTable; if (showTable) { this.tableMenuEl.updateComplete.then(() => this.positionOverlay(this.tableMenuEl, 'below') ); } } if (this.codeMenuEl) { const showCode = this.hasFocus && !!this.toolbarState && this.toolbarState.inCodeBlock && !panelOpen; this.codeMenuEl.open = showCode; if (showCode) { this.codeMenuEl.language = this.toolbarState!.codeBlockLanguage ?? ''; this.codeMenuEl.updateComplete.then(() => this.positionOverlay(this.codeMenuEl, 'above') ); } } if (this.imageMenuEl) { const showImage = this.hasFocus && !!this.toolbarState && this.toolbarState.imageSelected && !panelOpen; this.imageMenuEl.open = showImage; if (showImage) { this.imageMenuEl.toolbarState = this.toolbarState; this.imageMenuEl.updateComplete.then(() => this.positionOverlay(this.imageMenuEl, 'above') ); } } if (this.embedMenuEl) { const showEmbed = this.hasFocus && !!this.toolbarState && this.toolbarState.embedSelected && !panelOpen; this.embedMenuEl.open = showEmbed; if (showEmbed) { this.embedMenuEl.toolbarState = this.toolbarState; this.embedMenuEl.updateComplete.then(() => this.positionOverlay(this.embedMenuEl, 'above') ); } } } // -------------------------------------------------------- special chars private openSpecialChars(): void { if (!this.view || !this.specialCharsEl || this.disabled || this.readonly) return; this.specialCharsEl.open = true; this.specialCharsEl.updateComplete.then(() => this.positionOverlay(this.specialCharsEl, 'below') ); } private closeSpecialChars(refocus = true): void { if (this.specialCharsEl) this.specialCharsEl.open = false; if (refocus) this.view?.focus(); } private insertSpecialChar(char: string): void { if (!this.view) return; this.view.dispatch(this.view.state.tr.insertText(char).scrollIntoView()); this.view.focus(); } // ------------------------------------------------ templates & merge fields private openInsertMenu(mode: 'template' | 'merge'): void { if (!this.view || !this.insertMenuEl || this.disabled || this.readonly) return; this.insertMenuMode = mode; const menu = this.insertMenuEl; if (mode === 'merge') { menu.heading = 'Merge field'; menu.emptyText = 'No merge fields configured.'; menu.items = this.mergeFields.map(f => ({ label: f.label, description: `{{${f.id}}}` })); } else { menu.heading = 'Template'; menu.emptyText = 'No templates configured.'; menu.items = this.templates.map(t => ({ label: t.label, description: t.description })); } menu.open = true; if (this.bubbleMenuEl) this.bubbleMenuEl.open = false; menu.updateComplete.then(() => this.positionOverlay(menu, 'below')); } private closeInsertMenu(refocus = true): void { if (this.insertMenuEl) this.insertMenuEl.open = false; this.insertMenuMode = null; if (refocus) this.view?.focus(); } private handleInsertSelect(index: number): void { const mode = this.insertMenuMode; this.closeInsertMenu(); if (!this.view) return; if (mode === 'merge') { const field = this.mergeFields[index]; if (field) this.execCommand('insertMergeField', { id: field.id, label: field.label }); } else if (mode === 'template') { const template = this.templates[index]; if (template) this.insertHTML(template.html); } } /** Inserts a merge-field placeholder at the selection. */ public insertMergeField(id: string, label?: string): void { this.execCommand('insertMergeField', { id, label: label ?? null }); } /** Changes the case of the selected text. */ public changeCase(mode: 'upper' | 'lower' | 'title' | 'sentence'): void { this.execCommand('changeCase', { textCase: mode }); } // ---------------------------------------------------------------- math private openMathPanel(): void { if (!this.view || !this.mathPanelEl || this.disabled || this.readonly) return; this.mathPanelEl.reset(); this.mathPanelEl.open = true; if (this.bubbleMenuEl) this.bubbleMenuEl.open = false; if (this.tableMenuEl) this.tableMenuEl.open = false; this.mathPanelEl.updateComplete.then(() => { const anchor = this.toolbarButton('Insert math (LaTeX)'); if (anchor) this.positionOverlayBelowAnchor(this.mathPanelEl, anchor); else this.positionOverlay(this.mathPanelEl, 'below'); this.mathPanelEl.focusInput(); }); } private closeMathPanel(refocus = true): void { if (this.mathPanelEl) this.mathPanelEl.open = false; if (refocus) this.view?.focus(); } private openMarkdownPanel(): void { if (!this.view || !this.markdownPanelEl || this.disabled || this.readonly) return; this.markdownPanelEl.reset(); this.markdownPanelEl.open = true; if (this.bubbleMenuEl) this.bubbleMenuEl.open = false; if (this.tableMenuEl) this.tableMenuEl.open = false; this.markdownPanelEl.updateComplete.then(() => { const anchor = this.toolbarButton('Insert from Markdown'); if (anchor) this.positionOverlayBelowAnchor(this.markdownPanelEl, anchor); else this.positionOverlay(this.markdownPanelEl, 'below'); this.markdownPanelEl.focusInput(); }); } private closeMarkdownPanel(refocus = true): void { if (this.markdownPanelEl) this.markdownPanelEl.open = false; if (refocus) this.view?.focus(); } private applyMath(latex: string, display: 'inline' | 'block'): void { this.closeMathPanel(); this.execCommand('insertMath', { latex, mathDisplay: display }); } /** Inserts a math formula (LaTeX) at the selection. */ public insertMath(latex: string, display: 'inline' | 'block' = 'inline'): void { this.execCommand('insertMath', { latex, mathDisplay: display }); } // ----------------------------------------------------------------- emoji private openEmojiPicker(): void { if (!this.view || !this.emojiPickerEl || this.disabled || this.readonly) return; this.emojiPickerEl.open = true; if (this.bubbleMenuEl) this.bubbleMenuEl.open = false; if (this.tableMenuEl) this.tableMenuEl.open = false; this.emojiPickerEl.updateComplete.then(() => { this.positionOverlay(this.emojiPickerEl, 'below'); this.emojiPickerEl.focusInput(); }); } private closeEmojiPicker(refocus = true): void { if (this.emojiPickerEl) this.emojiPickerEl.open = false; if (refocus) this.view?.focus(); } // ---------------------------------------------------------------- embeds private openEmbedPanel(): void { if (!this.view || !this.embedPanelEl || this.disabled || this.readonly) return; this.embedPanelEl.reset(); this.embedPanelEl.open = true; if (this.bubbleMenuEl) this.bubbleMenuEl.open = false; if (this.tableMenuEl) this.tableMenuEl.open = false; this.embedPanelEl.updateComplete.then(() => { // Anchor below the embed toolbar button when available. const anchor = this.toolbarButton('Embed video (YouTube, Vimeo, Loom)'); if (anchor) this.positionOverlayBelowAnchor(this.embedPanelEl, anchor); else this.positionOverlay(this.embedPanelEl, 'below'); this.embedPanelEl.focusInput(); }); } private closeEmbedPanel(refocus = true): void { if (this.embedPanelEl) this.embedPanelEl.open = false; if (refocus) this.view?.focus(); } // -------------------------------------------------------------- bookmarks private openBookmarkPanel(): void { if (!this.view || !this.bookmarkPanelEl || this.disabled || this.readonly) return; this.bookmarkPanelEl.open = true; if (this.bubbleMenuEl) this.bubbleMenuEl.open = false; if (this.tableMenuEl) this.tableMenuEl.open = false; this.bookmarkPanelEl.updateComplete.then(() => { this.positionOverlay(this.bookmarkPanelEl, 'below'); this.bookmarkPanelEl.focusInput(); }); } private closeBookmarkPanel(refocus = true): void { if (this.bookmarkPanelEl) this.bookmarkPanelEl.open = false; if (refocus) this.view?.focus(); } // ---------------------------------------------------------------- images private openImagePanel(): void { if (!this.view || !this.imagePanelEl || this.disabled || this.readonly) return; // Editing an already-selected image/figure pre-fills the fields (and shows // Remove); otherwise open a blank insert panel. const info = selectedImageInfo(this.view.state); if (info) { this.imagePanelEl.prefill({ src: info.node.attrs.src, alt: info.node.attrs.alt ?? '', caption: info.isFigure ? info.node.textContent : '', }); } else { this.imagePanelEl.reset(); } this.imagePanelEl.open = true; if (this.bubbleMenuEl) this.bubbleMenuEl.open = false; if (this.tableMenuEl) this.tableMenuEl.open = false; this.imagePanelEl.updateComplete.then(() => { // Anchor below the image toolbar button when it's available; otherwise // fall back to positioning at the cursor. const anchor = this.toolbarButton('Insert image'); if (anchor) this.positionOverlayBelowAnchor(this.imagePanelEl, anchor); else this.positionOverlay(this.imagePanelEl, 'below'); this.imagePanelEl.focusInput(); }); } private closeImagePanel(refocus = true): void { if (this.imagePanelEl) this.imagePanelEl.open = false; if (refocus) this.view?.focus(); } private insertImageAt( src: string, alt: string | null, pos: number | null, caption: string | null = null ): void { if (!this.view) return; if (pos !== null) { const $pos = this.view.state.doc.resolve( Math.min(pos, this.view.state.doc.content.size) ); this.view.dispatch(this.view.state.tr.setSelection(TextSelection.near($pos))); } this.execCommand('insertImage', { src, alt, caption }); } /** * Routes an image file through the upload pipeline: a cancelable * `nile-image-upload-request` first, then `uploadHandler` (with an * uploading placeholder), then the base64 fallback. */ public async insertImageFile( file: File, pos: number | null = null, meta?: { alt?: string | null; caption?: string | null } ): Promise { if (!this.view) return; const metaAlt = meta?.alt ?? null; const metaCaption = meta?.caption ?? null; const request = this.emit( 'nile-image-upload-request', { file, insert: (src: string, alt?: string) => this.insertImageAt(src, alt ?? metaAlt, pos, metaCaption), }, true, true, true ); if (request.defaultPrevented) return; if (this.uploadHandler) { uploadCounter += 1; const id = `nile-upload-${uploadCounter}`; const at = pos ?? this.view.state.selection.from; this.view.dispatch(this.view.state.tr.setMeta(imageUploadKey, { add: { id, pos: at } })); try { const { src, alt } = await this.uploadHandler(file); const mappedPos = this.view ? findUploadPos(this.view.state, id) : null; this.view?.dispatch(this.view.state.tr.setMeta(imageUploadKey, { remove: { id } })); this.insertImageAt(src, metaAlt ?? alt ?? null, mappedPos, metaCaption); } catch (error) { this.view?.dispatch(this.view.state.tr.setMeta(imageUploadKey, { remove: { id } })); this.emit('nile-image-upload-error', { file, error }); } return; } if (this.allowBase64Images) { const src = await readFileAsDataURL(file); this.insertImageAt(src, metaAlt, pos, metaCaption); } } // ---------------------------------------------------- mentions & slash private dismissKeyFor( source: 'mention' | 'slash' | 'emoji', match: SuggestionMatch ): string { return `${source}:${match.trigger}:${match.from}`; } private handleSuggestKey( event: KeyboardEvent, source: 'mention' | 'slash' | 'emoji' ): boolean { if (this.activeSuggestSource !== source || !this.suggestListEl?.open) return false; switch (event.key) { case 'ArrowDown': this.suggestListEl.next(); return true; case 'ArrowUp': this.suggestListEl.prev(); return true; case 'Enter': case 'Tab': return this.suggestListEl.selectCurrent(); case 'Escape': { const match = source === 'mention' ? this.mentionMatch : source === 'slash' ? this.slashMatch : this.emojiMatch; if (match) this.suggestDismissed = this.dismissKeyFor(source, match); this.closeSuggestList(); return true; } default: return false; } } private closeSuggestList(): void { if (this.suggestListEl) this.suggestListEl.open = false; this.activeSuggestSource = null; } private updateSuggestList(): void { if (!this.suggestListEl || !this.view) return; const mention = this.mentionMatch?.active ? this.mentionMatch : null; const slash = !this.noSlashCommands && this.slashMatch?.active ? this.slashMatch : null; const emoji = !this.noEmoji && this.emojiMatch?.active ? this.emojiMatch : null; const source: 'mention' | 'slash' | 'emoji' | null = mention ? 'mention' : slash ? 'slash' : emoji ? 'emoji' : null; const match = mention ?? slash ?? emoji; if (!source || !match || this.disabled || this.readonly) { this.closeSuggestList(); return; } if (this.suggestDismissed === this.dismissKeyFor(source, match)) return; this.suggestDismissed = null; if (source === 'mention') { const all = this.mentions[match.trigger] ?? []; const query = match.query.toLowerCase(); this.suggestListEl.items = all.filter(item => item.label.toLowerCase().includes(query) ); // Async override: listeners may provide items for this exact query. const token = `${match.trigger} ${match.query}`; this.mentionQueryToken = token; this.emit('nile-mention-query', { trigger: match.trigger, query: match.query, provide: (items: { key: string; label: string }[]) => { if (this.mentionQueryToken !== token || !Array.isArray(items)) return; this.suggestListEl.items = items .filter(i => i && typeof i.key === 'string' && typeof i.label === 'string') .map(i => ({ key: i.key, label: i.label })); }, }); } else if (source === 'slash') { const query = match.query.toLowerCase(); const snippetItems: SuggestItem[] = this.snippets.map(s => ({ key: `snippet:${s.key}`, label: s.label, hint: 'snippet', })); this.suggestListEl.items = [...SLASH_ITEMS, ...snippetItems].filter(item => item.label.toLowerCase().includes(query) ); } else { const query = match.query.toLowerCase(); this.suggestListEl.items = EMOJI.filter(e => e.name.includes(query)) .slice(0, 12) .map(e => ({ key: e.name, label: `${e.char} ${e.name}` })); if (this.suggestListEl.items.length === 0) { this.closeSuggestList(); return; } } this.activeSuggestSource = source; this.suggestListEl.resetActive(); this.suggestListEl.open = true; this.suggestListEl.updateComplete.then(() => this.positionOverlay(this.suggestListEl, 'below', match.from) ); } private handleSuggestSelect(item: SuggestItem): void { if (!this.view) return; if (this.activeSuggestSource === 'mention' && this.mentionMatch?.active) { const match = this.mentionMatch; const { state } = this.view; const mention = state.schema.nodes.mention.create({ trigger: match.trigger, key: item.key, label: item.label, }); this.view.dispatch( state.tr.replaceWith(match.from, match.to, [mention, state.schema.text(' ')]) ); this.view.focus(); this.emit('nile-mention-select', { trigger: match.trigger, key: item.key, label: item.label, }); } else if (this.activeSuggestSource === 'slash' && this.slashMatch?.active) { const match = this.slashMatch; this.view.dispatch(this.view.state.tr.delete(match.from, match.to)); this.closeSuggestList(); if (item.key.startsWith('snippet:')) { const snippet = this.snippets.find(s => `snippet:${s.key}` === item.key); if (snippet) this.insertHTML(snippet.html); return; } const slashItem = SLASH_ITEMS.find(s => s.key === item.key); if (!slashItem) return; if (slashItem.command === 'openImage') this.openImagePanel(); else if (slashItem.command === 'openEmbed') this.openEmbedPanel(); else if (slashItem.command === 'openBookmark') this.openBookmarkPanel(); else this.execCommand(slashItem.command, slashItem.attrs); } else if (this.activeSuggestSource === 'emoji' && this.emojiMatch?.active) { const match = this.emojiMatch; const emoji = EMOJI.find(e => e.name === item.key); if (emoji) { this.view.dispatch( this.view.state.tr.insertText(emoji.char, match.from, match.to) ); this.view.focus(); } } this.closeSuggestList(); } // ---------------------------------------------------------- find & replace /** Opens the find & replace bar (also bound to Mod-f inside the editor). */ public openFindBar(): void { if (!this.findBarEl || this.sourceMode) return; this.findBarEl.open = true; const find = this.view ? getFindState(this.view.state) : null; this.findBarEl.matchCount = find?.matches.length ?? 0; this.findBarEl.activeIndex = find?.activeIndex ?? 0; this.findBarEl.updateComplete.then(() => { // Anchor below the Find & replace toolbar button when present; otherwise // fall back to the CSS-pinned top-right position. const anchor = this.toolbarButton('Find & replace (Ctrl+F)'); if (anchor) this.positionOverlayBelowAnchor(this.findBarEl, anchor); this.findBarEl.focusInput(); }); } public closeFindBar(): void { if (this.view) this.view.dispatch(clearFindTr(this.view.state)); if (this.findBarEl) this.findBarEl.open = false; this.view?.focus(); } public find(query: string): number { if (!this.view) return 0; this.view.dispatch(searchTr(this.view.state, query)); this.scrollToActiveMatch(); return getFindState(this.view.state).matches.length; } public findNext(): void { if (!this.view) return; this.view.dispatch(findStepTr(this.view.state, 'next')); this.scrollToActiveMatch(); } public findPrev(): void { if (!this.view) return; this.view.dispatch(findStepTr(this.view.state, 'prev')); this.scrollToActiveMatch(); } public replace(replacement: string): void { if (!this.view) return; const tr = replaceActiveTr(this.view.state, replacement); if (tr) this.view.dispatch(tr); } public replaceAll(replacement: string): void { if (!this.view) return; const tr = replaceAllTr(this.view.state, replacement); if (tr) this.view.dispatch(tr); } private scrollToActiveMatch(): void { if (!this.view) return; const find = getFindState(this.view.state); const match = find.matches[find.activeIndex]; if (!match) return; this.view.dispatch( this.view.state.tr .setSelection(TextSelection.create(this.view.state.doc, match.from, match.to)) .scrollIntoView() ); } // -------------------------------------------------------------- source view /** Toggles raw-HTML source editing. Content re-sanitizes on the way back. */ public toggleSourceView(): void { if (this.sourceMode) { this.applySourceView(this.sourceViewEl?.currentValue ?? null); } else { this.sourceMode = true; this.updateComplete.then(() => { if (this.sourceViewEl) { this.sourceViewEl.value = this.getHTML(); this.sourceViewEl.focusInput(); } }); } } private applySourceView(htmlContent: string | null): void { this.sourceMode = false; if (htmlContent !== null) this.setContent(htmlContent, true); this.updateComplete.then(() => this.view?.focus()); } /** Word and character counts for the current document. */ public getWordCount(): { words: number; characters: number } { return this.view ? countWords(this.view.state.doc) : { words: 0, characters: 0 }; } /** * Arms the format painter (copies the formatting at the selection) or, if * already armed, disarms it. Once armed, the next non-empty selection the * user makes receives the copied formatting. */ public toggleFormatPainter(): void { if (!this.view) return; const tr = isFormatPainterArmed(this.view.state) ? disarmFormatPainter(this.view.state) : armFormatPainter(this.view.state); if (tr) this.view.dispatch(tr); this.view.focus(); } // ---------------------------------------------------------- export & import /** The live, styled content element used as the source for exports. */ private get contentRoot(): HTMLElement | null { return (this.view?.dom as HTMLElement | undefined) ?? null; } /** Sanitizes `this.name` (or a fallback) into a safe download basename. */ private exportFilename(extension: string): string { const base = (this.name || 'document').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'document'; return `${base}.${extension}`; } private downloadBlob(content: string, mime: string, filename: string): void { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); // Revoke after the click so the download has a chance to start. setTimeout(() => URL.revokeObjectURL(url), 0); } /** * The document as HTML with every style folded into inline `style` * attributes — the form email clients (which strip `