UNPKG

typewriter-editor

Version:

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

313 lines (312 loc) 11.9 kB
import { TextDocument, isEqual } from '@typewriter/document'; import { Editor, EditorChangeEvent } from '../Editor'; import { combineLines, getChangedRanges, renderLine, setLineNodesRanges, } from '../rendering/rendering'; import { setSelection } from '../rendering/selection'; import { h, patch } from '../rendering/vdom'; export function virtualRendering(editor) { let start = 0; let end = 0; let heightMap = []; let children = []; let viewport = getScrollParent(editor.root); let offsetTop; let viewportHeight = 0; let averageHeight = 40; let items; let itemsDoc; let lastSelection = null; let lineSelection = null; // not doc index but Combined index let toRender; let hasChanged = false; let updateQueued = true; viewport.addEventListener('scroll', onScroll, { passive: true }); editor.on('change', onChange); const offResize = onResize(viewport, (width, height, changed) => { viewportHeight = height; if (changed & WIDTH) heightMap = []; // content may be different heights, recalculate everything update(); }); function render(what) { if (!what || !items) { const { doc } = editor.modules.decorations || editor; items = combineLines(editor, doc.lines).combined; itemsDoc = doc; hasChanged = true; lastSelection = doc.selection; update(); } else { const { doc, old } = what; const selection = what.selection || null; const newSelection = selection && selectedLineIndexes(selection, items).sort((a, b) => a - b); if (!isEqual(newSelection, lineSelection)) { hasChanged = hasChanged || !withinRender(newSelection, true); lineSelection = newSelection; } if (old && doc) { const newItems = combineLines(editor, doc.lines).combined; const [oldRange, newRange] = getChangedRanges(items, newItems); if (oldRange[0] + oldRange[1] + newRange[0] + newRange[1] > 0) { hasChanged = true; const oldLength = oldRange[1] - oldRange[0], newLength = newRange[1] - newRange[0]; if (oldLength < newLength) { // lines were added, add empty spots into the heightMap const empty = new Array(newLength - oldLength).fill(undefined); heightMap.splice(oldRange[1], 0, ...empty); } else if (oldLength > newLength) { heightMap.splice(oldRange[0], oldLength - newLength); } } items = newItems; itemsDoc = doc; } else if (doc) { items = combineLines(editor, doc.lines).combined; itemsDoc = doc; hasChanged = true; } lastSelection = selection; if (hasChanged) update(); } } // Determine start and end of visible range function update() { updateQueued = false; if (!items) return; const { scrollTop } = viewport; offsetTop = getOffsetTop(); const oldStart = start; const previousHeights = heightMap.slice(); let didUpdate = false; let count = 0; // failsafe while (shouldUpdate() && count++ < 20) { didUpdate = true; hasChanged = false; renderToDom(); updateHeights(); } if (count >= 20) console.error('Updated virtual max times'); setSelection(editor, lastSelection); if (!didUpdate) return; // prevent jumping if we scrolled up into unknown territory if (start < oldStart) { let expectedHeight = 0; let actualHeight = 0; let offset = toRender.indexOf(start); for (let i = start; i < oldStart; i++) { const childIndex = i - start + offset; if (children[childIndex]) { expectedHeight += getHeightFor(i, previousHeights); actualHeight += getHeightFor(i); } } const d = actualHeight - expectedHeight; viewport.scrollTo(0, scrollTop + d); } } function shouldUpdate() { const { scrollTop } = viewport; const renderSet = new Set([0, items.length - 1, ...(lineSelection || [])]); let i = 0; let y = offsetTop; let newStart = 0; let newEnd = 0; while (i < items.length) { const rowHeight = getHeightFor(i); if (y + rowHeight > scrollTop) { newStart = i; break; } y += rowHeight; i += 1; } while (i < items.length) { renderSet.add(i); y += getHeightFor(i); i += 1; if (y > scrollTop + viewportHeight) break; } // Include one extra item at the bottom to make a smoother visual update (should be i - 1) newEnd = Math.min(i, items.length - 1); const newRender = Array.from(renderSet).sort((a, b) => a - b); if (!isEqual(newRender, toRender)) { start = newStart; end = newEnd; toRender = newRender; return true; } return hasChanged; } function renderToDom() { const nodes = []; // Always render the first line, the last line, and the lines with the start/end selection so that deletion and // selection commands will work (e.g. selecting from one line to another no in-screen and let Select All work). const renderSet = new Set(toRender); let spacerKey = ''; let spacerMarginTop = 0; let spacerMarginBottom = 0; let total = 0; for (let i = 0, space = 0; i < items.length; i++) { if (renderSet.has(i)) { if (space) { spacerMarginBottom = getMarginBetween(i, -1); space -= spacerMarginTop; const spacer = h('div', { class: '-spacer-', ['data-key']: spacerKey, style: `height:${space}px;margin-top:${spacerMarginTop}px;margin-bottom:${spacerMarginBottom}px;`, key: spacerKey, }); spacerKey = ''; nodes.push(spacer); } space = 0; const node = renderLine(editor, items[i]); nodes.push(node); } else { if (i === 1) spacerKey = 'spacer-start'; else if (i === items.length - 2) spacerKey = 'spacer-end'; else if (!spacerKey && lineSelection && i > lineSelection[1]) spacerKey = 'spacer-selection-end'; else if (!spacerKey && lineSelection && i > lineSelection[0]) spacerKey = 'spacer-selection-start'; if (!space) spacerMarginTop = getMarginBetween(i, -1); space += getHeightFor(i); } total += getHeightFor(i); } editor.dispatchEvent(new Event('rendering')); patch(editor.root, nodes); setLineNodesRanges(editor); editor.dispatchEvent(new Event('render')); editor.dispatchEvent(new Event('rendered')); } function updateHeights() { children = Array.from(editor.root.children).filter(child => child.className !== '-spacer-'); for (let i = 0; i < children.length; i++) { const index = toRender[i]; heightMap[index] = getHeightInfo(children[i]); } if (!children.length) return; const heights = heightMap.filter(Boolean); averageHeight = Math.round(getMarginBetween(0, -1, heights) + heights.reduce((a, b, i, arr) => a + getHeightFor(i, arr), 0) / heights.length); } function getOffsetTop() { const { scrollTop } = viewport; const { root } = editor; if (viewport === root) return parseInt(getComputedStyle(root).paddingTop); return (root.getBoundingClientRect().top + parseInt(getComputedStyle(root).paddingTop) + scrollTop - viewport.getBoundingClientRect().top); } function getHeightInfo(node) { const styles = getComputedStyle(node); return [parseInt(styles.marginTop), node.offsetHeight, parseInt(styles.marginBottom)]; } function getHeightFor(i, array = heightMap) { if (!array[i]) return averageHeight; return (i === 0 ? getMarginBetween(i, -1, array) : 0) + array[i][1] + getMarginBetween(i, 1, array); } function getMarginBetween(i, direction, array = heightMap) { return Math.max((array[i] && array[i][2]) || 0, (array[i + direction] && array[i + direction][0]) || 0); } function withinRender(range, inclusive) { if (!range) return false; let [from, to] = range; if (inclusive) to++; return toRender.some(i => i >= from && i < to); } function onScroll() { if (updateQueued) return; requestAnimationFrame(update); updateQueued = true; } function onChange(event) { const { old, doc } = editor.modules.decorations || event; const selection = event.doc.selection; render({ old, doc, selection }); } return { render, init() { if (editor.modules.decorations) { editor.modules.decorations.gatherDecorations(); } render(); }, destroy() { offResize(); viewport.removeEventListener('scroll', onScroll); editor.off('change', onChange); }, }; } const scrollable = /auto|scroll/; function getScrollParent(node) { while (node && node !== node.ownerDocument.scrollingElement) { if (scrollable.test(getComputedStyle(node).overflowY)) return node; node = node.parentNode; } return node; } const WIDTH = 1; const HEIGHT = 2; const BOTH = 3; function onResize(node, callback) { let width = node.offsetWidth; let height = node.offsetHeight; callback(width, height, BOTH); if (typeof window.ResizeObserver !== 'undefined') { const observer = new window.ResizeObserver(onResize); observer.observe(node); return () => observer.disconnect(); } else { const window = node.ownerDocument.defaultView; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); } function onResize() { const { offsetWidth, offsetHeight } = node; const changed = (width !== offsetWidth ? WIDTH : 0) | (height !== offsetHeight ? HEIGHT : 0); if (changed) { width = offsetWidth; height = offsetHeight; callback(width, height, changed); } } } function selectedLineIndexes([from, to], lines) { let first = 0, last = 0; for (let i = 0, pos = 0; i < lines.length; i++) { const entry = lines[i]; const length = Array.isArray(entry) ? entry.reduce((length, line) => length + line.length, 0) : entry.length; if (from >= pos && from < pos + length) first = i; if (to >= pos && to < pos + length) { last = i; break; } pos += length; } return [first, last]; }