UNPKG

typewriter-editor

Version:

A rich text editor using the Delta format with decorations and rendered with a tiny virtual dom

293 lines (292 loc) 11.8 kB
import { AttributeMap, Line, Op, TextDocument, isEqual } from '@typewriter/document'; import { Editor } from '../Editor'; import { applyDecorations } from '../modules/decorations'; import { h, patch } from './vdom'; const EMPTY_ARR = []; const BR = h('br', {}); const nodeFormatType = new WeakMap(); const linesType = new WeakMap(); const linesMultiples = new WeakMap(); const linesCombined = new WeakMap(); const nodeRanges = new WeakMap(); export function getLineNodeStart(root, node) { return nodeRanges.get(root)?.get(node)?.[0]; } export function getLineNodeEnd(root, node) { return nodeRanges.get(root)?.get(node)?.[1]; } export function setLineNodesRanges(editor) { const { root, doc } = editor; const combined = combineLines(editor, doc.lines); const ranges = new WeakMap(); for (let i = 0; i < root.children.length; i++) { const child = root.children[i]; if (!child.key) continue; const entry = combined.byKey[child.key]; if (!entry) continue; if (Array.isArray(entry)) { // set the range for the entire combined section ranges.set(child, [doc.getLineRange(entry[0])[0], doc.getLineRange(entry[entry.length - 1])[1]]); // set the ranges for each line inside const lineElements = child.querySelectorAll(editor.typeset.lines.selector); for (let i = 0; i < lineElements.length; i++) { const lineElement = lineElements[i]; const line = doc.getLineBy(lineElement.key); if (!line) continue; ranges.set(lineElement, doc.getLineRange(line)); } } else { ranges.set(child, doc.getLineRange(entry)); } } const lineElements = root.querySelectorAll(editor.typeset.lines.selector); for (let i = 0; i < lineElements.length; i++) { const lineElement = lineElements[i]; if (ranges.has(lineElement) || !lineElement.key) continue; const line = doc.getLineBy(lineElement.key); ranges.set(lineElement, doc.getLineRange(line)); } nodeRanges.set(root, ranges); } export function render(editor, doc) { const { root } = editor; editor.dispatchEvent(new Event('rendering')); patch(root, renderDoc(editor, doc)); setLineNodesRanges(editor); editor.dispatchEvent(new Event('render')); editor.dispatchEvent(new Event('rendered')); } export function renderChanges(editor, oldDoc, newDoc) { const { root } = editor; // Ranges of line indexes, not document indexes const oldCombined = combineLines(editor, oldDoc.lines).combined; const newCombined = combineLines(editor, newDoc.lines).combined; const [oldRange, newRange] = getChangedRanges(oldCombined, newCombined); // If the changes include added or deleted lines, expand ranges by 1 on each side to ensure the vdom can rerender if (!isEqual(oldRange, newRange)) { oldRange[0] = Math.max(0, oldRange[0] - 1); newRange[0] = Math.max(0, newRange[0] - 1); oldRange[1] = Math.min(oldCombined.length, oldRange[1] + 1); newRange[1] = Math.min(newCombined.length, newRange[1] + 1); if (root.childNodes.length !== oldCombined.length) { // The DOM has changed since we last rendered, adjust the oldRange accordingly to get the correct slice oldRange[1] += root.childNodes.length - oldCombined.length; } } const oldSlice = Array.from(root.childNodes).slice(oldRange[0], oldRange[1]); const newSlice = newCombined.slice(newRange[0], newRange[1]); if (!oldSlice.length && !newSlice.length) return render(editor, newDoc); editor.dispatchEvent(new Event('rendering')); patch(root, renderCombined(editor, newSlice), oldSlice); setLineNodesRanges(editor); editor.dispatchEvent(new Event('render')); editor.dispatchEvent(new Event('rendered')); } export function renderDoc(editor, doc, forHTML) { return renderCombined(editor, combineLines(editor, doc.lines).combined, forHTML); } export function renderCombined(editor, combined, forHTML) { return combined.map(line => renderLine(editor, line, forHTML)).filter(Boolean); } export function renderLine(editor, line, forHTML) { return Array.isArray(line) ? renderMultiLine(editor, line, forHTML) : renderSingleLine(editor, line, forHTML); } export function renderSingleLine(editor, line, forHTML) { const type = getLineType(editor, line); if (!type.render) throw new Error('No render method defined for line'); const node = type.render(line.attributes, renderInline(editor, line), line, editor, forHTML); applyDecorations(node, line.attributes); node.key = line.id; return node; } export function renderMultiLine(editor, lines, forHTML) { const type = getLineType(editor, lines[0]); if (!type.renderMultiple) throw new Error('No render method defined for line'); const node = type.renderMultiple(lines.map(line => [line.attributes, renderInline(editor, line), line.id]), editor, forHTML); node.key = lines[0].id; return node; } // Join multi-lines into arrays. Memoize the results. export function combineLines(editor, lines) { const cache = linesCombined.get(lines); if (cache) return cache; const combined = []; const byKey = {}; let collect = []; lines.forEach((line, i) => { const type = getLineType(editor, line); if (type.shouldCombine) { collect.push(line); const next = lines[i + 1]; if (!next || getLineType(editor, next) !== type || !type.shouldCombine(collect[0].attributes, next.attributes)) { // By keeping the last array reference we can optimize updates const last = linesMultiples.get(collect[0]); if (last && last.length === collect.length && collect.every((v, i) => last[i] === v)) { collect = last; } else { linesMultiples.set(collect[0], collect); } combined.push(collect); byKey[collect[0].id] = collect; collect = []; } } else if (type.render) { combined.push(line); byKey[line.id] = line; } }); const data = { combined, byKey }; linesCombined.set(lines, data); return data; } // Most changes will occur to adjacent lines, so the simplistic approach export function getChangedRanges(oldC, newC) { const oldLength = oldC.length; const newLength = newC.length; const minLength = Math.min(oldLength, newLength); let oldStart = 0, oldEnd = 0, newStart = 0, newEnd = 0; for (let i = 0; i < minLength; i++) { if (!isSame(oldC[i], newC[i])) { oldStart = newStart = i; break; } } for (let i = 0; i < minLength; i++) { if (!isSame(oldC[oldLength - i - 1], newC[newLength - i - 1])) { oldEnd = oldLength - i; newEnd = newLength - i; break; } } return [ [oldStart, oldEnd], [newStart, newEnd], ]; } export function renderInline(editor, line, forHTML) { const { lines, formats, embeds } = editor.typeset; let inlineChildren = []; let tabbedChildren = []; let trailingBreak = true; let tabLine; line.content.ops.forEach((op, i, array) => { let children = []; if (op.insert === '\t' && op.attributes && (tabLine = lines.findByAttributes(op.attributes)) && tabLine.child) { if (tabLine.render) { inlineChildren = mergeChildren(inlineChildren); if (trailingBreak) inlineChildren.push(BR); tabbedChildren.push(tabLine.render(op.attributes, inlineChildren, line, editor, forHTML)); inlineChildren = []; } return; } if (typeof op.insert === 'string') { const prev = array[i - 1]; const next = array[i + 1]; let str = op.insert.replace(/ /g, '\xA0 ').replace(/ /g, ' \xA0'); if (!prev || typeof prev.insert === 'object') str = str.replace(/^ /, '\xA0'); if (!next || typeof next.insert === 'object' || startsWithSpace(next)) str = str.replace(/ $/, '\xA0'); trailingBreak = false; children.push(str); } else if (op.insert) { const embed = embeds.findByAttributes(op.insert); if (embed?.render) { children.push(embed.render(op.insert, EMPTY_ARR, line, editor, forHTML)); if (embed.name === 'br') trailingBreak = true; else if (!embed.noFill) trailingBreak = false; } } if (op.attributes) { // Sort them by the order found in formats Object.keys(op.attributes) .sort((a, b) => formats.priority(b) - formats.priority(a)) .forEach(name => { const type = formats.get(name); if (type?.render) { const node = type.render(op.attributes, children, line, editor, forHTML); if (node) { nodeFormatType.set(node, type); // Store for merging children = [node]; } } }); } inlineChildren.push.apply(inlineChildren, children); }); // Merge marks to optimize inlineChildren = mergeChildren(inlineChildren); if (trailingBreak) inlineChildren.push(BR); return tabbedChildren.length ? tabbedChildren : inlineChildren; } function isSame(oldEntry, newEntry) { if (oldEntry === newEntry) return true; return (Array.isArray(oldEntry) && Array.isArray(newEntry) && oldEntry.length === newEntry.length && oldEntry.every((b, i) => b === newEntry[i])); } function getLineType(editor, line) { let type = linesType.get(line.attributes); if (!type) { type = editor.typeset.lines.findByAttributes(line.attributes, true); linesType.set(line.attributes, type); } return type; } // Joins adjacent format nodes function mergeChildren(oldChildren) { const children = []; oldChildren.forEach((next, i) => { const index = children.length - 1; const prev = children[index]; if (prev && typeof prev !== 'string' && typeof next !== 'string' && nodeFormatType.has(prev) && nodeFormatType.get(prev) === nodeFormatType.get(next) && isSameFormat(prev.props, next.props)) { prev.children = prev.children.concat(next.children); } else if (prev && typeof prev === 'string' && typeof next === 'string') { children[index] += next; // combine adjacent text nodes } else { children.push(next); if (prev && typeof prev !== 'string' && prev.children) { prev.children = mergeChildren(prev.children); } } }); if (children.length) { const last = children[children.length - 1]; if (last && typeof last !== 'string' && last.children) { last.children = mergeChildren(last.children); } } return children; } function isSameFormat(a, b) { return Object.keys({ ...a, ...b }).every(key => key.slice(0, 2) === 'on' || a[key] === b[key]); } function startsWithSpace(op) { return typeof op.insert === 'string' && op.insert[0] === ' '; }