UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

190 lines (189 loc) 6.72 kB
import { html as _html } from 'very-small-parser/lib/html'; import { fromHast as _fromHast } from 'very-small-parser/lib/html/json-ml/fromHast'; import { SliceTypeName } from '../slice'; import { SliceStacking, SliceHeaderShift } from '../slice/constants'; import { Anchor } from '../rga/constants'; import { toPlainText } from 'very-small-parser/lib/toPlainText'; import { walk } from 'very-small-parser/lib/html/json-ml/walk'; import { fromBase64 } from '@jsonjoy.com/base64/lib/fromBase64'; /** * @todo Implement HTML normalization function, ensure that: * * - <blockquote> and <p> nodes are treated correctly, especially when sole node * is nested. * - list nodes are treated correctly. * - <svg> nodes are converted to Base64 and inlined as data URL images. */ /** * Flattens a {@link PeritextMlNode} tree structure into a {@link ViewRange} * flat string with annotation ranges. */ class ViewRangeBuilder { text = ''; slices = []; build0(node, path) { const skipWhitespace = path.length < 2; if (typeof node === 'string') { if (skipWhitespace && !node.trim()) return false; this.text += node; return false; } const [type, attr] = node; const start = this.text.length; const length = node.length; const inline = !!attr?.inline; const hasType = type === 0 || !!type; const firstChild = node[2]; const isFirstChildInline = firstChild && (typeof firstChild === 'string' || firstChild[1]?.inline); if (hasType && !inline && isFirstChildInline) { this.text += '\n'; const header = (SliceStacking.Marker << SliceHeaderShift.Stacking) + (Anchor.Before << SliceHeaderShift.X1Anchor) + (Anchor.Before << SliceHeaderShift.X2Anchor); const slice = [header, start, start, path.length ? [...path, type] : type]; const data = attr?.data; if (data) slice.push(data); this.slices.push(slice); } for (let i = 2; i < length; i++) this.build0(node[i], type === '' ? path : [...path, type]); if (hasType && inline) { let end = 0, header = 0; if (inline) { end = this.text.length; const stacking = attr?.stacking ?? SliceStacking.Many; header = (stacking << SliceHeaderShift.Stacking) + (Anchor.Before << SliceHeaderShift.X1Anchor) + (Anchor.After << SliceHeaderShift.X2Anchor); const slice = [header, start, end, type]; const data = attr?.data; if (data) slice.push(data); this.slices.push(slice); } } return false; } build(node) { this.build0(node, []); const view = [this.text, 0, this.slices]; return view; } } export const toViewRange = (node) => new ViewRangeBuilder().build(node); // HTML elements to completely ignore. const IGNORE_TAGS = new Set(['meta', 'style', 'script', 'link', 'head']); // HTML elements to rewrite as different block elements. const BLOCK_TAGS_REWRITE = new Map([ ['html', ''], ['body', ''], ['div', ''], ]); // HTML elements to rewrite as different inline elements. const INLINE_TAGS_REWRITE = new Map([['span', '']]); export const fromJsonMl = (jsonml, registry) => { if (typeof jsonml === 'string') return jsonml; let tag = jsonml[0]; let inlineHtmlTag = false; if (typeof tag === 'string') { tag = tag.toLowerCase(); if (IGNORE_TAGS.has(tag)) return ''; const mapped = BLOCK_TAGS_REWRITE.get(tag); if (mapped !== undefined) tag = mapped; else { const mapped = INLINE_TAGS_REWRITE.get(tag); if (mapped !== undefined) { tag = mapped; inlineHtmlTag = true; } } } const length = jsonml.length; const node = [tag, null]; for (let i = 2; i < length; i++) { const peritextNode = fromJsonMl(jsonml[i], registry); if (!peritextNode) continue; node.push(peritextNode); } const res = registry.fromHtml(jsonml); if (res) { node[0] = res[0]; node[1] = res[1]; } else { if (typeof tag === 'string') node[0] = SliceTypeName[tag] ?? tag; const attr = jsonml[1] || {}; let data = null; if (attr['data-attr'] !== void 0) { try { data = JSON.parse(attr['data-attr']); } catch { } } const inline = inlineHtmlTag || attr['data-inline'] === 'true'; if (data || inline) node[1] = { data, inline }; } if (typeof node[0] === 'number' && node[0] < 0) (node[1] ||= {}).inline = true; if (node.length < 3 && (node[1] || {}).inline) return ''; return node; }; export const fromHast = (hast, registry) => { const jsonml = _fromHast(hast); return fromJsonMl(jsonml, registry); }; export const fromHtml = (html, registry) => { const hast = _html.parsef(html); return fromHast(hast, registry); }; export const htmlToHast = (html) => _html.parsef(html); export const textFromHtml = (html) => { const hast = _html.parsef(html); return toPlainText(hast); }; const getExportData = (html) => { const attrName = 'data-json-joy-peritext'; const maybeHasPeritextExport = html.includes(attrName); const hast = _html.parsef(html); const jsonml = _fromHast(hast); if (maybeHasPeritextExport) { const iterator = walk(jsonml); let node; while ((node = iterator())) { if (node && typeof node === 'object') { const [tag, attr] = node; if (attr?.[attrName]) { const jsonBase64 = attr[attrName]; const buffer = fromBase64(jsonBase64); const json = new TextDecoder().decode(buffer); const data = JSON.parse(json); return [void 0, data]; } } } } return [jsonml]; }; export const importHtml = (html, registry) => { const [jsonml, data] = getExportData(html); if (data?.style) return [void 0, data.style]; if (data?.view) return [data.view]; const node = fromJsonMl(jsonml, registry); return [toViewRange(node)]; }; export const importStyle = (html) => { const [, data] = getExportData(html); return data?.style; };