alinea
Version:
Headless git-based CMS
435 lines (433 loc) • 13.6 kB
JavaScript
import {
YMap,
YText,
YXmlElement,
YXmlFragment,
YXmlHook,
YXmlText
} from "../../chunks/chunk-QUIANN6B.js";
import "../../chunks/chunk-AJJSW27C.js";
import "../../chunks/chunk-NZLE2WMY.js";
// src/core/shape/RichTextShape.ts
import { Entry } from "../Entry.js";
import { MediaFile } from "../media/MediaTypes.js";
import {
BlockNode,
ElementNode,
LinkMark,
Mark,
Node,
TextNode
} from "../TextDoc.js";
import { entries, fromEntries, keys } from "../util/Objects.js";
import { RecordShape } from "./RecordShape.js";
import { ScalarShape } from "./ScalarShape.js";
var RichTextElements = /* @__PURE__ */ ((RichTextElements2) => {
RichTextElements2["h1"] = "h1";
RichTextElements2["h2"] = "h2";
RichTextElements2["h3"] = "h3";
RichTextElements2["h4"] = "h4";
RichTextElements2["h5"] = "h5";
RichTextElements2["h6"] = "h6";
RichTextElements2["p"] = "p";
RichTextElements2["b"] = "b";
RichTextElements2["i"] = "i";
RichTextElements2["ul"] = "ul";
RichTextElements2["ol"] = "ol";
RichTextElements2["li"] = "li";
RichTextElements2["a"] = "a";
RichTextElements2["blockquote"] = "blockquote";
RichTextElements2["hr"] = "hr";
RichTextElements2["br"] = "br";
RichTextElements2["small"] = "small";
RichTextElements2["sup"] = "sup";
RichTextElements2["sub"] = "sub";
RichTextElements2["table"] = "table";
RichTextElements2["tbody"] = "tbody";
RichTextElements2["td"] = "td";
RichTextElements2["th"] = "th";
RichTextElements2["tr"] = "tr";
return RichTextElements2;
})(RichTextElements || {});
function serialize(item) {
if (item instanceof YXmlHook) {
return [];
}
if (item instanceof YXmlText) {
const delta = item.toDelta();
return delta.map((d) => {
const text = {
[Node.type]: "text",
[TextNode.text]: d.insert
};
if (d.attributes) {
text.marks = Object.keys(d.attributes).map((type) => {
const attrs2 = d.attributes[type];
const mark = { [Mark.type]: type };
if (attrs2)
for (const [key, value] of Object.entries(attrs2)) {
if (typeof value !== "string") continue;
if (key.startsWith("data-"))
mark[`_${key.slice("data-".length)}`] = value;
else mark[key] = value;
}
return mark;
});
}
return text;
});
}
const res = { [Node.type]: item.nodeName };
if (typeof item.getAttributes !== "function") return res;
const attrs = item.getAttributes();
if (attrs && Object.keys(attrs).length) Object.assign(res, attrs);
const children = item.toArray();
if (children.length) {
res.content = children.flatMap(serialize);
}
return res;
}
function unserializeMarks(marks) {
return Object.fromEntries(
marks.map((mark) => {
const { [Mark.type]: type, ...attrs } = mark;
const res = Object.fromEntries(
Object.entries(attrs).map(([key, value]) => {
if (key.startsWith("_")) return [`data-${key.slice(1)}`, value];
return [key, value];
})
);
return [type, res];
})
);
}
function unserialize(nodes) {
const result = [];
for (const node of nodes) {
if (Node.isText(node)) {
const text = node[TextNode.text];
const marks = node[TextNode.marks];
const type = new YXmlText();
if (text) type.insert(0, text, marks && unserializeMarks(marks));
result.push(type);
} else if (Node.isElement(node)) {
const { [Node.type]: type, [ElementNode.content]: content, ...attrs } = node;
const element = new YXmlElement(type);
for (const key in attrs) {
const val = attrs[key];
if (val) element.setAttribute(key, val);
}
if (content) element.insert(0, unserialize(content));
result.push(element);
} else if (Node.isBlock(node)) {
const element = new YXmlElement(node[Node.type]);
element.setAttribute(BlockNode.id, node[BlockNode.id]);
result.push(element);
}
}
return result;
}
var linkInfoFields = {
id: Entry.id,
url: Entry.url,
location: MediaFile.location
};
var RichTextShape = class {
constructor(label, shapes, initialValue, searchable) {
this.label = label;
this.shapes = shapes;
this.initialValue = initialValue;
this.searchable = searchable;
this.blocks = shapes ? fromEntries(
entries(shapes).map(([key, value]) => {
return [
key,
new RecordShape(value.label, {
[Node.type]: new ScalarShape("Type"),
[BlockNode.id]: new ScalarShape("Id"),
...value.shapes
})
];
})
) : {};
}
blocks;
create() {
return this.initialValue ?? [];
}
toXml(rows) {
return unserialize(rows);
}
toV1(value) {
if (!Array.isArray(value)) return [];
return value.map(this.normalizeRow);
}
normalizeRow = (row) => {
if (Node.type in row) return row;
const { type, ...data } = row;
if (type === "text") {
const updated = { [Node.type]: "text", [TextNode.text]: data.text };
if (!data.marks) return updated;
return {
...updated,
[TextNode.marks]: data.marks.map((mark) => {
const { type: type2, attrs } = mark;
if (type2 !== "link") return { [Mark.type]: type2, ...attrs };
const {
"data-id": id,
"data-entry": entry,
"data-type": link,
...rest2
} = attrs;
const res2 = {};
if (type2) res2[Mark.type] = type2;
if (id) res2[LinkMark.id] = id;
if (entry) res2[LinkMark.entry] = entry;
if (link) res2[LinkMark.link] = link;
for (const [key, value] of entries(rest2))
if (typeof value === "string") res2[key] = rest2[key];
return res2;
})
};
}
const shape = this.blocks[type];
if (shape) {
return {
[Node.type]: type,
[BlockNode.id]: data.id,
...shape.toV1(data)
};
}
const { content, ...rest } = data;
if (type === "heading" && rest.textAlign === "left")
rest.textAlign = void 0;
const res = { [Node.type]: type, ...rest };
if (content) res[ElementNode.content] = content.map(this.normalizeRow);
return res;
};
toY(value) {
const map = new YMap();
const text = new YXmlFragment();
map.set("$text", text);
const types = this.blocks;
if (!Array.isArray(value)) return map;
for (const node of value) {
if (!Node.isBlock(node)) continue;
const type = types[node[Node.type]];
map.set(node[BlockNode.id], type.toY(node));
}
text.insert(0, this.toXml(value));
return map;
}
fromY(map) {
if (!map) return [];
const text = map.get("$text");
const types = this.blocks ?? {};
const content = text?.toArray()?.flatMap(serialize) || [];
const [first] = content;
const isEmpty = content.length === 1 && Node.isElement(first) && first[Node.type] === "paragraph" && first[ElementNode.content]?.length === 0;
if (isEmpty) return [];
return content.map((node) => {
if (Node.isBlock(node)) {
const shape = types[node[Node.type]];
if (shape)
return {
[Node.type]: node[Node.type],
[BlockNode.id]: node[BlockNode.id],
...shape.fromY(map.get(node[BlockNode.id]))
};
}
if (Node.isElement(node)) {
if (node[Node.type] === "heading") {
if (node.textAlign === "left") node.textAlign = void 0;
}
}
return node;
});
}
applyY(value, parent, key) {
const current = parent.get(key);
if (!current || !value) return void parent.set(key, this.toY(value));
const blocks = value.filter(Node.isBlock);
const currentKeys = new Set(
[...current.keys()].filter((key2) => key2 !== "$text")
);
const valueKeys = new Set(blocks.map((row) => row[BlockNode.id]));
const removed = [...currentKeys].filter((key2) => !valueKeys.has(key2));
const added = [...valueKeys].filter((key2) => !currentKeys.has(key2));
const changed = [...valueKeys].filter((key2) => currentKeys.has(key2));
for (const id of removed) current.delete(id);
for (const id of added) {
const row = blocks.find((row2) => row2[BlockNode.id] === id);
if (!row) continue;
const type = row[Node.type];
const rowType = this.blocks[type];
if (!rowType) continue;
current.set(id, rowType.toY(row));
}
for (const id of changed) {
const row = blocks.find((row2) => row2[BlockNode.id] === id);
if (!row) continue;
const type = row[Node.type];
const currentRow = current.get(id);
if (!currentRow) continue;
const currentType = currentRow.get(Node.type);
if (currentType !== type) {
current.delete(id);
current.set(id, this.blocks[type].toY(row));
continue;
}
const rowType = this.blocks[type];
if (!rowType) continue;
rowType.applyY(row, current, id);
}
function syncText(source, target) {
const { text = "", marks = [] } = target;
const str = YText.prototype.toString.call(source);
if (text === str) {
source.format(0, source.length, unserializeMarks(marks));
} else {
source.delete(0, source.length);
source.insert(0, text, unserializeMarks(marks));
}
}
const syncElement = (source, target) => {
const { [Node.type]: type, content, ...attrs } = target;
const keysToHandle = keys(attrs);
for (const key2 of keys(attrs))
source.setAttribute(key2, attrs[key2]);
for (const key2 of keys(source.getAttributes()))
if (!keysToHandle.includes(key2)) source.removeAttribute(key2);
syncNodes(source, content ?? []);
};
const syncBlock = (source, target) => {
source.setAttribute(BlockNode.id, target[BlockNode.id]);
};
const syncNodes = (source, value2) => {
let i = 0;
for (; i < value2.length; i++) {
const row = value2[i];
const node = source.get(i);
if (!node) {
source.insert(i, this.toXml([row]));
continue;
}
const typeA = node instanceof YXmlText ? "text" : node.nodeName;
const typeB = row[Node.type];
if (typeA !== typeB) {
source.delete(i);
source.insert(i, this.toXml([row]));
continue;
}
if (Node.isText(row)) {
syncText(node, row);
} else if (Node.isElement(row)) {
syncElement(node, row);
} else if (Node.isBlock(row)) {
syncBlock(node, row);
}
}
while (source.length > i) source.delete(i);
};
syncNodes(current.get("$text"), value);
}
init(parent, key) {
if (!parent.has(key)) parent.set(key, this.toY(this.create()));
}
watch(parent, key) {
const map = parent.get(key);
return (fun) => {
const listener = (events, tx) => {
if (tx.origin === "self") return;
fun();
};
map.observeDeep(listener);
return () => map.unobserveDeep(listener);
};
}
mutator(parent, key) {
const map = parent.get(key);
return {
map,
fragment: map.get("$text"),
insert: (id, block) => {
if (!this.blocks) throw new Error("No types defined");
const shape = this.blocks[block];
map.set(
id,
shape.toY({
...shape.create(),
[Node.type]: block,
[BlockNode.id]: id
})
);
}
};
}
async applyLinks(doc, loader) {
if (!Array.isArray(doc)) return;
const links = /* @__PURE__ */ new Map();
iterMarks(doc, (mark) => {
if (mark[Mark.type] !== "link") return;
const entryId = mark[LinkMark.entry];
if (typeof entryId === "string") links.set(mark, entryId);
});
async function loadLinks() {
const linkIds = Array.from(new Set(links.values()));
const entries2 = await loader.resolveLinks(linkInfoFields, linkIds);
const info = new Map(
entries2.map((entry) => {
return [entry.id, entry];
})
);
for (const [mark, entryId] of links) {
const type = mark[LinkMark.link];
const data = info.get(entryId);
if (data) mark.href = type === "file" ? data.location : data.url;
}
}
await Promise.all(
[loadLinks()].concat(
doc.flatMap((row) => {
if (!this.blocks || !Node.isBlock(row)) return [];
const shape = this.blocks[row[Node.type]];
if (!shape) return [];
return [shape.applyLinks(row, loader)];
})
)
);
}
searchableText(value) {
const res = "";
if (!this.searchable) return res;
if (!Array.isArray(value)) return res;
return value.reduce((acc, node) => {
return acc + this.textOf(node);
}, "");
}
textOf(node) {
if (Node.isText(node)) {
return node.text ? ` ${node.text}` : "";
}
if (Node.isElement(node) && node.content) {
return node.content.reduce((acc, node2) => {
return acc + this.textOf(node2);
}, "");
}
if (Node.isBlock(node)) {
const shape = this.blocks[node[Node.type]];
if (shape) return shape.searchableText(node);
}
return "";
}
};
function iterMarks(doc, fn) {
for (const row of doc) {
if (Node.isText(row)) row.marks?.forEach(fn);
else if (Node.isElement(row) && row.content) iterMarks(row.content, fn);
}
}
export {
RichTextElements,
RichTextShape
};