import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import type { WysiwygButtonDef, WysiwygCommandContext, WysiwygDropdownItem, WysiwygModalDescriptor, WysiwygModalValues, WysiwygOptions, WysiwygTranslations, WysiwygUploadArgs, } from './plugins/wysiwyg/types'; import { createApp, h } from 'vue'; import { Prop, toNative, Watch } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import EsModuleImportHelper from '../../common/utils/esmodule-import-helper'; import FormItemWrapper from '../form/form-item-wrapper'; import { FileManagerDialog, FileManagerModalFileType } from '../modal/ts/file-manager-dialog'; import svgIcons from './img/wysiwyg-icons.svg'; import { WYSIWYG_DEFAULT_COLORS, WYSIWYG_DEFAULT_FONT_SIZES, WYSIWYG_DEFAULT_LINE_HEIGHTS, } from './plugins/wysiwyg/colors'; import { getTranslations } from './plugins/wysiwyg/i18n'; import WysiwygPromptModal from './plugins/wysiwyg/wysiwyg-prompt-modal'; import './css/wysiwig.css'; export interface WysiwygEditorArgs extends FormItemWrapperArgs { value: string; placeholder?: string; useFileManager?: boolean; itemId?: number; useCommonUpload?: boolean; uploadPluginArgs?: () => WysiwygUploadArgs | null; resizable?: boolean; initialHeight?: number; onResized?: (height: number) => void; changed: (newValue: string) => void; htmlViewControlled?: boolean; onHtmlViewChange?: (htmlView: boolean) => void; } export class WysiwygConfig { static uploadArgsFactory: () => WysiwygUploadArgs | null = null; } // Toolbar icons live in a single SVG sprite file. Referencing symbols via // `` is blocked by WebKit // (Safari) and produces a console warning in Chromium whenever the sprite is // served cross-origin (e.g. from a CDN). To make `` work // reliably regardless of where the sprite is hosted, we fetch the sprite once // at runtime and inline it into the current document. After that, all `` // references resolve against the inlined symbols (same document = no // cross-origin concerns). // // Requires CORS (`Access-Control-Allow-Origin`) on the sprite asset when // served from a different origin, otherwise the fetch body cannot be read. // In that failure case `renderIcon()` simply renders an empty `` (no // `` child) — we deliberately do not fall back to the absolute URL, // because that would re-introduce the cross-origin `` warning the whole // approach is meant to avoid. let wysiwygSpritePromise: Promise | null = null; let wysiwygSpriteInjected = false; const ensureWysiwygSpriteInjected = (): Promise => { // One shared fetch for all wysiwyg instances on the page. if (wysiwygSpritePromise != null) { return wysiwygSpritePromise; } // SSR / non-browser guard. if (typeof document === 'undefined' || typeof fetch === 'undefined') { wysiwygSpritePromise = Promise.resolve(false); return wysiwygSpritePromise; } const svgPath = EsModuleImportHelper.getObj(svgIcons) || ''; if (!svgPath) { wysiwygSpritePromise = Promise.resolve(false); return wysiwygSpritePromise; } wysiwygSpritePromise = fetch(svgPath) .then(r => (r.ok ? r.text() : null)) .then((text) => { if (text == null) { return false; } // DOMParser avoids the XSS surface of innerHTML; the parsed SVG is // hidden from layout but kept reachable for `` lookups. const parsed = new DOMParser().parseFromString(text, 'image/svg+xml'); const svgEl = parsed.documentElement; if (svgEl == null || svgEl.nodeName.toLowerCase() !== 'svg') { return false; } svgEl.setAttribute('aria-hidden', 'true'); svgEl.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;'; document.body.appendChild(document.importNode(svgEl, true)); wysiwygSpriteInjected = true; return true; }) .catch((err) => { // Most likely cause: CDN is missing CORS headers. Icons will not // render, but the editor itself stays functional. console.warn('[wysiwyg] sprite inject failed', err); return false; }); return wysiwygSpritePromise; }; // Start fetching immediately at module evaluation so the sprite is usually // already inlined before the first wysiwyg instance mounts. Without this, the // initial render of any toolbar icon would emit a `` referencing the // cross-origin CDN URL — which is exactly what we are trying to avoid. if (typeof document !== 'undefined') { ensureWysiwygSpriteInjected(); } @Component class WysiwygEditorComponent extends TsxComponent implements WysiwygEditorArgs { @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() subtitle!: string; @Prop() value!: string; @Prop() placeholder!: string; @Prop() mandatory!: boolean; @Prop() marginType?: MarginType; @Prop() maxWidth?: number; @Prop() wrap!: boolean; @Prop() showClearValueButton!: boolean; @Prop() hint: string; @Prop() useFileManager?: boolean; @Prop() itemId?: number; @Prop() useCommonUpload?: boolean; @Prop() uploadPluginArgs?: () => WysiwygUploadArgs | null; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() changed: (newValue: string) => void; @Prop() initialHeight: number = null; @Prop() onResized?: (height: number) => void; @Prop({ default: true }) resizable: boolean; @Prop() htmlViewControlled?: boolean; @Prop() onHtmlViewChange?: (htmlView: boolean) => void; openDropdownId: string | null = null; htmlView: boolean = false; fullscreen: boolean = false; htmlViewBuffer: string = ''; activeImageEl: HTMLImageElement | null = null; skipNextValueWatch: boolean = false; private savedRange: Range | null = null; private resizeObserver: ResizeObserver = null; private lastNotifiedHeight: number | null = null; private syncTimer: any = null; private lastEmittedHtml: string = ''; private documentClickHandler: ((e: MouseEvent) => void) | null = null; private modalContainer: HTMLElement = null; private modalApp: ReturnType = null; private imageResizeHandle: HTMLElement | null = null; private imageResizeHandlers: { mousemove?: (e: MouseEvent) => void; mouseup?: (e: MouseEvent) => void; } = {}; private btnDefs: Record = {}; private toolbarGroups: WysiwygButtonDef[][] = []; private translations: WysiwygTranslations = null; // Reactive flag: flips to true once the SVG sprite has been inlined into the // document. renderIcon() switches from an absolute URL ref to a fragment-only // ref (`#wysiwyg-X`) on the next render after this flips. spriteReady: boolean = wysiwygSpriteInjected; get options(): WysiwygOptions { return { colors: WYSIWYG_DEFAULT_COLORS, fontSizes: WYSIWYG_DEFAULT_FONT_SIZES, lineHeights: WYSIWYG_DEFAULT_LINE_HEIGHTS, formattingOptions: [ { tag: 'p', labelKey: 'p' }, { tag: 'h1', labelKey: 'header' }, { tag: 'h2', labelKey: 'header' }, { tag: 'h3', labelKey: 'header' }, { tag: 'h4', labelKey: 'header' }, { tag: 'blockquote', labelKey: 'blockquote' }, { tag: 'pre', labelKey: 'code' }, ], imageWidthModalEdit: true, imageResize: { minSize: 16, step: 4 }, }; } get editorEl(): HTMLElement | null { const refs = this.$refs as Record | undefined; return (refs?.editor as HTMLElement) || null; } get boxEl(): HTMLElement | null { const refs = this.$refs as Record | undefined; return (refs?.box as HTMLElement) || null; } get htmlAreaEl(): HTMLTextAreaElement | null { const refs = this.$refs as Record | undefined; return (refs?.htmlArea as HTMLTextAreaElement) || null; } created() { // Adopt the controlled Source-view flag before the first render so the // initial DOM already matches (otherwise we'd flicker from rich → source). if (this.htmlViewControlled != null) { this.htmlView = this.htmlViewControlled; } } mounted() { this.translations = getTranslations(PowerduckState.getCurrentLanguage()); this.buildToolbar(); if (this.value != null && this.editorEl != null) { this.editorEl.innerHTML = this.value; this.lastEmittedHtml = this.value; } // When we mount directly into Source view, seed the textarea buffer // from the current value — the textarea is bound to `htmlViewBuffer` // rather than to the editor element. if (this.htmlView) { this.htmlViewBuffer = this.value || ''; } this.attachEditorListeners(); this.attachDocumentListeners(); this.setupResizeObserver(); this.applyInitialHeight(); // Kick off the one-time sprite injection. The promise is shared across // every wysiwyg instance, so the fetch happens at most once per page load. if (!this.spriteReady) { ensureWysiwygSpriteInjected().then((ok) => { if (ok) { this.spriteReady = true; } }); } } beforeUnmount() { this.detachDocumentListeners(); this.detachImageResizeListeners(); if (this.syncTimer != null) { clearTimeout(this.syncTimer); this.syncTimer = null; } if (this.resizeObserver != null) { this.resizeObserver.disconnect(); this.resizeObserver = null; } this.destroyModalContainer(); } @Watch('value') onValueChanged(val: string, oldVal: string) { if (val === oldVal) { return; } if (this.skipNextValueWatch) { this.skipNextValueWatch = false; return; } if (this.editorEl == null) { return; } const currentHtml = this.editorEl.innerHTML; if (val !== currentHtml) { this.editorEl.innerHTML = val || ''; this.lastEmittedHtml = this.editorEl.innerHTML; } } private applyInitialHeight() { if (this.initialHeight != null && this.boxEl != null) { this.boxEl.style.height = `${this.initialHeight}px`; this.lastNotifiedHeight = this.initialHeight; } } private setupResizeObserver() { if (this.onResized == null || this.resizable === false || this.boxEl == null) { return; } this.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const height = entry?.contentRect?.height; if (height > 0 && (this.lastNotifiedHeight == null || Math.abs(height - this.lastNotifiedHeight) > 2)) { this.lastNotifiedHeight = height; this.onResized?.(height); } } }); this.resizeObserver.observe(this.boxEl); } private handleEditorInput() { this.scheduleSync(); } private handleEditorBlur() { this.saveCurrentRange(); } private handleEditorKeydown(e: KeyboardEvent) { if (e.key === 'Delete' || e.key === 'Backspace') { if (this.activeImageEl != null) { e.preventDefault(); this.activeImageEl.remove(); this.setActiveImage(null); this.scheduleSync(); return; } } if (this.activeImageEl != null && e.key.length === 1) { this.setActiveImage(null); } // Ctrl/Cmd shortcuts const mod = e.ctrlKey || e.metaKey; if (!mod) { return; } const key = e.key.toLowerCase(); if (key === 'b') { e.preventDefault(); this.execNative('bold'); } else if (key === 'i') { e.preventDefault(); this.execNative('italic'); } else if (key === 'u') { e.preventDefault(); this.execNative('underline'); } else if (key === 'z' && !e.shiftKey) { e.preventDefault(); this.execNative('undo'); } else if ((key === 'z' && e.shiftKey) || key === 'y') { e.preventDefault(); this.execNative('redo'); } } private handleEditorClick(e: MouseEvent) { const target = e.target as HTMLElement; if (target?.tagName === 'IMG') { e.preventDefault(); this.setActiveImage(target as HTMLImageElement); } else { this.setActiveImage(null); } } private handleEditorPaste(e: ClipboardEvent) { const html = e.clipboardData?.getData('text/html'); if (html == null || html.length === 0) { return; } e.preventDefault(); e.stopPropagation(); this.handlePaste(html); } private handleEditorMouseUp() { this.saveCurrentRange(); } private handleEditorKeyUp() { this.saveCurrentRange(); this.scheduleSync(); } private handleHtmlAreaInput() { this.htmlViewBuffer = this.htmlAreaEl?.value || ''; // Without this, edits made in Source view are lost the moment the // editor unmounts (e.g. the user switches to another language tab), // because the parent never receives the updated HTML. this.scheduleSync(); } private attachEditorListeners() { const ed = this.editorEl; if (ed == null) { return; } ed.addEventListener('input', () => this.handleEditorInput()); ed.addEventListener('blur', () => this.handleEditorBlur()); ed.addEventListener('keydown', e => this.handleEditorKeydown(e)); ed.addEventListener('click', e => this.handleEditorClick(e)); ed.addEventListener('paste', e => this.handleEditorPaste(e)); ed.addEventListener('mouseup', () => this.handleEditorMouseUp()); ed.addEventListener('keyup', () => this.handleEditorKeyUp()); } private attachDocumentListeners() { this.documentClickHandler = (e: MouseEvent) => { if (this.openDropdownId == null) { return; } const target = e.target as HTMLElement; if (target.closest('.wysiwyg-dropdown') || target.closest('.wysiwyg-btn[data-dropdown-id]')) { return; } this.openDropdownId = null; }; document.addEventListener( 'mousedown', this.documentClickHandler, true, ); } private detachDocumentListeners() { if (this.documentClickHandler != null) { document.removeEventListener( 'mousedown', this.documentClickHandler, true, ); this.documentClickHandler = null; } } private scheduleSync() { if (this.syncTimer != null) { clearTimeout(this.syncTimer); } this.syncTimer = setTimeout(() => { this.syncTimer = null; this.notifyChange(); }, 50); } private notifyChange() { this.populateValidationDeclaration(); const html = this.htmlView ? this.htmlViewBuffer : this.editorEl?.innerHTML || ''; if (html === this.lastEmittedHtml) { return; } this.lastEmittedHtml = html; if (this.changed != null && this.value !== html) { this.skipNextValueWatch = true; this.changed(html); } } private saveCurrentRange() { const sel = window.getSelection(); if (sel == null || sel.rangeCount === 0) { return; } const r = sel.getRangeAt(0); const ed = this.editorEl; if (ed != null && ed.contains(r.commonAncestorContainer)) { this.savedRange = r.cloneRange(); } } private restoreRange() { if (this.savedRange == null || this.editorEl == null) { return; } const sel = window.getSelection(); if (sel == null) { return; } sel.removeAllRanges(); sel.addRange(this.savedRange); } private execNative(command: string, value?: string): boolean { try { const ok = document.execCommand( command, false, value, ); this.scheduleSync(); return ok; } catch { return false; } } private buildCommandContext(): WysiwygCommandContext { return { editor: this.editorEl, getHtml: () => this.editorEl?.innerHTML || '', setHtml: (h2: string) => { if (this.editorEl != null) { this.editorEl.innerHTML = h2; this.scheduleSync(); } }, saveRange: () => this.saveCurrentRange(), restoreRange: () => this.restoreRange(), getRange: () => this.savedRange, emit: () => { /* placeholder for future events */ }, focus: () => { this.editorEl?.focus(); this.restoreRange(); }, exec: (command: string, value?: string) => this.execNative(command, value), openModal: (descriptor: WysiwygModalDescriptor) => this.openModal(descriptor), insertImage: ( url: string, alt?: string, width?: string, ) => this.insertImage( url, alt, width, ), insertHtmlAtCaret: (html2: string | Node) => this.insertHtmlAtCaret(html2), wrapSelectionInStyle: (style: Record) => this.wrapSelectionInStyle(style), getActiveImage: () => this.activeImageEl, setActiveImage: (img: HTMLImageElement | null) => this.setActiveImage(img), getTranslations: () => this.translations, getUploadArgs: () => this.resolveUploadArgs(), getFileManagerEnabled: () => this.useFileManager === true, openFileManager: kind => this.openFileManagerInternal(kind), toggleHtmlView: () => this.toggleHtmlView(), toggleFullscreen: () => this.toggleFullscreen(), isHtmlView: () => this.htmlView, isFullscreen: () => this.fullscreen, getItemId: () => this.itemId ?? null, getOptions: () => this.options, notifyChange: () => this.notifyChange(), }; } private setActiveImage(img: HTMLImageElement | null) { if (this.activeImageEl != null && this.activeImageEl !== img) { this.activeImageEl.classList.remove('wysiwyg-img-selected'); } this.activeImageEl = img; if (img != null) { img.classList.add('wysiwyg-img-selected'); this.showImageResizeHandle(img); } else { this.hideImageResizeHandle(); } } private showImageResizeHandle(img: HTMLImageElement) { this.hideImageResizeHandle(); const box = this.boxEl; if (box == null) { return; } const handle = document.createElement('div'); handle.className = 'wysiwyg-img-resize-handle'; this.imageResizeHandle = handle; box.appendChild(handle); this.positionImageResizeHandle(); handle.addEventListener('mousedown', (e: MouseEvent) => this.beginImageResize(e, img)); } private hideImageResizeHandle() { if (this.imageResizeHandle != null) { this.imageResizeHandle.remove(); this.imageResizeHandle = null; } this.detachImageResizeListeners(); } private positionImageResizeHandle() { const handle = this.imageResizeHandle; const img = this.activeImageEl; const box = this.boxEl; if (handle == null || img == null || box == null) { return; } const boxRect = box.getBoundingClientRect(); const imgRect = img.getBoundingClientRect(); handle.style.left = `${imgRect.right - boxRect.left - 8}px`; handle.style.top = `${imgRect.bottom - boxRect.top - 8 + box.scrollTop}px`; } private beginImageResize(e: MouseEvent, img: HTMLImageElement) { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; const startW = img.offsetWidth; const startH = img.offsetHeight; const ratio = startW > 0 ? startH / startW : 1; const opts = this.options.imageResize; const onMove = (ev: MouseEvent) => { let newW = startW + (ev.clientX - startX); let newH = startH + (ev.clientY - startY); if (newH < opts.minSize) { newH = opts.minSize; } if (newW < opts.minSize) { newW = opts.minSize; } newH -= newH % opts.step; newW = Math.round(newH / ratio); img.style.height = `${newH}px`; img.style.width = `${newW}px`; img.setAttribute('height', String(newH)); img.setAttribute('width', String(newW)); this.positionImageResizeHandle(); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); this.imageResizeHandlers = {}; this.scheduleSync(); }; this.imageResizeHandlers = { mousemove: onMove, mouseup: onUp }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } private detachImageResizeListeners() { if (this.imageResizeHandlers.mousemove) { document.removeEventListener('mousemove', this.imageResizeHandlers.mousemove); } if (this.imageResizeHandlers.mouseup) { document.removeEventListener('mouseup', this.imageResizeHandlers.mouseup); } this.imageResizeHandlers = {}; } private insertImage( url: string, alt?: string, width?: string, ) { this.editorEl?.focus(); this.restoreRange(); const img = document.createElement('img'); img.src = url; if (alt) { img.alt = alt; } if (width && /^\d+$/.test(width)) { img.setAttribute('width', width); } this.insertHtmlAtCaret(img); this.scheduleSync(); } private insertHtmlAtCaret(html: string | Node) { this.editorEl?.focus(); this.restoreRange(); const sel = window.getSelection(); if (sel == null) { return; } let range: Range; if (sel.rangeCount > 0) { range = sel.getRangeAt(0); } else { range = document.createRange(); range.selectNodeContents(this.editorEl); range.collapse(false); } range.deleteContents(); let node: Node; if (typeof html === 'string') { const tpl = document.createElement('template'); tpl.innerHTML = html; node = tpl.content.cloneNode(true); } else { node = html; } range.insertNode(node); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); this.saveCurrentRange(); this.scheduleSync(); } private wrapSelectionInStyle(style: Record) { this.editorEl?.focus(); this.restoreRange(); const sel = window.getSelection(); if (sel == null || sel.rangeCount === 0) { return; } const range = sel.getRangeAt(0); if (range.collapsed) { // Nothing selected — create an empty span to type into const span = document.createElement('span'); Object.assign(span.style, style); span.appendChild(document.createTextNode('​')); range.insertNode(span); const newRange = document.createRange(); newRange.setStart(span.firstChild!, 1); newRange.collapse(true); sel.removeAllRanges(); sel.addRange(newRange); this.saveCurrentRange(); this.scheduleSync(); return; } const contents = range.extractContents(); const wrapper = document.createElement('span'); Object.assign(wrapper.style, style); wrapper.appendChild(contents); range.insertNode(wrapper); const newRange = document.createRange(); newRange.selectNodeContents(wrapper); sel.removeAllRanges(); sel.addRange(newRange); this.saveCurrentRange(); this.scheduleSync(); } private resolveUploadArgs(): WysiwygUploadArgs | null { if (this.uploadPluginArgs != null) { const v = this.uploadPluginArgs(); if (v != null) { return v; } } if (WysiwygConfig.uploadArgsFactory != null) { return WysiwygConfig.uploadArgsFactory(); } return null; } private usesCommonUploadPlugin(): boolean { if (this.uploadPluginArgs != null) { return true; } if (this.useCommonUpload === true) { return true; } if (this.useFileManager !== true && WysiwygConfig.uploadArgsFactory != null) { return true; } return false; } private openFileManagerInternal(kind: 'image' | 'file'): Promise<{ url: string } | null> { return new Promise((resolve) => { FileManagerDialog.show({ fileType: kind === 'image' ? FileManagerModalFileType.Image : FileManagerModalFileType.All, itemId: this.itemId ?? undefined, callback: (data) => { resolve(data ? { url: data.url } : null); }, }); }); } private ensureModalContainer(): HTMLElement { if (this.modalContainer == null) { this.modalContainer = document.createElement('div'); this.modalContainer.className = 'wysiwyg-modal-host'; document.body.appendChild(this.modalContainer); } return this.modalContainer; } private safeUnmountModalApp() { if (this.modalApp != null) { try { this.modalApp.unmount(); } catch { /* noop */ } this.modalApp = null; } } private destroyModalContainer() { this.safeUnmountModalApp(); if (this.modalContainer != null) { this.modalContainer.remove(); this.modalContainer = null; } } private openModal(descriptor: WysiwygModalDescriptor): Promise { return new Promise((resolve) => { const host = this.ensureModalContainer(); host.innerHTML = ''; this.safeUnmountModalApp(); let settled = false; this.modalApp = createApp({ render: () => h(WysiwygPromptModal, { descriptor, onSubmit: (values: WysiwygModalValues) => { if (settled) { return; } settled = true; setTimeout(() => { this.safeUnmountModalApp(); resolve(values); }, 100); }, onCancel: () => { if (settled) { return; } settled = true; setTimeout(() => { this.safeUnmountModalApp(); resolve(null); }, 0); }, }), }); this.modalApp.mount(host); }); } private toggleHtmlView() { if (!this.htmlView) { this.htmlViewBuffer = this.editorEl?.innerHTML || ''; this.htmlView = true; } else { if (this.editorEl != null) { this.editorEl.innerHTML = this.htmlViewBuffer; } this.htmlView = false; this.scheduleSync(); } // Notify the parent so it can mirror this state into sibling editors // (e.g. other language tabs that mount after this toggle). this.onHtmlViewChange?.(this.htmlView); } private toggleFullscreen() { this.fullscreen = !this.fullscreen; document.body.classList.toggle('wysiwyg-fullscreen-on', this.fullscreen); } private handlePaste(rawHtml: string) { const cleaned = this.cleanPastedHtml(rawHtml); const tpl = document.createElement('template'); tpl.innerHTML = cleaned; const frag = tpl.content.cloneNode(true); this.insertHtmlAtCaret(frag); } private cleanPastedHtml(html: string): string { let inner = html; const bodyIdx = inner.indexOf(' -1) { const endIdx = inner.indexOf(''); if (endIdx > bodyIdx) { const startTagEnd = inner.indexOf('>', bodyIdx); inner = inner.substring(startTagEnd + 1, endIdx).trim(); } } const wrap = document.createElement('div'); wrap.innerHTML = inner; const strip = (el: HTMLElement) => { Array.from(el.children).forEach(c => strip(c as HTMLElement)); el.removeAttribute('style'); el.removeAttribute('class'); }; strip(wrap); const out = wrap.innerHTML; if (wrap.children.length > 1) { return `
${out}
`; } return out; } private buildToolbar() { this.btnDefs = {}; this.registerCoreButtons(); const groups: WysiwygButtonDef[][] = []; const def = (id: string) => { const b = this.btnDefs[id]; if (b == null) { console.warn('[wysiwyg] missing button def:', id); return null; } return b; }; const pushGroup = (ids: string[]) => { const arr = ids.map(def).filter(Boolean) as WysiwygButtonDef[]; if (arr.length > 0) { groups.push(arr); } }; pushGroup(['viewHTML']); pushGroup([ 'undo', 'redo', ]); pushGroup(['formatting']); pushGroup([ 'strong', 'em', ]); pushGroup([ 'fontsize', 'foreColor', 'backColor', ]); pushGroup(['lineheight']); pushGroup(['link']); pushGroup(['image']); pushGroup(['noembed']); pushGroup([ 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', ]); pushGroup([ 'unorderedList', 'orderedList', ]); pushGroup(['horizontalRule']); pushGroup(['removeformat']); pushGroup(['fullscreen']); const uploadGroup: string[] = []; if (this.useFileManager === true) { uploadGroup.push('uploadFileManager'); } if (this.usesCommonUploadPlugin()) { uploadGroup.push('uploadCustom'); } if (uploadGroup.length > 0) { pushGroup(uploadGroup); } this.toolbarGroups = groups; } private registerCoreButtons() { const t = () => this.translations; const addBtn = (b: WysiwygButtonDef) => { this.btnDefs[b.id] = b; }; const focusExec = (cmd: string) => (ctx: WysiwygCommandContext) => { ctx.focus(); ctx.exec(cmd); }; const cmdCtx = () => this.buildCommandContext(); addBtn({ id: 'viewHTML', icon: 'view-html', titleKey: 'viewHTML', exec: ctx => ctx.toggleHtmlView(), isActive: ctx => ctx.isHtmlView(), disableWhileHtml: false, }); addBtn({ id: 'undo', icon: 'undo', titleKey: 'historyUndo', exec: focusExec('undo'), }); addBtn({ id: 'redo', icon: 'redo', titleKey: 'historyRedo', exec: focusExec('redo'), }); addBtn({ id: 'formatting', icon: 'p', titleKey: 'formatting', isDropdown: true, dropdownItems: this.options.formattingOptions.map((opt, idx): WysiwygDropdownItem => ({ id: `formatting-${opt.tag}-${idx}`, label: `${t()[opt.labelKey]} (${opt.tag.toUpperCase()})`, exec: (ctx) => { ctx.focus(); ctx.exec('formatBlock', `<${opt.tag.toUpperCase()}>`); }, })), }); addBtn({ id: 'strong', icon: 'strong', titleKey: 'strong', exec: focusExec('bold'), }); addBtn({ id: 'em', icon: 'em', titleKey: 'em', exec: focusExec('italic'), }); addBtn({ id: 'fontsize', icon: 'fontsize', titleKey: 'fontsize', isDropdown: true, dropdownItems: this.buildFontSizeDropdown(), }); addBtn({ id: 'foreColor', icon: 'fore-color', titleKey: 'foreColor', isDropdown: true, dropdownItems: this.buildColorDropdown('foreColor'), cssClass: 'wysiwyg-color-btn', }); addBtn({ id: 'backColor', icon: 'back-color', titleKey: 'backColor', isDropdown: true, dropdownItems: this.buildColorDropdown('backColor'), cssClass: 'wysiwyg-color-btn', }); addBtn({ id: 'lineheight', icon: 'lineheight', titleKey: 'lineheight', isDropdown: true, dropdownItems: this.buildLineHeightDropdown(), }); addBtn({ id: 'link', icon: 'link', titleKey: 'createLink', exec: ctx => this.handleLinkButton(ctx), }); addBtn({ id: 'image', icon: 'insert-image', titleKey: 'image', isDropdown: true, dropdownItems: this.buildImageDropdownItems(), }); addBtn({ id: 'noembed', icon: 'noembed', titleKey: 'noembed', exec: ctx => this.handleEmbedButton(ctx), }); addBtn({ id: 'justifyLeft', icon: 'justify-left', titleKey: 'justifyLeft', exec: focusExec('justifyLeft'), }); addBtn({ id: 'justifyCenter', icon: 'justify-center', titleKey: 'justifyCenter', exec: focusExec('justifyCenter'), }); addBtn({ id: 'justifyRight', icon: 'justify-right', titleKey: 'justifyRight', exec: focusExec('justifyRight'), }); addBtn({ id: 'justifyFull', icon: 'justify-full', titleKey: 'justifyFull', exec: focusExec('justifyFull'), }); addBtn({ id: 'unorderedList', icon: 'unordered-list', titleKey: 'unorderedList', exec: focusExec('insertUnorderedList'), }); addBtn({ id: 'orderedList', icon: 'ordered-list', titleKey: 'orderedList', exec: focusExec('insertOrderedList'), }); addBtn({ id: 'horizontalRule', icon: 'horizontal-rule', titleKey: 'horizontalRule', exec: focusExec('insertHorizontalRule'), }); addBtn({ id: 'removeformat', icon: 'removeformat', titleKey: 'removeformat', exec: focusExec('removeFormat'), }); addBtn({ id: 'fullscreen', icon: 'fullscreen', titleKey: 'fullscreen', exec: ctx => ctx.toggleFullscreen(), isActive: ctx => ctx.isFullscreen(), }); addBtn({ id: 'uploadFileManager', icon: 'upload', titleKey: 'uploadImage', exec: ctx => this.handleFileManagerImageUpload(ctx), }); addBtn({ id: 'uploadCustom', icon: 'upload', titleKey: 'upload', exec: ctx => this.handleCommonUpload(ctx), }); // silence linter-style unused warnings void cmdCtx; void t; } private buildFontSizeDropdown(): WysiwygDropdownItem[] { const arr: WysiwygDropdownItem[] = []; this.options.fontSizes.forEach((size) => { arr.push({ id: `fontsize-${size}`, label: size, exec: (ctx) => { ctx.focus(); ctx.wrapSelectionInStyle({ fontSize: size }); }, }); }); arr.push({ id: 'fontsize-custom', label: this.translations.customSize, exec: async (ctx) => { ctx.saveRange(); const vals = await ctx.openModal({ title: this.translations.customSize, fields: { size: { label: this.translations.fontsize, value: '', type: 'text', placeholder: 'e.g. 24px', required: true, }, }, submitLabel: this.translations.submit, cancelLabel: this.translations.reset, }); if (vals?.size) { ctx.focus(); ctx.wrapSelectionInStyle({ fontSize: String(vals.size) }); } }, }); return arr; } private buildLineHeightDropdown(): WysiwygDropdownItem[] { const arr: WysiwygDropdownItem[] = []; this.options.lineHeights.forEach((lh) => { arr.push({ id: `lineheight-${lh}`, label: lh, exec: (ctx) => { ctx.focus(); ctx.wrapSelectionInStyle({ lineHeight: lh }); }, }); }); arr.push({ id: 'lineheight-custom', label: this.translations.customSize, exec: async (ctx) => { ctx.saveRange(); const vals = await ctx.openModal({ title: this.translations.customSize, fields: { lineheight: { label: this.translations.lineheight, value: '', type: 'text', required: true }, }, submitLabel: this.translations.submit, cancelLabel: this.translations.reset, }); if (vals?.lineheight) { ctx.focus(); ctx.wrapSelectionInStyle({ lineHeight: String(vals.lineheight) }); } }, }); return arr; } private buildColorDropdown(kind: 'foreColor' | 'backColor'): WysiwygDropdownItem[] { const arr: WysiwygDropdownItem[] = []; // Remove color arr.push({ id: `${kind}-remove`, label: this.translations[`${kind}Remove`] || 'Remove', style: 'background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAG0lEQVQIW2NkQAAfEJMRmwBYhoGBYQtMBYoAADziAp0jtJTgAAAAAElFTkSuQmCC);', exec: (ctx) => { ctx.focus(); ctx.exec('removeFormat', kind); }, }); // Custom color (free) arr.push({ id: `${kind}-free`, label: `# (${this.translations.customColor})`, exec: async (ctx) => { ctx.saveRange(); const vals = await ctx.openModal({ title: this.translations[kind], fields: { color: { label: this.translations[kind], value: '#FFFFFF', type: 'color', forceCss: true }, colorHex: { label: `${this.translations[kind]} (HEX)`, value: '#FFFFFF', type: 'text', forceCss: true }, }, submitLabel: this.translations.submit, cancelLabel: this.translations.reset, }); if (vals?.color) { ctx.focus(); this.applyColor(kind, String(vals.color)); } }, }); this.options.colors.forEach((c) => { arr.push({ id: `${kind}-${c}`, label: `#${c}`, style: `background-color: #${c};`, exec: (ctx) => { ctx.focus(); this.applyColor(kind, `#${c}`); }, }); }); return arr; } private applyColor(kind: 'foreColor' | 'backColor', color: string) { // Use span wrapping for cross-browser determinism and reliability under synthetic events. if (kind === 'foreColor') { this.wrapSelectionInStyle({ color }); } else { this.wrapSelectionInStyle({ backgroundColor: color }); } } private buildImageDropdownItems(): WysiwygDropdownItem[] { const arr: WysiwygDropdownItem[] = []; arr.push({ id: 'image-insert-url', label: this.translations.insertImage, exec: async (ctx) => { ctx.saveRange(); const opts = this.options; const fields: WysiwygModalDescriptor['fields'] = { url: { label: this.translations.url, value: '', type: 'url', required: true, placeholder: 'https://...' }, alt: { label: this.translations.description, value: '', type: 'text' }, }; if (opts.imageWidthModalEdit) { fields.width = { label: this.translations.width, value: '', type: 'text' }; } const vals = await ctx.openModal({ title: this.translations.insertImage, fields, submitLabel: this.translations.submit, cancelLabel: this.translations.reset, }); if (vals?.url) { ctx.focus(); ctx.insertImage( String(vals.url), String(vals.alt || ''), vals.width ? String(vals.width) : undefined, ); } }, }); if (this.useFileManager === true) { arr.push({ id: 'image-upload-filemanager', label: this.translations.uploadImage, exec: ctx => this.handleFileManagerImageUpload(ctx), }); } if (this.usesCommonUploadPlugin()) { arr.push({ id: 'image-upload-custom', label: this.translations.upload, exec: ctx => this.handleCommonUpload(ctx), }); } return arr; } private async handleLinkButton(ctx: WysiwygCommandContext) { ctx.saveRange(); const range = ctx.getRange(); const selText = range != null ? range.toString() : ''; // Find ancestor link if any let existingHref = ''; let existingTarget = ''; if (range != null && this.editorEl != null) { let node: Node | null = range.startContainer; while (node && node !== this.editorEl) { if ((node as HTMLElement).tagName === 'A') { const a = node as HTMLAnchorElement; existingHref = a.getAttribute('href') || ''; existingTarget = a.getAttribute('target') || ''; break; } node = node.parentNode; } } const vals = await ctx.openModal({ title: this.translations.createLink, fields: { url: { label: this.translations.url, value: existingHref, type: 'url', required: true, placeholder: 'https://...' }, text: { label: this.translations.text, value: selText, type: 'text' }, title: { label: this.translations.title, value: '', type: 'text' }, target: { label: 'target', value: existingTarget, type: 'select', options: [ { value: '', label: 'self' }, { value: '_blank', label: '_blank' }, ], }, }, submitLabel: this.translations.submit, cancelLabel: this.translations.reset, }); if (vals?.url) { ctx.focus(); const url = String(vals.url); const text = String(vals.text || '') || url; const title = String(vals.title || ''); const target = String(vals.target || ''); const a = document.createElement('a'); a.href = url; a.textContent = text; if (title) { a.title = title; } if (target) { a.target = target; } ctx.insertHtmlAtCaret(a); } } private async handleEmbedButton(ctx: WysiwygCommandContext) { ctx.saveRange(); const vals = await ctx.openModal({ title: this.translations.noembed, fields: { url: { label: this.translations.url, value: '', type: 'url', required: true, placeholder: 'https://...' }, }, submitLabel: this.translations.submit, cancelLabel: this.translations.reset, }); if (!vals?.url) { return; } try { const url = String(vals.url); const resp = await fetch(`https://noembed.com/embed?url=${encodeURIComponent(url)}`); const json = await resp.json(); if (json?.html) { ctx.focus(); ctx.insertHtmlAtCaret(String(json.html)); } else if (json?.error) { console.warn('[wysiwyg] noembed error', json.error); } } catch (err) { console.warn('[wysiwyg] embed failed', err); } } private async handleFileManagerImageUpload(ctx: WysiwygCommandContext) { ctx.saveRange(); const result = await ctx.openFileManager('image'); if (result?.url) { ctx.focus(); ctx.insertImage(result.url); } } private async handleCommonUpload(ctx: WysiwygCommandContext) { ctx.saveRange(); const args = ctx.getUploadArgs(); if (args == null) { return; } const desc: WysiwygModalDescriptor = { title: this.translations.upload, progressEnabled: true, fields: { file: { label: this.translations.file, value: '', type: 'file', accept: 'image/*', required: true }, alt: { label: this.translations.description, value: '', type: 'text' }, ...(this.options.imageWidthModalEdit ? { width: { label: this.translations.width, value: '', type: 'text' } } : {}), }, submitLabel: this.translations.upload, cancelLabel: this.translations.reset, beforeSubmit: async (vals) => { const file = vals.file as File | null; if (file == null) { return false; } try { await this.performUpload( args, file, vals, ctx, ); } catch (err) { console.warn('[wysiwyg] upload failed', err); if (args.error != null) { args.error(err); } return false; } return true; }, }; await ctx.openModal(desc); } private async performUpload( args: WysiwygUploadArgs, file: File, values: WysiwygModalValues, ctx: WysiwygCommandContext, ) { const form = new FormData(); (args.data || []).forEach(d => form.append(d.name, d.value)); form.append(args.fileFieldName || 'fileToUpload', file); const url = args.serverPath; const xhr = new XMLHttpRequest(); const promise = new Promise((resolve, reject) => { xhr.open( 'POST', url, true, ); if (args.headers) { Object.keys(args.headers).forEach(h2 => xhr.setRequestHeader(h2, args.headers[h2])); } if (args.xhrFields?.withCredentials) { xhr.withCredentials = true; } xhr.onload = () => { try { const data = JSON.parse(xhr.responseText); resolve(data); } catch { resolve(xhr.responseText); } }; xhr.onerror = () => reject(new Error('upload error')); xhr.send(form); }); const data = await promise; const statusKey = args.statusPropertyName || 'success'; const urlKey = args.urlPropertyName || 'file'; if (args.success != null) { args.success(data, { closeModal: () => { /* handled by descriptor return */ }, insertImage: (u: string) => ctx.insertImage( u, String(values.alt || ''), values.width ? String(values.width) : undefined, ), }); return; } const ok = this.getDeep(data, statusKey.split('.')); if (ok) { const uploadedUrl = this.getDeep(data, urlKey.split('.')); ctx.focus(); ctx.insertImage( String(uploadedUrl), String(values.alt || ''), values.width ? String(values.width) : undefined, ); } } private getDeep(obj: any, parts: string[]): any { let cur = obj; for (const p of parts) { if (cur == null) { return null; } cur = cur[p]; } return cur; } private renderIcon(iconId: string): VNode { // Always reference the inlined sprite via fragment-only href to avoid the // cross-origin `` warning entirely. While the sprite is still being // fetched the `` is omitted (briefly empty icon), then it appears // once `spriteReady` flips. We deliberately do NOT fall back to the // absolute CDN URL — that path would re-introduce the warning on the // initial render. return ( {this.spriteReady && } ); } private renderButton(btn: WysiwygButtonDef): VNode { const ctx = this.buildCommandContext(); const isActive = btn.isActive?.(ctx) ?? false; const title = btn.titleKey ? this.translations[btn.titleKey] : btn.titleText || btn.id; return ( ); } private handleButtonClick(btn: WysiwygButtonDef) { if (btn.isDropdown) { this.openDropdownId = this.openDropdownId === btn.id ? null : btn.id; return; } this.openDropdownId = null; this.saveCurrentRange(); const ctx = this.buildCommandContext(); try { Promise.resolve(btn.exec?.(ctx)).catch((e) => { console.error( '[wysiwyg] command failed', btn.id, e, ); }); } catch (e) { console.error( '[wysiwyg] command failed', btn.id, e, ); } } private renderDropdown(btn: WysiwygButtonDef): VNode { const items = btn.dropdownItems || []; return (
{items.map(item => ( ))}
); } private handleDropdownItem(item: WysiwygDropdownItem) { this.openDropdownId = null; this.saveCurrentRange(); const ctx = this.buildCommandContext(); try { Promise.resolve(item.exec(ctx)).catch((e) => { console.error( '[wysiwyg] dropdown command failed', item.id, e, ); }); } catch (e) { console.error( '[wysiwyg] dropdown command failed', item.id, e, ); } } private renderToolbar(): VNode { const groups = this.toolbarGroups; const openId = this.openDropdownId; return (
{groups.map(group => (
{group.map(btn => (
{this.renderButton(btn)} {btn.isDropdown && openId === btn.id && this.renderDropdown(btn)}
))}
))}
); } render() { const sizeMode = this.htmlView ? 'wysiwyg-html-view' : ''; const resizeCls = this.resizable !== false ? 'vertical-resize' : ''; const fullscreenCls = this.fullscreen ? 'wysiwyg-fullscreen' : ''; return (
{this.renderToolbar()}
{this.htmlView && ( )}
); } } const WysiwygEditor = toNative(WysiwygEditorComponent); export default WysiwygEditor;