json-joy
Version:
Collection of libraries for building collaborative editing apps.
190 lines (189 loc) • 6.72 kB
JavaScript
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;
};