UNPKG

wysiwyg4all

Version:

Free opensource minimal WYSIWYG editor for web developers

1 lines 147 kB
{"version":3,"sources":["../wysiwyg4all.ts"],"sourcesContent":["type EditorFontSize = {\n\tdesktop?: number | string;\n\ttablet?: number | string;\n\tphone?: number | string;\n\th1?: number | string;\n\th2?: number | string;\n\th3?: number | string;\n\th4?: number | string;\n\th5?: number | string;\n\th6?: number | string;\n\tsmall?: number | string;\n};\n\ntype ColorScheme = Record<string, string>;\n\ntype BuiltInCommand =\n\t| \"quote\"\n\t| \"unorderedList\"\n\t| \"orderedList\"\n\t| \"divider\"\n\t| \"image\"\n\t| \"alignLeft\"\n\t| \"alignCenter\"\n\t| \"alignRight\"\n\t| \"h1\"\n\t| \"h2\"\n\t| \"h3\"\n\t| \"h4\"\n\t| \"h5\"\n\t| \"h6\"\n\t| \"small\"\n\t| \"bold\"\n\t| \"italic\"\n\t| \"underline\"\n\t| \"strike\"\n\t| \"color\";\n\ntype InlineClassCommand =\n\t| \"h1\"\n\t| \"h2\"\n\t| \"h3\"\n\t| \"h4\"\n\t| \"h5\"\n\t| \"h6\"\n\t| \"small\"\n\t| \"bold\"\n\t| \"italic\"\n\t| \"underline\"\n\t| \"strike\"\n\t| \"color\"\n\t| \"backgroundColor\";\n\ntype AlignCommand = \"alignLeft\" | \"alignCenter\" | \"alignRight\";\n\ntype CommandObject = {\n\telement?: HTMLElement | string;\n\telementId?: string;\n\tstyle?: Partial<CSSStyleDeclaration> & Record<string, string>;\n\tinsert?: boolean;\n\tbackgroundColor?: string;\n\tcolor?: string;\n};\n\ntype CommandInput = BuiltInCommand | CommandObject | string;\n\ntype CommandTracker = {\n\t\"unorderedList\": boolean;\n\t\"orderedList\": boolean;\n\t\"alignLeft\": boolean;\n\t\"alignCenter\": boolean;\n\t\"alignRight\": boolean;\n\t\"h1\": boolean;\n\t\"h2\": boolean;\n\t\"h3\": boolean;\n\t\"h4\": boolean;\n\t\"h5\": boolean;\n\t\"h6\": boolean;\n\t\"small\": boolean;\n\t\"bold\": boolean;\n\t\"italic\": boolean;\n\t\"underline\": boolean;\n\t\"strike\": boolean;\n\t\"color\": string;\n\t\"backgroundColor\": string;\n};\n\ntype ImageData = {\n\telementId: string;\n\tsource: string;\n\tfilename?: string;\n\tfileType?: string;\n\tfileSize?: number;\n\tlastModified?: number;\n\tdimension?: { width: number; height: number };\n\telement?: HTMLImageElement;\n\tclass?: string[];\n\tstyle?: Record<string, string>;\n\tonclick?: (ev: MouseEvent) => void;\n};\n\ntype HashtagData = {\n\telementId: string;\n\ttag: string;\n\telement?: HTMLSpanElement;\n\tstyle?: Record<string, string>;\n\tonclick?: (ev: MouseEvent) => void;\n};\n\ntype UrlData = {\n\telementId: string;\n\turl: string;\n\telement?: HTMLSpanElement;\n\tstyle?: Record<string, string>;\n\tonclick?: (ev: MouseEvent) => void;\n};\n\ntype CustomData = {\n\telementId: string;\n\telement: HTMLElement;\n};\n\ntype CallbackPayload = {\n\tcommandTracker?: CommandTracker;\n\trange?: Range | null;\n\tcaratPosition?: DOMRect | null;\n\tloading?: boolean;\n\timage?: ImageData[];\n\thashtag?: HashtagData[];\n\turllink?: UrlData[];\n};\n\ntype ExportPayload = {\n\thtml: string;\n\ttitle: string;\n\ttext: string;\n\turllink?: UrlData[];\n\thashtag?: HashtagData[];\n\timage: ImageData[];\n\tcustom: CustomData[];\n};\n\ntype ExtensionApi = {\n\tregisterCommand: (name: string, handler: (editor: Wysiwyg4All, action: CommandInput) => void | Promise<void>) => void;\n\ton: (eventName: string, handler: (...args: unknown[]) => void) => () => void;\n\teditor: Wysiwyg4All;\n};\n\ntype ExtensionFactory = (api: ExtensionApi) => void;\n\ntype ExportSetup = {\n\tdom: HTMLElement;\n\turllink?: UrlData[];\n\thashtag?: HashtagData[];\n\timage: ImageData[];\n\tcustom: CustomData[];\n\ttitle?: string;\n};\n\nexport type Wysiwyg4AllOptions = {\n\telementId: string;\n\teditable?: boolean;\n\tplaceholder?: string;\n\tspellcheck?: boolean;\n\thighlightColor?: string | ColorScheme;\n\thtml?: string;\n\tcallback?: (payload: CallbackPayload) => CallbackPayload | Promise<CallbackPayload>;\n\tfontSize?: EditorFontSize | number;\n\t// lastLineBlank?: boolean;\n\thashtag?: boolean;\n\turllink?: boolean;\n\textensions?: ExtensionFactory[];\n};\n\nconst CLASS_BY_COMMAND: Record<InlineClassCommand, string> = {\n\th1: \"_h1\",\n\th2: \"_h2\",\n\th3: \"_h3\",\n\th4: \"_h4\",\n\th5: \"_h5\",\n\th6: \"_h6\",\n\tsmall: \"_small\",\n\tbold: \"_b\",\n\titalic: \"_i\",\n\tunderline: \"_u\",\n\tstrike: \"_del\",\n\tcolor: \"_color\",\n\tbackgroundColor: \"_backgroundColor\",\n};\n\nconst COUNTER_CLASSES: Partial<Record<InlineClassCommand, string[]>> = {\n\th1: [\"_small\", \"_h2\", \"_h3\", \"_h4\", \"_h5\", \"_h6\"],\n\th2: [\"_small\", \"_h1\", \"_h3\", \"_h4\", \"_h5\", \"_h6\"],\n\th3: [\"_small\", \"_h1\", \"_h2\", \"_h4\", \"_h5\", \"_h6\"],\n\th4: [\"_small\", \"_h1\", \"_h2\", \"_h3\", \"_h5\", \"_h6\"],\n\th5: [\"_small\", \"_h1\", \"_h2\", \"_h3\", \"_h4\", \"_h6\"],\n\th6: [\"_small\", \"_h1\", \"_h2\", \"_h3\", \"_h4\", \"_h5\"],\n\tsmall: [\"_h1\", \"_h2\", \"_h3\", \"_h4\", \"_h5\", \"_h6\", \"_b\"],\n\tbold: [\"_small\"],\n\tunderline: [\"_del\"],\n\tstrike: [\"_u\"],\n};\n\nconst ALIGN_CLASSES = [\"_alignCenter_\", \"_alignRight_\"];\n\nconst FONT_SIZE_CLASSES = [\"_h1\", \"_h2\", \"_h3\", \"_h4\", \"_h5\", \"_h6\", \"_small\"] as const;\nconst FONT_SIZE_CLASS_SET = new Set<string>(FONT_SIZE_CLASSES);\n\nconst BLOCK_TAGS = new Set([\"P\", \"LI\", \"BLOCKQUOTE\", \"UL\", \"OL\", \"HR\", \"DIV\"]);\n\n// Tokenize only after the user types a breakout whitespace.\nconst HASHTAG_REGEX = /(^|\\s)(#[\\p{L}\\p{N}_-]{1,80})(?=\\s)/gu;\nconst URL_REGEX = /(https?:\\/\\/[^\\s]+|www\\.[^\\s]+)(?=\\s)/gi;\n\nfunction clampNumber(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value));\n}\n\nfunction toPx(value: number | string | undefined, fallback: string): string {\n\tif (typeof value === \"number\") return `${value}px`;\n\tif (typeof value === \"string\" && value.trim()) return value;\n\treturn fallback;\n}\n\nfunction isHTMLElement(value: unknown): value is HTMLElement {\n\treturn value instanceof HTMLElement;\n}\n\nfunction isNode(value: unknown): value is Node {\n\treturn value instanceof Node;\n}\n\nfunction createUid(prefix: string): string {\n\tconst rand = Math.random().toString(36).slice(2, 8);\n\tconst ts = Date.now().toString().slice(-6);\n\treturn `${prefix}_${ts}${rand}`;\n}\n\nfunction tryNormalizeColor(input: string): string | null {\n\tconst test = document.createElement(\"span\");\n\ttest.style.color = \"\";\n\ttest.style.color = input;\n\tif (!test.style.color) return null;\n\tdocument.body.appendChild(test);\n\tconst computed = getComputedStyle(test).color;\n\ttest.remove();\n\tconst match = computed.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i);\n\tif (!match) return null;\n\tconst [, r, g, b] = match;\n\tconst hex = [r, g, b]\n\t\t.map((v) => clampNumber(Number(v), 0, 255).toString(16).padStart(2, \"0\"))\n\t\t.join(\"\");\n\treturn `#${hex}`;\n}\n\nfunction buildDefaultScheme(highlightColor: string): ColorScheme {\n\tconst focus = tryNormalizeColor(highlightColor) || \"#0d9488\";\n\treturn {\n\t\t\"--content\": \"#ffffff\",\n\t\t\"--content-text\": \"#111827\",\n\t\t\"--content-focus\": focus,\n\t\t\"--focus\": `${focus}33`,\n\t\t\"--placeholder\": \"#9ca3af\",\n\t};\n}\n\nexport class Wysiwyg4All {\n\tprivate readonly option: Wysiwyg4AllOptions;\n\tprivate readonly element: HTMLElement;\n\tprivate readonly styleTagOfCommand: Record<InlineClassCommand, string>;\n\tprivate readonly customCommandHandlers = new Map<string, (editor: Wysiwyg4All, action: CommandInput) => void | Promise<void>>();\n\tprivate readonly eventBus = new Map<string, Set<(...args: unknown[]) => void>>();\n\tprivate readonly imageInput: HTMLInputElement;\n\tprivate observer: MutationObserver | null = null;\n\tprivate range: Range | null = null;\n\tprivate rangeBackup: Range | null = null;\n\tprivate callback: Wysiwyg4AllOptions[\"callback\"];\n\tprivate readonly hashtagEnabled: boolean;\n\tprivate readonly urlEnabled: boolean;\n\tprivate lastKey: string | null = null;\n\tprivate suspendSelectionCaptureForColorPicker = false;\n\tprivate colorCommandDebounceTimer: number | null = null;\n\tprivate pendingColorCommand: { color?: string; backgroundColor?: string } | null = null;\n\tprivate pendingColorSelectionSnapshot: { start: number; end: number } | null = null;\n\tprivate trackerUpdateInFlight = false;\n\tprivate trackerUpdateQueued = false;\n\tprivate livePickerColor: string | null = null;\n\tprivate livePickerBackgroundColor: string | null = null;\n\tprivate colorPickerInteractionUntil = 0;\n\n\tpublic highlightColor: string;\n\tpublic defaultFontColor = \"#111827\";\n\tpublic defaultBackgroundColor = \"#ffffff\";\n\tpublic image_array: ImageData[] = [];\n\tpublic hashtag_array: HashtagData[] = [];\n\tpublic urllink_array: UrlData[] = [];\n\tpublic custom_array: CustomData[] = [];\n\tpublic commandTracker: CommandTracker = {} as CommandTracker;\n\t// public lastLineBlank: boolean;\n\n\tconstructor(option: Wysiwyg4AllOptions) {\n\t\tif (!option || typeof option !== \"object\") {\n\t\t\tthrow new Error(\"Wysiwyg4All option is required\");\n\t\t}\n\t\tif (!option.elementId || typeof option.elementId !== \"string\") {\n\t\t\tthrow new Error(\"The wysiwyg element should have an ID\");\n\t\t}\n\n\t\tconst elementId = option.elementId.startsWith(\"#\")\n\t\t\t? option.elementId.slice(1)\n\t\t\t: option.elementId;\n\t\tconst element = document.getElementById(elementId);\n\t\tif (!element) {\n\t\t\tthrow new Error(`element #${elementId} is null`);\n\t\t}\n\n\t\tthis.option = { ...option, elementId };\n\t\tthis.element = element;\n\t\tthis.callback = option.callback;\n\t\tthis.hashtagEnabled = !!option.hashtag;\n\t\tthis.urlEnabled = !!option.urllink;\n\n\t\tthis.styleTagOfCommand = { ...CLASS_BY_COMMAND };\n\t\tthis.highlightColor = tryNormalizeColor(\n\t\t\ttypeof option.highlightColor === \"string\" ? option.highlightColor : \"#0d9488\"\n\t\t) || \"#0d9488\";\n\n\t\tthis.applyTheme(option.highlightColor);\n\t\tthis.applyFontSize(option.fontSize);\n\n\t\tthis.element.classList.add(\"_wysiwyg4all\");\n\t\tthis.element.innerHTML = \"\";\n\t\tthis.setPlaceholder(option.placeholder || \"\");\n\t\tthis.setSpellcheck(!!option.spellcheck);\n\n\t\tthis.imageInput = document.createElement(\"input\");\n\t\tthis.imageInput.type = \"file\";\n\t\tthis.imageInput.accept = \"image/gif,image/png,image/jpeg,image/webp\";\n\t\tthis.imageInput.multiple = true;\n\t\tthis.imageInput.hidden = true;\n\t\tthis.imageInput.addEventListener(\"change\", (ev) => {\n\t\t\tvoid this.onImageSelected(ev).catch((err) => console.error(err));\n\t\t});\n\n\t\tthis.initializeCommandTracker();\n\t\tthis.bindCoreEvents();\n\t\tthis.bootstrapExtensions(option.extensions || []);\n\n\t\tqueueMicrotask(() => {\n\t\t\tvoid this.loadHTML(option.html || \"\", option.editable ?? true).catch((err) => {\n\t\t\t\tconsole.error(err);\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate initializeCommandTracker(): void {\n\t\tconst baseKeys = [\n\t\t\t\"quote\",\n\t\t\t\"unorderedList\",\n\t\t\t\"orderedList\",\n\t\t\t\"alignLeft\",\n\t\t\t\"alignCenter\",\n\t\t\t\"alignRight\",\n\t\t\t...Object.keys(this.styleTagOfCommand),\n\t\t];\n\t\tfor (const key of baseKeys) {\n\t\t\tthis.commandTracker[key] = false;\n\t\t}\n\t}\n\n\tprivate applyTheme(highlightColor?: string | ColorScheme): void {\n\t\tconst scheme: ColorScheme =\n\t\t\ttypeof highlightColor === \"object\" && highlightColor\n\t\t\t\t? highlightColor\n\t\t\t\t: buildDefaultScheme(typeof highlightColor === \"string\" ? highlightColor : \"#0d9488\");\n\t\tfor (const [key, value] of Object.entries(scheme)) {\n\t\t\tthis.element.style.setProperty(key, value);\n\t\t}\n\t\tconst computed = getComputedStyle(this.element);\n\t\tconst contentText = computed.getPropertyValue(\"--content-text\").trim();\n\t\tconst contentBg = computed.getPropertyValue(\"--content\").trim();\n\t\tconst contentFocus = computed.getPropertyValue(\"--content-focus\").trim();\n\t\tthis.defaultFontColor = tryNormalizeColor(contentText) || \"#111827\";\n\t\tthis.defaultBackgroundColor = tryNormalizeColor(contentBg) || \"#ffffff\";\n\t\tthis.highlightColor = tryNormalizeColor(contentFocus) || this.highlightColor;\n\t}\n\n\tprivate applyFontSize(fontSize?: EditorFontSize | number): void {\n\t\tconst fs =\n\t\t\ttypeof fontSize === \"number\"\n\t\t\t\t? { desktop: fontSize, tablet: fontSize, phone: fontSize }\n\t\t\t\t: fontSize || {};\n\n\t\tconst desktop = toPx(fs.desktop, \"18px\");\n\t\tconst tablet = toPx(fs.tablet ?? fs.desktop, desktop);\n\t\tconst phone = toPx(fs.phone ?? fs.tablet ?? fs.desktop, tablet);\n\t\tthis.element.style.setProperty(\"--wysiwyg-font-desktop\", desktop);\n\t\tthis.element.style.setProperty(\"--wysiwyg-font-tablet\", tablet);\n\t\tthis.element.style.setProperty(\"--wysiwyg-font-phone\", phone);\n\n\t\tconst ratioDefaults: Record<string, string | number> = {\n\t\t\th1: 4.2,\n\t\t\th2: 3.56,\n\t\t\th3: 2.92,\n\t\t\th4: 2.28,\n\t\t\th5: 1.64,\n\t\t\th6: 1.15,\n\t\t\tsmall: 0.8,\n\t\t};\n\n\t\tfor (const [tag, fallback] of Object.entries(ratioDefaults)) {\n\t\t\tconst raw = (fs as EditorFontSize)[tag as keyof EditorFontSize] ?? fallback;\n\t\t\tif (typeof raw === \"number\") {\n\t\t\t\tthis.element.style.setProperty(`--wysiwyg-${tag}`, `calc(var(--wysiwyg-font) * ${raw})`);\n\t\t\t} else {\n\t\t\t\tconst normalized = raw.trim();\n\t\t\t\tif (normalized.endsWith(\"px\")) {\n\t\t\t\t\tthis.element.style.setProperty(`--wysiwyg-${tag}`, normalized);\n\t\t\t\t} else {\n\t\t\t\t\tconst parsed = Number.parseFloat(normalized);\n\t\t\t\t\tif (Number.isFinite(parsed) && parsed > 0) {\n\t\t\t\t\t\tthis.element.style.setProperty(`--wysiwyg-${tag}`, `calc(var(--wysiwyg-font) * ${parsed})`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate bindCoreEvents(): void {\n\t\tdocument.addEventListener(\"selectionchange\", this.onSelectionChange);\n\t\tthis.element.addEventListener(\"keydown\", this.onKeyDown);\n\t\tthis.element.addEventListener(\"paste\", this.onPaste);\n\t\twindow.addEventListener(\"mousedown\", this.onWindowMouseDown);\n\t}\n\n\tprivate unbindCoreEvents(): void {\n\t\tdocument.removeEventListener(\"selectionchange\", this.onSelectionChange);\n\t\tthis.element.removeEventListener(\"keydown\", this.onKeyDown);\n\t\tthis.element.removeEventListener(\"paste\", this.onPaste);\n\t\twindow.removeEventListener(\"mousedown\", this.onWindowMouseDown);\n\t}\n\n\tprivate bootstrapExtensions(extensions: ExtensionFactory[]): void {\n\t\tconst api: ExtensionApi = {\n\t\t\tregisterCommand: (name, handler) => {\n\t\t\t\tthis.customCommandHandlers.set(name, handler);\n\t\t\t},\n\t\t\ton: (eventName, handler) => {\n\t\t\t\tif (!this.eventBus.has(eventName)) this.eventBus.set(eventName, new Set());\n\t\t\t\tthis.eventBus.get(eventName)?.add(handler);\n\t\t\t\treturn () => {\n\t\t\t\t\tthis.eventBus.get(eventName)?.delete(handler);\n\t\t\t\t};\n\t\t\t},\n\t\t\teditor: this,\n\t\t};\n\n\t\tfor (const createExtension of extensions) {\n\t\t\ttry {\n\t\t\t\tcreateExtension(api);\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"extension bootstrap failed\", err);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate emit(eventName: string, ...args: unknown[]): void {\n\t\tconst handlers = this.eventBus.get(eventName);\n\t\tif (!handlers) return;\n\t\tfor (const handler of handlers) {\n\t\t\ttry {\n\t\t\t\thandler(...args);\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(err);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate onWindowMouseDown = (ev: MouseEvent): void => {\n\t\tif (isNode(ev.target) && this.isUnSelectableElement(ev.target)) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst target = ev.target;\n\t\tif (target instanceof HTMLInputElement && target.type === \"color\") {\n\t\t\tconst sel = window.getSelection();\n\t\t\tif (sel && sel.rangeCount > 0 && this.element.contains(sel.anchorNode) && this.element.contains(sel.focusNode)) {\n\t\t\t\tconst normalized = this.normalizeEditorRange(sel.getRangeAt(0));\n\t\t\t\tif (normalized) {\n\t\t\t\t\tthis.range = normalized;\n\t\t\t\t\tthis.backupCurrentRange(normalized, { bypassNormalize: true });\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst active = document.activeElement;\n\t\tconst colorPickerActive = active instanceof HTMLInputElement && active.type === \"color\";\n\t\tif (colorPickerActive && isNode(ev.target) && this.element.contains(ev.target)) {\n\t\t\t// Clicking inside editor to close color picker can collapse the live\n\t\t\t// selection before color command runs. Keep backup during picker interaction,\n\t\t\t// then re-capture once the picker closes so commands use the new caret.\n\t\t\tthis.suspendSelectionCaptureForColorPicker = true;\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tconst nextActive = document.activeElement;\n\t\t\t\tconst stillOnColorInput = nextActive instanceof HTMLInputElement && nextActive.type === \"color\";\n\t\t\t\tif (stillOnColorInput) return;\n\t\t\t\tthis.suspendSelectionCaptureForColorPicker = false;\n\t\t\t\t// this.captureRange();\n\t\t\t\t// this.updateCommandTracker();\n\t\t\t}, 0);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.suspendSelectionCaptureForColorPicker = false;\n\t};\n\n\tprivate onSelectionChange = (): void => {\n\t\tif (this.suspendSelectionCaptureForColorPicker) {\n\t\t\tconst active = document.activeElement;\n\t\t\tif (!(active instanceof HTMLInputElement && active.type === \"color\")) {\n\t\t\t\tthis.suspendSelectionCaptureForColorPicker = false;\n\t\t\t} else {\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tthis.captureRange();\n\t\tthis.updateCommandTracker();\n\t};\n\n\tprivate onKeyDown = (ev: KeyboardEvent): void => {\n\t\tthis.lastKey = ev.key.toUpperCase();\n\t\tthis.captureRange();\n\n\t\tif (!this.range) return;\n\n\t\tif ([\"BACKSPACE\", \"DELETE\"].includes(this.lastKey)) {\n\t\t\tif (!this.element.textContent?.trim() && this.element.childNodes.length <= 1) {\n\t\t\t\tev.preventDefault();\n\t\t\t\tthis.ensureRootHasSafeLine();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (this.handleDeleteFromTrailingEmptyLine()) {\n\t\t\t\tev.preventDefault();\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tif (this.lastKey === \"TAB\") {\n\t\t\tev.preventDefault();\n\t\t\tthis.insertText(\"\\t\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.lastKey === \"ENTER\") {\n\t\t\tif (this.handleEnterFromQuoteTail()) {\n\t\t\t\tev.preventDefault();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tqueueMicrotask(() => this.ensureRootHasSafeLine());\n\t\t}\n\n\t\twindow.setTimeout(() => {\n\t\t\tthis.normalizeDocument();\n\t\t\tvoid this.scanSpecialTokens();\n\t\t}, 0);\n\t};\n\n\tprivate onPaste = (ev: ClipboardEvent): void => {\n\t\tev.preventDefault();\n\t\tconst text = ev.clipboardData?.getData(\"text/plain\") || \"\";\n\t\tthis.insertText(text.replace(/\\r\\n?/g, \"\\n\"));\n\t\tqueueMicrotask(() => {\n\t\t\tthis.normalizeDocument();\n\t\t\tvoid this.scanSpecialTokens();\n\t\t});\n\t};\n\n\tprivate ensureRootHasSafeLine(): void {\n\t\tif (this.element.childNodes.length === 0) {\n\t\t\tthis.element.append(this.createEmptyParagraph());\n\t\t\treturn;\n\t\t}\n\t\tif (this.element.childNodes.length === 1) {\n\t\t\tconst first = this.element.firstChild;\n\t\t\tif (first && first.nodeType === Node.TEXT_NODE && !first.textContent) {\n\t\t\t\tfirst.remove();\n\t\t\t\tthis.element.append(this.createEmptyParagraph());\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate createEmptyParagraph(seed?: string): HTMLParagraphElement {\n\t\tconst p = document.createElement(\"p\");\n\t\tif (seed && seed.length) p.append(document.createTextNode(seed));\n\t\telse p.append(document.createElement(\"br\"));\n\t\treturn p;\n\t}\n\n\tprivate setSelectionAtStart(node: Node): void {\n\t\tconst range = document.createRange();\n\t\trange.selectNodeContents(node);\n\t\trange.collapse(true);\n\t\tthis.restoreLastSelection(range);\n\t}\n\n\tprivate isLineVisuallyEmpty(line: HTMLElement): boolean {\n\t\tif (!this.isLineBlockElement(line)) return false;\n\t\tif (line.querySelector(\"img,video,audio,table,hr,._media_,._custom_\")) return false;\n\t\tconst text = (line.textContent || \"\").split(\"\\u200B\").join(\"\").trim();\n\t\treturn text.length === 0;\n\t}\n\n\tprivate isNonTextElement(el: HTMLElement): boolean {\n\t\tif (el.classList.contains(\"_media_\") || el.classList.contains(\"_custom_\")) return true;\n\t\treturn [\"HR\", \"BLOCKQUOTE\", \"UL\", \"OL\", \"TABLE\"].includes(el.tagName);\n\t}\n\n\tprivate getContainingQuote(node: Node): HTMLElement | null {\n\t\tconst el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element | null);\n\t\tif (!el) return null;\n\t\tconst quote = el.closest(\"blockquote\");\n\t\treturn quote instanceof HTMLElement ? quote : null;\n\t}\n\n\tprivate unwrapQuote(quote: HTMLElement): void {\n\t\tconst parent = quote.parentNode;\n\t\tif (!parent) return;\n\n\t\tconst firstChild = quote.firstChild;\n\t\twhile (quote.firstChild) {\n\t\t\tparent.insertBefore(quote.firstChild, quote);\n\t\t}\n\t\tquote.remove();\n\n\t\tif (firstChild) {\n\t\t\tconst target = firstChild.nodeType === Node.TEXT_NODE ? firstChild.parentNode : firstChild;\n\t\t\tif (target) this.setSelectionAtStart(target as Node);\n\t\t}\n\n\t\tthis.ensureRootHasSafeLine();\n\t}\n\n\tprivate getQuoteLineBlocks(quote: HTMLElement): HTMLElement[] {\n\t\treturn Array.from(quote.querySelectorAll<HTMLElement>(\"p,li,td,th,tr\"));\n\t}\n\n\tprivate handleEnterFromQuoteTail(): boolean {\n\t\tif (!this.range || !this.range.collapsed) return false;\n\n\t\tconst quote = this.getContainingQuote(this.range.startContainer);\n\t\tif (!quote) return false;\n\n\t\tconst currentLine = this.getContainingLineBlock(this.range.startContainer);\n\t\tif (!currentLine || !quote.contains(currentLine)) return false;\n\t\tif (!this.isLineVisuallyEmpty(currentLine)) return false;\n\n\t\tconst lines = this.getQuoteLineBlocks(quote);\n\t\tif (lines.length === 0 || lines[lines.length - 1] !== currentLine) return false;\n\n\t\tcurrentLine.remove();\n\n\t\tconst remainingLines = this.getQuoteLineBlocks(quote);\n\t\tif (remainingLines.length === 0 && !(quote.textContent || \"\").trim()) {\n\t\t\tquote.remove();\n\t\t\tthis.ensureRootHasSafeLine();\n\t\t\tthis.setSelectionAtStart(this.element.lastElementChild || this.element);\n\t\t\treturn true;\n\t\t}\n\n\t\tconst targetLine = this.ensureTrailingEditableLineAfter(quote);\n\t\tthis.setSelectionAtStart(targetLine);\n\t\treturn true;\n\t}\n\n\tprivate handleDeleteFromTrailingEmptyLine(): boolean {\n\t\tif (!this.range || !this.range.collapsed) return false;\n\n\t\tconst line = this.getContainingLineBlock(this.range.startContainer);\n\t\tif (!line) return false;\n\t\tif (line !== this.element.lastElementChild) return false;\n\t\tif (!this.isLineVisuallyEmpty(line)) return false;\n\n\t\tconst prev = line.previousElementSibling as HTMLElement | null;\n\t\tif (!prev || !this.isNonTextElement(prev)) return false;\n\n\t\tprev.remove();\n\t\tif (!line.isConnected) {\n\t\t\tthis.element.append(this.createEmptyParagraph());\n\t\t\tthis.setSelectionAtStart(this.element.lastElementChild as Node);\n\t\t\treturn true;\n\t\t}\n\n\t\tthis.setSelectionAtStart(line);\n\t\treturn true;\n\t}\n\n\tprivate captureRange(): void {\n\t\tconst sel = window.getSelection();\n\n\t\t// check if sel is within our editor\n\t\tif (!sel || !this.element.contains(sel.anchorNode)) {\n\t\t\t// this.range = null;\n\t\t\treturn;\n\t\t}\n\n\t\tif (!sel || sel.rangeCount === 0) {\n\t\t\tthis.range = null;\n\t\t\treturn;\n\t\t}\n\t\tconst normalizedRange = this.normalizeEditorRange(sel.getRangeAt(0));\n\t\tlet normalized = normalizedRange;\n\t\tif (!normalized) {\n\t\t\tthis.range = null;\n\t\t\treturn;\n\t\t}\n\n\t\tconst reanchored = this.reanchorCollapsedRangeToAdjacentStylePlaceholder(normalized);\n\t\tif (reanchored) {\n\t\t\tnormalized = reanchored;\n\t\t\tif (\n\t\t\t\tnormalizedRange &&\n\t\t\t\t(\n\t\t\t\t\tnormalized.startContainer !== normalizedRange.startContainer ||\n\t\t\t\t\tnormalized.startOffset !== normalizedRange.startOffset\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tsel.removeAllRanges();\n\t\t\t\tsel.addRange(normalized);\n\t\t\t}\n\t\t}\n\t\tthis.range = normalized;\n\t\tthis.backupCurrentRange(normalized, { bypassNormalize: true });\n\t}\n\n\tprivate isCollapsedStyleAnchorSpan(el: HTMLElement): boolean {\n\t\tif (el.tagName !== \"SPAN\") return false;\n\t\tif (this.isProtectedSpan(el)) return false;\n\t\tif (!this.isTextStyleWrapper(el, this.getKnownInlineStyleClassSet())) return false;\n\t\tif (el.querySelector(\"br,hr,img,video,audio,table,ul,ol,li,blockquote,div,._media_,._custom_\")) return false;\n\t\tconst textWithoutMarker = (el.textContent || \"\").split(\"\\u200B\").join(\"\").trim();\n\t\treturn textWithoutMarker.length === 0;\n\t}\n\n\tprivate reanchorCollapsedRangeToAdjacentStylePlaceholder(range: Range): Range | null {\n\t\tif (!range.collapsed) return null;\n\n\t\tconst buildRangeInsidePlaceholder = (node: Node | null): Range | null => {\n\t\t\tif (!(node instanceof HTMLElement)) return null;\n\t\t\tif (!this.isCollapsedStyleAnchorSpan(node)) return null;\n\t\t\tconst firstChild = node.firstChild;\n\t\t\tconst anchor: Text = firstChild instanceof Text\n\t\t\t\t? firstChild\n\t\t\t\t: document.createTextNode(\"\\u200B\");\n\t\t\tif (!(firstChild instanceof Text)) node.append(anchor);\n\t\t\tconst next = document.createRange();\n\t\t\tnext.setStart(anchor, anchor.length);\n\t\t\tnext.collapse(true);\n\t\t\treturn next;\n\t\t};\n\n\t\tconst tryFromContainer = (container: Node, offset: number): Range | null => {\n\t\t\tif (!(container instanceof Element)) return null;\n\t\t\tconst before = offset > 0 ? container.childNodes[offset - 1] : null;\n\t\t\tconst after = offset < container.childNodes.length ? container.childNodes[offset] : null;\n\t\t\tconst candidates = [before, after];\n\n\t\t\tfor (const node of candidates) {\n\t\t\t\tconst next = buildRangeInsidePlaceholder(node);\n\t\t\t\tif (next) return next;\n\t\t\t}\n\n\t\t\treturn null;\n\t\t};\n\n\t\tconst tryFromAncestorBoundary = (startNode: Node, preferNext: boolean): Range | null => {\n\t\t\tlet node: Node | null = startNode;\n\t\t\twhile (node && node !== this.element) {\n\t\t\t\tconst parent = node.parentNode;\n\t\t\t\tif (!parent || parent === this.element.parentNode) break;\n\t\t\t\tconst sibling = preferNext ? node.nextSibling : node.previousSibling;\n\t\t\t\tconst next = buildRangeInsidePlaceholder(sibling);\n\t\t\t\tif (next) return next;\n\t\t\t\tnode = parent;\n\t\t\t}\n\t\t\treturn null;\n\t\t};\n\n\t\tif (range.startContainer instanceof Element) {\n\t\t\tconst direct = tryFromContainer(range.startContainer, range.startOffset);\n\t\t\tif (direct) return direct;\n\t\t\tif (range.startOffset >= range.startContainer.childNodes.length) {\n\t\t\t\treturn tryFromAncestorBoundary(range.startContainer, true);\n\t\t\t}\n\t\t\tif (range.startOffset === 0) {\n\t\t\t\treturn tryFromAncestorBoundary(range.startContainer, false);\n\t\t\t}\n\t\t\treturn null;\n\t\t}\n\n\t\tconst textNode = range.startContainer;\n\t\tconst parent = textNode.parentElement;\n\t\tif (!parent) return null;\n\t\tconst siblings: Node[] = Array.from(parent.childNodes);\n\t\tconst idx = siblings.indexOf(textNode);\n\t\tif (idx < 0) return null;\n\n\t\tconst preferOffset = idx + (range.startOffset > 0 ? 1 : 0);\n\t\tconst direct = tryFromContainer(parent, preferOffset);\n\t\tif (direct) return direct;\n\n\t\tif (textNode instanceof Text) {\n\t\t\tif (range.startOffset >= textNode.length) {\n\t\t\t\treturn tryFromAncestorBoundary(parent, true);\n\t\t\t}\n\t\t\tif (range.startOffset === 0) {\n\t\t\t\treturn tryFromAncestorBoundary(parent, false);\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tprivate backupCurrentRange(\n\t\tsource?: Selection | Range | null,\n\t\tparams?: { bypassNormalize?: boolean }\n\t): Range | null {\n\t\tconst bypassNormalize = params?.bypassNormalize ?? false;\n\t\tlet candidate: Range | null = null;\n\n\t\tif (source instanceof Range) {\n\t\t\tcandidate = source.cloneRange();\n\t\t} else {\n\t\t\tconst sel = source ?? window.getSelection();\n\t\t\tif (!sel || sel.rangeCount === 0) return null;\n\t\t\tif (!this.element.contains(sel.anchorNode)) return null;\n\t\t\tcandidate = sel.getRangeAt(0).cloneRange();\n\t\t}\n\n\t\tconst rangeBackup = bypassNormalize ? candidate : this.normalizeEditorRange(candidate);\n\t\tif (!rangeBackup) return null;\n\t\tthis.rangeBackup = rangeBackup.cloneRange();\n\t\treturn this.rangeBackup;\n\t}\n\n\tprivate getTextOffsetWithinEditor(container: Node, offset: number): number | null {\n\t\tif (container !== this.element && !this.element.contains(container)) return null;\n\n\t\tconst probe = document.createRange();\n\t\ttry {\n\t\t\tprobe.setStart(this.element, 0);\n\t\t\tprobe.setEnd(container, offset);\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn probe.toString().length;\n\t}\n\n\tprivate resolveBoundaryFromTextOffset(charOffset: number): { container: Node; offset: number } | null {\n\t\tlet remaining = Math.max(0, charOffset);\n\t\tconst walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);\n\t\tlet lastText: Text | null = null;\n\n\t\twhile (walker.nextNode()) {\n\t\t\tconst textNode = walker.currentNode as Text;\n\t\t\tconst len = textNode.length;\n\t\t\tlastText = textNode;\n\n\t\t\tif (remaining <= len) {\n\t\t\t\treturn { container: textNode, offset: remaining };\n\t\t\t}\n\n\t\t\tremaining -= len;\n\t\t}\n\n\t\tif (lastText) {\n\t\t\treturn { container: lastText, offset: lastText.length };\n\t\t}\n\n\t\tconst last = this.element.lastChild;\n\t\tif (!last) return null;\n\t\treturn this.getDeepBoundaryPoint(last, false);\n\t}\n\n\tprivate snapshotRangeToTextOffsets(range: Range): { start: number; end: number } | null {\n\t\tconst normalized = this.normalizeEditorRange(range);\n\t\tif (!normalized) return null;\n\n\t\tconst start = this.getTextOffsetWithinEditor(normalized.startContainer, normalized.startOffset);\n\t\tconst end = this.getTextOffsetWithinEditor(normalized.endContainer, normalized.endOffset);\n\t\tif (start === null || end === null) return null;\n\n\t\treturn { start, end };\n\t}\n\n\tprivate restoreRangeFromTextOffsets(snapshot: { start: number; end: number }): Range | null {\n\t\tconst start = this.resolveBoundaryFromTextOffset(snapshot.start);\n\t\tconst end = this.resolveBoundaryFromTextOffset(snapshot.end);\n\t\tif (!start || !end) return null;\n\n\t\tconst range = document.createRange();\n\t\ttry {\n\t\t\trange.setStart(start.container, start.offset);\n\t\t\trange.setEnd(end.container, end.offset);\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn this.normalizeEditorRange(range);\n\t}\n\n\tprivate getDeepBoundaryPoint(node: Node, atStart: boolean): { container: Node; offset: number } {\n\t\tlet current = node;\n\t\twhile (current.nodeType !== Node.TEXT_NODE && current.childNodes.length > 0) {\n\t\t\tcurrent = atStart ? current.firstChild as Node : current.lastChild as Node;\n\t\t}\n\n\t\tif (current.nodeType === Node.TEXT_NODE) {\n\t\t\tconst text = current as Text;\n\t\t\treturn { container: text, offset: atStart ? 0 : text.length };\n\t\t}\n\n\t\treturn { container: current, offset: atStart ? 0 : current.childNodes.length };\n\t}\n\n\tprivate resolveRangeBoundaryPoint(\n\t\tcontainer: Node,\n\t\toffset: number,\n\t\tatStart: boolean\n\t): { container: Node; offset: number } | null {\n\t\tif (container === this.element) {\n\t\t\tconst { childNodes } = this.element;\n\t\t\tif (childNodes.length === 0) return null;\n\n\t\t\tif (atStart) {\n\t\t\t\tconst target = offset < childNodes.length ? childNodes[offset] : childNodes[childNodes.length - 1];\n\t\t\t\treturn this.getDeepBoundaryPoint(target, offset < childNodes.length);\n\t\t\t}\n\n\t\t\tconst target = offset > 0 ? childNodes[offset - 1] : childNodes[0];\n\t\t\treturn this.getDeepBoundaryPoint(target, offset === 0);\n\t\t}\n\n\t\tif (!this.element.contains(container)) {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn { container, offset };\n\t}\n\n\tprivate normalizeEditorRange(range: Range): Range | null {\n\t\tif (range.collapsed) {\n\t\t\tconst point = this.resolveRangeBoundaryPoint(range.startContainer, range.startOffset, true);\n\t\t\tif (!point) return null;\n\n\t\t\tconst normalizedCollapsed = range.cloneRange();\n\t\t\ttry {\n\t\t\t\tnormalizedCollapsed.setStart(point.container, point.offset);\n\t\t\t\tnormalizedCollapsed.setEnd(point.container, point.offset);\n\t\t\t} catch {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\treturn normalizedCollapsed;\n\t\t}\n\n\t\tconst start = this.resolveRangeBoundaryPoint(range.startContainer, range.startOffset, true);\n\t\tconst end = this.resolveRangeBoundaryPoint(range.endContainer, range.endOffset, false);\n\t\tif (!start || !end) return null;\n\n\t\tconst normalized = range.cloneRange();\n\t\ttry {\n\t\t\tnormalized.setStart(start.container, start.offset);\n\t\t\tnormalized.setEnd(end.container, end.offset);\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn normalized;\n\t}\n\n\tprivate restoreLastSelection(range: Range | null = this.rangeBackup): void {\n\t\tif (!range) return;\n\t\tconst normalized = this.normalizeEditorRange(range);\n\t\tif (!normalized) return;\n\t\tconst sel = window.getSelection();\n\t\tif (!sel) return;\n\n\t\tthis.range = normalized;\n\t\tsel.removeAllRanges();\n\t\tsel.addRange(normalized);\n\t}\n\n\tpublic setPlaceholder(placeholder: string): void {\n\t\tif (placeholder && placeholder.trim()) {\n\t\t\tthis.element.setAttribute(\"placeholder\", placeholder);\n\t\t} else {\n\t\t\tthis.element.removeAttribute(\"placeholder\");\n\t\t}\n\t\tthis.updatePlaceholderVisibility();\n\t}\n\n\tpublic setSpellcheck(enabled: boolean): void {\n\t\tthis.element.setAttribute(\"spellcheck\", enabled ? \"true\" : \"false\");\n\t}\n\n\tpublic setEditable(enabled: boolean): void {\n\t\tthis.element.setAttribute(\"contenteditable\", enabled ? \"true\" : \"false\");\n\t\tthis.observeMutation(enabled);\n\t\tthis.updatePlaceholderVisibility();\n\t}\n\n\tprivate isEditorInPlaceholderState(): boolean {\n\t\tif (this.element.childNodes.length === 0) return true;\n\t\tif (this.element.childNodes.length !== 1) return false;\n\n\t\tconst only = this.element.firstChild;\n\t\tif (!only) return true;\n\t\tif (only.nodeType === Node.TEXT_NODE) {\n\t\t\tconst text = (only.textContent || \"\").split(\"\\u200B\").join(\"\").trim();\n\t\t\treturn text.length === 0;\n\t\t}\n\n\t\tif (!(only instanceof HTMLElement)) return false;\n\t\tif (only.tagName === \"BR\") return true;\n\t\tif (only.tagName === \"P\") return this.isLineVisuallyEmpty(only);\n\t\treturn false;\n\t}\n\n\tprivate updatePlaceholderVisibility(): void {\n\t\tconst hasPlaceholder = !!this.element.getAttribute(\"placeholder\")?.trim();\n\t\tconst editable = this.element.getAttribute(\"contenteditable\") === \"true\";\n\t\tconst shouldShow = hasPlaceholder && editable && this.isEditorInPlaceholderState();\n\t\tthis.element.classList.toggle(\"_placeholderVisible\", shouldShow);\n\t}\n\n\tpublic destroy(): void {\n\t\tthis.unbindCoreEvents();\n\t\tthis.observeMutation(false);\n\t\tif (this.colorCommandDebounceTimer !== null) {\n\t\t\twindow.clearTimeout(this.colorCommandDebounceTimer);\n\t\t\tthis.colorCommandDebounceTimer = null;\n\t\t}\n\t\tthis.imageInput.remove();\n\t\tthis.eventBus.clear();\n\t\tthis.customCommandHandlers.clear();\n\t}\n\n\tprivate observeMutation(track: boolean): void {\n\t\tif (this.observer) {\n\t\t\tthis.observer.disconnect();\n\t\t\tthis.observer = null;\n\t\t}\n\t\tif (!track) return;\n\n\t\tthis.observer = new MutationObserver((mutations) => {\n\t\t\tfor (const mutation of mutations) {\n\t\t\t\tif (mutation.type !== \"childList\") continue;\n\t\t\t\tconst target = mutation.target;\n\t\t\t\tif (!(target instanceof HTMLElement)) continue;\n\n\t\t\t\tif (target === this.element && target.childNodes.length === 0) {\n\t\t\t\t\ttarget.append(this.createEmptyParagraph());\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (\n\t\t\t\t\tthis.isCeilingElement(target) &&\n\t\t\t\t\ttarget !== this.element &&\n\t\t\t\t\ttarget.childNodes.length === 0\n\t\t\t\t) {\n\t\t\t\t\ttarget.remove();\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (\n\t\t\t\t\tthis.isTextBlockElement(target) &&\n\t\t\t\t\ttarget.childNodes.length === 1 &&\n\t\t\t\t\tthis.isUnSelectableElement(target.firstChild)\n\t\t\t\t) {\n\t\t\t\t\ttarget.append(document.createTextNode(\"\"));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.updatePlaceholderVisibility();\n\t\t});\n\n\t\tthis.observer.observe(this.element, {\n\t\t\tattributes: true,\n\t\t\tchildList: true,\n\t\t\tcharacterData: true,\n\t\t\tsubtree: true,\n\t\t});\n\t}\n\n\tprivate isUnSelectableElement(node: Node | null): boolean {\n\t\tconst el = node?.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element | null);\n\t\tif (!el) return false;\n\t\treturn !!el.closest(\"._media_, ._hashtag_, ._urllink_, hr\");\n\t}\n\n\tprivate isTextBlockElement(node: Node | null): boolean {\n\t\tconst el = node?.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element | null);\n\t\tif (!el) return false;\n\t\treturn [\"P\", \"LI\", \"TD\", \"TH\", \"TR\"].includes(el.tagName);\n\t}\n\n\tprivate isCeilingElement(node: Node | null): boolean {\n\t\tconst el = node?.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element | null);\n\t\tif (!el) return false;\n\t\tif (el === this.element) return true;\n\t\treturn [\"UL\", \"OL\", \"LI\", \"BLOCKQUOTE\"].includes(el.tagName);\n\t}\n\n\tprivate cleanupZeroWidthSpaces(): void {\n\t\tconst textNodes: Text[] = [];\n\t\tconst walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT, {\n\t\t\tacceptNode: (node) => {\n\t\t\t\tconst parent = node.parentElement;\n\t\t\t\tif (!parent) return NodeFilter.FILTER_REJECT;\n\t\t\t\tif (parent.closest(\"._media_, ._custom_\")) return NodeFilter.FILTER_REJECT;\n\t\t\t\treturn (node.textContent || \"\").includes(\"\\u200B\")\n\t\t\t\t\t? NodeFilter.FILTER_ACCEPT\n\t\t\t\t\t: NodeFilter.FILTER_REJECT;\n\t\t\t},\n\t\t});\n\n\t\twhile (walker.nextNode()) {\n\t\t\ttextNodes.push(walker.currentNode as Text);\n\t\t}\n\n\t\tfor (const textNode of textNodes) {\n\t\t\t// Use deleteData() so the browser Selection tracks the mutation\n\t\t\t// and keeps the caret at the correct position (textContent= assignment loses it).\n\t\t\tlet i = textNode.length - 1;\n\t\t\twhile (i >= 0) {\n\t\t\t\tif (textNode.data[i] === \"\\u200B\") {\n\t\t\t\t\ttextNode.deleteData(i, 1);\n\t\t\t\t}\n\t\t\t\ti--;\n\t\t\t}\n\t\t\tif (textNode.length === 0) {\n\t\t\t\ttextNode.remove();\n\t\t\t}\n\t\t}\n\n\t\tthis.element.normalize();\n\t}\n\n\tprivate normalizeDocument(): void {\n\t\tconst sel = window.getSelection();\n\t\tconst hasLiveSelectionInEditor = !!(\n\t\t\tsel &&\n\t\t\tsel.rangeCount > 0 &&\n\t\t\tthis.element.contains(sel.anchorNode) &&\n\t\t\tthis.element.contains(sel.focusNode)\n\t\t);\n\t\tconst rangeSource = hasLiveSelectionInEditor\n\t\t\t? (this.range ?? this.rangeBackup)\n\t\t\t: (this.range ?? this.rangeBackup);\n\t\tconst rangeSnapshot = rangeSource\n\t\t\t? this.snapshotRangeToTextOffsets(rangeSource)\n\t\t\t: null;\n\n\t\tthis.cleanupZeroWidthSpaces();\n\t\tconst toRemove: HTMLElement[] = [];\n\t\tconst walker = document.createTreeWalker(this.element, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);\n\t\twhile (walker.nextNode()) {\n\t\t\tconst node = walker.currentNode;\n\n\t\t\tif (node.nodeType === Node.ELEMENT_NODE) {\n\t\t\t\tconst el = node as HTMLElement;\n\t\t\t\tif (el.classList.contains(\"_static_\")) continue;\n\n\t\t\t\tif (\n\t\t\t\t\t!el.classList.contains(\"_media_\") &&\n\t\t\t\t\t!el.classList.contains(\"_custom_\") &&\n\t\t\t\t\t!el.closest(\"._media_\") &&\n\t\t\t\t\t!el.closest(\"._custom_\") &&\n\t\t\t\t\t!this.isCeilingElement(el) &&\n\t\t\t\t\t!el.textContent &&\n\t\t\t\t\tel.childNodes.length === 0 &&\n\t\t\t\t\tel.tagName !== \"BR\" &&\n\t\t\t\t\tel.tagName !== \"HR\"\n\t\t\t\t) {\n\t\t\t\t\ttoRemove.push(el);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor (const node of toRemove) node.remove();\n\t\tthis.redistributeInlineStylesAcrossBlocks();\n\t\tthis.cleanupNestedFontSizeWrappers();\n\t\tthis.cleanupRedundantTextWrappers();\n\t\tthis.cleanupEmptyTextStyleElements();\n\t\tthis.ensureRootHasSafeLine();\n\n\t\tif (hasLiveSelectionInEditor) {\n\t\t\t// For live in-editor selection, browser already tracks caret through DOM\n\t\t\t// mutations more accurately than text-offset mapping in empty blocks.\n\t\t\tthis.captureRange();\n\t\t\treturn;\n\t\t}\n\n\t\tif (rangeSnapshot) {\n\t\t\tconst rebased = this.restoreRangeFromTextOffsets(rangeSnapshot);\n\t\t\tif (rebased) {\n\t\t\t\tthis.range = rebased;\n\t\t\t\tthis.rangeBackup = rebased.cloneRange();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate isLineBlockElement(el: HTMLElement): boolean {\n\t\treturn [\"P\", \"LI\", \"TD\", \"TH\", \"TR\"].includes(el.tagName);\n\t}\n\n\tprivate cloneInlineStyleSpan(span: HTMLElement): HTMLSpanElement {\n\t\tconst clone = document.createElement(\"span\");\n\t\tclone.className = span.className;\n\t\tif (span.style.cssText.trim()) {\n\t\t\tclone.style.cssText = span.style.cssText;\n\t\t}\n\t\treturn clone;\n\t}\n\n\tprivate getContainingLineBlock(node: Node): HTMLElement | null {\n\t\tlet current: Node | null = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;\n\t\twhile (current && current !== this.element) {\n\t\t\tif (current instanceof HTMLElement && this.isLineBlockElement(current)) {\n\t\t\t\treturn current;\n\t\t\t}\n\t\t\tcurrent = current.parentNode;\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate getLineBlocksBetween(startLine: HTMLElement, endLine: HTMLElement): HTMLElement[] {\n\t\tconst blocks = Array.from(this.element.querySelectorAll<HTMLElement>(\"p,li,td,th,tr\"));\n\t\tconst startIndex = blocks.indexOf(startLine);\n\t\tconst endIndex = blocks.indexOf(endLine);\n\t\tif (startIndex < 0 || endIndex < 0) return [];\n\t\tconst from = Math.min(startIndex, endIndex);\n\t\tconst to = Math.max(startIndex, endIndex);\n\t\treturn blocks.slice(from, to + 1);\n\t}\n\n\tprivate doesRangeIntersectElement(range: Range, el: HTMLElement): boolean {\n\t\tconst nodeRange = document.createRange();\n\t\tnodeRange.selectNodeContents(el);\n\t\tconst endsBefore = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) <= 0;\n\t\tconst startsAfter = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) >= 0;\n\t\treturn !endsBefore && !startsAfter;\n\t}\n\n\tprivate getSelectedLineBlocks(range: Range): HTMLElement[] {\n\t\tconst blocks = Array.from(this.element.querySelectorAll<HTMLElement>(\"p,li,td,th,tr\"));\n\t\treturn blocks.filter((block) => this.doesRangeIntersectElement(range, block));\n\t}\n\n\tprivate createLineIntersectionRange(selectionRange: Range, line: HTMLElement): Range {\n\t\tconst lineRange = document.createRange();\n\t\tlineRange.selectNodeContents(line);\n\n\t\tif (line.contains(selectionRange.startContainer)) {\n\t\t\tlineRange.setStart(selectionRange.startContainer, selectionRange.startOffset);\n\t\t}\n\n\t\tif (line.contains(selectionRange.endContainer)) {\n\t\t\tlineRange.setEnd(selectionRange.endContainer, selectionRange.endOffset);\n\t\t}\n\n\t\treturn this.splitRangeBoundaries(lineRange);\n\t}\n\n\tprivate applyStyleToRange(\n\t\trange: Range,\n\t\tclassName: string,\n\t\tcommand: InlineClassCommand,\n\t\tcssValue?: string\n\t): HTMLElement | null {\n\t\tif (range.collapsed) return null;\n\t\tconst fragment = range.extractContents();\n\t\tconst wrapper = document.createElement(\"span\");\n\t\twrapper.classList.add(className);\n\t\tif (command === \"color\" && cssValue) wrapper.style.setProperty(\"color\", cssValue);\n\t\tif (command === \"backgroundColor\" && cssValue) wrapper.style.setProperty(\"background-color\", cssValue);\n\t\twrapper.append(fragment);\n\t\tthis.stripSameStyleFromFragment(wrapper, className, command);\n\t\tthis.removeCounterClasses(wrapper, command);\n\t\trange.insertNode(wrapper);\n\t\treturn wrapper;\n\t}\n\n\tprivate wrapMultilineSelectionWithClass(\n\t\tselectionRange: Range,\n\t\tclassName: string,\n\t\tcommand: InlineClassCommand,\n\t\tcssValue?: string,\n\t\ttargetLines?: HTMLElement[]\n\t): boolean {\n\t\tconst lines = targetLines || this.getSelectedLineBlocks(selectionRange);\n\t\tif (lines.length <= 1) return false;\n\n\t\tconst ranges = lines\n\t\t\t.map((line) => this.createLineIntersectionRange(selectionRange, line))\n\t\t\t.filter((lineRange) => !lineRange.collapsed);\n\n\t\tif (ranges.length === 0) return false;\n\n\t\tlet lastWrapper: HTMLElement | null = null;\n\t\tfor (let i = ranges.length - 1; i >= 0; i--) {\n\t\t\tconst wrapper = this.applyStyleToRange(ranges[i], className, command, cssValue);\n\t\t\tif (wrapper && !lastWrapper) lastWrapper = wrapper;\n\t\t}\n\n\t\tif (lastWrapper) this.setSelectionAtEnd(lastWrapper);\n\t\tthis.normalizeDocument();\n\t\tthis.cleanupEmptyTextStyleElements();\n\t\treturn true;\n\t}\n\n\tprivate redistributeInlineStylesAcrossBlocks(): void {\n\t\tconst knownStyleClasses = this.getKnownInlineStyleClassSet();\n\t\tlet changed = true;\n\n\t\twhile (changed) {\n\t\t\tchanged = false;\n\t\t\tconst spans = Array.from(this.element.querySelectorAll<HTMLElement>(\"span\")).reverse();\n\n\t\t\tfor (const span of spans) {\n\t\t\t\tif (!span.isConnected) continue;\n\t\t\t\tif (!this.isTextStyleWrapper(span, knownStyleClasses)) continue;\n\t\t\t\tif (this.isProtectedSpan(span)) continue;\n\n\t\t\t\tconst children = Array.from(span.childNodes);\n\t\t\t\tconst hasBlockChild = children.some(\n\t\t\t\t\t(node) => node instanceof HTMLElement && this.isLineBlockElement(node)\n\t\t\t\t);\n\t\t\t\tif (!hasBlockChild) continue;\n\n\t\t\t\tconst parent = span.parentNode;\n\t\t\t\tif (!parent) continue;\n\n\t\t\t\tconst replacement = document.createDocumentFragment();\n\t\t\t\tlet pendingInlineNodes: Node[] = [];\n\n\t\t\t\tconst flushInlineNodes = () => {\n\t\t\t\t\tif (pendingInlineNodes.length === 0) return;\n\t\t\t\t\tconst inlineSpan = this.cloneInlineStyleSpan(span);\n\t\t\t\t\tfor (const node of pendingInlineNodes) {\n\t\t\t\t\t\tinlineSpan.append(node);\n\t\t\t\t\t}\n\t\t\t\t\treplacement.append(inlineSpan);\n\t\t\t\t\tpendingInlineNodes = [];\n\t\t\t\t};\n\n\t\t\t\tfor (const child of children) {\n\t\t\t\t\tspan.removeChild(child);\n\t\t\t\t\tif (child instanceof HTMLElement && this.isLineBlockElement(child)) {\n\t\t\t\t\t\tflushInlineNodes();\n\t\t\t\t\t\tconst line = child;\n\t\t\t\t\t\tconst lineSpan = this.cloneInlineStyleSpan(span);\n\t\t\t\t\t\twhile (line.firstChild) lineSpan.append(line.firstChild);\n\t\t\t\t\t\tline.append(lineSpan);\n\t\t\t\t\t\treplacement.append(line);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpendingInlineNodes.push(child);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tflushInlineNodes();\n\t\t\t\tparent.insertBefore(replacement, span);\n\t\t\t\tspan.remove();\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate isFontSizeClassName(className: string): boolean {\n\t\treturn FONT_SIZE_CLASS_SET.has(className);\n\t}\n\n\tprivate getElementFontSizeClass(el: HTMLElement): string | null {\n\t\tfor (const cls of FONT_SIZE_CLASSES) {\n\t\t\tif (el.classList.contains(cls)) return cls;\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate findOutermostFontSizeAncestor(node: Node): HTMLElement | null {\n\t\tlet current: Node | null = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;\n\t\tlet outermost: HTMLElement | null = null;\n\t\twhile (current && current !== this.element) {\n\t\t\tif (current instanceof HTMLElement && this.getElementFontSizeClass(current)) {\n\t\t\t\toutermost = current;\n\t\t\t}\n\t\t\tcurrent = current.parentNode;\n\t\t}\n\t\treturn outermost;\n\t}\n\n\tprivate cleanupNestedFontSizeWrappers(): void {\n\t\tlet changed = true;\n\t\twhile (changed) {\n\t\t\tchanged = false;\n\t\t\tconst spans = Array.from(this.element.querySelectorAll<HTMLElement>(\"span\")).reverse();\n\n\t\t\tfor (const span of spans) {\n\t\t\t\tif (!span.isConnected) continue;\n\t\t\t\tif (span.closest(\"._media_, ._custom_\")) continue;\n\t\t\t\tif (!this.getElementFontSizeClass(span)) continue;\n\n\t\t\t\tlet parent = span.parentElement;\n\t\t\t\tlet outerSizeAncestor: HTMLElement | null = null;\n\t\t\t\twhile (parent && parent !== this.element) {\n\t\t\t\t\tif (this.getElementFontSizeClass(parent)) {\n\t\t\t\t\t\touterSizeAncestor = parent;\n\t\t\t\t\t}\n\t\t\t\t\tparent = parent.parentElement;\n\t\t\t\t}\n\n\t\t\t\tif (!outerSizeAncestor) continue;\n\t\t\t\tthis.breakOutFromAncestor(span, outerSizeAncestor);\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate hasMeaningfulAttributes(el: HTMLElement): boolean {\n\t\tfor (const attr of Array.from(el.attributes)) {\n\t\t\tconst name = attr.name.toLowerCase();\n\t\t\tconst value = attr.value.trim();\n\t\t\tif (name === \"class\") {\n\t\t\t\tif (el.classList.length > 0) return true;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (name === \"style\") {\n\t\t\t\tif (el.style.length > 0 || value.length > 0) return true;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (name.startsWith(\"data-\")) return true;\n\t\t\tif (name === \"id\" && value.length > 0) return true;\n\t\t\tif (name === \"contenteditable\" && value.length > 0) return true;\n\t\t\tif (value.length > 0) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate hasNonClassStyleAttributes(el: HTMLElement): boolean {\n\t\tfor (const attr of Array.from(el.attributes)) {\n\t\t\tconst name = attr.name.toLowerCase();\n\t\t\tif (name === \"class\" || name === \"style\") continue;\n\t\t\tif (name.startsWith(\"data-\")) return true;\n\t\t\tif (attr.value.trim().length > 0) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate isProtectedSpan(el: HTMLElement): boolean {\n\t\tif (el.tagName !== \"SPAN\") return true;\n\t\tif (el.classList.contains(\"_hashtag_\") || el.classList.contains(\"_urllink_\")) return true;\n\t\tif (el.closest(\"._media_, ._custom_\")) return true;\n\t\treturn false;\n\t}\n\n\tprivate cleanupRedundantTextWrappers(): void {\n\t\tconst knownStyleClasses = this.getKnownInlineStyleClassSet();\n\t\tlet changed = true;\n\t\twhile (changed) {\n\t\t\tchanged = false;\n\t\t\tconst spans = Array.from(this.element.querySelectorAll<HTMLElement>(\"span\")).reverse();\n\n\t\t\tfor (const span of spans) {\n\t\t\t\tif (!span.isConnected) continue;\n\t\t\t\tif (this.isProtectedSpan(span)) continue;\n\n\t\t\t\tconst children = Array.from(span.childNodes);\n\t\t\t\tconst elementChildren = ch