379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
|
|
// The editor's document model.
|
||
|
|
//
|
||
|
|
// This schema is the contract between the two halves of the round-trip: the
|
||
|
|
// reader (mammoth's document model -> here) and the writer (here -> the docx
|
||
|
|
// builder). Every attribute below exists because something on the Word side
|
||
|
|
// needs it, and every one of them is written back out. If you add a node or a
|
||
|
|
// mark, add it to BOTH read.js and write.js or it will silently vanish the
|
||
|
|
// first time someone saves.
|
||
|
|
//
|
||
|
|
// Units follow Word rather than CSS, deliberately — converting twips to
|
||
|
|
// pixels and back is how round-trips accumulate drift:
|
||
|
|
// indent integer level, 1 level = 720 twips (Word's default tab)
|
||
|
|
// fsize points
|
||
|
|
// lineHeight multiple of single spacing (1, 1.15, 1.5, 2)
|
||
|
|
// space* points before/after the paragraph
|
||
|
|
// highlight Word's highlight enum name, NOT a hex colour — w:highlight
|
||
|
|
// only accepts the 15 named values, so storing a hex here
|
||
|
|
// would mean guessing on the way out
|
||
|
|
(function (root, factory) {
|
||
|
|
const api = factory();
|
||
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||
|
|
root.DocxEditor = Object.assign(root.DocxEditor || {}, { schema: api });
|
||
|
|
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||
|
|
"use strict";
|
||
|
|
|
||
|
|
const V = () => globalThis.DOCXV;
|
||
|
|
|
||
|
|
// Word's highlight palette, in the order the ribbon shows it, with the CSS
|
||
|
|
// each one renders as in the editor.
|
||
|
|
const HIGHLIGHTS = [
|
||
|
|
{ name: "yellow", css: "#ffff00", label: "Yellow" },
|
||
|
|
{ name: "green", css: "#00ff00", label: "Bright green" },
|
||
|
|
{ name: "cyan", css: "#00ffff", label: "Turquoise" },
|
||
|
|
{ name: "magenta", css: "#ff00ff", label: "Pink" },
|
||
|
|
{ name: "blue", css: "#0000ff", label: "Blue" },
|
||
|
|
{ name: "red", css: "#ff0000", label: "Red" },
|
||
|
|
{ name: "darkBlue", css: "#000080", label: "Dark blue" },
|
||
|
|
{ name: "darkCyan", css: "#008080", label: "Teal" },
|
||
|
|
{ name: "darkGreen", css: "#008000", label: "Green" },
|
||
|
|
{ name: "darkMagenta", css: "#800080", label: "Violet" },
|
||
|
|
{ name: "darkRed", css: "#800000", label: "Dark red" },
|
||
|
|
{ name: "darkYellow", css: "#808000", label: "Dark yellow" },
|
||
|
|
{ name: "darkGray", css: "#808080", label: "Grey 50%" },
|
||
|
|
{ name: "lightGray", css: "#c0c0c0", label: "Grey 25%" },
|
||
|
|
{ name: "black", css: "#000000", label: "Black" },
|
||
|
|
];
|
||
|
|
const HIGHLIGHT_CSS = Object.fromEntries(HIGHLIGHTS.map((h) => [h.name, h.css]));
|
||
|
|
|
||
|
|
const TWIPS_PER_INDENT = 720;
|
||
|
|
|
||
|
|
// Attributes shared by every block that can carry paragraph formatting.
|
||
|
|
function paragraphAttrs(extra) {
|
||
|
|
return Object.assign({
|
||
|
|
align: { default: null }, // left | center | right | justify
|
||
|
|
indent: { default: 0 }, // 0..8
|
||
|
|
lineHeight: { default: null }, // 1 | 1.15 | 1.5 | 2 | …
|
||
|
|
spaceBefore: { default: null }, // points
|
||
|
|
spaceAfter: { default: null }, // points
|
||
|
|
}, extra || {});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reading paragraph formatting back off a DOM element, for copy/paste and
|
||
|
|
// for the drag-and-drop of HTML into the editor.
|
||
|
|
function readParagraphAttrs(dom) {
|
||
|
|
const st = dom.style || {};
|
||
|
|
const alignRaw = (st.textAlign || "").toLowerCase();
|
||
|
|
const align = ["left", "center", "right", "justify"].includes(alignRaw) ? alignRaw : null;
|
||
|
|
const indentAttr = dom.getAttribute("data-indent");
|
||
|
|
let indent = indentAttr ? parseInt(indentAttr, 10) : 0;
|
||
|
|
if (!Number.isFinite(indent) || indent < 0) indent = 0;
|
||
|
|
const lh = parseFloat(st.lineHeight);
|
||
|
|
const num = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n : null; };
|
||
|
|
return {
|
||
|
|
align,
|
||
|
|
indent: Math.min(8, indent),
|
||
|
|
lineHeight: Number.isFinite(lh) ? lh : null,
|
||
|
|
spaceBefore: num(dom.getAttribute("data-space-before")),
|
||
|
|
spaceAfter: num(dom.getAttribute("data-space-after")),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function paragraphStyle(attrs) {
|
||
|
|
const css = [];
|
||
|
|
if (attrs.align) css.push(`text-align:${attrs.align}`);
|
||
|
|
if (attrs.indent) css.push(`margin-left:${attrs.indent * 0.5}in`);
|
||
|
|
if (attrs.lineHeight) css.push(`line-height:${attrs.lineHeight}`);
|
||
|
|
if (attrs.spaceBefore != null) css.push(`margin-top:${attrs.spaceBefore}pt`);
|
||
|
|
if (attrs.spaceAfter != null) css.push(`margin-bottom:${attrs.spaceAfter}pt`);
|
||
|
|
return css.join(";");
|
||
|
|
}
|
||
|
|
|
||
|
|
function paragraphDomAttrs(attrs) {
|
||
|
|
const out = {};
|
||
|
|
const style = paragraphStyle(attrs);
|
||
|
|
if (style) out.style = style;
|
||
|
|
if (attrs.indent) out["data-indent"] = String(attrs.indent);
|
||
|
|
if (attrs.spaceBefore != null) out["data-space-before"] = String(attrs.spaceBefore);
|
||
|
|
if (attrs.spaceAfter != null) out["data-space-after"] = String(attrs.spaceAfter);
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function build() {
|
||
|
|
const { model, schemaList, tables } = V().pm;
|
||
|
|
const { Schema } = model;
|
||
|
|
|
||
|
|
const nodes = {
|
||
|
|
doc: { content: "block+" },
|
||
|
|
|
||
|
|
paragraph: {
|
||
|
|
content: "inline*",
|
||
|
|
group: "block",
|
||
|
|
attrs: paragraphAttrs(),
|
||
|
|
parseDOM: [{ tag: "p", getAttrs: readParagraphAttrs }],
|
||
|
|
toDOM(node) { return ["p", paragraphDomAttrs(node.attrs), 0]; },
|
||
|
|
},
|
||
|
|
|
||
|
|
heading: {
|
||
|
|
content: "inline*",
|
||
|
|
group: "block",
|
||
|
|
defining: true,
|
||
|
|
attrs: paragraphAttrs({ level: { default: 1 } }),
|
||
|
|
parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({
|
||
|
|
tag: "h" + level,
|
||
|
|
getAttrs: (dom) => Object.assign(readParagraphAttrs(dom), { level }),
|
||
|
|
})),
|
||
|
|
toDOM(node) { return ["h" + node.attrs.level, paragraphDomAttrs(node.attrs), 0]; },
|
||
|
|
},
|
||
|
|
|
||
|
|
blockquote: {
|
||
|
|
content: "block+",
|
||
|
|
group: "block",
|
||
|
|
defining: true,
|
||
|
|
parseDOM: [{ tag: "blockquote" }],
|
||
|
|
toDOM() { return ["blockquote", 0]; },
|
||
|
|
},
|
||
|
|
|
||
|
|
code_block: {
|
||
|
|
content: "text*",
|
||
|
|
marks: "",
|
||
|
|
group: "block",
|
||
|
|
code: true,
|
||
|
|
defining: true,
|
||
|
|
parseDOM: [{ tag: "pre", preserveWhitespace: "full" }],
|
||
|
|
toDOM() { return ["pre", ["code", 0]]; },
|
||
|
|
},
|
||
|
|
|
||
|
|
horizontal_rule: {
|
||
|
|
group: "block",
|
||
|
|
parseDOM: [{ tag: "hr" }],
|
||
|
|
toDOM() { return ["hr"]; },
|
||
|
|
},
|
||
|
|
|
||
|
|
// Word's hard page break. An atom so the caret skips over it rather
|
||
|
|
// than landing inside something with no content.
|
||
|
|
page_break: {
|
||
|
|
group: "block",
|
||
|
|
atom: true,
|
||
|
|
selectable: true,
|
||
|
|
parseDOM: [{ tag: "div.docx-page-break" }],
|
||
|
|
toDOM() { return ["div", { class: "docx-page-break", contenteditable: "false" }, ["span", "Page break"]]; },
|
||
|
|
},
|
||
|
|
|
||
|
|
text: { group: "inline" },
|
||
|
|
|
||
|
|
image: {
|
||
|
|
inline: true,
|
||
|
|
group: "inline",
|
||
|
|
draggable: true,
|
||
|
|
attrs: {
|
||
|
|
src: {}, alt: { default: null }, title: { default: null },
|
||
|
|
width: { default: null }, height: { default: null }, // points
|
||
|
|
},
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "img[src]",
|
||
|
|
getAttrs: (dom) => ({
|
||
|
|
src: dom.getAttribute("src"),
|
||
|
|
alt: dom.getAttribute("alt"),
|
||
|
|
title: dom.getAttribute("title"),
|
||
|
|
width: parseFloat(dom.getAttribute("data-w")) || null,
|
||
|
|
height: parseFloat(dom.getAttribute("data-h")) || null,
|
||
|
|
}),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
const a = { src: node.attrs.src, alt: node.attrs.alt || "", title: node.attrs.title || "" };
|
||
|
|
if (node.attrs.width) {
|
||
|
|
a["data-w"] = String(node.attrs.width);
|
||
|
|
a.style = `width:${node.attrs.width}pt`;
|
||
|
|
}
|
||
|
|
if (node.attrs.height) a["data-h"] = String(node.attrs.height);
|
||
|
|
return ["img", a];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
hard_break: {
|
||
|
|
inline: true, group: "inline", selectable: false,
|
||
|
|
parseDOM: [{ tag: "br" }],
|
||
|
|
toDOM() { return ["br"]; },
|
||
|
|
},
|
||
|
|
|
||
|
|
// A footnote or endnote the editor doesn't render but refuses to throw
|
||
|
|
// away: the note text stays in the package (see pkg.graft) and this
|
||
|
|
// carries the reference that points at it.
|
||
|
|
note_ref: {
|
||
|
|
inline: true, group: "inline", atom: true, selectable: true,
|
||
|
|
attrs: { noteType: { default: "footnote" }, noteId: { default: "" }, label: { default: "*" } },
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "sup.docx-note-ref",
|
||
|
|
getAttrs: (dom) => ({
|
||
|
|
noteType: dom.getAttribute("data-note-type") || "footnote",
|
||
|
|
noteId: dom.getAttribute("data-note-id") || "",
|
||
|
|
label: dom.textContent || "*",
|
||
|
|
}),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
return ["sup", {
|
||
|
|
class: "docx-note-ref", contenteditable: "false",
|
||
|
|
"data-note-type": node.attrs.noteType,
|
||
|
|
"data-note-id": node.attrs.noteId,
|
||
|
|
title: (node.attrs.noteType === "endnote" ? "Endnote" : "Footnote") +
|
||
|
|
" — kept in the file, not shown in the editor",
|
||
|
|
}, node.attrs.label];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const marks = {
|
||
|
|
strong: {
|
||
|
|
parseDOM: [{ tag: "strong" }, { tag: "b" },
|
||
|
|
{ style: "font-weight", getAttrs: (v) => /^(bold(er)?|[5-9]\d{2,})$/.test(v) && null }],
|
||
|
|
toDOM() { return ["strong", 0]; },
|
||
|
|
},
|
||
|
|
em: {
|
||
|
|
parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }],
|
||
|
|
toDOM() { return ["em", 0]; },
|
||
|
|
},
|
||
|
|
underline: {
|
||
|
|
parseDOM: [{ tag: "u" }, { style: "text-decoration=underline" }],
|
||
|
|
toDOM() { return ["u", 0]; },
|
||
|
|
},
|
||
|
|
strike: {
|
||
|
|
parseDOM: [{ tag: "s" }, { tag: "strike" }, { tag: "del" },
|
||
|
|
{ style: "text-decoration=line-through" }],
|
||
|
|
toDOM() { return ["s", 0]; },
|
||
|
|
},
|
||
|
|
// Word treats these as one property (w:vertAlign), so they exclude
|
||
|
|
// each other here too.
|
||
|
|
sup: {
|
||
|
|
group: "vertalign", excludes: "vertalign",
|
||
|
|
parseDOM: [{ tag: "sup:not(.docx-note-ref)" }],
|
||
|
|
toDOM() { return ["sup", 0]; },
|
||
|
|
},
|
||
|
|
sub: {
|
||
|
|
group: "vertalign", excludes: "vertalign",
|
||
|
|
parseDOM: [{ tag: "sub" }],
|
||
|
|
toDOM() { return ["sub", 0]; },
|
||
|
|
},
|
||
|
|
caps: {
|
||
|
|
parseDOM: [{ style: "text-transform=uppercase" }],
|
||
|
|
toDOM() { return ["span", { style: "text-transform:uppercase" }, 0]; },
|
||
|
|
},
|
||
|
|
smallcaps: {
|
||
|
|
parseDOM: [{ style: "font-variant=small-caps" }],
|
||
|
|
toDOM() { return ["span", { style: "font-variant:small-caps" }, 0]; },
|
||
|
|
},
|
||
|
|
link: {
|
||
|
|
attrs: { href: { default: "" }, title: { default: null }, anchor: { default: null } },
|
||
|
|
inclusive: false,
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "a[href]",
|
||
|
|
getAttrs: (dom) => ({
|
||
|
|
href: dom.getAttribute("href") || "",
|
||
|
|
title: dom.getAttribute("title"),
|
||
|
|
anchor: dom.getAttribute("data-anchor"),
|
||
|
|
}),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
const a = { href: node.attrs.href || "#", title: node.attrs.title || "" };
|
||
|
|
if (node.attrs.anchor) a["data-anchor"] = node.attrs.anchor;
|
||
|
|
return ["a", a, 0];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
font: {
|
||
|
|
attrs: { family: {} },
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "span[data-font]",
|
||
|
|
getAttrs: (dom) => ({ family: dom.getAttribute("data-font") }),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
return ["span", { "data-font": node.attrs.family, style: `font-family:${JSON.stringify(node.attrs.family)}` }, 0];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
fsize: {
|
||
|
|
attrs: { pt: {} },
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "span[data-size]",
|
||
|
|
getAttrs: (dom) => ({ pt: parseFloat(dom.getAttribute("data-size")) || 11 }),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
return ["span", { "data-size": String(node.attrs.pt), style: `font-size:${node.attrs.pt}pt` }, 0];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
color: {
|
||
|
|
attrs: { hex: {} }, // RRGGBB, no leading #
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "span[data-color]",
|
||
|
|
getAttrs: (dom) => ({ hex: (dom.getAttribute("data-color") || "").replace("#", "") }),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
return ["span", { "data-color": node.attrs.hex, style: `color:#${node.attrs.hex}` }, 0];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
highlight: {
|
||
|
|
attrs: { name: { default: "yellow" } },
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "mark",
|
||
|
|
getAttrs: (dom) => ({ name: dom.getAttribute("data-highlight") || "yellow" }),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
const css = HIGHLIGHT_CSS[node.attrs.name] || "#ffff00";
|
||
|
|
return ["mark", {
|
||
|
|
"data-highlight": node.attrs.name,
|
||
|
|
style: `background-color:${css};color:${css === "#000000" || css === "#000080" || css === "#800000" || css === "#808000" ? "#fff" : "inherit"}`,
|
||
|
|
}, 0];
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Lists and tables come from the ProseMirror packages, so the node specs
|
||
|
|
// match what their commands expect. Building a throwaway Schema first is
|
||
|
|
// the cheapest way to get an OrderedMap without reaching for the
|
||
|
|
// orderedmap module directly — it isn't re-exported by the bundle.
|
||
|
|
let nodeMap = new Schema({ nodes, marks }).spec.nodes;
|
||
|
|
nodeMap = schemaList.addListNodes(nodeMap, "paragraph block*", "block");
|
||
|
|
|
||
|
|
// Ordered lists keep Word's numbering format instead of collapsing
|
||
|
|
// every list to 1. 2. 3.
|
||
|
|
nodeMap = nodeMap.update("ordered_list", {
|
||
|
|
content: "list_item+",
|
||
|
|
group: "block",
|
||
|
|
attrs: {
|
||
|
|
order: { default: 1 },
|
||
|
|
// decimal | lowerLetter | upperLetter | lowerRoman | upperRoman
|
||
|
|
format: { default: "decimal" },
|
||
|
|
},
|
||
|
|
parseDOM: [{
|
||
|
|
tag: "ol",
|
||
|
|
getAttrs: (dom) => ({
|
||
|
|
order: dom.hasAttribute("start") ? parseInt(dom.getAttribute("start"), 10) || 1 : 1,
|
||
|
|
format: dom.getAttribute("data-format") || "decimal",
|
||
|
|
}),
|
||
|
|
}],
|
||
|
|
toDOM(node) {
|
||
|
|
const a = {};
|
||
|
|
if (node.attrs.order !== 1) a.start = node.attrs.order;
|
||
|
|
if (node.attrs.format !== "decimal") a["data-format"] = node.attrs.format;
|
||
|
|
return ["ol", a, 0];
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
nodeMap = nodeMap.append(tables.tableNodes({
|
||
|
|
tableGroup: "block",
|
||
|
|
cellContent: "block+",
|
||
|
|
cellAttributes: {
|
||
|
|
background: {
|
||
|
|
default: null,
|
||
|
|
getFromDOM: (dom) => dom.style.backgroundColor || null,
|
||
|
|
setDOMAttr: (value, attrs) => {
|
||
|
|
if (value) attrs.style = (attrs.style || "") + `background-color:${value};`;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
return new Schema({ nodes: nodeMap, marks });
|
||
|
|
}
|
||
|
|
|
||
|
|
return { build, HIGHLIGHTS, HIGHLIGHT_CSS, TWIPS_PER_INDENT };
|
||
|
|
});
|