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 `