snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)

This commit is contained in:
upstream-mirror
2026-09-15 15:15:34 +05:00
commit 2653349432
1615 changed files with 202632 additions and 0 deletions

2
packages/base-formula/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
tsconfig.tsbuildinfo

View File

@@ -0,0 +1,37 @@
The Docmost Enterprise License (the “Enterprise License”)
Copyright (c) 2023-present Docmost, Inc
With regard to the Docmost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Docmost Subscription Terms of Service, available
at https://docmost.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Docmost
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Docmost Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Docmost and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Docmost Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,329 @@
import {
parseRaw,
resolve,
typecheck,
evaluate,
registry,
DEFAULT_MAX_DEPTH,
} from "../src/index.server";
import type {
FormulaAST,
EvalContext,
PropertyLookup,
Value,
FormulaResultType,
} from "../src/index.server";
// sample row: properties a..j (numbers), name (string)
const NUM_PROPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
const cells: Record<string, unknown> = {
prop_name: "widget",
};
NUM_PROPS.forEach((p, idx) => {
cells[`prop_${p}`] = (idx + 1) * 7.3 - idx; // arbitrary non-trivial floats
});
const nameToId = new Map<string, string>([
["name", "prop_name"],
...NUM_PROPS.map((p) => [p, `prop_${p}`] as [string, string]),
]);
const propertyTypes = new Map<string, FormulaResultType>([
["prop_name", "string"],
...NUM_PROPS.map(
(p) => [`prop_${p}`, "number"] as [string, FormulaResultType],
),
]);
// Base (non-formula) property lookup. Nested-formula cases extend this.
const baseProps = new Map<string, PropertyLookup>([
["prop_name", { id: "prop_name", type: "string", typeOptions: {} }],
...NUM_PROPS.map(
(p) =>
[`prop_${p}`, { id: `prop_${p}`, type: "number", typeOptions: {} }] as [
string,
PropertyLookup,
],
),
]);
function mkCtx(properties: ReadonlyMap<string, PropertyLookup>): EvalContext {
return {
registry,
properties,
depth: 0,
maxDepth: DEFAULT_MAX_DEPTH,
memo: new Map<string, Value>(),
};
}
//AST shape metrics
function astStats(ast: FormulaAST): { nodes: number; depth: number } {
let nodes = 0;
const walk = (n: FormulaAST, d: number): number => {
nodes++;
let max = d;
const kids: FormulaAST[] = [];
switch (n.t) {
case "op":
kids.push(...n.args);
break;
case "and":
case "or":
kids.push(...n.args);
break;
case "call":
kids.push(...n.args);
break;
case "if":
kids.push(n.cond, n.then, n.else);
break;
}
for (const k of kids) max = Math.max(max, walk(k, d + 1));
return max;
};
const depth = walk(ast, 1);
return { nodes, depth };
}
// timing harness
function timed(
fn: () => void,
targetMs = 600,
): { opsPerSec: number; nsPerOp: number } {
// warmup ~150ms to let V8 JIT settle
const warmEnd = performance.now() + 150;
while (performance.now() < warmEnd) fn();
// measure: 5 samples, keep the fastest (least noise from GC/scheduler)
let bestNsPerOp = Infinity;
for (let s = 0; s < 5; s++) {
// calibrate batch so each sample ~ targetMs
let iters = 1024;
let elapsedMs = 0;
while (true) {
const t0 = process.hrtime.bigint();
for (let i = 0; i < iters; i++) fn();
const t1 = process.hrtime.bigint();
elapsedMs = Number(t1 - t0) / 1e6;
if (elapsedMs >= targetMs) break;
iters = Math.ceil(
iters * Math.max(2, targetMs / Math.max(elapsedMs, 0.01)),
);
}
const nsPerOp = (elapsedMs * 1e6) / iters;
bestNsPerOp = Math.min(bestNsPerOp, nsPerOp);
}
return { opsPerSec: 1e9 / bestNsPerOp, nsPerOp: bestNsPerOp };
}
// formula corpus
type Case = { tier: string; name: string; src: string };
function buildArithChain(n: number): string {
// ((((a + b) * c) - d) ... ) cycling through props/ops
const ops = ["+", "*", "-"];
let expr = 'prop("a")';
for (let i = 0; i < n; i++) {
const p = NUM_PROPS[(i + 1) % NUM_PROPS.length];
const op = ops[i % ops.length];
expr = `(${expr} ${op} prop("${p}"))`;
}
return expr;
}
function buildIfChain(tiers: number): string {
// if(a>t1, "1", if(a>t2, "2", ... "fallback"))
let expr = '"fallback"';
for (let i = tiers; i >= 1; i--) {
expr = `if(prop("a") > ${i * 5}, "${i}", ${expr})`;
}
return expr;
}
function buildBalancedAddTree(depth: number): string {
// add(add(.., ..), add(.., ..)) = full binary tree of `add` calls
const leaf = () =>
`prop("${NUM_PROPS[Math.floor(Math.random() * NUM_PROPS.length)]}")`;
const build = (d: number): string =>
d === 0 ? leaf() : `add(${build(d - 1)}, ${build(d - 1)})`;
return build(depth);
}
const cases: Case[] = [
// BASIC
{ tier: "basic", name: "literal add", src: "1 + 2" },
{ tier: "basic", name: "two-prop add", src: 'prop("a") + prop("b")' },
{ tier: "basic", name: "comparison", src: 'prop("a") > 10' },
{ tier: "basic", name: "neg + mul", src: '-prop("a") * 2' },
// INTERMEDIATE
{
tier: "intermediate",
name: "round(mul)",
src: 'round(prop("a") * 1.5, 2)',
},
{
tier: "intermediate",
name: "if/then/else",
src: 'if(prop("a") > prop("b"), "hi", "lo")',
},
{
tier: "intermediate",
name: "string concat",
src: 'concat(upper(prop("name")), "-", toString(prop("a")))',
},
{
tier: "intermediate",
name: "bool and/or",
src: 'and(prop("a") > 0, or(prop("b") < 100, prop("c") == 0))',
},
// COMPLEX
{
tier: "complex",
name: "hypotenuse",
src: 'sqrt(pow(prop("a"), 2) + pow(prop("b"), 2))',
},
{
tier: "complex",
name: "sum(10 props)",
src: `sum(${NUM_PROPS.map((p) => `prop("${p}")`).join(", ")})`,
},
{
tier: "complex",
name: "nested if (4-tier grade)",
src: 'if(prop("a") > 90, "A", if(prop("a") > 80, "B", if(prop("a") > 70, "C", "F")))',
},
{
tier: "complex",
name: "mixed math+string+logic",
src: 'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
},
// DEEPLY NESTED
{ tier: "deep", name: "arith chain x20", src: buildArithChain(20) },
{ tier: "deep", name: "nested if x10 tiers", src: buildIfChain(10) },
{ tier: "deep", name: "balanced fn tree d6", src: buildBalancedAddTree(6) },
];
// nested-formula (cross-property) case
// prop_total (formula) -> prop_sub (formula) -> raw props. Exercises evalProp
// recursion + per-row memoization, the multi-formula recompute hot path.
function buildNestedFormulaCtx(): {
ast: FormulaAST;
ctx: EvalContext;
stats: { nodes: number; depth: number };
} {
const subRaw = resolve(
parseRaw('round((prop("a") + prop("b") + prop("c")) / 3, 2)'),
nameToId,
).ast;
const totalRaw = resolve(
parseRaw('prop("sub") * prop("d") + prop("e")'),
// @ts-ignore
new Map([...nameToId, ["sub", "prop_sub"]]),
).ast;
const props = new Map<string, PropertyLookup>(baseProps);
props.set("prop_sub", {
id: "prop_sub",
type: "formula",
typeOptions: {
ast: subRaw,
source: "",
resultType: "number",
dependencies: [],
astVersion: 1,
},
});
return { ast: totalRaw, ctx: mkCtx(props), stats: astStats(totalRaw) };
}
// run
const fmt = (n: number) =>
n >= 1e6
? `${(n / 1e6).toFixed(2)}M`
: n >= 1e3
? `${(n / 1e3).toFixed(1)}K`
: n.toFixed(0);
console.log(`\nnode ${process.version} | base-formula engine benchmark\n`);
console.log(
"tier".padEnd(13) +
"formula".padEnd(28) +
"nodes".padStart(6) +
"depth".padStart(6) +
"compile op/s".padStart(15) +
"eval op/s".padStart(13) +
"eval ns/op".padStart(13),
);
console.log("-".repeat(94));
for (const c of cases) {
const raw = parseRaw(c.src);
const { ast } = resolve(raw, nameToId);
const stats = astStats(ast);
const ctx = mkCtx(baseProps);
const compile = timed(() => {
const r = resolve(parseRaw(c.src), nameToId);
typecheck(r.ast, propertyTypes, registry);
});
const ev = timed(() => {
ctx.memo.clear(); // fresh per "row" — matches production new Map() per row
evaluate(ast, cells, ctx);
});
console.log(
c.tier.padEnd(13) +
c.name.padEnd(28) +
String(stats.nodes).padStart(6) +
String(stats.depth).padStart(6) +
fmt(compile.opsPerSec).padStart(15) +
fmt(ev.opsPerSec).padStart(13) +
ev.nsPerOp.toFixed(0).padStart(13),
);
}
// nested cross-property formula
{
const { ast, ctx, stats } = buildNestedFormulaCtx();
const ev = timed(() => {
ctx.memo.clear();
evaluate(ast, cells, ctx);
});
console.log(
"nested-prop".padEnd(13) +
"total->sub->raw".padEnd(28) +
String(stats.nodes).padStart(6) +
String(stats.depth).padStart(6) +
"-".padStart(15) +
fmt(ev.opsPerSec).padStart(13) +
ev.nsPerOp.toFixed(0).padStart(13),
);
}
// whole-table simulation: eval N rows for the complex grade formula
console.log(
"\nwhole-table recompute simulation (mixed math+string+logic formula):",
);
const tableAst = resolve(
parseRaw(
'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
),
nameToId,
).ast;
for (const rows of [1_000, 10_000, 100_000]) {
const ctx = mkCtx(baseProps);
const t0 = process.hrtime.bigint();
for (let r = 0; r < rows; r++) {
ctx.memo.clear();
evaluate(tableAst, cells, ctx);
}
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(
` ${fmt(rows).padStart(6)} rows -> ${ms.toFixed(1)} ms (${fmt((rows / ms) * 1000)} rows/sec)`,
);
}
console.log();

View File

@@ -0,0 +1,28 @@
{
"name": "@docmost/base-formula",
"homepage": "https://docmost.com",
"private": true,
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch",
"bench": "tsx bench/formula-bench.ts"
},
"main": "dist/index.server.js",
"module": "./dist/index.server.js",
"exports": {
"./client": {
"types": "./dist/index.client.d.ts",
"default": "./src/index.client.ts"
},
"./server": {
"types": "./dist/index.server.d.ts",
"default": "./dist/index.server.js"
},
".": {
"types": "./dist/index.server.d.ts",
"default": "./dist/index.server.js"
}
},
"types": "dist/index.server.d.ts",
"dependencies": {}
}

View File

@@ -0,0 +1,33 @@
export type OpCode =
| "+" | "-" | "*" | "/" | "%"
| "==" | "!=" | ">" | "<" | ">=" | "<="
| "neg" | "not";
export type FormulaAST =
| { t: "num"; v: number }
| { t: "str"; v: string }
| { t: "bool"; v: boolean }
| { t: "null" }
| { t: "prop"; id: string }
| { t: "op"; op: OpCode; args: FormulaAST[] }
| { t: "if"; cond: FormulaAST; then: FormulaAST; else: FormulaAST }
| { t: "and"; args: FormulaAST[] }
| { t: "or"; args: FormulaAST[] }
| { t: "call"; fn: string; args: FormulaAST[] };
/*
* Raw AST: what the parser produces before resolving property names to IDs.
* Only the `propName` variant differs from FormulaAST — every other node is
* reused directly. We deliberately keep this type-level to avoid duplicating
* the tree shape.
*/
export type RawFormulaAST =
| Exclude<FormulaAST, { t: "prop" }>
| { t: "propName"; name: string }
| { t: "op"; op: OpCode; args: RawFormulaAST[] }
| { t: "if"; cond: RawFormulaAST; then: RawFormulaAST; else: RawFormulaAST }
| { t: "and"; args: RawFormulaAST[] }
| { t: "or"; args: RawFormulaAST[] }
| { t: "call"; fn: string; args: RawFormulaAST[] };
export const AST_VERSION = 1 as const;

View File

@@ -0,0 +1,41 @@
import type { ErrorCell, ErrorCode } from "./types";
export type ParseErrorCode =
| "UNEXPECTED_TOKEN"
| "UNEXPECTED_EOF"
| "UNKNOWN_PROPERTY"
| "UNKNOWN_FUNCTION"
| "ARITY_MISMATCH"
| "TYPE_MISMATCH"
| "CYCLE"
| "INPUT_TOO_LONG"
| "DEPTH_EXCEEDED";
export type ParseError = {
code: ParseErrorCode;
message: string;
span: { start: number; end: number };
hint?: string;
};
export class FormulaParseError extends Error {
readonly errors: ParseError[];
constructor(errors: ParseError[]) {
super(errors.map((e) => `${e.code}: ${e.message}`).join("; "));
this.errors = errors;
this.name = "FormulaParseError";
}
}
export function makeErrorCell(code: ErrorCode, msg: string): ErrorCell {
return { __err: code, msg, v: 1 };
}
export function isErrorCell(v: unknown): v is ErrorCell {
return (
typeof v === "object" &&
v !== null &&
"__err" in v &&
typeof (v as { __err: unknown }).__err === "string"
);
}

View File

@@ -0,0 +1,126 @@
import { makeErrorCell, isErrorCell } from "./error";
import { valueToString } from "./number";
import { MAX_EVAL_DEPTH } from "./types";
import type { FormulaAST, OpCode } from "./ast";
import type { Value, EvalContext } from "./types";
export function evaluate(
ast: FormulaAST,
row: Record<string, unknown>,
ctx: EvalContext,
astDepth = 0,
): Value {
// astDepth bounds AST tree-walk recursion (guards a hand-crafted deep
// typeOptions.ast); ctx.depth separately bounds nested-formula hops.
const depth = astDepth + 1;
if (depth > MAX_EVAL_DEPTH) {
return makeErrorCell("DEPTH_EXCEEDED", `formula too deeply nested (max ${MAX_EVAL_DEPTH})`);
}
switch (ast.t) {
case "num": return ast.v;
case "str": return ast.v;
case "bool": return ast.v;
case "null": return null;
case "prop": return evalProp(ast.id, row, ctx, depth);
case "op": return evalOp(ast.op, ast.args, row, ctx, depth);
case "if": {
const c = evaluate(ast.cond, row, ctx, depth);
if (isErrorCell(c)) return c;
return evaluate(c === true ? ast.then : ast.else, row, ctx, depth);
}
case "and": {
const xs = ast.args;
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return v;
if (v === false) return false;
if (v == null) return null;
}
return true;
}
case "or": {
const xs = ast.args;
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return v;
if (v === true) return true;
}
return false;
}
case "call": {
const fn = ctx.registry.get(ast.fn.toLowerCase());
if (!fn) return makeErrorCell("MISSING_PROP", `unknown function ${ast.fn}`);
const xs = ast.args;
const args: Value[] = new Array(xs.length);
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
args[i] = v;
}
try { return fn.eval(args, ctx); }
catch (e) { return makeErrorCell("TYPE_MISMATCH", (e as Error).message); }
}
}
}
function evalProp(id: string, row: Record<string, unknown>, ctx: EvalContext, astDepth: number): Value {
if (ctx.memo.has(id)) return ctx.memo.get(id)!;
const prop = ctx.properties.get(id);
if (!prop) return makeErrorCell("MISSING_PROP", `missing property ${id}`);
if (prop.type !== "formula") return normalize(row[id] ?? null);
// astDepth continues (not reset) across the nested-formula boundary.
if (ctx.depth >= ctx.maxDepth) return makeErrorCell("DEPTH_EXCEEDED", `max depth ${ctx.maxDepth}`);
const opts: any = prop.typeOptions;
const nested: EvalContext = { ...ctx, depth: ctx.depth + 1, memo: ctx.memo };
const v = evaluate(opts.ast, row, nested, astDepth);
ctx.memo.set(id, v);
return v;
}
function normalize(v: unknown): Value {
if (v === undefined) return null;
if (v === null) return null;
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") return v;
if (isErrorCell(v)) return v;
return null;
}
function evalOp(
op: OpCode,
args: FormulaAST[],
row: Record<string, unknown>,
ctx: EvalContext,
astDepth: number,
): Value {
const a = evaluate(args[0], row, ctx, astDepth);
if (isErrorCell(a)) return { ...a, __err: "DEPENDENCY_ERROR" };
if (op === "neg") return a == null ? null : -Number(a);
if (op === "not") return a == null ? null : !Boolean(a);
const b = evaluate(args[1], row, ctx, astDepth);
if (isErrorCell(b)) return { ...b, __err: "DEPENDENCY_ERROR" };
switch (op as Exclude<OpCode, "neg" | "not">) {
case "+":
if (typeof a === "string" || typeof b === "string") return valueToString(a) + valueToString(b);
if (a == null || b == null) return null;
return Number(a) + Number(b);
case "-": return a == null || b == null ? null : Number(a) - Number(b);
case "*": return a == null || b == null ? null : Number(a) * Number(b);
case "/":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
return Number(a) / Number(b);
case "%":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
return Number(a) % Number(b);
case "==": return a === b;
case "!=": return a !== b;
case ">": return a != null && b != null && (a as any) > (b as any);
case "<": return a != null && b != null && (a as any) < (b as any);
case ">=": return a != null && b != null && (a as any) >= (b as any);
case "<=": return a != null && b != null && (a as any) <= (b as any);
}
}

View File

@@ -0,0 +1,31 @@
import type { FormulaAST, OpCode } from "./ast";
const OP_STR: Partial<Record<OpCode, string>> = {
"+": " + ", "-": " - ", "*": " * ", "/": " / ", "%": " % ",
"==": " == ", "!=": " != ", ">": " > ", "<": " < ", ">=": " >= ", "<=": " <= ",
};
export function format(
ast: FormulaAST,
idToName: ReadonlyMap<string, string>,
): string {
switch (ast.t) {
case "num": return String(ast.v);
case "str": return `"${ast.v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
case "bool": return ast.v ? "true" : "false";
case "null": return "null";
case "prop": return `prop("${idToName.get(ast.id) ?? ast.id}")`;
case "op":
if (ast.op === "neg") return `-${format(ast.args[0], idToName)}`;
if (ast.op === "not") return `not ${format(ast.args[0], idToName)}`;
return `(${format(ast.args[0], idToName)}${OP_STR[ast.op]}${format(ast.args[1], idToName)})`;
case "if":
return `if(${format(ast.cond, idToName)}, ${format(ast.then, idToName)}, ${format(ast.else, idToName)})`;
case "and":
return `(${ast.args.map((a) => format(a, idToName)).join(" and ")})`;
case "or":
return `(${ast.args.map((a) => format(a, idToName)).join(" or ")})`;
case "call":
return `${ast.fn}(${ast.args.map((a) => format(a, idToName)).join(", ")})`;
}
}

View File

@@ -0,0 +1,17 @@
import { register } from "./registry";
import { valueToString } from "../number";
register({
name: "toNumber", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "number",
eval: ([v]) => {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
},
doc: "Parses the value as a number, or null.", category: "coercion",
});
register({
name: "toString", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "string",
eval: ([v]) => valueToString(v),
doc: "Converts the value to a string.", category: "coercion",
});

View File

@@ -0,0 +1,53 @@
import { register } from "./registry";
import { makeErrorCell } from "../error";
const toDate = (v: unknown): Date | null => {
if (v == null) return null;
const d = new Date(String(v));
return isNaN(d.getTime()) ? null : d;
};
register({
name: "now", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
eval: () => new Date().toISOString(),
doc: "Current timestamp.", category: "date",
});
register({
name: "today", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
eval: () => {
const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d.toISOString();
},
doc: "Midnight UTC of today.", category: "date",
});
register({
name: "dateAdd", arity: { min: 3, max: 3 }, paramTypes: ["date", "number", "string"], returnType: "date",
eval: ([base, amt, unit]) => {
const d = toDate(base);
if (!d) return makeErrorCell("DATE_INVALID", "invalid date");
const n = Number(amt);
const u = String(unit);
const r = new Date(d);
if (u === "days") r.setUTCDate(r.getUTCDate() + n);
else if (u === "hours") r.setUTCHours(r.getUTCHours() + n);
else if (u === "minutes") r.setUTCMinutes(r.getUTCMinutes() + n);
else if (u === "months") r.setUTCMonth(r.getUTCMonth() + n);
else if (u === "years") r.setUTCFullYear(r.getUTCFullYear() + n);
else return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
return r.toISOString();
},
doc: "Adds a duration to a date. Units: days, hours, minutes, months, years.", category: "date",
});
register({
name: "dateBetween", arity: { min: 3, max: 3 }, paramTypes: ["date", "date", "string"], returnType: "number",
eval: ([a, b, unit]) => {
const da = toDate(a), db = toDate(b);
if (!da || !db) return makeErrorCell("DATE_INVALID", "invalid date");
const ms = db.getTime() - da.getTime();
const u = String(unit);
if (u === "days") return Math.floor(ms / 86_400_000);
if (u === "hours") return Math.floor(ms / 3_600_000);
if (u === "minutes") return Math.floor(ms / 60_000);
return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
},
doc: "Difference between two dates in a given unit.", category: "date",
});

View File

@@ -0,0 +1,7 @@
import "./logic";
import "./math";
import "./string";
import "./date";
import "./coercion";
export { registry, register } from "./registry";
export type { FormulaFn } from "./registry";

View File

@@ -0,0 +1,11 @@
import { register } from "./registry";
register({
name: "empty",
arity: { min: 1, max: 1 },
paramTypes: "any",
returnType: "boolean",
eval: ([v]) => v == null || v === "" || (typeof v === "object" && v !== null && "__err" in v),
doc: "Returns true if the value is null or empty string or an error.",
category: "logic",
});

View File

@@ -0,0 +1,160 @@
import { register } from "./registry";
import { makeErrorCell } from "../error";
import type { Value } from "../types";
const num = (v: unknown): number | null => v == null ? null : Number(v);
register({
name: "round", arity: { min: 1, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([v, places]) => {
const n = num(v);
if (n == null) return null;
const p = places == null ? 0 : Math.trunc(Number(places));
const factor = Math.pow(10, p);
return Math.round(n * factor) / factor;
},
doc: "Rounds to the nearest integer, or to `places` decimals if given.", category: "math",
});
register({
name: "floor", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.floor(n); },
doc: "Rounds down.", category: "math",
});
register({
name: "ceil", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.ceil(n); },
doc: "Rounds up.", category: "math",
});
register({
name: "abs", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.abs(n); },
doc: "Absolute value.", category: "math",
});
register({
name: "min", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums = args.map(num).filter((n): n is number => n != null);
return nums.length ? Math.min(...nums) : null;
},
doc: "Minimum of the arguments.", category: "math",
});
register({
name: "max", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums = args.map(num).filter((n): n is number => n != null);
return nums.length ? Math.max(...nums) : null;
},
doc: "Maximum of the arguments.", category: "math",
});
register({
name: "mod", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
if (na == null || nb == null) return null;
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
return na % nb;
},
doc: "Remainder after division.", category: "math",
});
register({
name: "add", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na + nb;
},
doc: "Sum of two numbers.", category: "math",
});
register({
name: "subtract", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na - nb;
},
doc: "Difference of two numbers.", category: "math",
});
register({
name: "multiply", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na * nb;
},
doc: "Product of two numbers.", category: "math",
});
register({
name: "divide", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
if (na == null || nb == null) return null;
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
return na / nb;
},
doc: "Quotient of two numbers.", category: "math",
});
register({
name: "pow", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : Math.pow(na, nb);
},
doc: "Base raised to an exponent.", category: "math",
});
register({
name: "sqrt", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => {
const n = num(v);
if (n == null) return null;
if (n < 0) return makeErrorCell("TYPE_MISMATCH", "sqrt of negative number");
return Math.sqrt(n);
},
doc: "Positive square root.", category: "math",
});
register({
name: "sum", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
// Null propagates as 0 so `sum(prop("A"), prop("B"))` still works when
// some cells are empty — matches Airtable/Notion semantics.
let total = 0;
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) total += n;
}
return total;
},
doc: "Sum of the arguments.", category: "math",
});
const meanEval = (args: Value[]): Value => {
const nums: number[] = [];
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) nums.push(n);
}
if (nums.length === 0) return null;
return nums.reduce((a, b) => a + b, 0) / nums.length;
};
register({
name: "mean", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: meanEval,
doc: "Arithmetic average of the arguments.", category: "math",
});
register({
name: "average", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: meanEval,
doc: "Arithmetic average of the arguments (alias of mean).", category: "math",
});
register({
name: "median", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums: number[] = [];
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) nums.push(n);
}
if (nums.length === 0) return null;
nums.sort((a, b) => a - b);
const mid = Math.floor(nums.length / 2);
return nums.length % 2 === 0
? (nums[mid - 1] + nums[mid]) / 2
: nums[mid];
},
doc: "Middle value of the arguments.", category: "math",
});

View File

@@ -0,0 +1,24 @@
import type { FormulaResultType, Value, EvalContext } from "../types";
export type FormulaFn = {
name: string;
arity: { min: number; max: number | null };
paramTypes: FormulaResultType[] | "any" | "variadic-any";
returnType: FormulaResultType | ((argTypes: FormulaResultType[]) => FormulaResultType);
eval: (args: Value[], ctx: EvalContext) => Value;
doc: string;
category: "logic" | "math" | "string" | "date" | "coercion";
};
export const registry: Map<string, FormulaFn> = new Map();
export function register(fn: FormulaFn): void {
// Functions are looked up case-insensitively (see eval/typecheck), so the
// registry is keyed by the lowercased name. fn.name keeps its canonical
// casing for display in the function picker and `format()`.
const key = fn.name.toLowerCase();
if (registry.has(key)) {
throw new Error(`Duplicate formula function: ${fn.name}`);
}
registry.set(key, fn);
}

View File

@@ -0,0 +1,35 @@
import { register } from "./registry";
import { valueToString } from "../number";
const s = (v: unknown): string => valueToString(v);
register({
name: "concat", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "string",
eval: (args) => args.map(s).join(""),
doc: "Concatenates strings.", category: "string",
});
register({
name: "length", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "number",
eval: ([v]) => s(v).length,
doc: "Length of a string.", category: "string",
});
register({
name: "contains", arity: { min: 2, max: 2 }, paramTypes: ["string", "string"], returnType: "boolean",
eval: ([a, b]) => s(a).includes(s(b)),
doc: "Returns true if the first string contains the second.", category: "string",
});
register({
name: "lower", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).toLowerCase(),
doc: "Lowercases the string.", category: "string",
});
register({
name: "upper", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).toUpperCase(),
doc: "Uppercases the string.", category: "string",
});
register({
name: "trim", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).trim(),
doc: "Strips whitespace from both ends.", category: "string",
});

View File

@@ -0,0 +1,88 @@
type PropLike = { id: string; type: string; typeOptions: unknown };
export class BaseFormulaGraph {
private readonly direct = new Map<string, string[]>();
private readonly reverse = new Map<string, Set<string>>();
constructor(properties: PropLike[]) {
for (const p of properties) {
if (p.type !== "formula") continue;
const deps: string[] = Array.isArray((p.typeOptions as any)?.dependencies)
? ((p.typeOptions as any).dependencies as string[])
: [];
this.direct.set(p.id, deps);
for (const d of deps) {
if (!this.reverse.has(d)) this.reverse.set(d, new Set());
this.reverse.get(d)!.add(p.id);
}
}
}
directDeps(propId: string): string[] { return this.direct.get(propId) ?? []; }
dependents(propId: string): string[] { return Array.from(this.reverse.get(propId) ?? []); }
affectedFormulas(changedPropIds: string[]): string[] {
const out = new Set<string>();
const stack = [...changedPropIds];
while (stack.length) {
const id = stack.pop()!;
for (const d of this.reverse.get(id) ?? []) {
if (!out.has(d)) { out.add(d); stack.push(d); }
}
}
return Array.from(out).sort();
}
evalOrder(): string[] {
const order: string[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
const visit = (id: string) => {
if (visited.has(id)) return;
if (temp.has(id)) return;
temp.add(id);
for (const d of this.direct.get(id) ?? []) visit(d);
temp.delete(id);
visited.add(id);
order.push(id);
};
for (const id of this.direct.keys()) visit(id);
return order;
}
/*
* Returns the cycle path (list of prop IDs) if introducing `newProp` (or
* keeping its current deps) would create one, else null. `newProp` may be
* either a property already registered or a hypothetical replacement; we
* re-read its deps at call time, so pass the candidate object.
*/
detectCycle(newProp: PropLike): string[] | null {
const local = new Map(this.direct);
if (newProp.type === "formula") {
local.set(newProp.id, (newProp.typeOptions as any)?.dependencies ?? []);
}
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map<string, number>();
const path: string[] = [];
const dfs = (id: string): string[] | null => {
color.set(id, GRAY);
path.push(id);
for (const d of local.get(id) ?? []) {
const c = color.get(d) ?? WHITE;
if (c === GRAY) { return [...path.slice(path.indexOf(d)), d]; }
if (c === WHITE) { const r = dfs(d); if (r) return r; }
}
path.pop();
color.set(id, BLACK);
return null;
};
for (const id of local.keys()) {
if ((color.get(id) ?? WHITE) === WHITE) {
const r = dfs(id);
if (r) return r;
}
}
return null;
}
}

View File

@@ -0,0 +1,14 @@
// Client-side public surface: parse, typecheck, cycle-detect, pretty-print.
import "./functions/index";
export * from "./ast";
export * from "./types";
export * from "./error";
export * from "./tokenizer";
export * from "./parser";
export * from "./resolver";
export * from "./typecheck";
export * from "./format";
export { registry, register } from "./functions/registry";
export type { FormulaFn } from "./functions/registry";
export * from "./graph";
export * from "./number";

View File

@@ -0,0 +1,15 @@
// Server-side public surface: everything in client + evaluator + registry.
export * from "./ast";
export * from "./types";
export * from "./error";
export * from "./tokenizer";
export * from "./parser";
export * from "./resolver";
export * from "./typecheck";
export * from "./format";
import "./functions/index"; // side-effect: populate registry
export { registry, register } from "./functions/index";
export type { FormulaFn } from "./functions/index";
export * from "./graph";
export * from "./eval";
export * from "./number";

View File

@@ -0,0 +1,10 @@
export function snapNumber(n: number): number {
if (!Number.isFinite(n)) return n;
return Number(n.toPrecision(15));
}
export function valueToString(v: unknown): string {
if (v == null) return "";
if (typeof v === "number") return String(snapNumber(v));
return String(v);
}

View File

@@ -0,0 +1,201 @@
import { tokenize, Token, TokenKind } from "./tokenizer";
import { FormulaParseError } from "./error";
import { MAX_PARSE_DEPTH } from "./types";
import type { OpCode } from "./ast";
import type { RawFormulaAST } from "./ast";
/*
* Pratt parser. Top-level entry parses a full expression and then asserts EOF.
* Binary operators are dispatched through a precedence table in `bp` below.
* `prop(...)`, `if(...)`, `and(...)`, `or(...)` are intercepted when an
* identifier is followed by `(` so they become their dedicated AST nodes.
*/
export function parseRaw(src: string): RawFormulaAST {
const tokens = tokenize(src);
const p = new Parser(tokens);
const expr = p.parseExpr(0);
p.expect(TokenKind.EOF, "Expected end of input");
return expr;
}
const BP: Partial<Record<TokenKind, number>> = {
[TokenKind.OR]: 10,
[TokenKind.AND]: 20,
[TokenKind.EQ]: 30, [TokenKind.NEQ]: 30,
[TokenKind.LT]: 40, [TokenKind.GT]: 40,
[TokenKind.LTE]: 40, [TokenKind.GTE]: 40,
[TokenKind.PLUS]: 50, [TokenKind.MINUS]: 50,
[TokenKind.STAR]: 60, [TokenKind.SLASH]: 60, [TokenKind.PERCENT]: 60,
};
const TOK_TO_OP: Partial<Record<TokenKind, OpCode>> = {
[TokenKind.PLUS]: "+", [TokenKind.MINUS]: "-",
[TokenKind.STAR]: "*", [TokenKind.SLASH]: "/", [TokenKind.PERCENT]: "%",
[TokenKind.EQ]: "==", [TokenKind.NEQ]: "!=",
[TokenKind.LT]: "<", [TokenKind.GT]: ">",
[TokenKind.LTE]: "<=", [TokenKind.GTE]: ">=",
};
class Parser {
private i = 0;
private depth = 0;
constructor(private tokens: Token[]) {}
peek(): Token { return this.tokens[this.i]; }
next(): Token { return this.tokens[this.i++]; }
expect(kind: TokenKind, msg: string): Token {
const t = this.peek();
if (t.kind !== kind) {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN", message: msg, span: { start: t.start, end: t.end },
}]);
}
return this.next();
}
// Bound recursive descent. Every path that recurses (parens, unary chains,
// binary rhs, call args) funnels through parseExpr/parseUnary, so guarding
// their entry caps the JS stack and turns pathological nesting into a
// catchable FormulaParseError instead of a RangeError.
private enter(): void {
if (++this.depth > MAX_PARSE_DEPTH) {
const t = this.peek();
throw new FormulaParseError([{
code: "DEPTH_EXCEEDED",
message: `Formula nesting too deep (max ${MAX_PARSE_DEPTH})`,
span: { start: t.start, end: t.end },
}]);
}
}
parseExpr(minBp: number): RawFormulaAST {
this.enter();
try {
return this.parseExprInner(minBp);
} finally {
this.depth--;
}
}
private parseExprInner(minBp: number): RawFormulaAST {
let lhs = this.parseUnary();
while (true) {
const tok = this.peek();
if (tok.kind === TokenKind.AND) {
if (BP[TokenKind.AND]! < minBp) break;
this.next();
const rhs = this.parseExpr(BP[TokenKind.AND]! + 1);
lhs = { t: "and", args: [lhs, rhs] };
continue;
}
if (tok.kind === TokenKind.OR) {
if (BP[TokenKind.OR]! < minBp) break;
this.next();
const rhs = this.parseExpr(BP[TokenKind.OR]! + 1);
lhs = { t: "or", args: [lhs, rhs] };
continue;
}
const bp = BP[tok.kind];
if (bp == null || bp < minBp) break;
this.next();
const rhs = this.parseExpr(bp + 1);
const op = TOK_TO_OP[tok.kind]!;
lhs = { t: "op", op, args: [lhs, rhs] };
}
return lhs;
}
parseUnary(): RawFormulaAST {
const tok = this.peek();
if (tok.kind === TokenKind.MINUS) {
this.next();
this.enter();
try {
return { t: "op", op: "neg", args: [this.parseUnary()] };
} finally {
this.depth--;
}
}
if (tok.kind === TokenKind.NOT) {
this.next();
this.enter();
try {
return { t: "op", op: "not", args: [this.parseUnary()] };
} finally {
this.depth--;
}
}
return this.parsePrimary();
}
parsePrimary(): RawFormulaAST {
const tok = this.next();
switch (tok.kind) {
case TokenKind.NUMBER: return { t: "num", v: Number(tok.text) };
case TokenKind.STRING: return { t: "str", v: tok.text };
case TokenKind.TRUE: return { t: "bool", v: true };
case TokenKind.FALSE: return { t: "bool", v: false };
case TokenKind.NULL: return { t: "null" };
case TokenKind.LPAREN: {
const e = this.parseExpr(0);
this.expect(TokenKind.RPAREN, "Expected ')'");
return e;
}
case TokenKind.AND:
case TokenKind.OR:
case TokenKind.IDENT: {
if (this.peek().kind !== TokenKind.LPAREN) {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected identifier '${tok.text}' (did you mean prop("${tok.text}")?)`,
span: { start: tok.start, end: tok.end },
}]);
}
this.next(); // LPAREN
const args: RawFormulaAST[] = [];
if (this.peek().kind !== TokenKind.RPAREN) {
args.push(this.parseExpr(0));
while (this.peek().kind === TokenKind.COMMA) {
this.next();
args.push(this.parseExpr(0));
}
}
this.expect(TokenKind.RPAREN, "Expected ')'");
// Match special-form/keyword names case-insensitively; the `call`
// node below keeps the raw casing the user typed.
const head = tok.text.toLowerCase();
if (head === "prop") {
if (args.length !== 1 || args[0].t !== "str") {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: 'prop() expects exactly one string literal argument',
span: { start: tok.start, end: tok.end },
}]);
}
return { t: "propName", name: args[0].v };
}
if (head === "if") {
if (args.length !== 3) {
throw new FormulaParseError([{
code: "ARITY_MISMATCH",
message: "if() expects exactly 3 arguments",
span: { start: tok.start, end: tok.end },
}]);
}
return { t: "if", cond: args[0], then: args[1], else: args[2] };
}
if (head === "and") return { t: "and", args };
if (head === "or") return { t: "or", args };
return { t: "call", fn: tok.text, args };
}
default:
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected token '${tok.text || tok.kind}'`,
span: { start: tok.start, end: tok.end },
}]);
}
}
}

View File

@@ -0,0 +1,68 @@
import { FormulaParseError } from "./error";
import type { FormulaAST, RawFormulaAST } from "./ast";
export type ResolveResult = {
ast: FormulaAST;
dependencies: string[];
};
export function resolve(
raw: RawFormulaAST,
nameToId: ReadonlyMap<string, string>,
): ResolveResult {
const deps = new Set<string>();
const ast = walk(raw, nameToId, deps);
return { ast, dependencies: Array.from(deps).sort() };
}
function walk(
node: RawFormulaAST,
nameToId: ReadonlyMap<string, string>,
deps: Set<string>,
): FormulaAST {
switch (node.t) {
case "num": case "str": case "bool": case "null":
return node as FormulaAST;
case "propName": {
const id = nameToId.get(node.name);
if (!id) {
throw new FormulaParseError([{
code: "UNKNOWN_PROPERTY",
message: `Unknown property '${node.name}'`,
span: { start: 0, end: 0 }, // parser carries real spans; resolver is post-parse
}]);
}
deps.add(id);
return { t: "prop", id };
}
case "op":
return {
t: "op",
op: (node as any).op,
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "if":
return {
t: "if",
cond: walk((node as any).cond, nameToId, deps),
then: walk((node as any).then, nameToId, deps),
else: walk((node as any).else, nameToId, deps),
};
case "and":
return {
t: "and",
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "or":
return {
t: "or",
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "call":
return {
t: "call",
fn: (node as any).fn,
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
}
}

View File

@@ -0,0 +1,158 @@
import { FormulaParseError } from "./error";
import { MAX_FORMULA_SOURCE_LENGTH } from "./types";
export enum TokenKind {
NUMBER = "NUMBER",
STRING = "STRING",
IDENT = "IDENT",
TRUE = "TRUE",
FALSE = "FALSE",
NULL = "NULL",
AND = "AND",
OR = "OR",
NOT = "NOT",
PLUS = "PLUS",
MINUS = "MINUS",
STAR = "STAR",
SLASH = "SLASH",
PERCENT = "PERCENT",
EQ = "EQ",
NEQ = "NEQ",
LT = "LT",
GT = "GT",
LTE = "LTE",
GTE = "GTE",
LPAREN = "LPAREN",
RPAREN = "RPAREN",
COMMA = "COMMA",
EOF = "EOF",
}
export type Token = {
kind: TokenKind;
text: string;
start: number;
end: number;
};
const KEYWORDS: Record<string, TokenKind> = {
true: TokenKind.TRUE,
false: TokenKind.FALSE,
null: TokenKind.NULL,
and: TokenKind.AND,
or: TokenKind.OR,
not: TokenKind.NOT,
};
export function tokenize(src: string): Token[] {
if (src.length > MAX_FORMULA_SOURCE_LENGTH) {
throw new FormulaParseError([{
code: "INPUT_TOO_LONG",
message: `Formula is too long (${src.length} chars; max ${MAX_FORMULA_SOURCE_LENGTH})`,
span: { start: 0, end: MAX_FORMULA_SOURCE_LENGTH },
}]);
}
const tokens: Token[] = [];
let i = 0;
const push = (kind: TokenKind, text: string, start: number, end: number) =>
tokens.push({ kind, text, start, end });
while (i < src.length) {
const ch = src[i];
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
if (ch >= "0" && ch <= "9") {
const start = i;
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
if (src[i] === ".") {
i++;
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
}
push(TokenKind.NUMBER, src.slice(start, i), start, i);
continue;
}
if (ch === '"' || ch === "'") {
const quote = ch;
const start = i;
i++;
let body = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\") {
if (i + 1 >= src.length) {
throw new FormulaParseError([{
code: "UNEXPECTED_EOF",
message: "Unterminated escape in string",
span: { start, end: i + 1 },
}]);
}
const esc = src[i + 1];
body += esc === "n" ? "\n" : esc === "t" ? "\t" : esc;
i += 2;
} else {
body += src[i];
i++;
}
}
if (i >= src.length) {
throw new FormulaParseError([{
code: "UNEXPECTED_EOF",
message: "Unterminated string literal",
span: { start, end: src.length },
}]);
}
i++;
push(TokenKind.STRING, body, start, i);
continue;
}
if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_") {
const start = i;
while (
i < src.length &&
(
(src[i] >= "a" && src[i] <= "z") ||
(src[i] >= "A" && src[i] <= "Z") ||
(src[i] >= "0" && src[i] <= "9") ||
src[i] === "_"
)
) i++;
const text = src.slice(start, i);
// Keywords and function names are case-insensitive: match on the
// lowercased text but keep `text` raw on the token so error messages
// and `format()` preserve the user's casing.
// hasOwnProperty guards against inherited Object.prototype names
// (toString, valueOf, hasOwnProperty, …) matching the KEYWORDS lookup —
// those are valid identifiers/function names (e.g. the toString() fn).
const lower = text.toLowerCase();
const kw = Object.prototype.hasOwnProperty.call(KEYWORDS, lower) ? KEYWORDS[lower] : undefined;
push(kw ?? TokenKind.IDENT, text, start, i);
continue;
}
const start = i;
const two = src.slice(i, i + 2);
if (two === "==") { push(TokenKind.EQ, two, start, i + 2); i += 2; continue; }
if (two === "!=") { push(TokenKind.NEQ, two, start, i + 2); i += 2; continue; }
if (two === "<=") { push(TokenKind.LTE, two, start, i + 2); i += 2; continue; }
if (two === ">=") { push(TokenKind.GTE, two, start, i + 2); i += 2; continue; }
const singleMap: Record<string, TokenKind> = {
"+": TokenKind.PLUS, "-": TokenKind.MINUS, "*": TokenKind.STAR,
"/": TokenKind.SLASH, "%": TokenKind.PERCENT,
"<": TokenKind.LT, ">": TokenKind.GT,
"(": TokenKind.LPAREN, ")": TokenKind.RPAREN, ",": TokenKind.COMMA,
};
if (singleMap[ch]) { push(singleMap[ch], ch, start, i + 1); i++; continue; }
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected character '${ch}'`,
span: { start: i, end: i + 1 },
}]);
}
tokens.push({ kind: TokenKind.EOF, text: "", start: i, end: i });
return tokens;
}

View File

@@ -0,0 +1,89 @@
import { FormulaParseError } from "./error";
import type { FormulaAST, OpCode } from "./ast";
import type { FormulaResultType } from "./types";
import type { FormulaFn } from "./functions";
export type PropertyTypeMap = ReadonlyMap<string, FormulaResultType>;
export type TypecheckResult = { resultType: FormulaResultType };
const ARITH_OPS: OpCode[] = ["+", "-", "*", "/", "%"];
const CMP_OPS: OpCode[] = ["==", "!=", ">", "<", ">=", "<="];
export function typecheck(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): TypecheckResult {
return { resultType: infer(ast, propertyTypes, registry) };
}
function infer(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): FormulaResultType {
switch (ast.t) {
case "num": return "number";
case "str": return "string";
case "bool": return "boolean";
case "null": return "null";
case "prop": return propertyTypes.get(ast.id) ?? "null";
case "op": {
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
if (ARITH_OPS.includes(ast.op)) {
// '+' is overloaded to match the evaluator: any string operand makes
// it string concatenation; otherwise it's numeric addition.
if (ast.op === "+" && argTypes.some((t) => t === "string")) return "string";
const allow = argTypes.every((t) => t === "number" || t === "null");
if (!allow) throw typeErr(`Operator '${ast.op}' needs numbers`);
return "number";
}
if (CMP_OPS.includes(ast.op)) return "boolean";
if (ast.op === "neg") {
if (argTypes[0] !== "number" && argTypes[0] !== "null") throw typeErr("Unary '-' needs number");
return "number";
}
if (ast.op === "not") {
if (argTypes[0] !== "boolean" && argTypes[0] !== "null") throw typeErr("'not' needs boolean");
return "boolean";
}
return "null";
}
case "if": {
const thenT = infer(ast.then, propertyTypes, registry);
const elseT = infer(ast.else, propertyTypes, registry);
if (thenT === elseT) return thenT;
if (thenT === "null") return elseT;
if (elseT === "null") return thenT;
throw typeErr(`if() branches have different types: ${thenT} vs ${elseT}`);
}
case "and": case "or":
ast.args.forEach((a) => {
const t = infer(a, propertyTypes, registry);
if (t !== "boolean" && t !== "null") throw typeErr(`'${ast.t}' needs boolean args`);
});
return "boolean";
case "call": {
const fn = registry.get(ast.fn.toLowerCase());
if (!fn) throw new FormulaParseError([{
code: "UNKNOWN_FUNCTION",
message: `Unknown function '${ast.fn}'`,
span: { start: 0, end: 0 },
}]);
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
if (argTypes.length < fn.arity.min || (fn.arity.max != null && argTypes.length > fn.arity.max)) {
throw new FormulaParseError([{
code: "ARITY_MISMATCH",
message: `${fn.name}() expects ${fn.arity.min}-${fn.arity.max ?? "∞"} args, got ${argTypes.length}`,
span: { start: 0, end: 0 },
}]);
}
return typeof fn.returnType === "function" ? fn.returnType(argTypes) : fn.returnType;
}
}
}
function typeErr(message: string): FormulaParseError {
return new FormulaParseError([{ code: "TYPE_MISMATCH", message, span: { start: 0, end: 0 } }]);
}

View File

@@ -0,0 +1,74 @@
import type { FormulaAST } from "./ast";
export type FormulaResultType =
| "number"
| "string"
| "boolean"
| "date"
| "null";
export type FormulaTypeOptions = {
source: string;
ast: FormulaAST;
resultType: FormulaResultType;
dependencies: string[];
astVersion: 1;
formatOptions?: Record<string, unknown>;
};
/*
* The runtime value produced by evaluating a node. Strings and numbers are
* their JS equivalents; dates are ISO 8601 UTC strings (matches how the date
* property type already stores cells); booleans are booleans; missing or
* filtered-out values are null. Errors are distinguishable from all valid
* values because they are objects with a `__err` key.
*/
export type Value = number | string | boolean | null | ErrorCell;
export type ErrorCell = {
__err: ErrorCode;
msg: string;
v: 1;
};
export type ErrorCode =
| "MISSING_PROP"
| "TYPE_MISMATCH"
| "DIV_BY_ZERO"
| "DATE_INVALID"
| "DEPTH_EXCEEDED"
| "DEPENDENCY_ERROR";
/*
* EvalContext carries everything the evaluator needs that isn't in the AST:
* the function registry (server-only), the property map for resolving `prop`
* nodes to their formula ASTs when nested, and the current recursion depth.
*/
export type EvalContext = {
registry: ReadonlyMap<string, import("./functions/registry").FormulaFn>;
properties: ReadonlyMap<string, PropertyLookup>;
depth: number;
maxDepth: number;
memo: Map<string, Value>; // keyed by propId for the current row-eval
};
export type PropertyLookup = {
id: string;
type: string;
typeOptions: unknown;
};
export const DEFAULT_MAX_DEPTH = 64;
/*
* DoS guards. A formula source longer than MAX_FORMULA_SOURCE_LENGTH is
* rejected before tokenizing (cheap backstop against pathological input like
* "(".repeat(50000)). MAX_PARSE_DEPTH bounds recursive-descent nesting so a
* deeply nested source throws a catchable FormulaParseError instead of
* overflowing the JS stack with a RangeError. MAX_EVAL_DEPTH bounds the
* tree-walking evaluator so an oversized AST that slipped past the parser
* degrades to an error cell instead of crashing the recompute worker.
*/
export const MAX_FORMULA_SOURCE_LENGTH = 10_000;
export const MAX_PARSE_DEPTH = 256;
export const MAX_EVAL_DEPTH = 512;

View File

@@ -0,0 +1,12 @@
{
"extends": "../editor-ext/tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"composite": true,
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}

37
packages/editor-ext/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
.env
package-lock.json
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -0,0 +1,3 @@
# editor extensions
Tiptap editor extensions

View File

@@ -0,0 +1,16 @@
{
"name": "@docmost/editor-ext",
"homepage": "https://docmost.com",
"private": true,
"sideEffects": false,
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch"
},
"main": "dist/index.js",
"module": "./src/index.ts",
"types": "dist/index.d.ts",
"dependencies": {
"marked": "17.0.5"
}
}

View File

@@ -0,0 +1,41 @@
export * from "./lib/trailing-node";
export * from "./lib/comment/comment";
export * from "./lib/utils";
export * from "./lib/math";
export * from "./lib/details";
export * from "./lib/table";
export * from "./lib/image";
export * from "./lib/video";
export * from "./lib/callout";
export * from "./lib/media-utils";
export * from "./lib/link";
export * from "./lib/selection";
export * from "./lib/attachment";
export * from "./lib/audio";
export * from "./lib/custom-code-block";
export * from "./lib/drawio";
export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/transclusion";
export * from "./lib/highlight";
export * from "./lib/indent";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/shared-storage";
export * from "./lib/recreate-transform";
export * from "./lib/columns";
export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/footnotes";
export * from "./lib/resizable-nodeview";
export {
pageNodeToDocxBuffer,
type DocxImageResolver,
} from "./lib/prosemirror-docx";
export * from "./lib/base-embed";

View File

@@ -0,0 +1,125 @@
import { Node } from "@tiptap/pm/model";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Command } from "@tiptap/core";
const findAttachmentNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "attachment" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId, allowMedia) => {
const validated = validateFn?.(file, allowMedia);
// @ts-ignore
if (!validated) return;
const placeholderId = generateNodeId();
let placeholderInserted = false;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
placeholder: {
id: placeholderId,
},
name: file.name,
size: file.size,
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithAttachment = (
attachment: IAttachment,
): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
// If the placeholder is not found or attachment is missing, abort the process
if (currentPos === null || !attachment) return false;
// Update the placeholder node with the actual attachment data
tr.setNodeMarkup(currentPos, undefined, {
url: `/api/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName,
mime: attachment.mimeType,
size: attachment.fileSize,
attachmentId: attachment.id,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithAttachment(attachment));
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAttachment(attachment))
.run();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
}
};
export { handleAttachmentUpload };

View File

@@ -0,0 +1,136 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from "../utils";
export interface AttachmentOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface AttachmentAttributes {
url?: string;
name?: string;
mime?: string; // e.g. application/zip
size?: number;
attachmentId?: string;
placeholder?: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
attachment: {
setAttachment: (attributes: AttachmentAttributes) => ReturnType;
};
}
}
export const Attachment = Node.create<AttachmentOptions>({
name: "attachment",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return {
url: {
default: "",
parseHTML: (element) => {
const url = element.getAttribute("data-attachment-url");
return sanitizeUrl(url);
},
renderHTML: (attributes) => ({
"data-attachment-url": sanitizeUrl(attributes.url),
}),
},
name: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-name"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-name": attributes.name,
}),
},
mime: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-mime"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-mime": attributes.mime,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-attachment-size"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-size": attributes.size,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
[
"a",
{
href: sanitizeUrl(HTMLAttributes["data-attachment-url"]),
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
],
];
},
addCommands() {
return {
setAttachment:
(attrs: AttachmentAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "attachment",
attrs: attrs,
});
},
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});

View File

@@ -0,0 +1,2 @@
export { Attachment } from "./attachment";
export * from "./attachment-upload";

View File

@@ -0,0 +1,139 @@
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findAudioNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "audio" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handleAudioUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const objectUrl = URL.createObjectURL(file);
const placeholderId = generateNodeId();
let placeholderInserted = false;
editor.storage.shared.audioPreviews =
editor.storage.shared.audioPreviews || {};
editor.storage.shared.audioPreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.audio?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithAudio = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null || !attachment) return;
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id,
size: attachment.fileSize,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.audioPreviews) {
delete editor.storage.shared.audioPreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithAudio(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAudio(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
};
export { handleAudioUpload };

View File

@@ -0,0 +1,134 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "../media-utils";
import { sanitizeUrl, isInternalFileUrl } from "../utils";
export interface AudioOptions {
view: any;
HTMLAttributes: Record<string, any>;
}
export interface AudioAttributes {
src?: string;
attachmentId?: string;
size?: number;
placeholder?: {
id: string;
name: string;
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
audioBlock: {
setAudio: (attributes: AudioAttributes) => ReturnType;
};
}
}
export const TiptapAudio = Node.create<AudioOptions>({
name: "audio",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
view: null,
HTMLAttributes: {},
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => {
const src = element.getAttribute("src");
const sanitized = sanitizeUrl(src);
return isInternalFileUrl(sanitized) ? sanitized : "";
},
renderHTML: (attributes) => ({
src: isInternalFileUrl(attributes.src)
? sanitizeUrl(attributes.src)
: "",
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: AudioAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: AudioAttributes) => ({
"data-size": attributes.size,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: "audio",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"audio",
mergeAttributes(
{ controls: "true", preload: "metadata" },
this.options.HTMLAttributes,
HTMLAttributes,
),
["source", { src: HTMLAttributes.src }],
];
},
addCommands() {
return {
setAudio:
(attrs: AudioAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "audio",
attrs: attrs,
});
},
};
},
addNodeView() {
if (this.options.view) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
return ({ node, HTMLAttributes }) => {
const dom = document.createElement("div");
const audio = document.createElement("audio");
const src = node.attrs.src;
if (src && isInternalFileUrl(src)) {
audio.src = normalizeFileUrl(src);
}
audio.controls = true;
audio.preload = "metadata";
audio.style.width = "100%";
dom.append(audio);
return { dom };
};
},
});

View File

@@ -0,0 +1,2 @@
export { TiptapAudio } from "./audio";
export * from "./audio-upload";

View File

@@ -0,0 +1,133 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { EditorState, NodeSelection, Plugin } from '@tiptap/pm/state';
export interface BaseEmbedOptions {
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
baseEmbed: {
insertBaseEmbed: (attrs: {
pageId: string | null;
pendingKey?: string | null;
}) => ReturnType;
};
}
}
export const BaseEmbed = Node.create<BaseEmbedOptions>({
name: 'base',
group: 'block',
atom: true,
selectable: true,
draggable: true,
addOptions() {
return { HTMLAttributes: {} };
},
// prosemirror-dropcursor draws a block-boundary indicator on every
// `dragover` it sees. Pragmatic-dnd (used for column / choice reorder
// inside the embed) fires native `dragstart`/`dragover`, which bubble
// up to the editor and trigger dropcursor — visible as a stray blue
// line above or below the embed during an internal drag. The cursor
// event lands over the atom node, so dropcursor consults
// `disableDropCursor` on this node spec; returning true suppresses
// the indicator while still letting pragmatic-dnd handle the drag.
extendNodeSchema(extension) {
return extension.name === 'base'
? { disableDropCursor: true }
: {};
},
addAttributes() {
return {
pageId: {
default: null,
parseHTML: (el) => el.getAttribute('data-page-id'),
renderHTML: (attrs) =>
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
},
// Transient marker set when the slash command inserts the embed
// before the server has assigned a pageId. The view renders a
// skeleton in this state. Cleared once the API responds and the
// real pageId is patched in. Not serialized — embeds saved with
// a pendingKey would orphan if the page were closed mid-request.
pendingKey: {
default: null,
parseHTML: () => null,
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="base-embed"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'base-embed',
}),
];
},
addCommands() {
return {
insertBaseEmbed:
(attrs) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs,
}),
};
},
addKeyboardShortcuts() {
// Block Backspace / Delete when the base embed itself is the
// current selection — the "click on the embed and hit delete"
// accidental-delete path. Returning true tells TipTap we've
// handled the key, preventing the default removal. Range
// selections covering the node and programmatic deletes still
// work normally.
const isThisNodeSelected = (): boolean => {
const { selection } = this.editor.state;
return (
selection instanceof NodeSelection &&
selection.node.type.name === this.name
);
};
return {
Backspace: () => isThisNodeSelected(),
Delete: () => isThisNodeSelected(),
};
},
addProseMirrorPlugins() {
// Same idea as the Backspace/Delete shortcuts above, but for the
// other accidental-delete path: when the embed is the selection,
// a typed character or paste would replace the whole node. These
// hooks return true (handled, no-op) so the node stays put. The
// user can still press an arrow key to deselect and then type.
const nodeName = this.name;
const isThisNodeSelected = (state: EditorState): boolean => {
const { selection } = state;
return (
selection instanceof NodeSelection &&
selection.node.type.name === nodeName
);
};
return [
new Plugin({
props: {
handleTextInput: (view) => isThisNodeSelected(view.state),
handlePaste: (view) => isThisNodeSelected(view.state),
},
}),
];
},
});

View File

@@ -0,0 +1,2 @@
export { BaseEmbed } from './base-embed';
export type { BaseEmbedOptions } from './base-embed';

View File

@@ -0,0 +1,296 @@
import {
findParentNode,
mergeAttributes,
Node,
wrappingInputRule,
} from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { CalloutType, getValidCalloutType } from "./utils";
export interface CalloutOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface CalloutAttributes {
/**
* The type of callout.
*/
type: CalloutType;
/**
* The custom icon name for the callout.
*/
icon?: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
callout: {
setCallout: (attributes?: CalloutAttributes) => ReturnType;
unsetCallout: () => ReturnType;
toggleCallout: (attributes?: CalloutAttributes) => ReturnType;
updateCalloutType: (type: CalloutType) => ReturnType;
updateCalloutIcon: (icon: string) => ReturnType;
};
}
}
/**
* Matches a callout to a `:::` as input.
*/
export const inputRegex = /^:::([a-z]+)?[\s\n]$/;
export const Callout = Node.create<CalloutOptions>({
name: "callout",
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
content: "block+",
group: "block",
defining: true,
isolating: true,
addAttributes() {
return {
type: {
default: "info",
parseHTML: (element) => element.getAttribute("data-callout-type"),
renderHTML: (attributes) => ({
"data-callout-type": attributes.type,
}),
},
icon: {
default: null,
parseHTML: (element) => element.getAttribute("data-callout-icon"),
renderHTML: (attributes) => ({
"data-callout-icon": attributes.icon,
}),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
0,
];
},
addCommands() {
return {
setCallout:
(attributes) =>
({ commands }) => {
return commands.setNode(this.name, attributes);
},
unsetCallout:
() =>
({ commands }) => {
return commands.lift(this.name);
},
toggleCallout:
(attributes) =>
({ commands }) => {
return commands.toggleWrap(this.name, attributes);
},
updateCalloutType:
(type: string) =>
({ commands }) =>
commands.updateAttributes("callout", {
type: getValidCalloutType(type),
}),
updateCalloutIcon:
(icon: string) =>
({ commands }) =>
commands.updateAttributes("callout", {
icon: icon || null,
}),
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addKeyboardShortcuts() {
return {
//"Mod-Shift-c": () => this.editor.commands.toggleCallout(),
/**
* Handle the backspace key when deleting content.
* Aims to stop merging callouts when deleting content in between.
*/
Backspace: ({ editor }) => {
const { state, view } = editor;
const { selection } = state;
// If the selection is not empty, return false
// and let other extension handle the deletion.
if (!selection.empty) {
return false;
}
const { $from } = selection;
// If not at the start of current node, no joining will happen
if ($from.parentOffset !== 0) {
return false;
}
// Empty callout: delete the whole node so Backspace inside it isn't
// a no-op (isolating: true blocks the default join with the block
// above).
const calloutDepth = $from.depth - 1;
if (calloutDepth >= 0) {
const calloutNode = $from.node(calloutDepth);
if (
calloutNode.type === this.type &&
calloutNode.childCount === 1 &&
calloutNode.firstChild?.content.size === 0
) {
const calloutPos = $from.before(calloutDepth);
const { tr } = state;
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
tr.setSelection(
TextSelection.near(tr.doc.resolve(calloutPos), -1),
);
view.dispatch(tr);
return true;
}
}
const previousPosition = $from.before($from.depth) - 1;
// If nothing above to join with
if (previousPosition < 1) {
return false;
}
const previousPos = state.doc.resolve(previousPosition);
// If resolving previous position fails, bail out
if (!previousPos?.parent) {
return false;
}
const previousNode = previousPos.parent;
const parentNode = findParentNode(() => true)(selection);
if (!parentNode) {
return false;
}
const { node, pos, depth } = parentNode;
// If current node is nested
if (depth !== 1) {
return false;
}
// If previous node is a callout, cut current node's content into it
if (node.type !== this.type && previousNode.type === this.type) {
const { content, nodeSize } = node;
const { tr } = state;
tr.delete(pos, pos + nodeSize);
tr.setSelection(
TextSelection.near(tr.doc.resolve(previousPosition - 1))
);
tr.insert(previousPosition - 1, content);
view.dispatch(tr);
return true;
}
return false;
},
// Exit the callout into a fresh paragraph below when the cursor sits
// in an empty trailing child. An empty callout (single empty
// paragraph) exits on the first Enter and keeps the empty callout
// intact; a callout with content needs the double-Enter pattern
// (first Enter splits, second Enter on the new trailing empty exits
// and removes that trailing paragraph).
Enter: ({ editor }) => {
const { state, view } = editor;
const { selection } = state;
if (!selection.empty) return false;
const { $from } = selection;
const calloutDepth = $from.depth - 1;
if (calloutDepth < 0) return false;
const calloutNode = $from.node(calloutDepth);
if (calloutNode.type !== this.type) return false;
if ($from.parent.content.size !== 0) return false;
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
return false;
}
const paragraphType = state.schema.nodes.paragraph;
const containerDepth = calloutDepth - 1;
const container = $from.node(containerDepth);
const indexAfter = $from.indexAfter(containerDepth);
if (
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
) {
return false;
}
const calloutEnd = $from.after(calloutDepth);
const paragraph = paragraphType.create();
const { tr } = state;
if (calloutNode.childCount === 1) {
tr.insert(calloutEnd, paragraph);
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
} else {
tr.delete($from.before(), $from.after());
const insertPos = tr.mapping.map(calloutEnd);
tr.insert(insertPos, paragraph);
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
}
view.dispatch(tr);
return true;
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: inputRegex,
type: this.type,
getAttributes: (match) => ({
type: getValidCalloutType(match[1]),
}),
}),
];
},
});

View File

@@ -0,0 +1,2 @@
export { Callout } from "./callout";
export * from "./utils";

View File

@@ -0,0 +1,21 @@
export type CalloutType =
| 'default'
| 'info'
| 'note'
| 'success'
| 'warning'
| 'danger';
const validCalloutTypes = [
'default',
'info',
'note',
'success',
'warning',
'danger',
];
export function getValidCalloutType(value: string): string {
if (value) {
return validCalloutTypes.includes(value) ? value : 'info';
}
}

View File

@@ -0,0 +1,127 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/state";
export interface ColumnOptions {
HTMLAttributes: Record<string, any>;
}
export interface ColumnAttributes {
width?: number | null;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
column: {
setColumnWidth: (width: number | null) => ReturnType;
};
}
}
export const Column = Node.create<ColumnOptions>({
name: "column",
group: "block",
content: "block+",
defining: true,
isolating: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
width: {
default: null,
parseHTML: (element) => {
const value = element.getAttribute("data-width");
return value ? parseFloat(value) : null;
},
renderHTML: (attributes: ColumnAttributes) => {
if (!attributes.width) return {};
return {
"data-width": attributes.width,
style: `flex: ${attributes.width}`,
};
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addKeyboardShortcuts() {
const jumpToColumn = (direction: 1 | -1) => () => {
const { state, dispatch } = this.editor.view;
const columns = findParentNode(
(node) => node.type.name === "columns",
)(state.selection);
if (!columns) return false;
const column = findParentNode(
(node) => node.type.name === "column",
)(state.selection);
if (!column) return false;
let currentIndex = -1;
columns.node.forEach((_child, offset, index) => {
if (columns.pos + 1 + offset === column.pos) {
currentIndex = index;
}
});
const targetIndex = currentIndex + direction;
if (targetIndex < 0 || targetIndex >= columns.node.childCount) {
return true;
}
let offset = 0;
for (let j = 0; j < targetIndex; j++) {
offset += columns.node.child(j).nodeSize;
}
const targetPos = columns.pos + 1 + offset + 1 + 1;
if (dispatch) {
dispatch(
state.tr.setSelection(TextSelection.create(state.doc, targetPos)),
);
}
return true;
};
return {
Tab: jumpToColumn(1),
"Shift-Tab": jumpToColumn(-1),
};
},
addCommands() {
return {
setColumnWidth:
(width) =>
({ commands }) =>
commands.updateAttributes("column", { width }),
};
},
});

View File

@@ -0,0 +1,237 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { Fragment, Node as PMNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
export type ColumnsLayout =
| "two_equal"
| "two_left_sidebar"
| "two_right_sidebar"
| "three_equal"
| "three_left_wide"
| "three_right_wide"
| "three_with_sidebars"
| "four_equal"
| "five_equal";
export interface ColumnsOptions {
HTMLAttributes: Record<string, any>;
}
export type WidthMode = "normal" | "wide";
export interface ColumnsAttributes {
layout?: ColumnsLayout;
widthMode?: WidthMode;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
columns: {
insertColumns: (attributes?: ColumnsAttributes) => ReturnType;
setColumnsWidthMode: (widthMode: WidthMode) => ReturnType;
setColumnCount: (count: number) => ReturnType;
setColumnsLayout: (layout: ColumnsLayout) => ReturnType;
};
}
}
function columnCountFromLayout(layout: string): number {
if (layout.startsWith("five")) return 5;
if (layout.startsWith("four")) return 4;
if (layout.startsWith("three")) return 3;
return 2;
}
function defaultLayoutForCount(count: number): ColumnsLayout {
if (count === 3) return "three_equal";
if (count === 4) return "four_equal";
if (count === 5) return "five_equal";
return "two_equal";
}
export const Columns = Node.create<ColumnsOptions>({
name: "columns",
group: "block",
content: "column+",
defining: true,
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
layout: {
default: "two_equal",
parseHTML: (element) => element.getAttribute("data-layout"),
renderHTML: (attributes: ColumnsAttributes) => ({
"data-layout": attributes.layout,
}),
},
widthMode: {
default: "normal",
parseHTML: (element) =>
element.getAttribute("data-width-mode") || "normal",
renderHTML: (attributes: ColumnsAttributes) => {
if (!attributes.widthMode || attributes.widthMode === "normal")
return {};
return { "data-width-mode": attributes.widthMode };
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addCommands() {
return {
insertColumns:
(attributes) =>
({ tr, state, dispatch }) => {
const layout = attributes?.layout || "two_equal";
const count = columnCountFromLayout(layout);
const columnType = state.schema.nodes.column;
const paraType = state.schema.nodes.paragraph;
const children = Array.from({ length: count }, () =>
columnType.create(null, paraType.create()),
);
const columnsNode = this.type.create(
attributes,
Fragment.from(children),
);
const stepsBefore = tr.steps.length;
tr.replaceSelectionWith(columnsNode);
if (tr.steps.length > stepsBefore) {
const stepMap = tr.steps[tr.steps.length - 1].getMap();
let insertStart = 0;
stepMap.forEach((_from, _to, newFrom) => {
insertStart = newFrom;
});
tr.setSelection(
TextSelection.near(tr.doc.resolve(insertStart + 1), 1),
);
}
if (dispatch) dispatch(tr);
return true;
},
setColumnsWidthMode:
(widthMode) =>
({ commands }) =>
commands.updateAttributes("columns", { widthMode }),
setColumnCount:
(count: number) =>
({ tr, state }) => {
const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(state.selection);
if (!parent) return false;
const { node: columnsNode, pos: parentPos } = parent;
const currentCount = columnsNode.childCount;
if (count === currentCount || count < 2 || count > 5) return false;
const columnType = state.schema.nodes.column;
const paraType = state.schema.nodes.paragraph;
const newChildren: PMNode[] = [];
if (count > currentCount) {
for (let i = 0; i < currentCount; i++) {
newChildren.push(columnsNode.child(i));
}
for (let i = currentCount; i < count; i++) {
newChildren.push(columnType.create(null, paraType.create()));
}
} else {
for (let i = 0; i < count - 1; i++) {
newChildren.push(columnsNode.child(i));
}
let mergedContent = columnsNode.child(count - 1).content;
for (let j = count; j < currentCount; j++) {
const col = columnsNode.child(j);
const nonEmpty: PMNode[] = [];
col.content.forEach((child) => {
if (
child.type.name !== "paragraph" ||
child.content.size > 0
) {
nonEmpty.push(child);
}
});
if (nonEmpty.length > 0) {
mergedContent = mergedContent.append(
Fragment.from(nonEmpty),
);
}
}
newChildren.push(columnType.create(null, mergedContent));
}
const newLayout = defaultLayoutForCount(count);
const newNode = columnsNode.type.create(
{ ...columnsNode.attrs, layout: newLayout },
Fragment.from(newChildren),
);
tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode);
tr.setSelection(
TextSelection.near(tr.doc.resolve(parentPos + 1), 1),
);
return true;
},
setColumnsLayout:
(layout) =>
({ commands }) =>
commands.updateAttributes("columns", { layout }),
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("columnsFocus"),
props: {
decorations: (state) => {
const parent = findParentNode(
(node) => node.type.name === "columns",
)(state.selection);
if (!parent) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{ class: "has-focus" },
),
]);
},
},
}),
];
},
});

View File

@@ -0,0 +1,4 @@
export { Columns } from "./columns";
export type { ColumnsOptions, ColumnsAttributes, ColumnsLayout, WidthMode } from "./columns";
export { Column } from "./column";
export type { ColumnOptions, ColumnAttributes } from "./column";

View File

@@ -0,0 +1,35 @@
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { commentDecorationMetaKey, commentMarkClass } from './comment';
export function commentDecoration(): Plugin {
const commentDecorationPlugin = new PluginKey('commentDecoration');
return new Plugin({
key: commentDecorationPlugin,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, oldSet) {
const decorationMeta = tr.getMeta(commentDecorationMetaKey);
if (decorationMeta) {
const { from, to } = tr.selection;
const decoration = Decoration.inline(from, to, { class: commentMarkClass });
return DecorationSet.create(tr.doc, [decoration]);
} else if (decorationMeta === false) {
return DecorationSet.empty;
}
return oldSet.map(tr.mapping, tr.doc);
},
},
props: {
decorations: (state) => {
return commentDecorationPlugin.getState(state);
},
},
});
}

View File

@@ -0,0 +1,217 @@
import { Mark, mergeAttributes } from "@tiptap/core";
import { commentDecoration } from "./comment-decoration";
import { Plugin } from "@tiptap/pm/state";
export interface ICommentOptions {
HTMLAttributes: Record<string, any>;
}
export interface ICommentStorage {
activeCommentId: string | null;
}
export const commentMarkClass = "comment-mark";
export const commentDecorationMetaKey = "decorateComment";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
comment: {
setCommentDecoration: () => ReturnType;
unsetCommentDecoration: () => ReturnType;
setComment: (commentId: string) => ReturnType;
unsetComment: (commentId: string) => ReturnType;
setCommentResolved: (commentId: string, resolved: boolean) => ReturnType;
};
}
}
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
name: "comment",
exitable: true,
inclusive: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
addStorage() {
return {
activeCommentId: null,
};
},
addAttributes() {
return {
commentId: {
default: null,
parseHTML: (element) => element.getAttribute("data-comment-id"),
renderHTML: (attributes) => {
if (!attributes.commentId) return;
return {
"data-comment-id": attributes.commentId,
};
},
},
resolved: {
default: false,
parseHTML: (element) => element.hasAttribute("data-resolved"),
renderHTML: (attributes) => {
if (!attributes.resolved) return {};
return {
"data-resolved": "true",
};
},
},
};
},
parseHTML() {
return [
{
tag: "span[data-comment-id]",
getAttrs: (el) => {
const element = el as HTMLSpanElement;
const commentId = element.getAttribute("data-comment-id")?.trim();
const resolved = element.hasAttribute("data-resolved");
if (!commentId) return false;
return {
commentId,
resolved,
};
},
},
];
},
addCommands() {
return {
setCommentDecoration:
() =>
({ tr, dispatch }) => {
tr.setMeta(commentDecorationMetaKey, true);
if (dispatch) dispatch(tr);
return true;
},
unsetCommentDecoration:
() =>
({ tr, dispatch }) => {
tr.setMeta(commentDecorationMetaKey, false);
if (dispatch) dispatch(tr);
return true;
},
setComment:
(commentId) =>
({ commands }) => {
if (!commentId) return false;
// Just add the new mark, do not remove existing ones
return commands.setMark(this.name, { commentId, resolved: false });
},
unsetComment:
(commentId) =>
({ tr, dispatch }) => {
if (!commentId) return false;
tr.doc.descendants((node, pos) => {
const from = pos;
const to = pos + node.nodeSize;
const commentMark = node.marks.find(
(mark) =>
mark.type.name === this.name &&
mark.attrs.commentId === commentId
);
if (commentMark) {
tr = tr.removeMark(from, to, commentMark);
}
});
return dispatch?.(tr);
},
setCommentResolved:
(commentId, resolved) =>
({ tr, dispatch }) => {
if (!commentId) return false;
tr.doc.descendants((node, pos) => {
const from = pos;
const to = pos + node.nodeSize;
const commentMark = node.marks.find(
(mark) =>
mark.type.name === this.name &&
mark.attrs.commentId === commentId
);
if (commentMark) {
// Remove the existing mark and add a new one with updated resolved state
tr = tr.removeMark(from, to, commentMark);
tr = tr.addMark(
from,
to,
this.type.create({
commentId: commentMark.attrs.commentId,
resolved: resolved,
})
);
}
});
return dispatch?.(tr);
},
};
},
renderHTML({ HTMLAttributes }) {
const commentId = HTMLAttributes?.["data-comment-id"] || null;
const resolved = HTMLAttributes?.["data-resolved"] || false;
if (typeof window === "undefined" || typeof document === "undefined") {
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: resolved ? "comment-mark resolved" : "comment-mark",
"data-comment-id": commentId,
...(resolved && { "data-resolved": "true" }),
}),
0,
];
}
const elem = document.createElement("span");
Object.entries(
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
).forEach(([attr, val]) => elem.setAttribute(attr, val));
// Add resolved class if the comment is resolved
if (resolved) {
elem.classList.add("resolved");
}
elem.addEventListener("click", (e) => {
const selection = document.getSelection();
if (selection.type === "Range") return;
this.storage.activeCommentId = commentId;
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
bubbles: true,
detail: { commentId, resolved },
});
elem.dispatchEvent(commentEventClick);
});
return elem;
},
addProseMirrorPlugins(): Plugin[] {
return [commentDecoration()];
},
});

View File

@@ -0,0 +1,235 @@
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
import CodeBlock from '@tiptap/extension-code-block';
import { Plugin, Selection, TextSelection } from '@tiptap/pm/state';
import { GapCursor } from '@tiptap/pm/gapcursor';
import { LowlightPlugin } from './lowlight-plugin.js';
import { ReactNodeViewRenderer } from '@tiptap/react';
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
/**
* The lowlight instance.
*/
lowlight: any;
view: any;
}
const TAB_CHAR = '\u00A0\u00A0';
/**
* This extension allows you to highlight code blocks with lowlight.
* @see https://tiptap.dev/api/nodes/code-block-lowlight
*/
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
// Run ahead of Gapcursor (100) so the mermaid arrow-into-source plugin
// can intercept before gapcursor takes over.
priority: 101,
selectable: true,
isolating: true,
addOptions() {
return {
...this.parent?.(),
lowlight: {},
languageClassPrefix: 'language-',
exitOnTripleEnter: true,
exitOnArrowDown: true,
defaultLanguage: null,
HTMLAttributes: {},
view: null,
};
},
addKeyboardShortcuts() {
const isMermaid = (node: any) =>
node?.type === this.type && node.attrs.language === 'mermaid';
return {
...this.parent?.(),
// Stop at the gap (or enter mermaid source) instead of jumping
// straight into the next block, so the user can place a cursor
// between two adjacent isolating blocks.
ArrowDown: ({ editor }) => {
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) return false;
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
const after = $from.after();
if (after >= doc.content.size) {
return editor.commands.exitCode();
}
const $after = doc.resolve(after);
const nodeAfter = $after.nodeAfter;
if (isMermaid(nodeAfter)) {
return editor.commands.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, after + 1));
return true;
});
}
if (
nodeAfter?.type.spec.isolating &&
!nodeAfter.type.spec.atom
) {
return editor.commands.command(({ tr }) => {
tr.setSelection(new GapCursor(tr.doc.resolve(after)));
return true;
});
}
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(tr.doc.resolve(after)));
return true;
});
},
// Mirror of ArrowDown; upstream has no ArrowUp handler.
ArrowUp: ({ editor }) => {
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) return false;
if ($from.parentOffset !== 0) return false;
const before = $from.before();
if (before <= 0) return false;
const $before = doc.resolve(before);
const nodeBefore = $before.nodeBefore;
if (isMermaid(nodeBefore)) {
return editor.commands.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, before - 1));
return true;
});
}
if (
nodeBefore?.type.spec.isolating &&
!nodeBefore.type.spec.atom
) {
return editor.commands.command(({ tr }) => {
tr.setSelection(new GapCursor(tr.doc.resolve(before)));
return true;
});
}
return false;
},
'Mod-a': () => {
if (this.editor.isActive('codeBlock')) {
const { state } = this.editor;
const { $from } = state.selection;
let codeBlockNode = null;
let codeBlockPos = null;
let depth = 0;
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === 'codeBlock') {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
}
}
if (codeBlockNode && codeBlockPos !== null) {
const codeBlockStart = codeBlockPos;
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
const contentStart = codeBlockStart + 1;
const contentEnd = codeBlockEnd - 1;
this.editor.commands.setTextSelection({
from: contentStart,
to: contentEnd,
});
return true;
}
}
return false;
},
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addProseMirrorPlugins() {
const codeBlockType = this.type;
return [
...(this.parent?.() || []),
LowlightPlugin({
name: this.name,
lowlight: this.options.lowlight,
defaultLanguage: this.options.defaultLanguage,
}),
// Mermaid hides its <pre> when unselected, so the browser's native
// vertical caret movement skips past it. Land the cursor inside the
// source explicitly.
new Plugin({
props: {
handleKeyDown: (view, event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return false;
}
const { state } = view;
const { selection } = state;
if (
!selection.empty ||
!(selection instanceof TextSelection)
) {
return false;
}
const { $from } = selection;
if ($from.depth === 0 || $from.parent.type === codeBlockType) {
return false;
}
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
if (!view.endOfTextblock(dir)) return false;
const isMermaid = (node: any) =>
node?.type === codeBlockType && node.attrs.language === 'mermaid';
if (event.key === 'ArrowUp') {
if ($from.parentOffset !== 0) return false;
const beforePos = $from.before();
const prev = state.doc.resolve(beforePos).nodeBefore;
if (!isMermaid(prev)) return false;
const endPos = beforePos - 1;
view.dispatch(
state.tr.setSelection(
TextSelection.create(state.doc, endPos),
),
);
return true;
}
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
const afterPos = $from.after();
const next = state.doc.resolve(afterPos).nodeAfter;
if (!isMermaid(next)) return false;
const startPos = afterPos + 1;
view.dispatch(
state.tr.setSelection(
TextSelection.create(state.doc, startPos),
),
);
return true;
},
},
}),
];
},
});

View File

@@ -0,0 +1 @@
export { CustomCodeBlock } from "./custom-code-block";

View File

@@ -0,0 +1,202 @@
import { findChildren } from '@tiptap/core';
import type { Node as ProsemirrorNode } from '@tiptap/pm/model';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
// @ts-ignore
import highlight from 'highlight.js/lib/core';
function parseNodes(
nodes: any[],
className: string[] = [],
): { text: string; classes: string[] }[] {
return nodes
.map((node) => {
const classes = [
...className,
...(node.properties ? node.properties.className : []),
];
if (node.children) {
return parseNodes(node.children, classes);
}
return {
text: node.value,
classes,
};
})
.flat();
}
function getHighlightNodes(result: any) {
// `.value` for lowlight v1, `.children` for lowlight v2
return result.value || result.children || [];
}
function registered(aliasOrLanguage: string) {
return Boolean(highlight.getLanguage(aliasOrLanguage));
}
// Max characters to sample for auto-detection to avoid performance issues with large code blocks
const AUTO_DETECT_SAMPLE_SIZE = 3000;
function getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}: {
doc: ProsemirrorNode;
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
const decorations: Decoration[] = [];
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
let from = block.pos + 1;
const language = block.node.attrs.language || defaultLanguage;
const languages = lowlight.listLanguages();
const textContent = block.node.textContent;
let nodes;
if (
language &&
(languages.includes(language) ||
registered(language) ||
lowlight.registered?.(language))
) {
nodes = getHighlightNodes(lowlight.highlight(language, textContent));
} else {
// For auto-detection, sample a limited portion to detect the language,
// then highlight the full content with the detected language
const sample =
textContent.length > AUTO_DETECT_SAMPLE_SIZE
? textContent.slice(0, AUTO_DETECT_SAMPLE_SIZE)
: textContent;
const autoResult = lowlight.highlightAuto(sample);
const detectedLanguage = autoResult.data?.language;
if (detectedLanguage && textContent.length > AUTO_DETECT_SAMPLE_SIZE) {
nodes = getHighlightNodes(
lowlight.highlight(detectedLanguage, textContent),
);
} else {
nodes = getHighlightNodes(autoResult);
}
}
parseNodes(nodes).forEach((node) => {
const to = from + node.text.length;
if (node.classes.length) {
const decoration = Decoration.inline(from, to, {
class: node.classes.join(' '),
});
decorations.push(decoration);
}
from = to;
});
});
return DecorationSet.create(doc, decorations);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
function isFunction(param: any): param is Function {
return typeof param === 'function';
}
export function LowlightPlugin({
name,
lowlight,
defaultLanguage,
}: {
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
if (
!['highlight', 'highlightAuto', 'listLanguages'].every((api) =>
isFunction(lowlight[api]),
)
) {
throw Error(
'You should provide an instance of lowlight to use the code-block-lowlight extension',
);
}
const lowlightPlugin: Plugin<any> = new Plugin({
key: new PluginKey('lowlight'),
state: {
init: (_, { doc }) =>
getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}),
apply: (transaction, decorationSet, oldState, newState) => {
const oldNodeName = oldState.selection.$head.parent.type.name;
const newNodeName = newState.selection.$head.parent.type.name;
const oldNodes = findChildren(
oldState.doc,
(node) => node.type.name === name,
);
const newNodes = findChildren(
newState.doc,
(node) => node.type.name === name,
);
if (
transaction.docChanged &&
// Apply decorations if:
// selection includes named node,
([oldNodeName, newNodeName].includes(name) ||
// OR transaction adds/removes named node,
newNodes.length !== oldNodes.length ||
// OR transaction has changes that completely encapsulte a node
// (for example, a transaction that affects the entire document).
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some((step) => {
// @ts-ignore
return (
// @ts-ignore
step.from !== undefined &&
// @ts-ignore
step.to !== undefined &&
oldNodes.some((node) => {
// @ts-ignore
return (
// @ts-ignore
node.pos >= step.from &&
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
);
})
);
}))
) {
return getDecorations({
doc: transaction.doc,
name,
lowlight,
defaultLanguage,
});
}
return decorationSet.map(transaction.mapping, transaction.doc);
},
},
props: {
decorations(state) {
return lowlightPlugin.getState(state);
},
},
});
return lowlightPlugin;
}

View File

@@ -0,0 +1,111 @@
import {
Node,
defaultBlockAt,
findParentNode,
mergeAttributes,
} from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
export interface DetailsContentOptions {
HTMLAttributes: Record<string, any>;
}
export const DetailsContent = Node.create<DetailsContentOptions>({
name: "detailsContent",
group: "block",
content: "block*",
defining: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
const view = editor.view;
const state = editor.state;
const selection = state.selection;
const findNode = findParentNode((node) => node.type.name === this.name)(
selection,
);
if (!selection.empty || !findNode || !findNode.node.childCount) {
return false;
}
const childCount = findNode.node.childCount;
if (!(childCount === selection.$from.index(findNode.depth) + 1)) {
return false;
}
const fillNode =
findNode.node.type.contentMatch.defaultType?.createAndFill();
if (!fillNode) {
return false;
}
const lastNode = findNode.node.child(childCount - 1);
if (!lastNode.eq(fillNode)) {
return false;
}
const rootNode = selection.$from.node(-3);
if (!rootNode) {
return false;
}
const indexAfter = selection.$from.indexAfter(-3);
const nodeType = defaultBlockAt(rootNode.contentMatchAt(indexAfter));
if (
!nodeType ||
!rootNode.canReplaceWith(indexAfter, indexAfter, nodeType)
) {
return false;
}
const defaultNode = nodeType.createAndFill();
if (!defaultNode) {
return false;
}
const tr = state.tr;
const after = selection.$from.after(-2);
tr.replaceWith(after, after, defaultNode);
tr.setSelection(Selection.near(tr.doc.resolve(after), 1));
const from = state.doc
.resolve(findNode.pos + 1)
.posAtIndex(childCount - 1, findNode.depth);
const to = from + lastNode.nodeSize;
tr.delete(from, to);
tr.scrollIntoView();
view.dispatch(tr);
return true;
},
};
},
});

View File

@@ -0,0 +1,96 @@
import { Node, defaultBlockAt, mergeAttributes } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
export interface DetailsSummaryOptions {
HTMLAttributes: Record<string, any>;
}
export const DetailsSummary = Node.create<DetailsSummaryOptions>({
name: "detailsSummary",
group: "block",
content: "inline*",
defining: true,
isolating: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: "summary",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"summary",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const state = editor.state;
const selection = state.selection;
if (selection.$anchor.parent.type.name !== this.name) {
return false;
}
if (selection.$anchor.parentOffset !== 0) {
return false;
}
return editor.chain().unsetDetails().focus().run();
},
Enter: ({ editor }) => {
const view = editor.view;
const state = editor.state;
const head = state.selection.$head;
if (head.parent.type.name !== this.name) {
return false;
}
const hasOffset =
// @ts-ignore
view.domAtPos(head.after() + 1).node.offsetParent !== null;
const findNode = hasOffset
? state.doc.nodeAt(head.after())
: head.node(-2);
if (!findNode) {
return false;
}
const indexAfter = hasOffset ? 0 : head.indexAfter(-1);
const nodeType = defaultBlockAt(findNode.contentMatchAt(indexAfter));
if (
!nodeType ||
!findNode.canReplaceWith(indexAfter, indexAfter, nodeType)
) {
return false;
}
const defaultNode = nodeType.createAndFill();
if (!defaultNode) {
return false;
}
const tr = state.tr;
const after = hasOffset ? head.after() + 1 : head.after(-1);
tr.replaceWith(after, after, defaultNode);
tr.setSelection(Selection.near(tr.doc.resolve(after), 1));
tr.scrollIntoView();
view.dispatch(tr);
return true;
},
};
},
});

View File

@@ -0,0 +1,245 @@
import {
Node,
findChildren,
findParentNode,
mergeAttributes,
wrappingInputRule,
} from "@tiptap/core";
import { icon, setAttributes } from "../utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
details: {
setDetails: () => ReturnType;
unsetDetails: () => ReturnType;
toggleDetails: () => ReturnType;
};
}
}
export interface DetailsOptions {
HTMLAttributes: Record<string, any>;
}
export const Details = Node.create<DetailsOptions>({
name: "details",
group: "block",
content: "detailsSummary detailsContent",
defining: true,
isolating: true,
// @ts-ignore
allowGapCursor: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
open: {
default: false,
parseHTML: (e) => e.getAttribute("open"),
renderHTML: (a) => (a.open ? { open: "" } : {}),
},
};
},
parseHTML() {
return [
{
tag: "details",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"details",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addNodeView() {
return ({ node, editor, getPos }) => {
const dom = document.createElement("div");
const btn = document.createElement("button");
const ico = document.createElement("div");
const div = document.createElement("div");
for (const [key, value] of Object.entries(
mergeAttributes(this.options.HTMLAttributes),
)) {
if (value !== undefined && value !== null) {
dom.setAttribute(key, value);
}
}
dom.setAttribute("data-type", this.name);
btn.setAttribute("data-type", `${this.name}Button`);
div.setAttribute("data-type", `${this.name}Container`);
if (editor.isEditable) {
if (node.attrs.open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
}
}
ico.innerHTML = icon("right-line");
btn.addEventListener("click", () => {
const open = !dom.hasAttribute("open");
if (!editor.isEditable) {
// In readonly mode, toggle the 'open' attribute without updating the document state.
if (open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
}
return;
}
setAttributes(editor, getPos, { ...node.attrs, open });
});
btn.append(ico);
dom.append(btn);
dom.append(div);
return {
dom,
contentDOM: div,
update: (updatedNode) => {
if (updatedNode.type !== this.type) {
return false;
}
if (!editor.isEditable) return true;
if (updatedNode.attrs.open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
}
return true;
},
};
};
},
addCommands() {
return {
setDetails: () => {
return ({ state, chain }) => {
const range = state.selection.$from.blockRange(state.selection.$to);
if (!range) {
return false;
}
const slice = state.doc.slice(range.start, range.end);
if (slice.content.firstChild.type.name === "detailsSummary")
return false;
if (
!state.schema.nodes.detailsContent.contentMatch.matchFragment(
slice.content,
)
) {
return false;
}
return chain()
.insertContentAt(
{
from: range.start,
to: range.end,
},
{
type: this.name,
attrs: {
open: true,
},
content: [
{
type: "detailsSummary",
},
{
type: "detailsContent",
content: slice.toJSON()?.content ?? [],
},
],
},
)
.setTextSelection(range.start + 2)
.run();
};
},
unsetDetails: () => {
return ({ state, chain }) => {
const parent = findParentNode((node) => node.type === this.type)(
state.selection,
);
if (!parent) {
return false;
}
const summary = findChildren(
parent.node,
(node) => node.type.name === "detailsSummary",
);
const content = findChildren(
parent.node,
(node) => node.type.name === "detailsContent",
);
if (!summary.length || !content.length) {
return false;
}
const range = {
from: parent.pos,
to: parent.pos + parent.node.nodeSize,
};
const defaultType = state.doc.resolve(range.from).parent.type
.contentMatch.defaultType;
return chain()
.insertContentAt(range, [
defaultType?.create(null, summary[0].node.content).toJSON(),
...(content[0].node.content.toJSON() ?? []),
])
.setTextSelection(range.from + 1)
.run();
};
},
toggleDetails: () => {
return ({ state, chain }) => {
const node = findParentNode((node) => node.type === this.type)(
state.selection,
);
if (node) {
return chain().unsetDetails().run();
} else {
return chain().setDetails().run();
}
};
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: /^:::details\s$/,
type: this.type,
}),
];
},
addKeyboardShortcuts() {
return {
"Mod-Alt-d": () => this.editor.commands.toggleDetails(),
};
},
});

View File

@@ -0,0 +1,3 @@
export { Details } from "./details";
export { DetailsSummary } from "./details-summary";
export { DetailsContent } from "./details-content";

View File

@@ -0,0 +1,364 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
export type DrawioResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface DrawioOptions {
HTMLAttributes: Record<string, any>;
view: any;
resize: DrawioResizeOptions | false;
}
export interface DrawioAttributes {
src?: string;
title?: string;
alt?: string;
size?: number;
width?: number | string;
height?: number;
aspectRatio?: number;
align?: string;
attachmentId?: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
drawio: {
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
setDrawioAlign: (align: "left" | "center" | "right") => ReturnType;
setDrawioSize: (width: number, height: number) => ReturnType;
};
}
}
export const Drawio = Node.create<DrawioOptions>({
name: "drawio",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
resize: false,
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => element.getAttribute("data-src"),
renderHTML: (attributes) => ({
"data-src": attributes.src,
}),
},
title: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-title"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-title": attributes.title,
}),
},
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-alt"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-alt": attributes.alt,
}),
},
width: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: DrawioAttributes) => ({
"data-width": attributes.width,
}),
},
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: DrawioAttributes) => ({
"data-height": attributes.height,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-size": attributes.size,
}),
},
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-align": attributes.align,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
[
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
];
},
addCommands() {
return {
setDrawio:
(attrs: DrawioAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "drawio",
attrs: attrs,
});
},
setDrawioAlign:
(align) =>
({ commands }) =>
commands.updateAttributes("drawio", { align }),
setDrawioSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("drawio", { width, height }),
};
},
addNodeView() {
const resize = this.options.resize;
if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("img");
el.src = normalizeFileUrl(node.attrs.src);
el.alt = node.attrs.alt || node.attrs.title || "";
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = normalizeFileUrl(updatedNode.attrs.src);
}
if (
updatedNode.attrs.alt !== currentNode.attrs.alt ||
updatedNode.attrs.title !== currentNode.attrs.title
) {
el.alt =
updatedNode.attrs.alt || updatedNode.attrs.title || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
syncAltBadge(nodeView.wrapper, node.attrs.alt);
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
el.classList.remove("media-pulse");
};
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}

View File

@@ -0,0 +1,146 @@
export interface IEmbedProvider {
id: string;
name: string;
regex: RegExp;
getEmbedUrl: (match: RegExpMatchArray, url?: string) => string;
}
export const embedProviders: IEmbedProvider[] = [
{
id: "loom",
name: "Loom",
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
getEmbedUrl: (match, url) => {
if (url.includes("/embed/")) {
return url;
}
return `https://loom.com/embed/${match[1]}`;
},
},
{
id: "airtable",
name: "Airtable",
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
getEmbedUrl: (match, url: string) => {
const path = url.split("airtable.com/");
if (url.includes("/embed/")) {
return url;
}
return `https://airtable.com/embed/${path[1]}`;
},
},
{
id: "figma",
name: "Figma",
regex:
/^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
getEmbedUrl: (match, url: string) => {
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
},
},
{
id: "typeform",
name: "Typeform",
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
getEmbedUrl: (match, url: string) => {
return url;
},
},
{
id: "miro",
name: "Miro",
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
getEmbedUrl: (match, url) => {
if (url.includes("/live-embed/")) {
return url;
}
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
},
},
{
id: "youtube",
name: "YouTube",
regex:
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
getEmbedUrl: (match, url) => {
if (url.includes("/embed/")) {
return url;
}
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
},
},
{
id: "vimeo",
name: "Vimeo",
regex:
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)(?:\/([\da-zA-Z]+))?/,
getEmbedUrl: (match, url: string) => {
// preserve ?h= hash for unlisted videos
const hash =
match[5] ?? new URL(url, "https://vimeo.com").searchParams.get("h");
const base = `https://player.vimeo.com/video/${match[4]}`;
return hash ? `${base}?h=${hash}` : base;
},
},
{
id: "framer",
name: "Framer",
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
getEmbedUrl: (match, url: string) => {
return url;
},
},
{
id: "gdrive",
name: "Google Drive",
regex:
/^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
getEmbedUrl: (match) => {
return `https://drive.google.com/file/d/${match[4]}/preview`;
},
},
{
id: "gsheets",
name: "Google Sheets",
regex:
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/([a-zA-Z0-9_-]+)\/.*$/,
getEmbedUrl: (match, url: string) => {
return url;
},
},
{
id: "iframe",
name: "Iframe",
regex: /any-iframe/,
getEmbedUrl: (match, url) => {
return url;
},
},
];
export function getEmbedProviderById(id: string) {
return embedProviders.find(
(provider) => provider.id.toLowerCase() === id.toLowerCase(),
);
}
export interface IEmbedResult {
embedUrl: string;
provider: string;
}
export function getEmbedUrlAndProvider(url: string): IEmbedResult {
for (const provider of embedProviders) {
const match = url.match(provider.regex);
if (match) {
return {
embedUrl: provider.getEmbedUrl(match, url),
provider: provider.name.toLowerCase(),
};
}
}
return {
embedUrl: url,
provider: "iframe",
};
}

View File

@@ -0,0 +1,138 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from "./utils";
export interface EmbedOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface EmbedAttributes {
src?: string;
provider: string;
align?: string;
width?: number;
height?: number;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
embeds: {
setEmbed: (attributes?: EmbedAttributes) => ReturnType;
};
}
}
export const Embed = Node.create<EmbedOptions>({
name: "embed",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => {
const src = element.getAttribute("data-src");
return sanitizeUrl(src);
},
renderHTML: (attributes: EmbedAttributes) => ({
"data-src": sanitizeUrl(attributes.src),
}),
},
provider: {
default: "",
parseHTML: (element) => element.getAttribute("data-provider"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-provider": attributes.provider,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-align": attributes.align,
}),
},
width: {
default: 800,
parseHTML: (element) => element.getAttribute("data-width"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-width": attributes.width,
}),
},
height: {
default: 600,
parseHTML: (element) => element.getAttribute("data-height"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-height": attributes.height,
}),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes["data-src"];
const safeHref = sanitizeUrl(src);
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
[
"a",
{
href: safeHref,
target: "blank",
},
safeHref,
],
];
},
addCommands() {
return {
setEmbed:
(attrs: EmbedAttributes) =>
({ commands }) => {
// Validate the URL before inserting
const validatedAttrs = {
...attrs,
src: sanitizeUrl(attrs.src),
};
return commands.insertContent({
type: "embed",
attrs: validatedAttrs,
});
},
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});

View File

@@ -0,0 +1,364 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
export type ExcalidrawResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface ExcalidrawOptions {
HTMLAttributes: Record<string, any>;
view: any;
resize: ExcalidrawResizeOptions | false;
}
export interface ExcalidrawAttributes {
src?: string;
title?: string;
alt?: string;
size?: number;
width?: number | string;
height?: number;
aspectRatio?: number;
align?: string;
attachmentId?: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
excalidraw: {
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType;
setExcalidrawSize: (width: number, height: number) => ReturnType;
};
}
}
export const Excalidraw = Node.create<ExcalidrawOptions>({
name: "excalidraw",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
resize: false,
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => element.getAttribute("data-src"),
renderHTML: (attributes) => ({
"data-src": attributes.src,
}),
},
title: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-title"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-title": attributes.title,
}),
},
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-alt"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-alt": attributes.alt,
}),
},
width: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-width": attributes.width,
}),
},
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-height": attributes.height,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-size": attributes.size,
}),
},
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-align": attributes.align,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
[
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
];
},
addCommands() {
return {
setExcalidraw:
(attrs: ExcalidrawAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "excalidraw",
attrs: attrs,
});
},
setExcalidrawAlign:
(align) =>
({ commands }) =>
commands.updateAttributes("excalidraw", { align }),
setExcalidrawSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("excalidraw", { width, height }),
};
},
addNodeView() {
const resize = this.options.resize;
if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("img");
el.src = normalizeFileUrl(node.attrs.src);
el.alt = node.attrs.alt || node.attrs.title || "";
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = normalizeFileUrl(updatedNode.attrs.src);
}
if (
updatedNode.attrs.alt !== currentNode.attrs.alt ||
updatedNode.attrs.title !== currentNode.attrs.title
) {
el.alt =
updatedNode.attrs.alt || updatedNode.attrs.title || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
syncAltBadge(nodeView.wrapper, node.attrs.alt);
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
el.classList.remove("media-pulse");
};
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}

View File

@@ -0,0 +1,189 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { mergeAttributes } from "@tiptap/core";
import ListItem, { ListItemOptions } from "@tiptap/extension-list-item";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
footnote: {
/**
* scrolls to & sets the text selection at the end of the footnote with the given id
* @param id the id of the footote (i.e. the `data-id` attribute value of the footnote)
* @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50")
*/
focusFootnote: (id: string) => ReturnType;
};
}
}
export interface FootnoteOptions extends ListItemOptions {
/**
* Content expression for this node
* @default "paragraph+"
*/
content: string;
}
const Footnote = ListItem.extend<FootnoteOptions>({
name: "footnote",
content() {
return this.options.content;
},
isolating: true,
defining: true,
draggable: false,
addOptions() {
return {
HTMLAttributes: {},
bulletListTypeName: 'bulletList',
orderedListTypeName: 'orderedList',
...this.parent?.(),
content: "paragraph+",
};
},
addAttributes() {
return {
id: {
isRequired: true,
},
// the data-id field should match the data-id field of a footnote reference.
// it's used to link footnotes and references together.
"data-id": {
isRequired: true,
},
};
},
parseHTML() {
return [
{
tag: "li",
getAttrs(node) {
const id = node.getAttribute("data-id");
if (id) {
return {
"data-id": node.getAttribute("data-id"),
};
}
return false;
},
priority: 1000,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"li",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
focusFootnote:
(id: string) =>
({ editor, chain }) => {
const matchedFootnote = editor.$node("footnote", {
"data-id": id,
});
if (matchedFootnote) {
// sets the text selection to the end of the footnote definition and scroll to it.
chain()
.focus()
.setTextSelection(
matchedFootnote.from + matchedFootnote.content.size
)
.run();
matchedFootnote.element.scrollIntoView();
return true;
}
return false;
},
};
},
addKeyboardShortcuts() {
return {
// when inside a footnote, Mod-a should select only the footnote content
"Mod-a": ({ editor }) => {
try {
const { selection } = editor.state;
const { $from } = selection;
for (let depth = $from.depth; depth >= 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "footnote") {
const start = $from.start(depth);
const end = $from.end(depth);
editor.commands.setTextSelection({
from: start + 1,
to: end - 1,
});
return true;
}
}
return false;
} catch (e) {
return false;
}
},
// when the user presses tab, adjust the text selection to be at the end of the next footnote
Tab: ({ editor }) => {
try {
const { selection } = editor.state;
const pos = editor.$pos(selection.anchor);
if (!pos.after) return false;
// if the next node is "footnotes", place the text selection at the end of the first footnote
if (pos.after.node.type.name == "footnotes") {
const firstChild = pos.after.node.child(0);
editor
.chain()
.setTextSelection(pos.after.from + firstChild.content.size)
.scrollIntoView()
.run();
return true;
} else {
const startPos = selection.$from.start(2);
if (Number.isNaN(startPos)) return false;
const parent = editor.$pos(startPos);
if (parent.node.type.name != "footnote" || !parent.after) {
return false;
}
// if the next node is a footnote, place the text selection at the end of it
editor
.chain()
.setTextSelection(parent.after.to - 1)
.scrollIntoView()
.run();
return true;
}
} catch {
return false;
}
},
// inverse of the tab command - place the text selection at the end of the previous footnote
"Shift-Tab": ({ editor }) => {
const { selection } = editor.state;
const startPos = selection.$from.start(2);
if (Number.isNaN(startPos)) return false;
const parent = editor.$pos(startPos);
if (parent.node.type.name != "footnote" || !parent.before) {
return false;
}
editor
.chain()
.setTextSelection(parent.before.to - 1)
.scrollIntoView()
.run();
return true;
},
};
},
});
export default Footnote;

View File

@@ -0,0 +1,46 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import OrderedList from "@tiptap/extension-ordered-list";
import FootnoteRules from "./rules";
const Footnotes = OrderedList.extend({
name: "footnotes",
group: "", // removed the default group of the ordered list extension
isolating: true,
defining: true,
draggable: false,
content() {
return "footnote*";
},
addAttributes() {
return {
class: {
default: "footnotes",
},
};
},
parseHTML() {
return [
{
tag: "ol.footnotes",
priority: 1000,
},
];
},
addKeyboardShortcuts() {
return {};
},
addCommands() {
return {};
},
addInputRules() {
return [];
},
addExtensions() {
return [FootnoteRules];
},
});
export default Footnotes;

View File

@@ -0,0 +1,4 @@
export { default as Footnotes } from "./footnotes";
export { default as Footnote } from "./footnote";
export type { FootnoteOptions } from "./footnote";
export { default as FootnoteReference } from "./reference";

View File

@@ -0,0 +1,221 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { mergeAttributes, Node } from "@tiptap/core";
import {
Fragment as PMFragment,
Node as PMNode,
Slice,
} from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
import { generateNodeId } from "../utils";
const REFNUM_ATTR = "data-reference-number";
const REF_CLASS = "footnote-ref";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
footnoteReference: {
/**
* add a new footnote reference
* @example editor.commands.addFootnote()
*/
addFootnote: () => ReturnType;
};
}
}
const FootnoteReference = Node.create({
name: "footnoteReference",
inline: true,
content: "text*",
group: "inline",
atom: true,
draggable: true,
parseHTML() {
return [
{
tag: `sup`,
priority: 1000,
getAttrs(node) {
const anchor = node.querySelector<HTMLAnchorElement>(
`a.${REF_CLASS}:first-child`
);
if (!anchor) {
return false;
}
const id = anchor.getAttribute("data-id");
const ref = anchor.getAttribute(REFNUM_ATTR);
return {
"data-id": id ?? generateNodeId(),
referenceNumber: ref ?? anchor.innerText,
};
},
contentElement(node) {
return node.firstChild as HTMLElement;
},
},
];
},
addAttributes() {
return {
class: {
default: REF_CLASS,
},
"data-id": {
renderHTML(attributes) {
return {
"data-id": attributes["data-id"] || generateNodeId(),
};
},
},
referenceNumber: {},
href: {
renderHTML(attributes) {
return {
href: `#fn:${attributes["referenceNumber"]}`,
};
},
},
};
},
renderHTML({ HTMLAttributes }) {
const { referenceNumber, ...attributes } = HTMLAttributes;
const attrs = mergeAttributes(this.options.HTMLAttributes, attributes);
attrs[REFNUM_ATTR] = referenceNumber;
return [
"sup",
{ id: `fnref:${referenceNumber}` },
["a", attrs, HTMLAttributes.referenceNumber],
];
},
addProseMirrorPlugins() {
const { editor } = this;
// Ensures pasted footnote references get unique IDs.
const mapNode = (node: PMNode): PMNode => {
if (node.type.name === this.name) {
const newAttrs = { ...node.attrs, "data-id": generateNodeId() };
return node.type.create(newAttrs, node.content, node.marks);
}
if (node.content && node.content.size > 0) {
const newChildren: PMNode[] = [];
let changed = false;
node.content.forEach((child) => {
const mapped = mapNode(child);
if (mapped !== child) {
changed = true;
}
newChildren.push(mapped);
});
if (changed) {
return node.copy(PMFragment.from(newChildren));
}
}
return node;
};
return [
new Plugin({
key: new PluginKey("footnotePasteHandler"),
props: {
transformPasted(slice) {
const mappedNodes: PMNode[] = [];
let changed = false;
slice.content.forEach((node) => {
const mapped = mapNode(node);
if (mapped !== node) {
changed = true;
}
mappedNodes.push(mapped);
});
if (!changed) {
return slice;
}
return new Slice(
PMFragment.from(mappedNodes),
slice.openStart,
slice.openEnd
);
},
},
}),
new Plugin({
key: new PluginKey("footnoteRefClick"),
props: {
// on double-click, focus on the footnote
handleDoubleClickOn(view, pos, node, nodePos, event) {
if (node.type.name != "footnoteReference") return false;
event.preventDefault();
const id = node.attrs["data-id"];
return editor.commands.focusFootnote(id);
},
// click the footnote reference once to get focus, click twice to scroll to the footnote
handleClickOn(view, pos, node, nodePos, event) {
if (node.type.name != "footnoteReference") return false;
event.preventDefault();
const { selection } = editor.state.tr;
if (selection instanceof NodeSelection && selection.node.eq(node)) {
const id = node.attrs["data-id"];
return editor.commands.focusFootnote(id);
} else {
editor.chain().setNodeSelection(nodePos).run();
return true;
}
},
},
}),
];
},
addCommands() {
return {
addFootnote:
() =>
({ state, tr }) => {
const node = this.type.create({
"data-id": generateNodeId(),
});
tr.insert(state.selection.anchor, node);
return true;
},
};
},
addInputRules() {
// when a user types [^text], add a new footnote
return [
{
find: /\[\^(.*?)\]/,
type: this.type,
undoable: true,
handler({ range, match, chain }) {
const start = range.from;
let end = range.to;
if (match[1]) {
chain().deleteRange({ from: start, to: end }).addFootnote().run();
}
},
},
];
},
});
export default FootnoteReference;

View File

@@ -0,0 +1,90 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReplaceStep } from "@tiptap/pm/transform";
import { Extension } from "@tiptap/core";
import { updateFootnotesList } from "./utils";
const FootnoteRules = Extension.create({
name: "footnoteRules",
priority: 1000,
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("footnoteRules"),
filterTransaction(tr) {
const { from, to } = tr.selection;
// Allow full document selections (Mod-a/Ctrl-a)
if (from === 0 && to === tr.doc.content.size) return true;
let selectedFootnotes = false;
let selectedContent = false;
let footnoteCount = 0;
tr.doc.nodesBetween(from, to, (node, _, parent) => {
if (parent?.type.name == "doc" && node.type.name != "footnotes") {
selectedContent = true;
} else if (node.type.name == "footnote") {
footnoteCount += 1;
} else if (node.type.name == "footnotes") {
selectedFootnotes = true;
}
});
const overSelected = selectedContent && selectedFootnotes;
/*
* Here, we don't allow any transaction that spans between the "content" nodes and the "footnotes" node. This also rejects any transaction that spans between more than 1 footnote.
*/
return !overSelected && footnoteCount <= 1;
},
// if there are some to the footnote references (added/deleted/dragged), append a transaction that updates the footnotes list accordingly
appendTransaction(transactions, oldState, newState) {
let newTr = newState.tr;
let refsChanged = false; // true if the footnote references have been changed, false otherwise
for (let tr of transactions) {
if (!tr.docChanged) continue;
if (refsChanged) break;
for (let step of tr.steps) {
if (!(step instanceof ReplaceStep)) continue;
if (refsChanged) break;
const isDelete = step.from != step.to; // the user deleted items from the document (from != to & the step is a replace step)
const isInsert = step.slice.size > 0;
// check if any footnote references have been inserted
if (isInsert) {
step.slice.content.descendants((node) => {
if (node?.type.name == "footnoteReference") {
refsChanged = true;
return false;
}
});
}
if (isDelete && !refsChanged) {
// check if any footnote references have been deleted
tr.before.nodesBetween(
step.from,
Math.min(tr.before.content.size, step.to), // make sure to not go over the old document's limit
(node) => {
if (node.type.name == "footnoteReference") {
refsChanged = true;
return false;
}
},
);
}
}
}
if (refsChanged) {
updateFootnotesList(newTr, newState);
return newTr;
}
return null;
},
}),
];
},
});
export default FootnoteRules;

View File

@@ -0,0 +1,123 @@
//Source MIT - https://github.com/buttondown/tiptap-footnotes
import { EditorState, Transaction } from "@tiptap/pm/state";
import { Fragment, Node } from "@tiptap/pm/model";
// update the reference number of all the footnote references in the document
export function updateFootnoteReferences(tr: Transaction) {
let count = 1;
const nodes: any[] = [];
tr.doc.descendants((node, pos) => {
if (node.type.name == "footnoteReference") {
tr.setNodeAttribute(pos, "referenceNumber", `${count}`);
nodes.push(node);
count += 1;
}
});
// return the updated footnote references (in the order that they appear in the document)
return nodes;
}
function getFootnotes(tr: Transaction) {
let footnotesRange: { from: number; to: number } | undefined;
const footnotes: Node[] = [];
tr.doc.descendants((node, pos) => {
if (node.type.name == "footnote") {
footnotes.push(node);
} else if (node.type.name == "footnotes") {
footnotesRange = { from: pos, to: pos + node.nodeSize };
} else {
return false;
}
});
return { footnotesRange, footnotes };
}
// update the "footnotes" ordered list based on the footnote references in the document
export function updateFootnotesList(tr: Transaction, state: EditorState) {
const footnoteReferences = updateFootnoteReferences(tr);
const footnoteType = state.schema.nodes.footnote;
const footnotesType = state.schema.nodes.footnotes;
const emptyParagraph = state.schema.nodeFromJSON({
type: "paragraph",
content: [],
});
const { footnotesRange, footnotes } = getFootnotes(tr);
// a mapping of footnote id -> footnote node
const footnoteIds: { [key: string]: Node } = footnotes.reduce(
(obj, footnote) => {
obj[footnote.attrs["data-id"]] = footnote;
return obj;
},
{} as any,
);
const newFootnotes: Node[] = [];
let footnoteRefIds = new Set(
footnoteReferences.map((ref) => ref.attrs["data-id"]),
);
const deleteFootnoteIds: Set<string> = new Set();
for (let footnote of footnotes) {
const id = footnote.attrs["data-id"];
if (!footnoteRefIds.has(id) || deleteFootnoteIds.has(id)) {
deleteFootnoteIds.add(id);
// we traverse through this footnote's content because it may contain footnote references.
// we want to delete the footnotes associated with these references, so we add them to the delete set.
footnote.content.descendants((node) => {
if (node.type.name == "footnoteReference")
deleteFootnoteIds.add(node.attrs["data-id"]);
});
}
}
for (let i = 0; i < footnoteReferences.length; i++) {
let refId = footnoteReferences[i].attrs["data-id"];
if (deleteFootnoteIds.has(refId)) continue;
// if there is a footnote w/ the same id as this `ref`, we preserve its content and update its id attribute
if (refId in footnoteIds) {
let footnote = footnoteIds[refId];
newFootnotes.push(
footnoteType.create(
{ ...footnote.attrs, id: `fn:${i + 1}` },
footnote.content,
),
);
} else {
let newNode = footnoteType.create(
{
"data-id": refId,
id: `fn:${i + 1}`,
},
[emptyParagraph],
);
newFootnotes.push(newNode);
}
}
if (newFootnotes.length == 0) {
// no footnotes in the doc, delete the "footnotes" node
if (footnotesRange) {
tr.delete(footnotesRange.from, footnotesRange.to);
}
} else if (!footnotesRange) {
// there is no footnotes node present in the doc, add it
tr.insert(
tr.doc.content.size,
footnotesType.create(undefined, Fragment.from(newFootnotes)),
);
} else {
tr.replaceWith(
footnotesRange!.from + 1, // add 1 to point at the position after the opening ol tag
footnotesRange!.to - 1, // substract 1 to point to the position before the closing ol tag
Fragment.from(newFootnotes),
);
}
}

View File

@@ -0,0 +1,80 @@
import TiptapHeading, {
HeadingOptions as TiptapHeadingOptions,
} from "@tiptap/extension-heading";
import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Plugin } from "@tiptap/pm/state";
import { copyToClipboard } from "../utils";
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`;
export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
// @ts-ignore
addProseMirrorPlugins() {
return [
new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = [];
const { doc } = state;
doc.descendants((node, pos) => {
if (node.type.name === "heading" && node.content.size > 1) {
const deco = Decoration.widget(
pos + node.nodeSize - 1,
() => {
const icon = document.createElement("span");
icon.classList.add("link-btn");
icon.innerHTML = "&nbsp;";
icon.contentEditable = "false";
const linkBtnContent = document.createElement("span");
linkBtnContent.classList.add("link-btn-content");
linkBtnContent.innerHTML = copyIcon;
icon.appendChild(linkBtnContent);
icon.addEventListener("mousedown", (e) =>
e.preventDefault(),
);
icon.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const id = node.attrs.id;
const baseUrl = window.location.href.split('#')[0];
const url = `${baseUrl}#${id}`;
copyToClipboard(url);
linkBtnContent.innerHTML = successIcon;
setTimeout(
() => (linkBtnContent.innerHTML = copyIcon),
2000,
);
});
return icon;
},
{ side: 1 }, // render after node content
);
decorations.push(deco);
}
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
renderHTML({ node, HTMLAttributes }) {
const hasLevel = this.options.levels.includes(node.attrs.level);
const level = hasLevel ? node.attrs.level : this.options.levels[0];
return [
`h${level}`,
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
id: node.attrs.id,
}),
0,
];
},
});

View File

@@ -0,0 +1,40 @@
import {
Highlight as TiptapHighlight,
type HighlightOptions,
} from "@tiptap/extension-highlight";
export const Highlight = TiptapHighlight.extend<HighlightOptions>({
addAttributes() {
return {
...this.parent?.(),
color: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-color") || element.style.backgroundColor,
renderHTML: (attributes) => {
if (!attributes.color) {
return {};
}
return {
"data-color": attributes.color,
style: `background-color: ${attributes.color}; color: inherit`,
};
},
},
colorName: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-highlight-color-name") || null,
renderHTML: (attributes) => {
if (!attributes.colorName) {
return {};
}
return {
"data-highlight-color-name": attributes.colorName.toLowerCase(),
};
},
},
};
},
});

View File

@@ -0,0 +1,155 @@
import { imageDimensionsFromData } from 'image-dimensions';
import { MediaUploadOptions, UploadFn } from '../media-utils';
import { IAttachment } from '../types';
import { generateNodeId } from '../utils';
import { Node } from '@tiptap/pm/model';
import { Command } from '@tiptap/core';
const findImageNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === 'image' &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handleImageUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
// check if the file is an image
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const objectUrl = URL.createObjectURL(file);
const imageDimensions = imageDimensionsFromData(
new Uint8Array(await file.arrayBuffer()),
);
const placeholderId = generateNodeId();
const width = imageDimensions?.width ?? undefined;
const height = imageDimensions?.height ?? undefined;
const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: undefined;
let placeholderInserted = false;
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.image?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
width,
height,
aspectRatio,
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
// Replace e.g. empty paragraph with the image
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
// If the placeholder is not found or attachment is missing, abort the process
if (currentPos === null || !attachment) return false;
// Update the placeholder node with the actual image data
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id,
size: attachment.fileSize,
width,
height,
aspectRatio,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
// Remove the placeholder node
tr.delete(currentPos, currentPos + 2);
return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.imagePreviews) {
delete editor.storage.shared.imagePreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithImage(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithImage(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
};
export { handleImageUpload };

View File

@@ -0,0 +1,389 @@
import Image from "@tiptap/extension-image";
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import {
mergeAttributes,
Range,
} from "@tiptap/core";
import { ResizableNodeView } from "../resizable-nodeview";
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
import { normalizeFileUrl, syncAltBadge } from "../media-utils";
export type ImageResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface ImageOptions extends DefaultImageOptions {
view: any;
resize: ImageResizeOptions | false;
}
export interface ImageAttributes {
src?: string;
alt?: string;
align?: string;
attachmentId?: string;
size?: number;
width?: number | string;
height?: number;
aspectRatio?: number;
placeholder?: {
id: string;
name: string;
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
imageBlock: {
setImage: (attributes: ImageAttributes) => ReturnType;
setImageAt: (
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
};
}
}
export const TiptapImage = Image.extend<ImageOptions>({
name: "image",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
addOptions() {
return {
...this.parent?.(),
view: null,
resize: false,
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => element.getAttribute("src"),
renderHTML: (attributes) => ({
src: attributes.src,
}),
},
width: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ImageAttributes) => ({
width: attributes.width,
}),
},
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ImageAttributes) => ({
height: attributes.height,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
renderHTML: (attributes: ImageAttributes) => ({
"data-align": attributes.align,
}),
},
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("alt"),
renderHTML: (attributes: ImageAttributes) => ({
alt: attributes.alt,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: ImageAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: ImageAttributes) => ({
"data-size": attributes.size,
}),
},
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: ImageAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
renderHTML({ HTMLAttributes }) {
return [
"img",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
];
},
addCommands() {
return {
setImage:
(attrs: ImageAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "image",
attrs: attrs,
});
},
setImageAt:
(attrs) =>
({ commands }) => {
return commands.insertContentAt(attrs.pos, {
type: "image",
attrs: attrs,
});
},
setImageAlign:
(align) =>
({ commands }) =>
commands.updateAttributes("image", { align }),
setImageWidth:
(width) =>
({ commands }) =>
commands.updateAttributes("image", { width }),
setImageSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("image", { width, height }),
};
},
addNodeView() {
const resize = this.options.resize;
if (!resize || !resize.enabled) {
// Fallback to React node view (existing behavior)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
// If no src yet (placeholder/uploading), use React view for loading UI
if (!HTMLAttributes.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
// When the node gets a src, return false from update to force rebuild
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
// Has src — use ResizableNodeView
const el = document.createElement("img");
Object.entries(HTMLAttributes).forEach(([key, value]) => {
if (value != null) {
switch (key) {
case "width":
case "height":
break;
default:
el.setAttribute(key, String(value));
break;
}
}
});
el.src = normalizeFileUrl(HTMLAttributes.src);
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
if (typeof node.attrs.width === "number" && node.attrs.width > 0) {
el.style.width = `${node.attrs.width}px`;
if (typeof node.attrs.height === "number" && node.attrs.height > 0) {
el.style.height = `${node.attrs.height}px`;
}
}
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = normalizeFileUrl(updatedNode.attrs.src);
}
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
el.alt = updatedNode.attrs.alt || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
// Update alignment on container
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
syncAltBadge(nodeView.wrapper, node.attrs.alt);
// Apply initial alignment
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
// Defer conversion until we can measure the container
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
el.classList.remove("media-pulse");
};
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}

View File

@@ -0,0 +1,2 @@
export { TiptapImage } from "./image";
export * from "./image-upload";

View File

@@ -0,0 +1,225 @@
import { Extension } from '@tiptap/core';
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from '@tiptap/pm/state';
export type IndentOptions = {
types: string[];
min: number;
max: number;
};
declare module '@tiptap/core' {
interface Commands<ReturnType> {
indent: {
indent: () => ReturnType;
outdent: () => ReturnType;
};
}
}
// Containers whose descendants must never carry an `indent` attribute. These
// nodes own their own Tab semantics (list nesting, cell navigation, literal
// tab) and visually conflict with our indent padding, so paragraphs and
// headings inside them stay flat
const NON_INDENTABLE_ANCESTORS = new Set([
'listItem',
'taskItem',
'tableCell',
'tableHeader',
'codeBlock',
]);
const clampIndent = (value: number, min: number, max: number): number => {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, Math.trunc(value)));
};
const hasNonIndentableAncestor = (
doc: EditorState['doc'],
pos: number,
): boolean => {
const $pos = doc.resolve(pos);
for (let depth = $pos.depth; depth >= 0; depth--) {
if (NON_INDENTABLE_ANCESTORS.has($pos.node(depth).type.name)) {
return true;
}
}
return false;
};
export const Indent = Extension.create<IndentOptions>({
name: 'indent',
priority: 1000,
addOptions() {
return {
types: ['paragraph', 'heading'],
min: 0,
max: 8,
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
indent: {
default: this.options.min,
keepOnSplit: true,
parseHTML: (element) => {
const raw = element.getAttribute('data-indent');
if (raw === null) return this.options.min;
return clampIndent(
parseInt(raw, 10),
this.options.min,
this.options.max,
);
},
renderHTML: (attributes) => {
const value = attributes.indent;
if (value <= this.options.min) return {};
return { 'data-indent': String(value) };
},
},
},
},
];
},
addCommands() {
return {
indent:
() =>
({ state, tr, dispatch }) => {
return updateIndent(state, tr, dispatch, this.options, +1);
},
outdent:
() =>
({ state, tr, dispatch }) => {
return updateIndent(state, tr, dispatch, this.options, -1);
},
};
},
addKeyboardShortcuts() {
const isInIndentableBlock = (): boolean => {
const { $from } = this.editor.state.selection;
if (!this.options.types.includes($from.parent.type.name)) return false;
for (let depth = $from.depth - 1; depth >= 0; depth--) {
if (NON_INDENTABLE_ANCESTORS.has($from.node(depth).type.name)) {
return false;
}
}
return true;
};
return {
// Return the command's result so Tab falls through to the browser
// (moving focus out of the editor) once the user has reached max
// indent. Without this Tab stays trapped at max depth, failing
// WCAG 2.1.2.
Tab: () => {
if (!isInIndentableBlock()) return false;
return this.editor.commands.indent();
},
'Shift-Tab': () => {
if (!isInIndentableBlock()) return false;
return this.editor.commands.outdent();
},
Backspace: () => {
const { $from, empty } = this.editor.state.selection;
if (!empty) return false;
if ($from.parentOffset !== 0) return false;
if (!isInIndentableBlock()) return false;
if ($from.parent.attrs.indent <= this.options.min) return false;
this.editor.commands.outdent();
return true;
},
};
},
addProseMirrorPlugins() {
const types = new Set(this.options.types);
const min = this.options.min;
return [
new Plugin({
key: new PluginKey('indentNormalizer'),
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((tr) => tr.docChanged)) return null;
const tr = newState.tr;
let modified = false;
newState.doc.descendants((node, pos) => {
// Containers: descend so we can find paragraph/heading children.
if (!types.has(node.type.name)) return true;
if (node.attrs.indent <= min) return false;
if (hasNonIndentableAncestor(newState.doc, pos)) {
tr.setNodeMarkup(
pos,
undefined,
{ ...node.attrs, indent: min },
node.marks,
);
modified = true;
}
// paragraph/heading don't contain other paragraphs/headings —
// never descend into their inline content.
return false;
});
if (!modified) return null;
// Normalisation must not show up as a separate undo step;
// otherwise undo would re-introduce the illegal indent.
return tr.setMeta('addToHistory', false);
},
}),
];
},
});
function updateIndent(
state: EditorState,
tr: Transaction,
dispatch: ((tr: Transaction) => void) | undefined,
options: IndentOptions,
delta: number,
): boolean {
const { selection } = state;
const { from, to } = selection;
const types = new Set(options.types);
let updated = false;
state.doc.nodesBetween(from, to, (node, pos) => {
// Skip non-block nodes (text, inline atoms) up front.
if (!node.type.isBlock) return false;
// Don't descend into containers whose children must stay flat — handles
// multi-block selections that span across e.g. a list-item or table-cell.
if (NON_INDENTABLE_ANCESTORS.has(node.type.name)) return false;
if (!types.has(node.type.name)) return true;
const current = node.attrs.indent as number;
const next = clampIndent(current + delta, options.min, options.max);
if (next === current) return false;
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next });
updated = true;
return false;
});
if (!updated) return false;
if (dispatch) dispatch(tr);
return true;
}

View File

@@ -0,0 +1,96 @@
import TiptapLink from '@tiptap/extension-link';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
export const LinkExtension = TiptapLink.extend({
inclusive: false,
addAttributes() {
return {
...this.parent?.(),
internal: {
default: false,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-internal') === 'true',
renderHTML: (attributes) =>
attributes.internal ? { 'data-internal': 'true' } : {},
},
};
},
addProseMirrorPlugins() {
const { editor } = this;
return [
...(this.parent?.() || []),
new Plugin({
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
const { selection } = editor.state;
if (event.key === 'Escape' && selection.empty !== true) {
editor.commands.focus(selection.to, { scrollIntoView: false });
}
return false;
},
},
}),
// Fix for Firefox: when the cursor is at a boundary of a link,
// Firefox's contenteditable inserts new text *inside* the <a> element.
// ProseMirror then rejects the mutation because inclusive is false,
// causing keystrokes to be silently swallowed. Firefox also does not
// fire handleTextInput in this state, so we intercept at handleKeyDown.
// This handles both:
// - right boundary: cursor just after a link (typing appends to link)
// - left boundary: cursor just before a link, e.g. at the start of a
// line (#1748), where Firefox places new text inside the link node
new Plugin({
key: new PluginKey('linkBoundaryInput'),
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
// Only handle single printable characters
if (event.key.length !== 1) return false;
// Don't handle modified keys (shortcuts) or composing (IME)
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.isComposing
)
return false;
const { state } = view;
const linkType = state.schema.marks.link;
if (!linkType) return false;
// Don't interfere if the user has explicitly set storedMarks
if (state.storedMarks !== null) return false;
const { from, to } = state.selection;
const $from = state.doc.resolve(from);
const nodeBefore = $from.nodeBefore;
const nodeAfter = $from.nodeAfter;
const linkBefore = nodeBefore && linkType.isInSet(nodeBefore.marks);
const linkAfter = nodeAfter && linkType.isInSet(nodeAfter.marks);
// If both sides have link marks we're in the middle — don't interfere
if (linkBefore && linkAfter) return false;
// Not at any link boundary — nothing to do
if (!linkBefore && !linkAfter) return false;
// We're at a link boundary (left or right).
// Prevent native input and insert text without the link mark.
event.preventDefault();
const tr = state.tr.insertText(event.key, from, to);
tr.removeMark(from, from + event.key.length, linkType);
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
});

View File

@@ -0,0 +1,2 @@
export * from "./utils/marked.utils";
export * from "./utils/turndown.utils";

View File

@@ -0,0 +1,29 @@
/**
* Flexible `basename` implementation for node and the browser
* @see https://stackoverflow.com/a/59907288/2228771
*/
export function getBasename(path: string) {
// make sure the basename is not empty, if string ends with separator
let end = path.length - 1;
while (path[end] === '/' || path[end] === '\\') {
--end;
}
// support mixing of Win + Unix path separators
const i1 = path.lastIndexOf('/', end);
const i2 = path.lastIndexOf('\\', end);
let start: number;
if (i1 === -1) {
if (i2 === -1) {
// no separator in the whole thing
return path;
}
start = i2;
} else if (i2 === -1) {
start = i1;
} else {
start = Math.max(i1, i2);
}
return path.substring(start + 1, end + 1);
}

View File

@@ -0,0 +1,41 @@
import { Token, marked } from 'marked';
interface CalloutToken {
type: 'callout';
calloutType: string;
text: string;
raw: string;
}
export const calloutExtension = {
name: 'callout',
level: 'block',
start(src: string) {
return src.match(/:::/)?.index ?? -1;
},
tokenizer(src: string): CalloutToken | undefined {
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
const match = rule.exec(src);
const validCalloutTypes = ['info', 'success', 'warning', 'danger'];
if (match) {
let type = match[1];
if (!validCalloutTypes.includes(type)) {
type = 'info';
}
return {
type: 'callout',
calloutType: type,
raw: match[0],
text: match[2].trim(),
};
}
},
renderer(token: Token) {
const calloutToken = token as CalloutToken;
const body = marked.parse(calloutToken.text);
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
},
};

View File

@@ -0,0 +1,110 @@
import { Token, marked } from 'marked';
import { generateNodeId } from '../../utils';
interface FootnoteRefToken {
type: 'footnoteRef';
label: string;
raw: string;
}
interface FootnoteDefToken {
type: 'footnoteDef';
label: string;
text: string;
raw: string;
}
// Parse-scoped state: markdownToHtml resets before the top-level parse and
// appends the collected list after it. Nested marked.parse calls (callout,
// footnote definitions) share this state, so hooks cannot be used here.
let footnoteRefs: { label: string; id: string; number: number }[] = [];
let footnoteDefs = new Map<string, string>();
export function resetFootnotes() {
footnoteRefs = [];
footnoteDefs = new Map();
}
export function renderFootnotesList(): string {
if (!footnoteRefs.length) return '';
const items = footnoteRefs.map(({ label, id, number }) => {
const body = footnoteDefs.get(label) || '<p></p>';
return `<li id="fn:${number}" data-id="${id}">${body}</li>`;
});
return `<ol class="footnotes">\n${items.join('\n')}\n</ol>\n`;
}
export const footnoteRefExtension = {
name: 'footnoteRef',
level: 'inline',
start(src: string) {
return src.indexOf('[^');
},
tokenizer(src: string): FootnoteRefToken | undefined {
const match = /^\[\^([^\]\s]+)\]/.exec(src);
if (match) {
return {
type: 'footnoteRef',
raw: match[0],
label: match[1].toLowerCase(),
};
}
},
renderer(token: Token) {
const refToken = token as FootnoteRefToken;
const number = footnoteRefs.length + 1;
const id = generateNodeId();
footnoteRefs.push({ label: refToken.label, id, number });
return `<sup id="fnref:${number}"><a class="footnote-ref" data-id="${id}" data-reference-number="${number}" href="#fn:${number}">${number}</a></sup>`;
},
};
export const footnoteDefExtension = {
name: 'footnoteDef',
level: 'block',
start(src: string) {
return src.match(/^\[\^[^\]\s]+\]:/m)?.index ?? -1;
},
tokenizer(src: string): FootnoteDefToken | undefined {
const firstLine = /^\[\^([^\]\s]+)\]:[ \t]*/.exec(src);
if (!firstLine) return undefined;
const lines = src.split('\n');
const contentLines = [lines[0].slice(firstLine[0].length)];
let consumed = 1;
while (consumed < lines.length) {
const line = lines[consumed];
if (/^[ \t]{2,}\S/.test(line)) {
contentLines.push(line.replace(/^[ \t]{1,4}/, ''));
consumed += 1;
} else if (
/^[ \t]*$/.test(line) &&
consumed + 1 < lines.length &&
/^[ \t]{2,}\S/.test(lines[consumed + 1])
) {
contentLines.push('');
consumed += 1;
} else {
break;
}
}
const raw =
lines.slice(0, consumed).join('\n') +
(consumed < lines.length ? '\n' : '');
return {
type: 'footnoteDef',
raw,
label: firstLine[1].toLowerCase(),
text: contentLines.join('\n').trim(),
};
},
renderer(token: Token) {
const defToken = token as FootnoteDefToken;
const body = defToken.text
? marked.parse(defToken.text).toString()
: '<p></p>';
footnoteDefs.set(defToken.label, body);
return '';
},
};

View File

@@ -0,0 +1,71 @@
import { marked } from "marked";
import { calloutExtension } from "./callout.marked";
import { mathBlockExtension } from "./math-block.marked";
import { mathInlineExtension } from "./math-inline.marked";
import {
footnoteDefExtension,
footnoteRefExtension,
renderFootnotesList,
resetFootnotes,
} from "./footnotes.marked";
marked.use({
renderer: {
list({ ordered, start, items }) {
let body = "";
for (const item of items) {
body += this.listitem(item);
}
if (ordered) {
const startAttr = start !== 1 ? ` start="${start}"` : "";
return `<ol${startAttr}>\n${body}</ol>\n`;
}
const isTaskList = items.some((item) => item.task);
const dataType = isTaskList ? ' data-type="taskList"' : "";
return `<ul${dataType}>\n${body}</ul>\n`;
},
listitem({ tokens, task: isTask, checked: isChecked }) {
const text = this.parser.parse(tokens);
if (!isTask) {
return `<li>${text}</li>\n`;
}
const checkedAttr = isChecked
? 'data-checked="true"'
: 'data-checked="false"';
return `<li data-type="taskItem" ${checkedAttr}>${text}</li>\n`;
},
},
});
marked.use({
extensions: [
calloutExtension,
mathBlockExtension,
mathInlineExtension,
footnoteDefExtension,
footnoteRefExtension,
],
});
marked.setOptions({ breaks: true });
export function markdownToHtml(
markdownInput: string,
): string | Promise<string> {
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
const markdown = markdownInput
.replace(YAML_FONT_MATTER_REGEX, "")
.trimStart();
resetFootnotes();
// marked always closes fenced code with a newline that the editor keeps as
// an empty trailing line inside the code block.
const html = marked
.parse(markdown)
.toString()
.replace(/\n<\/code><\/pre>/g, "</code></pre>");
return html + renderFootnotesList();
}

View File

@@ -0,0 +1,37 @@
import { Token, marked } from 'marked';
interface MathBlockToken {
type: 'mathBlock';
text: string;
raw: string;
}
export const mathBlockExtension = {
name: 'mathBlock',
level: 'block',
start(src: string) {
return src.match(/\$\$/)?.index ?? -1;
},
tokenizer(src: string): MathBlockToken | undefined {
const rule = /^\$\$(?!(\$))([\s\S]+?)\$\$/;
const match = rule.exec(src);
if (match) {
return {
type: 'mathBlock',
raw: match[0],
text: match[2]?.trim(),
};
}
},
renderer(token: Token) {
const mathBlockToken = token as MathBlockToken;
// parse to prevent escaping slashes
const latex = marked
.parse(mathBlockToken.text)
.toString()
.replace(/<(\/)?p>/g, '');
return `<div data-type="${mathBlockToken.type}" data-katex="true">${latex}</div>`;
},
};

View File

@@ -0,0 +1,55 @@
import { Token, marked } from 'marked';
interface MathInlineToken {
type: 'mathInline';
text: string;
raw: string;
}
const inlineMathRegex = /^\$(?!\s)(.+?)(?<!\s)\$(?!\d)/;
export const mathInlineExtension = {
name: 'mathInline',
level: 'inline',
start(src: string) {
let index: number;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('$');
if (index === -1) {
return;
}
const f = index === 0 || indexSrc.charAt(index - 1) === ' ';
if (f) {
const possibleKatex = indexSrc.substring(index);
if (possibleKatex.match(inlineMathRegex)) {
return index;
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
}
},
tokenizer(src: string): MathInlineToken | undefined {
const match = inlineMathRegex.exec(src);
if (match) {
return {
type: 'mathInline',
raw: match[0],
text: match[1]?.trim(),
};
}
},
renderer(token: Token) {
const mathInlineToken = token as MathInlineToken;
// parse to prevent escaping slashes
const latex = marked
.parse(mathInlineToken.text)
.toString()
.replace(/<(\/)?p>/g, '');
return `<span data-type="${mathInlineToken.type}" data-katex="true">${latex}</span>`;
},
};

View File

@@ -0,0 +1,12 @@
// Map @joplin/turndown types to @types/turndown
declare module "@joplin/turndown" {
import TurndownService from "turndown";
export = TurndownService;
}
declare module "@joplin/turndown-plugin-gfm" {
import TurndownService from "turndown";
export const tables: TurndownService.Plugin;
export const strikethrough: TurndownService.Plugin;
export const highlightedCodeBlock: TurndownService.Plugin;
}

View File

@@ -0,0 +1,272 @@
import * as _TurndownService from '@joplin/turndown';
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
import { getBasename } from './basename';
// CJS/ESM interop: .default exists in Vite, not in NestJS
const TurndownService = (_TurndownService as any).default || _TurndownService;
function sanitizeMdLinkText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/([\[\]!])/g, '\\$1')
.replace(/[\r\n]+/g, ' ');
}
export function htmlToMarkdown(html: string): string {
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
hr: '---',
bulletListMarker: '-',
});
turndownService.use([
TurndownPluginGfm.tables,
TurndownPluginGfm.strikethrough,
TurndownPluginGfm.highlightedCodeBlock,
taskList,
callout,
preserveDetail,
listParagraph,
orderedListItem,
mathInline,
mathBlock,
iframeEmbed,
image,
video,
footnoteRef,
footnotesList,
]);
return turndownService.turndown(html).replaceAll('<br>', ' ');
}
function listParagraph(turndownService: _TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
replacement: (content: string, node: HTMLInputElement) => {
if (node.parentElement?.nodeName === 'LI') {
return content;
}
return `\n\n${content}\n\n`;
},
});
}
function orderedListItem(turndownService: _TurndownService) {
turndownService.addRule('orderedListItem', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
},
replacement: (content: string, node: HTMLInputElement, options: any) => {
const parent = node.parentNode as HTMLElement;
if (parent.nodeName !== 'OL' && parent.nodeName !== 'UL') {
return content;
}
content = content
.replace(/^\n+/, '')
.replace(/\n+$/, '\n')
.replace(/\n/gm, '\n ');
let prefix: string;
if (parent.nodeName === 'OL') {
const start = parseInt(parent.getAttribute('start') || '1', 10);
const index = Array.prototype.indexOf.call(parent.children, node);
prefix = `${start + index}. `;
} else {
prefix = `${options.bulletListMarker} `;
}
return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
);
},
});
}
function callout(turndownService: _TurndownService) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
);
},
replacement: function (content: string, node: HTMLInputElement) {
const calloutType = node.getAttribute('data-callout-type');
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
},
});
}
function taskList(turndownService: _TurndownService) {
turndownService.addRule('taskListItem', {
filter: function (node: HTMLInputElement) {
return (
node.getAttribute('data-type') === 'taskItem' &&
node.parentNode.nodeName === 'UL'
);
},
replacement: function (_content: string, node: HTMLInputElement) {
const isChecked = node.getAttribute('data-checked') === 'true';
const div = node.querySelector('div');
const text = div ? div.textContent.trim() : node.textContent.trim();
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return (
prefix +
text +
(node.nextSibling && !/\n$/.test(text) ? '\n' : '')
);
},
});
}
function preserveDetail(turndownService: _TurndownService) {
turndownService.addRule('preserveDetail', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'DETAILS';
},
replacement: function (_content: string, node: HTMLInputElement) {
const summary = node.querySelector(':scope > summary');
let detailSummary = '';
if (summary) {
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
}
const detailsContent = Array.from(node.childNodes)
.filter((child) => child.nodeName !== 'SUMMARY')
.map((child) =>
child.nodeType === 1
? turndownService.turndown((child as HTMLElement).outerHTML)
: child.textContent,
)
.join('');
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
},
});
}
function mathInline(turndownService: _TurndownService) {
turndownService.addRule('mathInline', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'SPAN' &&
node.getAttribute('data-type') === 'mathInline'
);
},
replacement: function (content: string) {
return `$${content}$`;
},
});
}
function mathBlock(turndownService: _TurndownService) {
turndownService.addRule('mathBlock', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' &&
node.getAttribute('data-type') === 'mathBlock'
);
},
replacement: function (content: string) {
return `\n$$\n${content}\n$$\n`;
},
});
}
function iframeEmbed(turndownService: _TurndownService) {
turndownService.addRule('iframeEmbed', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'IFRAME';
},
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src');
return '[' + src + '](' + src + ')';
},
});
}
function image(turndownService: _TurndownService) {
turndownService.addRule('image', {
filter: 'img',
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
if (!src) return '';
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
const title = node.getAttribute('title') || '';
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';
return '![' + alt + '](' + src + titlePart + ')';
},
});
}
function getFootnoteAnchor(node: HTMLElement): HTMLElement | null {
const child = node.firstElementChild as HTMLElement | null;
return child?.nodeName === 'A' && child.classList.contains('footnote-ref')
? child
: null;
}
function footnoteRef(turndownService: _TurndownService) {
turndownService.addRule('footnoteRef', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'SUP' && !!getFootnoteAnchor(node);
},
replacement: function (_content: string, node: HTMLInputElement) {
const anchor = getFootnoteAnchor(node);
const number =
anchor.getAttribute('data-reference-number') || anchor.textContent;
return `[^${number}]`;
},
});
}
function footnotesList(turndownService: _TurndownService) {
turndownService.addRule('footnotesList', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'OL' && node.classList.contains('footnotes');
},
replacement: function (_content: string, node: HTMLInputElement) {
const items = Array.from(node.children).filter(
(child) => child.nodeName === 'LI',
);
const definitions = items.map((li, index) => {
const number =
(li.getAttribute('id') || '').replace('fn:', '') ||
String(index + 1);
const markdown = turndownService
.turndown((li as HTMLElement).innerHTML)
.trim();
// continuation lines need a 4-space indent to stay in the footnote
const [first, ...rest] = markdown.split('\n');
const body = [
first,
...rest.map((line: string) => (line.trim() ? ` ${line}` : line)),
].join('\n');
return `[^${number}]: ${body}`;
});
return `\n\n${definitions.join('\n')}\n\n`;
},
});
}
function video(turndownService: _TurndownService) {
turndownService.addRule('video', {
filter: function (node: HTMLInputElement) {
return node.tagName === 'VIDEO';
},
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
const ariaLabel = node.getAttribute('aria-label');
const name = sanitizeMdLinkText(
ariaLabel || getBasename(src) || src,
);
return '[' + name + '](' + src + ')';
},
});
}

View File

@@ -0,0 +1,2 @@
export { MathInline } from "./math-inline";
export { MathBlock } from "./math-block";

View File

@@ -0,0 +1,97 @@
import { Node, nodeInputRule } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
mathBlock: {
setMathBlock: () => ReturnType;
};
}
}
export interface MathBlockOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface MathBlockAttributes {
text: string;
}
export const inputRegex = /(?:^|\s)((?:\$\$\$)((?:[^$]+))(?:\$\$\$))$/;
export const MathBlock = Node.create({
name: "mathBlock",
group: "block",
atom: true,
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return {
text: {
default: "",
parseHTML: (element) => {
return element.innerHTML;
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
getAttrs: (node: HTMLElement) => {
return node.hasAttribute("data-katex") ? {} : false;
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
{ "data-type": this.name, "data-katex": true },
`${HTMLAttributes.text}`,
];
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addCommands() {
return {
setMathBlock:
(attributes?: Record<string, any>) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
};
},
addInputRules() {
return [
nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes: (match) => ({
text: match[1].replaceAll("$", ""),
}),
}),
];
},
});

View File

@@ -0,0 +1,97 @@
import { Node, nodeInputRule } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
mathInline: {
setMathInline: () => ReturnType;
};
}
}
export interface MathInlineOption {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface MathInlineAttributes {
text: string;
}
export const inputRegex = /(?:^|\s)((?:\$\$)((?:[^$]+))(?:\$\$))$/;
export const MathInline = Node.create<MathInlineOption>({
name: "mathInline",
group: "inline",
inline: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return {
text: {
default: "",
parseHTML: (element) => {
return element.innerHTML;
},
},
};
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
getAttrs: (node: HTMLElement) => {
return node.hasAttribute("data-katex") ? {} : false;
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
{ "data-type": this.name, "data-katex": true },
`${HTMLAttributes.text}`,
];
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addCommands() {
return {
setMathInline:
(attributes?: Record<string, any>) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
};
},
addInputRules() {
return [
nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes: (match) => ({
text: match[1].replaceAll("$", ""),
}),
}),
];
},
});

View File

@@ -0,0 +1,43 @@
import { Editor } from "@tiptap/core";
export function normalizeFileUrl(src: string): string {
if (src && src.startsWith("/files/")) {
return "/api" + src;
}
return src || "";
}
export function syncAltBadge(wrapper: HTMLElement, alt: unknown): void {
const existing = wrapper.querySelector<HTMLElement>(
":scope > .media-alt-badge",
);
if (typeof alt !== "string" || !alt.trim()) {
existing?.remove();
return;
}
const badge = existing ?? document.createElement("span");
badge.dataset.alt = alt;
if (!existing) {
badge.className = "media-alt-badge";
badge.textContent = "ALT";
badge.setAttribute("aria-hidden", "true");
wrapper.appendChild(badge);
}
}
export type UploadFn = (
file: File,
editor: Editor,
pos: number,
pageId: string,
// only applicable to file attachments
allowMedia?: boolean,
) => void;
export interface MediaUploadOptions {
validateFn?: (file: File, allowMedia?: boolean) => void;
onUpload: (file: File, pageId: string) => Promise<any>;
}

View File

@@ -0,0 +1,354 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
export interface MentionNodeAttrs {
/**
* unique mention node id (uuidv7)
*/
id: string | null;
/**
* The label to be rendered by the editor as the displayed text for this mentioned
* item, if provided.
*/
label?: string | null;
/**
* the entity type - user or page
*/
entityType: "user" | "page";
/**
* the entity id - userId or pageId
*/
entityId?: string | null;
/**
* page slugId
*/
slugId?: string | null;
/**
* the id of the user who initiated the mention
*/
creatorId?: string;
/**
* the anchor hash for page mentions (e.g., "heading-1")
*/
anchorId?: string;
}
export type MentionOptions<
SuggestionItem = any,
Attrs extends Record<string, any> = MentionNodeAttrs,
> = {
/**
* The HTML attributes for a mention node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>;
/**
* A function to render the text of a mention.
* @param props The render props
* @returns The text
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
*/
renderText: (props: {
options: MentionOptions<SuggestionItem, Attrs>;
node: ProseMirrorNode;
}) => string;
/**
* A function to render the HTML of a mention.
* @param props The render props
* @returns The HTML as a ProseMirror DOM Output Spec
* @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
*/
renderHTML: (props: {
options: MentionOptions<SuggestionItem, Attrs>;
node: ProseMirrorNode;
}) => DOMOutputSpec;
/**
* Whether to delete the trigger character with backspace.
* @default false
*/
deleteTriggerWithBackspace: boolean;
/**
* The suggestion options.
* @default {}
* @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
*/
suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, "editor">;
};
/**
* The plugin key for the mention plugin.
* @default 'mention'
*/
export const MentionPluginKey = new PluginKey("mention");
/**
* This extension allows you to insert mentions into the editor.
* @see https://www.tiptap.dev/api/extensions/mention
*/
export const Mention = Node.create<MentionOptions>({
name: "mention",
priority: 101,
addOptions() {
return {
HTMLAttributes: {},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
deleteTriggerWithBackspace: false,
renderHTML({ options, node }) {
const isUserMention = node.attrs.entityType === "user";
return [
"span",
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`${isUserMention ? options.suggestion.char : ""}${node.attrs.label ?? node.attrs.entityId}`,
];
},
suggestion: {
char: "@",
pluginKey: MentionPluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
const overrideSpace = nodeAfter?.text?.startsWith(" ");
if (overrideSpace) {
range.to += 1;
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props,
},
{
type: "text",
text: " ",
},
])
.run();
// get reference to `window` object from editor element, to support cross-frame JS usage
editor.view.dom.ownerDocument.defaultView
?.getSelection()
?.collapseToEnd();
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const type = state.schema.nodes[this.name];
const allow = !!$from.parent.type.contentMatch.matchType(type);
return allow;
},
},
};
},
group: "inline",
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute("data-id"),
renderHTML: (attributes) => {
if (!attributes.id) {
return {};
}
return {
"data-id": attributes.id,
};
},
},
label: {
default: null,
parseHTML: (element) => element.getAttribute("data-label"),
renderHTML: (attributes) => {
if (!attributes.label) {
return {};
}
return {
"data-label": attributes.label,
};
},
},
entityType: {
default: null,
parseHTML: (element) => element.getAttribute("data-entity-type"),
renderHTML: (attributes) => {
if (!attributes.entityType) {
return {};
}
return {
"data-entity-type": attributes.entityType,
};
},
},
entityId: {
default: null,
parseHTML: (element) => element.getAttribute("data-entity-id"),
renderHTML: (attributes) => {
if (!attributes.entityId) {
return {};
}
return {
"data-entity-id": attributes.entityId,
};
},
},
slugId: {
default: null,
parseHTML: (element) => element.getAttribute("data-slug-id"),
renderHTML: (attributes) => {
if (!attributes.slugId) {
return {};
}
return {
"data-slug-id": attributes.slugId,
};
},
},
creatorId: {
default: null,
parseHTML: (element) => element.getAttribute("data-creator-id"),
renderHTML: (attributes) => {
if (!attributes.creatorId) {
return {};
}
return {
"data-creator-id": attributes.creatorId,
};
},
},
anchorId: {
default: null,
parseHTML: (element) => element.getAttribute("data-anchor-id"),
renderHTML: (attributes) => {
if (!attributes.anchorId) {
return {};
}
return {
"data-anchor-id": attributes.anchorId,
};
},
},
};
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
const mergedOptions = { ...this.options };
mergedOptions.HTMLAttributes = mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
);
const html = this.options.renderHTML({
options: mergedOptions,
node,
});
if (typeof html === "string") {
return [
"span",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
html,
];
}
return html;
},
renderText({ node }) {
return this.options.renderText({
options: this.options,
node,
});
},
addKeyboardShortcuts() {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false;
const { selection } = state;
const { empty, anchor } = selection;
if (!empty) {
return false;
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true;
tr.insertText(
this.options.deleteTriggerWithBackspace
? ""
: this.options.suggestion.char || "",
pos,
pos + node.nodeSize,
);
return false;
}
});
return isMention;
}),
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1 @@
export * from "./page-break";

View File

@@ -0,0 +1,60 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface PageBreakOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
pageBreak: {
setPageBreak: () => ReturnType;
};
}
}
export const PageBreak = Node.create<PageBreakOptions>({
name: "pageBreak",
group: "block",
atom: true,
selectable: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name, class: "page-break" },
this.options.HTMLAttributes,
HTMLAttributes,
),
];
},
addCommands() {
return {
setPageBreak:
() =>
({ chain }) =>
chain()
.insertContent({ type: this.name })
.focus()
.run(),
};
},
});

View File

@@ -0,0 +1,2 @@
export { TiptapPdf } from "./pdf";
export * from "./pdf-upload";

View File

@@ -0,0 +1,123 @@
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findPdfNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "pdf" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handlePdfUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const placeholderId = generateNodeId();
let placeholderInserted = false;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.pdf?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithPdf = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null || !attachment) return;
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName,
attachmentId: attachment.id,
size: attachment.fileSize,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithPdf(attachment));
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithPdf(attachment))
.run();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
}
};
export { handlePdfUpload };

View File

@@ -0,0 +1,156 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Node, mergeAttributes } from "@tiptap/core";
import { sanitizeUrl, isInternalFileUrl } from "../utils";
export type PdfOptions = {
view: any;
HTMLAttributes: Record<string, any>;
};
export type PdfAttributes = {
src?: string;
name?: string;
attachmentId?: string;
size?: number;
width?: number;
height?: number;
placeholder?: {
id: string;
name: string;
};
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
pdfBlock: {
setPdf: (attributes: PdfAttributes) => ReturnType;
};
}
}
export const TiptapPdf = Node.create<PdfOptions>({
name: "pdf",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
view: null,
HTMLAttributes: {},
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => {
const src = element.getAttribute("src");
const sanitized = sanitizeUrl(src);
return isInternalFileUrl(sanitized) ? sanitized : "";
},
renderHTML: (attributes) => ({
src: isInternalFileUrl(attributes.src) ? sanitizeUrl(attributes.src) : "",
}),
},
name: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-name"),
renderHTML: (attributes: PdfAttributes) => ({
"data-name": attributes.name,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: PdfAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: PdfAttributes) => ({
"data-size": attributes.size,
}),
},
width: {
default: 800,
parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: PdfAttributes) => ({
width: attributes.width,
}),
},
height: {
default: 600,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: PdfAttributes) => ({
height: attributes.height,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
[
"iframe",
{
src: isInternalFileUrl(HTMLAttributes.src) ? sanitizeUrl(HTMLAttributes.src) : "",
width: HTMLAttributes.width || 800,
height: HTMLAttributes.height || 600,
},
],
];
},
addCommands() {
return {
setPdf:
(attrs: PdfAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "pdf",
attrs,
});
},
};
},
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});

View File

@@ -0,0 +1,167 @@
# `prosemirror-docx`
[![prosemirror-docx on npm](https://img.shields.io/npm/v/prosemirror-docx.svg)](https://www.npmjs.com/package/prosemirror-docx)
[![prosemirror-docx on GitHub](https://img.shields.io/github/stars/curvenote/prosemirror-docx.svg?style=social)](https://github.com/curvenote/prosemirror-docx)
[
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
![CI](https://github.com/curvenote/prosemirror-docx/workflows/CI/badge.svg)
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
![image](https://user-images.githubusercontent.com/913249/134953610-886047eb-2a21-4929-9a53-9a29d8f6184f.png)
## Overview
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but can’t read from `*.docx`), and has most of the basic nodes covered (see below).
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
## Basic usage
```ts
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
import { EditorState } from 'prosemirror-state';
import { writeFileSync } from 'fs'; // Or some other way to write a file
// Set up your prosemirror state/document as you normally do
const state = EditorState.create({ schema: mySchema });
// If there are images, we will need to preload the buffers
const opts = {
getImageBuffer(src: string) {
return anImageBuffer;
},
};
// Create a doc in memory, and then write it to disk
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
await writeDocx(wordDocument).then((buffer) => {
writeFileSync('HelloWorld.docx', buffer);
});
```
### Advanced usage
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
```ts
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
return {
numbering: {
config: state.numbering,
},
fonts: [], // embed fonts,
styles: {
paragraphStyles,
default: {
heading1: paragraphStyles[1],
},
},
};
});
```
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
## Extended usage
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
```ts
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
const nodeSerializer = {
...defaultNodes,
my_paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
};
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
```
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
```ts
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
import { EditorState } from 'prosemirror-state';
import { writeFileSync } from 'fs';
const state = EditorState.create({ schema: mySchema });
export const docxSerializer = new DocxSerializerAsync(
{
...defaultAsyncNodes,
async image(state, node) {
const { src } = node.attrs;
await state.image(src, 70, 'center', undefined, 'png');
state.closeBlock(node);
},
},
defaultMarks,
);
// If there are images, we will need to preload the buffers
const opts = {
async getImageBuffer(src: string) {
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
return new Uint8Array(arrayBuffer);
},
};
// Create a doc in memory, and then write it to disk
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
await writeDocx(wordDocument).then((buffer) => {
writeFileSync('HelloWorld.docx', buffer);
});
```
## Supported Nodes
- text
- paragraph
- heading (levels)
- TODO: Support numbering of headings
- blockquote
- code_block
- TODO: No styles supported
- horizontal_rule
- hard_break
- ordered_list
- unordered_list
- list_item
- image
- math
- equations (numbered & unnumbered)
- tables
Planned:
- Internal References (e.g. see Table 1)
## Supported Marks
- em
- strong
- link
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
- code
- subscript
- superscript
- strikethrough
- underline
- smallcaps
- allcaps
## Resources
- [Prosemirror Docs](https://prosemirror.net/docs/)
- [docx](https://docx.js.org/)
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!

View File

@@ -0,0 +1,24 @@
// MIT - https://github.com/curvenote/prosemirror-docx/
export type { SectionConfig, SerializationState } from './types';
export type {
MarkSerializer,
NodeSerializer,
NodeSerializerAsync,
Options,
OptionsAsync,
} from './serializer';
export {
DocxSerializerStateAsync,
DocxSerializerAsync,
DocxSerializerState,
DocxSerializer,
MAX_IMAGE_WIDTH,
} from './serializer';
export {
defaultAsyncNodes,
defaultMarks,
pageNodeToDocxBuffer,
type DocxImageResolver,
} from './schema';
export { writeDocx, createDocFromState, buildDoc } from './utils';

View File

@@ -0,0 +1,47 @@
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
import { INumbering } from './types';
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
return {
alignment: AlignmentType.START,
style: {
paragraph: {
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
},
},
};
}
const numbered = Array(3)
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
.flat()
.map((format, level) => ({
level,
format,
text: `%${level + 1}.`,
...basicIndentStyle((level + 1) / 2),
}));
const bullets = Array(3)
.fill(['●', '○', '■'])
.flat()
.map((text, level) => ({
level,
format: LevelFormat.BULLET,
text,
...basicIndentStyle((level + 1) / 2),
}));
const styles = {
numbered,
bullets,
};
export type NumberingStyles = keyof typeof styles;
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
return {
reference,
levels: styles[style],
};
}

View File

@@ -0,0 +1,315 @@
import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx';
import { Node } from 'prosemirror-model';
import {
DocxSerializerAsync,
MarkSerializer,
NodeSerializerAsync,
OptionsAsync,
} from './serializer';
import { writeDocx } from './utils';
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
// (named colors, hsl, etc.) so the caller omits the color rather than letting
// docx throw "Invalid hex value".
function toDocxColor(input?: string): string | undefined {
if (!input) return undefined;
const value = input.trim().toLowerCase();
const hex = value.startsWith('#') ? value.slice(1) : value;
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
if (/^[0-9a-f]{3}$/.test(hex)) {
return hex
.split('')
.map((ch) => ch + ch)
.join('');
}
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) {
const channel = (n: string) =>
Math.max(0, Math.min(255, parseInt(n, 10)))
.toString(16)
.padStart(2, '0');
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
}
return undefined;
}
// Images and diagrams embed via the image resolver; the URL (with its file
// extension) is passed through so docx can infer the image type.
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
const src = node.attrs?.src || node.attrs?.attachmentId;
if (src) {
try {
await state.image(src, 100);
} catch {
// Unrenderable/missing image: skip rather than fail the whole export.
}
}
state.closeBlock(node);
};
// Non-embeddable media render as a labelled line.
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
const label =
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
state.text(label);
state.closeBlock(node);
};
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
const label = node.attrs?.src || node.attrs?.url || 'embed';
state.text(label);
state.closeBlock(node);
};
export const defaultAsyncNodes: NodeSerializerAsync = {
text(state, node) {
state.text(node.text ?? '');
},
async paragraph(state, node) {
await state.renderInline(node);
state.closeBlock(node);
},
async heading(state, node) {
await state.renderInline(node);
const heading = [
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
][(node.attrs.level ?? 1) - 1];
state.closeBlock(node, { heading });
},
async blockquote(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
async codeBlock(state, node) {
await state.renderContent(node);
state.closeBlock(node);
},
horizontalRule(state, node) {
state.closeBlock(node, { thematicBreak: true });
state.closeBlock(node);
},
hardBreak(state) {
state.addRunOptions({ break: 1 });
},
async bulletList(state, node) {
await state.renderList(node, 'bullets');
},
async orderedList(state, node) {
await state.renderList(node, 'numbered');
},
async listItem(state, node) {
await state.renderListItem(node);
},
async taskList(state, node) {
await state.renderList(node, 'bullets');
},
async taskItem(state, node) {
if (state.currentNumbering) {
state.addParagraphOptions({ numbering: state.currentNumbering });
}
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
await state.renderContent(node);
},
async table(state, node) {
await state.table(node);
},
// Docmost stores LaTeX in attrs.text.
mathInline(state, node) {
state.math(node.attrs?.text ?? '', { inline: true });
},
mathBlock(state, node) {
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
state.closeBlock(node);
},
image: renderImage,
drawio: renderImage,
excalidraw: renderImage,
video: renderFileLine,
audio: renderFileLine,
pdf: renderFileLine,
attachment: renderFileLine,
embed: renderEmbedLine,
youtube: renderEmbedLine,
async callout(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
async details(state, node) {
await state.renderContent(node);
},
async detailsSummary(state, node) {
await state.renderInline(node);
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
},
async detailsContent(state, node) {
await state.renderContent(node);
},
async columns(state, node) {
await state.renderContent(node);
},
async column(state, node) {
await state.renderContent(node);
},
async transclusionSource(state, node) {
await state.renderContent(node);
},
mention(state, node) {
state.text(`@${node.attrs?.label ?? ''}`);
},
status(state, node) {
state.text(`[${node.attrs?.text ?? ''}]`);
},
pageBreak(state, node) {
state.closeBlock(node, { pageBreakBefore: true });
},
footnoteReference(state, node) {
const number =
Number(node.attrs?.referenceNumber) || state.$footnoteCounter + 1;
state.$footnoteCounter = Math.max(state.$footnoteCounter, number);
// seed an empty body so the reference stays valid even if the trailing
// footnotes list is missing; the footnotes node overwrites it with content
if (!state.footnotes[number]) {
state.footnotes[number] = { children: [new Paragraph('')] };
}
state.current.push(new FootnoteReferenceRun(number));
},
async footnotes(state, node) {
for (let i = 0; i < node.childCount; i += 1) {
const item = node.child(i);
const number =
Number(String(item.attrs?.id ?? '').replace('fn:', '')) || i + 1;
await state.footnoteDefinition(item, number);
}
},
// items are consumed by the footnotes handler above
footnote() {},
// No usable static export representation: skip without failing.
subpages() {},
transclusionReference() {},
base() {},
};
export const defaultMarks: MarkSerializer = {
bold() {
return { bold: true };
},
italic() {
return { italics: true };
},
strike() {
return { strike: true };
},
underline() {
return { underline: {} };
},
code() {
return {
font: { name: 'Monospace' },
color: '000000',
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
};
},
superscript() {
return { superScript: true };
},
subscript() {
return { subScript: true };
},
link() {
// Handled specifically in the serializer; Word treats links as nodes.
return {};
},
highlight(_state, _node, mark) {
const fill = toDocxColor(mark.attrs?.color);
return fill
? { shading: { type: ShadingType.CLEAR, fill } }
: { highlight: 'yellow' };
},
// @tiptap/extension-color stores the color on the textStyle mark.
textStyle(_state, _node, mark) {
const color = toDocxColor(mark.attrs?.color);
return color ? { color } : {};
},
// Comments are editor-only; drop the annotation in the export.
comment() {
return {};
},
};
export async function pageNodeToDocxBuffer(
doc: Node,
getImageBuffer: DocxImageResolver,
): Promise<Buffer> {
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
const wordDoc = await serializer.serializeAsync(
doc,
{ getImageBuffer },
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
// has no heading color, so override the default heading run colors to the
// normal text color. Sizes/italics mirror docx's own defaults so only the
// color changes.
() =>
({
styles: {
default: {
document: { paragraph: { spacing: { after: 160 } } },
heading1: {
run: { color: '000000', size: 32 },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 240, after: 0 },
},
},
heading2: {
run: { color: '000000', size: 26 },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading3: {
run: { color: '000000', size: 24 },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading4: {
run: { color: '000000', italics: true },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading5: {
run: { color: '000000' },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading6: {
run: { color: '000000' },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
},
},
}) as any,
);
return writeDocx(wordDoc);
}

View File

@@ -0,0 +1,948 @@
import { Node, Mark } from 'prosemirror-model';
import {
IParagraphOptions,
IRunOptions,
Paragraph,
TextRun,
ExternalHyperlink,
ParagraphChild,
MathRun,
Math,
TabStopType,
TabStopPosition,
SequentialIdentifier,
Bookmark,
ImageRun,
AlignmentType,
Table,
TableRow,
TableCell,
ITableCellOptions,
InternalHyperlink,
SimpleField,
FootnoteReferenceRun,
IImageOptions,
Document,
ITableOptions,
ITableRowOptions,
IPropertiesOptions,
} from 'docx';
import { imageDimensionsFromData } from 'image-dimensions';
import { createNumbering, NumberingStyles } from './numbering';
import { buildDoc, createShortId } from './utils';
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
// This is duplicated from @curvenote/schema
export type AlignOptions = 'left' | 'center' | 'right';
export type NodeSerializer = Record<
string,
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
>;
export type NodeSerializerAsync = Record<
string,
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
>;
export type MarkSerializer = Record<
string,
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
>;
export type Options = {
getImageBuffer: (src: string) => Uint8Array;
sections?: SectionConfig[];
};
export type OptionsAsync = {
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
sections?: SectionConfig[];
};
export type IMathOpts = {
inline?: boolean;
id?: string | null;
numbered?: boolean;
};
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
export const MAX_IMAGE_WIDTH = 600;
function createReferenceBookmark(
id: string,
kind: 'Equation' | 'Figure' | 'Table',
before?: string,
after?: string,
) {
const textBefore = before ? [new TextRun(before)] : [];
const textAfter = after ? [new TextRun(after)] : [];
return new Bookmark({
id,
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
});
}
export class DocxSerializerState {
nodes: NodeSerializer;
options: Options;
marks: MarkSerializer;
children: (Paragraph | Table)[];
sections: Array<{
config: SectionConfig;
children: (Paragraph | Table)[];
}>;
currentSectionIndex = 0;
numbering: INumbering[];
footnotes: IFootnotes = {};
nextRunOpts?: IRunOptions;
current: ParagraphChild[] = [];
currentLink?: { link: string; children: IRunOptions[] };
// Optionally add options
nextParentParagraphOpts?: IParagraphOptions;
currentNumbering?: { reference: string; level: number };
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? ({} as Options);
this.children = [];
this.numbering = [];
// Initialize sections
if (options.sections && options.sections.length > 0) {
this.sections = options.sections.map((config) => ({
config,
children: [],
}));
this.children = this.sections[0].children;
} else {
this.sections = [];
}
}
renderContent(parent: Node, opts?: IParagraphOptions) {
parent.forEach((node, _, i) => {
if (opts) this.addParagraphOptions(opts);
this.render(node, parent, i);
});
}
render(node: Node, parent: Node, index: number) {
if (typeof parent === 'number') throw new Error('!');
if (!this.nodes[node.type.name])
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
this.nodes[node.type.name](this, node, parent, index);
}
renderMarks(node: Node, marks: Mark[]): IRunOptions {
return marks
.map((mark) => {
return this.marks[mark.type.name]?.(this, node, mark);
})
.reduce((a, b) => ({ ...a, ...b }), {});
}
renderInline(parent: Node) {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
const closeLink = () => {
if (!currentLink) return;
const hyperlink = new ExternalHyperlink({
link: currentLink.link,
// child: this.current[0],
children: this.current,
});
this.current = [...currentLink.stack, hyperlink];
currentLink = undefined;
};
const openLink = (href: string) => {
const sameLink = href === currentLink?.link;
this.addRunOptions({ style: 'Hyperlink' });
// TODO: https://github.com/dolanmiu/docx/issues/1119
// Remove the if statement here and oneLink!
const oneLink = true;
if (!oneLink) {
closeLink();
} else {
if (currentLink && sameLink) return;
if (currentLink && !sameLink) {
// Close previous, and open a new one
closeLink();
}
}
currentLink = {
link: href,
stack: this.current,
};
this.current = [];
};
const progress = (node: Node, offset: number, index: number) => {
const links = node.marks.filter((m) => m.type.name === 'link');
const hasLink = links.length > 0;
if (hasLink) {
openLink(links[0].attrs.href);
} else if (!hasLink && currentLink) {
closeLink();
}
if (node.isText) {
this.text(node.text, this.renderMarks(node, [...node.marks]));
} else {
this.render(node, parent, index);
}
};
parent.forEach(progress);
// Must call close at the end of everything, just in case
closeLink();
}
renderList(node: Node, style: NumberingStyles) {
if (!this.currentNumbering) {
const nextId = createShortId();
this.numbering.push(createNumbering(nextId, style));
this.currentNumbering = { reference: nextId, level: 0 };
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level + 1 };
}
this.renderContent(node);
if (this.currentNumbering.level === 0) {
delete this.currentNumbering;
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level - 1 };
}
}
// This is a pass through to the paragraphs, etc. underneath they will close the block
renderListItem(node: Node) {
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
this.addParagraphOptions({ numbering: this.currentNumbering });
this.renderContent(node);
}
addParagraphOptions(opts: IParagraphOptions) {
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
}
addRunOptions(opts: IRunOptions) {
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
}
text(text: string | null | undefined, opts?: IRunOptions) {
if (!text) return;
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
delete this.nextRunOpts;
}
math(latex: string, opts: IMathOpts = { inline: true }) {
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = opts.id ?? createShortId();
this.current = [
new TextRun('\t'),
new Math({
children: [new MathRun(latex)],
}),
new TextRun('\t('),
createReferenceBookmark(id, 'Equation'),
new TextRun(')'),
];
this.addParagraphOptions({
tabStops: [
{
type: TabStopType.CENTER,
position: TabStopPosition.MAX / 2,
},
{
type: TabStopType.RIGHT,
position: TabStopPosition.MAX,
},
],
});
}
// not sure what this actually is, seems to be close for 8.5x11
maxImageWidth = MAX_IMAGE_WIDTH;
image(
src: string,
widthPercent = 70,
align: AlignOptions = 'center',
imageRunOpts?: IImageOptions,
imageType?: ImageType,
) {
const buffer = this.options.getImageBuffer(src);
const dimensions = imageDimensionsFromData(buffer);
/* If the image is not a valid image, don't add it */
if (!dimensions) return;
const aspect = dimensions.height / dimensions.width;
const width = this.maxImageWidth * (widthPercent / 100);
let it;
try {
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
} catch (e) {
it = 'png';
}
this.current.push(
new ImageRun({
data: buffer,
...imageRunOpts,
type: it,
transformation: {
...(imageRunOpts?.transformation || {}),
width,
height: width * aspect,
},
}),
);
let alignment: string;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment as any,
});
}
table(
node: Node,
opts: {
getCellOptions?: (cell: Node) => ITableCellOptions;
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
tableOptions?: Omit<ITableOptions, 'rows'>;
} = {},
) {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
node.content.forEach((row) => {
const cells: TableCell[] = [];
// Check if all cells are headers in this row
let tableHeader = true;
row.content.forEach((cell) => {
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
});
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
row.content.forEach((cell) => {
this.children = [];
this.renderContent(cell);
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
cells.push(
new TableCell({
...tableCellOpts,
...(getCellOptions?.(cell) || {}),
}),
);
});
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
});
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
actualChildren.push(table);
// If there are multiple tables, this seperates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
}
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
$footnoteCounter = 0;
footnote(node: Node) {
const { current, nextRunOpts } = this;
// Delete everything and work with the footnote inline on the current
this.current = [];
delete this.nextRunOpts;
this.$footnoteCounter += 1;
this.renderInline(node);
this.footnotes[this.$footnoteCounter] = {
children: [new Paragraph({ children: this.current })],
};
this.current = current;
this.nextRunOpts = nextRunOpts;
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
}
closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({
children: this.current,
...this.nextParentParagraphOpts,
...props,
});
this.current = [];
delete this.nextParentParagraphOpts;
this.children.push(paragraph);
}
/**
* Move to the next section. If no more sections are available,
* this will be ignored (content continues in current section).
*/
nextSection() {
if (this.currentSectionIndex < this.sections.length - 1) {
this.currentSectionIndex += 1;
this.children = this.sections[this.currentSectionIndex].children;
}
}
/**
* Update the current section's configuration
*/
setSectionConfig(config: Partial<SectionConfig>) {
this.sections[this.currentSectionIndex].config = {
...this.sections[this.currentSectionIndex].config,
...config,
};
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config: SectionConfig = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex(): number {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig(): SectionConfig {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState(): SerializationState {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id: string, before?: string, after?: string) {
const children: ParagraphChild[] = [];
if (before) children.push(new TextRun(before));
children.push(new SimpleField(`REF ${id} \\h`));
if (after) children.push(new TextRun(after));
const ref = new InternalHyperlink({ anchor: id, children });
this.current.push(ref);
}
}
export class DocxSerializer {
nodes: NodeSerializer;
marks: MarkSerializer;
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
this.nodes = nodes;
this.marks = marks;
}
serialize(
content: Node,
options: Options,
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
): Document {
const state = new DocxSerializerState(this.nodes, this.marks, options);
state.renderContent(content);
return buildDoc(state, getDocumentOptions?.(state));
}
}
export class DocxSerializerStateAsync {
nodes: NodeSerializerAsync;
options: OptionsAsync;
marks: MarkSerializer;
children: (Paragraph | Table)[];
sections: Array<{
config: SectionConfig;
children: (Paragraph | Table)[];
}>;
currentSectionIndex = 0;
numbering: INumbering[];
footnotes: IFootnotes = {};
nextRunOpts?: IRunOptions;
current: ParagraphChild[] = [];
currentLink?: { link: string; children: IRunOptions[] };
// Optionally add options
nextParentParagraphOpts?: IParagraphOptions;
currentNumbering?: { reference: string; level: number };
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? ({} as OptionsAsync);
this.children = [];
this.numbering = [];
// Initialize sections
if (options.sections && options.sections.length > 0) {
this.sections = options.sections.map((config) => ({
config,
children: [],
}));
this.children = this.sections[0].children;
} else {
this.sections = [];
}
}
async renderContent(parent: Node, opts?: IParagraphOptions) {
for (let i = 0; i < parent.childCount; i += 1) {
const node = parent.child(i);
if (opts) this.addParagraphOptions(opts);
// eslint-disable-next-line no-await-in-loop
await this.render(node, parent, i);
}
}
async render(node: Node, parent: Node, index: number) {
if (typeof parent === 'number') throw new Error('!');
if (!this.nodes[node.type.name])
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
}
renderMarks(node: Node, marks: Mark[]): IRunOptions {
return marks
.map((mark) => {
return this.marks[mark.type.name]?.(this, node, mark);
})
.reduce((a, b) => ({ ...a, ...b }), {});
}
async renderInline(parent: Node) {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
const closeLink = () => {
if (!currentLink) return;
const hyperlink = new ExternalHyperlink({
link: currentLink.link,
// child: this.current[0],
children: this.current,
});
this.current = [...currentLink.stack, hyperlink];
currentLink = undefined;
};
const openLink = (href: string) => {
const sameLink = href === currentLink?.link;
this.addRunOptions({ style: 'Hyperlink' });
// TODO: https://github.com/dolanmiu/docx/issues/1119
// Remove the if statement here and oneLink!
const oneLink = true;
if (!oneLink) {
closeLink();
} else {
if (currentLink && sameLink) return;
if (currentLink && !sameLink) {
// Close previous, and open a new one
closeLink();
}
}
currentLink = {
link: href,
stack: this.current,
};
this.current = [];
};
const progress = async (node: Node, offset: number, index: number) => {
const links = node.marks.filter((m) => m.type.name === 'link');
const hasLink = links.length > 0;
if (hasLink) {
openLink(links[0].attrs.href);
} else if (!hasLink && currentLink) {
closeLink();
}
if (node.isText) {
this.text(node.text, this.renderMarks(node, [...node.marks]));
} else {
await this.render(node, parent, index);
}
};
// Process nodes sequentially to maintain order
for (let i = 0; i < parent.childCount; i += 1) {
// eslint-disable-next-line no-await-in-loop
await progress(parent.child(i), 0, i);
}
// Must call close at the end of everything, just in case
closeLink();
}
async renderList(node: Node, style: NumberingStyles) {
if (!this.currentNumbering) {
const nextId = createShortId();
this.numbering.push(createNumbering(nextId, style));
this.currentNumbering = { reference: nextId, level: 0 };
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level + 1 };
}
await this.renderContent(node);
if (this.currentNumbering.level === 0) {
delete this.currentNumbering;
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level - 1 };
}
}
// This is a pass through to the paragraphs, etc. underneath they will close the block
async renderListItem(node: Node) {
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
this.addParagraphOptions({ numbering: this.currentNumbering });
await this.renderContent(node);
}
addParagraphOptions(opts: IParagraphOptions) {
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
}
addRunOptions(opts: IRunOptions) {
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
}
text(text: string | null | undefined, opts?: IRunOptions) {
if (!text) return;
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
delete this.nextRunOpts;
}
math(latex: string, opts: IMathOpts = { inline: true }) {
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = opts.id ?? createShortId();
this.current = [
new TextRun('\t'),
new Math({
children: [new MathRun(latex)],
}),
new TextRun('\t('),
createReferenceBookmark(id, 'Equation'),
new TextRun(')'),
];
this.addParagraphOptions({
tabStops: [
{
type: TabStopType.CENTER,
position: TabStopPosition.MAX / 2,
},
{
type: TabStopType.RIGHT,
position: TabStopPosition.MAX,
},
],
});
}
// not sure what this actually is, seems to be close for 8.5x11
maxImageWidth = MAX_IMAGE_WIDTH;
async image(
src: string,
widthPercent = 70,
align: AlignOptions = 'center',
imageRunOpts?: IImageOptions,
imageType?: ImageType,
) {
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
const dimensions = imageDimensionsFromData(buffer);
/* If the image is not a valid image, don't add it */
if (!dimensions) return;
const aspect = dimensions.height / dimensions.width;
const width = this.maxImageWidth * (widthPercent / 100);
let it;
try {
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
} catch (e) {
it = 'png';
}
this.current.push(
new ImageRun({
data: buffer,
...imageRunOpts,
type: it,
transformation: {
...(imageRunOpts?.transformation || {}),
width,
height: width * aspect,
},
}),
);
let alignment: string;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment as any,
});
}
async table(
node: Node,
opts: {
getCellOptions?: (cell: Node) => ITableCellOptions;
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
tableOptions?: Omit<ITableOptions, 'rows'>;
} = {},
) {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
const row = node.content.child(rowIndex);
const cells: TableCell[] = [];
// Check if all cells are headers in this row
let tableHeader = true;
// Check if all cells in the row are headers
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
const cell = row.content.child(cellIndex);
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
}
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
// Iterate through cells and ensure order
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
const cell = row.content.child(cellIndex);
this.children = [];
// eslint-disable-next-line no-await-in-loop
await this.renderContent(cell); // Ensure order
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
cells.push(
new TableCell({
...tableCellOpts,
...(getCellOptions?.(cell) || {}),
}),
);
}
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
}
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
actualChildren.push(table);
// If there are multiple tables, this separates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
}
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
$footnoteCounter = 0;
async footnote(node: Node) {
const { current, nextRunOpts } = this;
// Delete everything and work with the footnote inline on the current
this.current = [];
delete this.nextRunOpts;
this.$footnoteCounter += 1;
await this.renderInline(node);
this.footnotes[this.$footnoteCounter] = {
children: [new Paragraph({ children: this.current })],
};
this.current = current;
this.nextRunOpts = nextRunOpts;
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
}
// Fills the footnote body for an already-referenced footnote number from a
// node holding block content (Docmost keeps footnote text in a trailing
// list, separate from the inline reference).
async footnoteDefinition(node: Node, number: number) {
const { current, children, nextRunOpts, nextParentParagraphOpts } = this;
this.current = [];
this.children = [];
delete this.nextRunOpts;
delete this.nextParentParagraphOpts;
await this.renderContent(node);
this.footnotes[number] = {
children: this.children.filter(
(child): child is Paragraph => child instanceof Paragraph,
),
};
this.current = current;
this.children = children;
this.nextRunOpts = nextRunOpts;
this.nextParentParagraphOpts = nextParentParagraphOpts;
}
closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({
children: this.current,
...this.nextParentParagraphOpts,
...props,
});
this.current = [];
delete this.nextParentParagraphOpts;
this.children.push(paragraph);
}
/**
* Move to the next section. If no more sections are available,
* this will be ignored (content continues in current section).
*/
nextSection() {
if (this.currentSectionIndex < this.sections.length - 1) {
this.currentSectionIndex += 1;
this.children = this.sections[this.currentSectionIndex].children;
}
}
/**
* Update the current section's configuration
*/
setSectionConfig(config: Partial<SectionConfig>) {
this.sections[this.currentSectionIndex].config = {
...this.sections[this.currentSectionIndex].config,
...config,
};
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config: SectionConfig = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex(): number {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig(): SectionConfig {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState(): SerializationState {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id: string, before?: string, after?: string) {
const children: ParagraphChild[] = [];
if (before) children.push(new TextRun(before));
children.push(new SimpleField(`REF ${id} \\h`));
if (after) children.push(new TextRun(after));
const ref = new InternalHyperlink({ anchor: id, children });
this.current.push(ref);
}
}
export class DocxSerializerAsync {
nodes: NodeSerializerAsync;
marks: MarkSerializer;
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
this.nodes = nodes;
this.marks = marks;
}
async serializeAsync(
content: Node,
options: OptionsAsync,
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
) {
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
await state.renderContent(content);
return buildDoc(state, getDocumentOptions?.(state));
}
}

View File

@@ -0,0 +1,34 @@
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
export type Mutable<T> = {
-readonly [k in keyof T]: T[k];
};
export type IFootnotes = Mutable<
Readonly<
Record<
string,
{
readonly children: readonly Paragraph[];
}
>
>
>;
export type INumbering = INumberingOptions['config'][0];
export interface SectionConfig {
properties?: ISectionOptions['properties'];
headers?: ISectionOptions['headers'];
footers?: ISectionOptions['footers'];
}
export interface SerializationState {
numbering: INumberingOptions['config'];
sections?: Array<{
config: SectionConfig;
children: ISectionOptions['children'];
}>;
children?: ISectionOptions['children'];
footnotes?: IFootnotes;
}

View File

@@ -0,0 +1,91 @@
import {
Document,
INumberingOptions,
IPropertiesOptions,
ISectionOptions,
Packer,
SectionType,
} from 'docx';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { IFootnotes, SerializationState } from './types';
export function createShortId() {
return Math.random().toString(36).slice(2, 11);
}
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
let sections = state?.sections?.length
? state.sections.map((section) => ({
properties: section.config.properties || {
type: SectionType.CONTINUOUS,
},
headers: section.config.headers,
footers: section.config.footers,
children: section.children,
}))
: undefined;
if (!sections) {
sections = [
{
headers: undefined,
footers: undefined,
properties: {
type: SectionType.CONTINUOUS,
},
children: state?.children || [],
},
];
}
const doc = new Document({
footnotes: state.footnotes,
numbering: {
config: state.numbering,
},
sections,
...(opts || {}),
});
return doc;
}
/**
* @deprecated - use `buildDoc` instead
* Creates a docx document from the given state.
* */
export function createDocFromState(state: {
numbering: INumberingOptions['config'];
children: ISectionOptions['children'];
footnotes?: IFootnotes;
}) {
return buildDoc({
numbering: state.numbering,
sections: [
{
config: {},
children: state.children,
},
],
footnotes: state.footnotes,
});
}
export async function writeDocx(
doc: Document,
/**
* @deprecated use `.then()` or `await` instead
*/
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
) {
const buffer = await Packer.toBuffer(doc);
await write?.(buffer);
return buffer;
}
export function getLatexFromNode(node: ProsemirrorNode): string {
let math = '';
node.forEach((child) => {
if (child.isText) math += child.text;
// TODO: improve this as we may have other things in the future
});
return math;
}

View File

@@ -0,0 +1,3 @@
export function copy<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}

View File

@@ -0,0 +1,17 @@
import { AnyObject } from "./types";
/**
* get target value from json-pointer (e.g. /content/0/content)
* @param {AnyObject} obj object to resolve path into
* @param {string} path json-pointer
* @return {any} target value
*/
export function getFromPath(obj: AnyObject, path: string): any {
const pathParts = path.split("/");
pathParts.shift(); // remove root-entry
while (pathParts.length) {
const property = pathParts.shift();
obj = obj[property];
}
return obj;
}

View File

@@ -0,0 +1,29 @@
import { ReplaceStep } from "@tiptap/pm/transform";
import { Node } from "@tiptap/pm/model";
export function getReplaceStep(fromDoc: Node, toDoc: Node) {
let start = toDoc.content.findDiffStart(fromDoc.content);
if (start === null) {
return false;
}
// @ts-ignore property access to content
let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content);
const overlap = start - Math.min(endA, endB);
if (overlap > 0) {
// If there is an overlap, there is some freedom of choice in how to calculate the
// start/end boundary. for an inserted/removed slice. We choose the extreme with
// the lowest depth value.
if (
fromDoc.resolve(start - overlap).depth <
toDoc.resolve(endA + overlap).depth
) {
start -= overlap;
} else {
endA += overlap;
endB += overlap;
}
}
return new ReplaceStep(start, endB, toDoc.slice(start, endA));
}

View File

@@ -0,0 +1,4 @@
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
export { recreateTransform, RecreateTransform } from "./recreateTransform";
export type { Options } from "./recreateTransform";

Some files were not shown because too many files have changed in this diff Show More