import { type } from "@heydovetail/assert"; import { DOMOutputSpec, Fragment, MarkSpec, Node, NodeSpec, Schema } from "prosemirror-model"; import * as attachmentSchema from "./attachment/schema"; import { RefsFragment, RefsNode } from "./composing"; import { AttrsSpec } from "./types"; import * as dom from "./util/dom"; import { safeUrl } from "./util/safeUrl"; export { RefsNode, RefsFragment, Fragment }; export type NodeTypeName = // Attachment | attachmentSchema.TypeName // Doc | "doc" // Paragraph | "p" // Unordered List | "ul" // Ordered List | "ol" // List Item | "li" // Horizontal Rule | "hr" // Block Quote | "bq" // Heading | "h" // Hard Break | "br" // Table | "tbl" // Table Row | "tr" // Table Header Cell | "th" // Table Data Cell | "td" // Text | "text"; export type MarkTypeName = // Bold | "b" // Italic | "i" // Link | "l" // Strikethrough | "s" // Underline | "u" // ClipboardRange | "cr"; const doc: NodeSpec = { content: "block+" }; export interface HeadingAttrs { /** * "Level" * * The heading level (e.g. h1 → 1, h2 → 2). Valid values: 1, 2 */ l: number; } interface CellDomAttrs { // HACK // tslint:disable-next-line:no-any [attr: string]: any; "data-colwidth"?: string; colspan?: string; rowspan?: string; } export interface CellAttrs { colspan: number; rowspan: number; colwidth?: ReadonlyArray | null; } function getCellAttrs(dom: dom.Element): CellAttrs { const widthAttr = dom.getAttribute("data-colwidth"); const colspanAttr = dom.getAttribute("colspan"); const rowspanAttr = dom.getAttribute("rowspan"); const widths = widthAttr !== null && /^\d+(,\d+)*$/.test(widthAttr) ? widthAttr.split(",").map(s => Number(s)) : null; const colspan = colspanAttr !== null ? Number(colspanAttr) : 1; const rowspan = rowspanAttr !== null ? Number(rowspanAttr) : 1; return { colspan, rowspan, colwidth: widths !== null && widths.length == colspan ? widths : null }; } function setCellAttrs(node: Node) { const attrs: CellDomAttrs = {}; if (node.attrs.colspan != 1) { attrs.colspan = node.attrs.colspan; } if (node.attrs.rowspan != 1) { attrs.rowspan = node.attrs.rowspan; } if (node.attrs.colwidth) { attrs["data-colwidth"] = node.attrs.colwidth.join(","); } return attrs; } const bold: MarkSpec = { parseDOM: [ { tag: "strong" }, // This works around a Google Docs misbehavior where // pasted content will be inexplicably wrapped in `` // tags with a font-weight normal. { tag: "b", getAttrs: value => { const elem = value as HTMLElement; return elem.style.fontWeight !== "normal" ? null : false; } }, { style: "font-weight", getAttrs: value => (typeof value === "string" && /^(bold(er)?|[5-9]\d{2,})$/.test(value) ? null : false) } ], toDOM() { return ["strong"]; } }; const italic: MarkSpec = { parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style", getAttrs: value => (value == "italic" ? null : false) }], toDOM() { return ["em"]; } }; const strikethrough: MarkSpec = { parseDOM: [ { tag: "s" }, { tag: "del" }, { tag: "strike" }, { style: "text-decoration", getAttrs: value => (value == "line-through" ? null : false) } ], toDOM() { return ["del"]; } }; const underline: MarkSpec = { parseDOM: [{ tag: "u" }, { style: "text-decoration", getAttrs: value => (value == "underline" ? null : false) }], toDOM() { return ["span", { style: "text-decoration: underline;" }]; } }; export const enum LinkAttr { URL = "u", TITLE = "t" } export interface LinkAttrs { u: string; t?: string; } /** * Hyperlink mark */ const link: MarkSpec = { attrs: { [LinkAttr.URL]: {}, [LinkAttr.TITLE]: { default: undefined } }, inclusive: false, parseDOM: [ { tag: "a[href]", getAttrs: (dom): LinkAttrs => { const anchor = dom as HTMLAnchorElement; return { [LinkAttr.URL]: anchor.href, [LinkAttr.TITLE]: anchor.title }; } } ], toDOM(mark): DOMOutputSpec { const attrs = mark.attrs as LinkAttrs; const base = { href: safeUrl(attrs[LinkAttr.URL]), target: "_blank" }; const title = attrs[LinkAttr.TITLE]; return ["a", title !== undefined ? { ...base, title } : base]; } }; /** * ClipboardRange mark */ export const enum ClipboardRangeAttr { ID = "i", TYPE = "t", ATTRS = "a" } export interface ClipboardRangeAttrs { i: string; t: string; a: string; } const clipboardRange: MarkSpec = { attrs: { [ClipboardRangeAttr.ID]: {}, [ClipboardRangeAttr.TYPE]: {}, [ClipboardRangeAttr.ATTRS]: {} }, inclusive: false, parseDOM: [ { tag: "span[data-range-id][data-range-type]", getAttrs: (dom): ClipboardRangeAttrs => { const span = dom as HTMLSpanElement; return { [ClipboardRangeAttr.ID]: span.dataset.rangeId!, [ClipboardRangeAttr.TYPE]: span.dataset.rangeType!, [ClipboardRangeAttr.ATTRS]: JSON.parse(span.dataset.rangeAttrs!) }; } } ], toDOM(mark): DOMOutputSpec { const attrs = mark.attrs as ClipboardRangeAttrs; return [ "span", { [`data-range-id`]: attrs[ClipboardRangeAttr.ID], [`data-range-type`]: attrs[ClipboardRangeAttr.TYPE], [`data-range-attrs`]: JSON.stringify(attrs[ClipboardRangeAttr.ATTRS]) } ]; } }; export type EditorSchema = Schema; const tableCellContent = `(${"p" as NodeTypeName} | ${"ol" as NodeTypeName} | ${"ul" as NodeTypeName} | ${"at" as NodeTypeName})+`; const tableCellAttrs = { colspan: { default: 1 }, rowspan: { default: 1 }, colwidth: { default: null } }; export const schema = new Schema({ nodes: { doc, p: { group: "block", content: `(${"text" as NodeTypeName} | ${"br" as NodeTypeName})*`, marks: `${"b" as MarkTypeName} ${"i" as MarkTypeName} ${"l" as MarkTypeName} ${"s" as MarkTypeName} ${"u" as MarkTypeName} ${"cr" as MarkTypeName}`, parseDOM: [{ tag: "p" }], toDOM: () => ["p", 0] }, ul: { group: "block", content: `${"li" as NodeTypeName}+`, parseDOM: [{ tag: "ul" }], toDOM() { return ["ul", 0]; } }, ol: { group: "block", content: `${"li" as NodeTypeName}+`, parseDOM: [{ tag: "ol" }], toDOM() { return ["ol", 0]; } }, li: { content: `${"p" as NodeTypeName} (${"ul" as NodeTypeName} | ${"ol" as NodeTypeName})?`, parseDOM: [{ tag: "li" }], toDOM() { return ["li", 0]; }, defining: true }, hr: { group: "block", parseDOM: [{ tag: "hr" }], toDOM() { return ["hr"]; } }, bq: { content: `${"p" as NodeTypeName}+`, group: "block", defining: true, parseDOM: [{ tag: "blockquote" }], toDOM() { return ["blockquote", 0]; } }, tbl: { content: `${"tr" as NodeTypeName}+`, isolating: true, group: "block", parseDOM: [{ tag: "table" }], toDOM() { return ["table", ["tbody", 0]]; } }, tr: { content: `(${"td" as NodeTypeName} | ${"th" as NodeTypeName})*`, parseDOM: [{ tag: "tr" }], toDOM() { return ["tr", 0]; } }, td: { content: tableCellContent, attrs: tableCellAttrs, isolating: true, parseDOM: [{ tag: "td", getAttrs: dom => getCellAttrs(dom as HTMLTableDataCellElement) }], toDOM(node) { return ["td", setCellAttrs(node), 0]; } }, th: { content: tableCellContent, attrs: tableCellAttrs, isolating: true, parseDOM: [{ tag: "th", getAttrs: dom => getCellAttrs(dom as HTMLTableHeaderCellElement) }], toDOM(node) { return ["th", setCellAttrs(node), 0]; } }, h: { attrs: type>({ l: { default: 1 } }), content: `${"text" as NodeTypeName}*`, marks: `${"b" as MarkTypeName} ${"i" as MarkTypeName} ${"s" as MarkTypeName} ${"u" as MarkTypeName} ${"cr" as MarkTypeName}`, group: "block", defining: true, parseDOM: [ // We only support two levels of heading, so map everything to h1 or h2. { tag: "h1", attrs: { l: 1 } as HeadingAttrs }, { tag: "h2", attrs: { l: 2 } as HeadingAttrs }, { tag: "h3", attrs: { l: 2 } as HeadingAttrs }, { tag: "h4", attrs: { l: 2 } as HeadingAttrs }, { tag: "h5", attrs: { l: 2 } as HeadingAttrs }, { tag: "h6", attrs: { l: 2 } as HeadingAttrs } ], toDOM(node) { // The schema doesn't enforce a particular range of values for the level, so // when we're rendering to DOM we're extra careful to only support the // heading levels we expect. // // Unsupported heading levels fall back to `

`. const level = (node.attrs as HeadingAttrs).l; switch (level) { case 1: case 2: return [`h${level}`, 0]; default: return ["h2", 0]; } } }, br: { inline: true, selectable: false, parseDOM: [{ tag: "br" }], toDOM() { return ["br"]; } }, at: attachmentSchema.nodeSpec, text: {} }, marks: { l: link, b: bold, i: italic, s: strikethrough, u: underline, cr: clipboardRange } });