window.RichNote = (() => { const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); const IMAGE_MAX_BYTES = 2 * 1024 * 1024; function safeImageUrl(value) { return /^data:image\/(?:jpeg|png|gif|webp);base64,[A-Za-z0-9+/]*={0,2}$/.test( String(value || '') ); } function imageFigure(block, editable) { if (!safeImageUrl(block && block.dataUrl)) return null; const figure = document.createElement('figure'); figure.className = 'rich-note-image'; figure.dataset.richImage = 'true'; figure.dataset.dataUrl = block.dataUrl; figure.dataset.alt = String(block.alt || ''); figure.contentEditable = 'false'; const image = document.createElement('img'); image.src = block.dataUrl; image.alt = String(block.alt || ''); figure.appendChild(image); if (editable) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'rich-note-image-remove'; remove.title = '删除图片'; remove.setAttribute('aria-label', '删除图片'); remove.textContent = '×'; remove.onclick = () => figure.remove(); figure.appendChild(remove); } return figure; } function legacyToDelta(value) { if (!value || !Array.isArray(value.blocks)) return value; const ops = []; value.blocks.forEach((block) => { if (block && block.type === 'image' && safeImageUrl(block.dataUrl)) { ops.push({ insert: { image: block.dataUrl } }); return; } if (!block || block.type !== 'text' || !Array.isArray(block.runs)) return; block.runs.forEach((run) => { const insert = String(run && run.text || ''); if (!insert) return; const attributes = { ...(run.bold === true ? { bold: true } : {}), ...(run.italic === true ? { italic: true } : {}), ...(run.underline === true ? { underline: true } : {}), ...(run.strike === true ? { strike: true } : {}), ...(run.code === true ? { code: true } : {}) }; ops.push({ insert, ...(Object.keys(attributes).length ? { attributes } : {}) }); }); const attributes = block.style === 'heading1' ? { header: 1 } : block.style === 'heading2' ? { header: 2 } : block.style === 'quote' ? { blockquote: true } : block.style === 'bullet' ? { list: 'bullet' } : block.style === 'number' ? { list: 'ordered' } : block.style === 'code' ? { 'code-block': 'plain' } : null; ops.push({ insert: '\n', ...(attributes ? { attributes } : {}) }); }); return { version: 2, ops }; } function clientDelta(value) { const source = legacyToDelta(value); if (!source || !Array.isArray(source.ops)) return null; const ops = []; source.ops.forEach((op) => { if (!op || !Object.prototype.hasOwnProperty.call(op, 'insert')) return; const attributes = {}; const rawAttributes = op.attributes && typeof op.attributes === 'object' ? op.attributes : {}; ['bold', 'italic', 'underline', 'strike', 'code', 'blockquote'] .forEach((key) => { if (rawAttributes[key] === true) attributes[key] = true; }); if (rawAttributes['code-block'] === true || rawAttributes['code-block'] === 'plain') { attributes['code-block'] = 'plain'; } if (rawAttributes.header === 1 || rawAttributes.header === 2) { attributes.header = rawAttributes.header; } if (rawAttributes.list === 'bullet' || rawAttributes.list === 'ordered') { attributes.list = rawAttributes.list; } if (typeof op.insert === 'string') { if (op.insert) { ops.push({ insert: op.insert, ...(Object.keys(attributes).length ? { attributes } : {}) }); } } else if (op.insert && safeImageUrl(op.insert.image)) { ops.push({ insert: { image: op.insert.image } }); } }); return ops.length ? { version: 2, ops } : null; } function plainText(content) { const delta = clientDelta(content); if (!delta) return ''; return delta.ops .filter((op) => typeof op.insert === 'string') .map((op) => op.insert) .join('') .replace(/\n$/, ''); } function hasContent(content) { const delta = clientDelta(content); return !!(delta && delta.ops.some((op) => ( typeof op.insert === 'string' ? op.insert.trim() : !!op.insert.image ))); } function fromText(value) { const text = String(value || ''); if (!text) return null; return { version: 2, ops: [{ insert: text.endsWith('\n') ? text : `${text}\n` }] }; } function inlineText(value, attributes) { let node = document.createTextNode(value); [ ['code', 'code'], ['strike', 's'], ['underline', 'u'], ['italic', 'em'], ['bold', 'strong'] ].forEach(([field, tag]) => { if (attributes && attributes[field] === true) { const wrapper = document.createElement(tag); wrapper.appendChild(node); node = wrapper; } }); return node; } function renderDelta(target, content) { target.textContent = ''; let line = document.createDocumentFragment(); const finishLine = (attributes = {}) => { const tag = attributes.header === 1 ? 'h2' : attributes.header === 2 ? 'h3' : attributes.blockquote === true ? 'blockquote' : attributes['code-block'] === 'plain' ? 'pre' : attributes.list === 'bullet' ? 'ul' : attributes.list === 'ordered' ? 'ol' : 'p'; const block = document.createElement(tag); const body = tag === 'ul' || tag === 'ol' ? block.appendChild(document.createElement('li')) : block; if (line.childNodes.length) body.appendChild(line); else body.appendChild(document.createElement('br')); target.appendChild(block); line = document.createDocumentFragment(); }; content.ops.forEach((op) => { if (op.insert && typeof op.insert === 'object') { if (line.childNodes.length) finishLine(); const figure = imageFigure({ dataUrl: op.insert.image, alt: '' }, false); if (figure) target.appendChild(figure); return; } const parts = String(op.insert || '').split('\n'); parts.forEach((part, index) => { if (part) line.appendChild(inlineText(part, op.attributes)); if (index < parts.length - 1) finishLine(op.attributes || {}); }); }); if (line.childNodes.length) finishLine(); } function render(target, content, fallbackText) { const delta = clientDelta(content); if (delta && hasContent(delta)) { target.classList.add('rich-note-content'); renderDelta(target, delta); return; } target.classList.remove('rich-note-content'); target.textContent = String(fallbackText || ''); } function readImage(file) { return new Promise((resolve, reject) => { if (!file || !IMAGE_TYPES.has(file.type)) { reject(new Error('仅支持 JPEG、PNG、GIF 和 WebP 图片')); return; } if (file.size <= 0 || file.size > IMAGE_MAX_BYTES) { reject(new Error('单张图片不能超过 2 MB')); return; } const reader = new FileReader(); reader.onerror = () => reject(new Error('图片读取失败')); reader.onload = () => resolve({ type: 'image', dataUrl: String(reader.result || ''), alt: String(file.name || '').slice(0, 500) }); reader.readAsDataURL(file); }); } function mount(host, initialContent, options = {}) { host.textContent = ''; if (typeof window.Quill !== 'function') { throw new Error('富文本编辑组件加载失败'); } const box = document.createElement('div'); box.className = 'rich-note-editor quill-note-editor'; const toolbar = document.createElement('div'); toolbar.className = 'rich-note-toolbar ql-toolbar ql-snow'; toolbar.setAttribute('role', 'toolbar'); toolbar.setAttribute('aria-label', '笔记格式工具栏'); const formats = document.createElement('span'); formats.className = 'ql-formats'; const header = document.createElement('select'); header.className = 'ql-header'; header.title = '段落样式'; [ ['', '正文'], ['1', '一级标题'], ['2', '二级标题'] ].forEach(([value, label], index) => { const option = document.createElement('option'); option.value = value; option.textContent = label; if (index === 0) option.selected = true; header.appendChild(option); }); formats.appendChild(header); [ ['bold', '加粗'], ['italic', '斜体'], ['underline', '下划线'], ['strike', '删除线'], ['blockquote', '引用'], ['code-block', '代码块'] ].forEach(([name, title]) => { const button = document.createElement('button'); button.type = 'button'; button.className = `ql-${name}`; button.title = title; button.setAttribute('aria-label', title); formats.appendChild(button); }); const ordered = document.createElement('button'); ordered.type = 'button'; ordered.className = 'ql-list'; ordered.value = 'ordered'; ordered.title = '有序列表'; ordered.setAttribute('aria-label', '有序列表'); formats.appendChild(ordered); const bullet = document.createElement('button'); bullet.type = 'button'; bullet.className = 'ql-list'; bullet.value = 'bullet'; bullet.title = '无序列表'; bullet.setAttribute('aria-label', '无序列表'); formats.appendChild(bullet); const imageButton = document.createElement('button'); imageButton.type = 'button'; imageButton.className = 'ql-image'; imageButton.title = '插入图片'; imageButton.setAttribute('aria-label', '插入图片'); formats.appendChild(imageButton); toolbar.appendChild(formats); const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp'; fileInput.multiple = true; fileInput.className = 'hidden'; const history = document.createElement('span'); history.className = 'ql-formats rich-note-history'; const undo = document.createElement('button'); undo.type = 'button'; undo.className = 'rich-note-undo'; undo.title = '撤销'; undo.setAttribute('aria-label', '撤销'); undo.textContent = '↶'; const redo = document.createElement('button'); redo.type = 'button'; redo.className = 'rich-note-redo'; redo.title = '重做'; redo.setAttribute('aria-label', '重做'); redo.textContent = '↷'; history.append(undo, redo); toolbar.append(history, fileInput); const surface = document.createElement('div'); surface.className = 'rich-note-quill'; box.append(toolbar, surface); host.appendChild(box); const quill = new window.Quill(surface, { theme: 'snow', placeholder: options.placeholder || '记录想法、摘要或研究结论', formats: [ 'header', 'bold', 'italic', 'underline', 'strike', 'blockquote', 'code', 'code-block', 'list', 'image' ], modules: { toolbar: { container: toolbar, handlers: { image() { fileInput.click(); } } }, history: { delay: 700, maxStack: 100, userOnly: true } } }); quill.root.classList.add('rich-note-surface'); quill.root.setAttribute('aria-label', '笔记正文'); quill.root.setAttribute('aria-multiline', 'true'); undo.onclick = () => quill.history.undo(); redo.onclick = () => quill.history.redo(); const insertFiles = async (files) => { for (const file of Array.from(files || [])) { if (!IMAGE_TYPES.has(file.type)) continue; try { const block = await readImage(file); const range = quill.getSelection(true); const index = range ? range.index : Math.max(0, quill.getLength() - 1); quill.insertEmbed(index, 'image', block.dataUrl, 'user'); quill.setSelection(index + 1, 0, 'silent'); } catch (error) { if (typeof options.onError === 'function') options.onError(error.message || String(error)); } } }; fileInput.onchange = async () => { await insertFiles(fileInput.files); fileInput.value = ''; }; quill.clipboard.addMatcher('IMG', (node, delta) => { const Delta = window.Quill.import('delta'); return safeImageUrl(node && node.getAttribute('src')) ? delta : new Delta(); }); quill.root.addEventListener('paste', (event) => { const files = Array.from(event.clipboardData && event.clipboardData.files || []); if (files.some((file) => IMAGE_TYPES.has(file.type))) { event.preventDefault(); insertFiles(files); } }); quill.root.addEventListener('dragover', (event) => { if (Array.from(event.dataTransfer && event.dataTransfer.files || []) .some((file) => IMAGE_TYPES.has(file.type))) event.preventDefault(); }); quill.root.addEventListener('drop', (event) => { const files = Array.from(event.dataTransfer && event.dataTransfer.files || []); if (!files.some((file) => IMAGE_TYPES.has(file.type))) return; event.preventDefault(); insertFiles(files); }); const initial = clientDelta(initialContent); if (initial) quill.setContents(initial.ops, 'silent'); quill.history.clear(); return { content: () => clientDelta({ version: 2, ops: quill.getContents().ops }), text: () => plainText({ version: 2, ops: quill.getContents().ops }), focus: () => quill.focus(), surface: quill.root, quill, destroy: () => { host.textContent = ''; } }; } return { mount, render, plainText, fromText, hasContent }; })();