UNPKG

typewriter-editor

Version:

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

276 lines (275 loc) 11.1 kB
import { AttributeMap, Delta, normalizeRange } from '@typewriter/document'; import { applyDecorations } from '../modules/decorations'; import { h } from '../rendering/vdom'; import { line } from './typeset'; export const paragraph = line({ name: 'paragraph', selector: 'p', commands: editor => () => editor.formatLine({}), shortcuts: 'Mod+0', render: (attributes, children) => h('p', null, children), }); export const header = line({ name: 'header', selector: 'h1, h2, h3, h4, h5, h6', defaultFollows: true, commands: editor => ({ header: (header) => editor.toggleLineFormat({ header }), header1: () => editor.toggleLineFormat({ header: 1 }), header2: () => editor.toggleLineFormat({ header: 2 }), header3: () => editor.toggleLineFormat({ header: 3 }), header4: () => editor.toggleLineFormat({ header: 4 }), header5: () => editor.toggleLineFormat({ header: 5 }), header6: () => editor.toggleLineFormat({ header: 6 }), }), shortcuts: { 'Mod+1': 'header1', 'Mod+2': 'header2', 'Mod+3': 'header3', 'Mod+4': 'header4', 'Mod+5': 'header5', 'Mod+6': 'header6', }, fromDom: (node) => ({ header: parseInt(node.nodeName.replace('H', '')) }), render: (attributes, children) => h(`h${attributes.header}`, null, children), }); export const list = line({ name: 'list', selector: 'ul > li, ol > li', indentable: true, commands: editor => ({ bulletList: () => editor.toggleLineFormat({ list: 'bullet' }), orderedList: () => editor.toggleLineFormat({ list: 'ordered' }), checkList: () => editor.toggleLineFormat({ list: 'check' }), indent: () => editor.indent(), outdent: () => editor.outdent(), toggleCheck: (id) => { const line = typeof id === 'string' ? editor.doc.getLineBy(id) : editor.doc.selection ? editor.doc.getLineAt(editor.doc.selection[0]) : null; if (!line) return false; const [at] = editor.doc.getLineRange(line); const format = { list: 'check' }; if (!line.attributes.checked) format.checked = true; editor.formatLine(format, at); }, }), shortcuts: { 'Mod+Space': 'toggleCheck', }, fromDom(node) { let indent = -1, parent = node.parentNode, type = parent && parent.getAttribute('type'); const list = node.hasAttribute('data-checked') ? 'check' : parent && parent.nodeName === 'OL' ? 'ordered' : 'bullet'; while (parent) { if (/^UL|OL$/.test(parent.nodeName)) indent++; else if (parent.nodeName !== 'LI') break; parent = parent.parentNode; } if (!indent && node.className.startsWith('ql-indent-')) { // Support pasting from quilljs content indent = parseInt(node.className.replace('ql-indent-', '')); } const attr = { list }; if (indent) attr.indent = indent; if (type) attr.type = type; if (node.getAttribute('data-checked') === 'true') attr.checked = true; return attr; }, nextLineAttributes(attributes) { const { start, ...rest } = attributes; return rest; }, shouldCombine: (prev, next) => (prev.list === next.list && !next.start && prev.type === next.type) || next.indent, renderMultiple: (lists, editor, forHTML) => { const topLevelChildren = []; const levels = []; // e.g. levels = [ul, ul] lists.forEach(([attributes, children, id]) => { const type = attributes.list === 'ordered' ? 'ol' : 'ul'; const index = attributes.indent || 0; let props = { key: id }; if (attributes.list === 'check') { function toggle(event) { if (!editor.enabled) return; event.preventDefault(); editor.commands.toggleCheck(id); } const check = h('button', { class: 'check-list-check', onmousedown: toggle, ontouchstart: toggle }); if (children.length === 1 && children[0].type === 'br') children.push(check); else children.unshift(check); props = { ...props, class: 'check-list-item', ['data-checked']: '' + (attributes.checked || false), }; } const item = applyDecorations(h('li', props, children), attributes); while (index >= levels.length) { const newLevel = h(type, { start: attributes.start, type: attributes.type, key: `${id}-outer` }); const childrenArray = levels.length ? levels[levels.length - 1].children : topLevelChildren; const lastChild = childrenArray[childrenArray.length - 1]; if (typeof lastChild === 'object' && lastChild.type === 'li') { if (forHTML) { // Correct HTML lastChild.children.push(newLevel); } else { // Technically incorrect HTML needed to fix selection bug: when clicking to the right of a list item with a // sub-item, the selection goes to the start of the line instead of the end childrenArray.push(newLevel); } } else { childrenArray.push(newLevel); } levels.push(newLevel); } if (!compareLists(levels[index], type, attributes)) { const newLevel = h(type, { start: attributes.start, type: attributes.type }); const childrenArray = index ? levels[index - 1].children : topLevelChildren; childrenArray.push(newLevel); levels[index] = newLevel; } levels[index].children.push(item); levels.length = index + 1; }); function compareLists(list, type, attributes) { return (list.type === type && (list.props.start === attributes.start || (list.props.start && !attributes.start)) && list.props.type === attributes.type); } return topLevelChildren[0]; }, }); export const blockquote = line({ name: 'blockquote', selector: 'blockquote p', commands: editor => (blockquote = true) => { if (typeof blockquote !== 'string') blockquote = true; editor.toggleLineFormat({ blockquote }); }, fromDom(node) { const { className } = node.parentNode; const match = className.match(/quote-(\S+)/); const blockquote = (match && match[1] !== 'true' && match[1]) || true; return { blockquote }; }, shouldCombine: (prev, next) => prev.blockquote === next.blockquote, renderMultiple: quotes => { const type = quotes[0][0].blockquote; const props = typeof type === 'string' ? { className: `quote-${type}` } : null; const children = quotes.map(([attributes, children, id]) => h('p', { key: id }, children)); return h('blockquote', props, children); }, }); export const codeblock = line({ name: 'code-block', selector: 'pre code', contained: true, commands: editor => () => editor.toggleLineFormat({ ['code-block']: true }), renderMultiple: lines => { const children = []; lines.forEach(([attributes, inlineChildren, id]) => { if (inlineChildren.length && inlineChildren[inlineChildren.length - 1].type === 'br') { inlineChildren.pop(); } children.push(h('code', { key: id }, inlineChildren)); children.push('\n'); }); return h('pre', { spellcheck: false }, children); }, }); export const hr = line({ name: 'hr', selector: 'hr', frozen: true, commands: editor => () => { const { doc } = editor; const { selection } = doc; if (!selection) return; const range = normalizeRange(selection); const change = editor.change.delete(range); if (range[0] === range[1] && doc.getLineAt(range[0]).length === 1) { change.insert(range[0], '\n', { ...doc.getLineFormat(range[0]) }).formatLine(range[0], { hr: true }); } else { const delta = new Delta().insert('\n', doc.getLineAt(range[0]).attributes).insert('\n', { hr: true }); change.insertContent(range[0], delta); change.select(range[0] + 2); } editor.update(change); }, render: () => h('hr'), }); export const dl = line({ name: 'dl', selector: 'dl dt, dl dd', fromDom(node) { return { dl: node.nodeName.toLowerCase() }; }, onTab: (editor, shiftKey) => { const { doc } = editor; const { selection } = doc; if (!selection) return; const at = shiftKey ? selection[0] === selection[1] || selection[0] > selection[1] ? selection[1] : selection[1] - 1 : selection[0] === selection[1] || selection[1] > selection[0] ? selection[0] : selection[0] - 1; const line = doc.getLineAt(at); const index = doc.lines.indexOf(line); const next = doc.lines[index + (shiftKey ? -1 : 1)]; if ((next?.attributes.dl === line.attributes.dl || !next?.attributes.dl) && !shiftKey) { if (line.length === 1 && line.attributes.dl === 'dt') { editor.formatLine({}, doc.getLineRange(line)); } else { const at = doc.getLineRange(line)[1] - 1; editor.insert('\n', { dl: line.attributes.dl === 'dt' ? 'dd' : 'dt' }, [at, at]); } } else if (next) { let nextRange = doc.getLineRange(next); nextRange = [nextRange[0], nextRange[1] - 1]; if (shiftKey && !next.attributes.dl) nextRange = [nextRange[1], nextRange[1]]; editor.select(nextRange); } }, commands: editor => () => editor.toggleLineFormat({ dl: 'dt' }), shouldCombine: () => true, nextLineAttributes: attrs => ({ dl: attrs.dl === 'dt' ? 'dd' : 'dt' }), renderMultiple: lines => { const children = []; let last = ''; for (const [attrs, chdlrn, key] of lines) { if (!last || attrs.dl === 'dt') { children.push(h('div', {}, [])); } children[children.length - 1].children.push(h(attrs.dl, { key }, chdlrn)); last = attrs.dl; } return h('dl', {}, children); }, });