snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)
This commit is contained in:
2
packages/base-formula/.gitignore
vendored
Normal file
2
packages/base-formula/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
tsconfig.tsbuildinfo
|
||||
37
packages/base-formula/LICENSE
Normal file
37
packages/base-formula/LICENSE
Normal 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.
|
||||
329
packages/base-formula/bench/formula-bench.ts
Normal file
329
packages/base-formula/bench/formula-bench.ts
Normal 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();
|
||||
28
packages/base-formula/package.json
Normal file
28
packages/base-formula/package.json
Normal 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": {}
|
||||
}
|
||||
33
packages/base-formula/src/ast.ts
Normal file
33
packages/base-formula/src/ast.ts
Normal 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;
|
||||
41
packages/base-formula/src/error.ts
Normal file
41
packages/base-formula/src/error.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
126
packages/base-formula/src/eval.ts
Normal file
126
packages/base-formula/src/eval.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
packages/base-formula/src/format.ts
Normal file
31
packages/base-formula/src/format.ts
Normal 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(", ")})`;
|
||||
}
|
||||
}
|
||||
17
packages/base-formula/src/functions/coercion.ts
Normal file
17
packages/base-formula/src/functions/coercion.ts
Normal 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",
|
||||
});
|
||||
53
packages/base-formula/src/functions/date.ts
Normal file
53
packages/base-formula/src/functions/date.ts
Normal 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",
|
||||
});
|
||||
7
packages/base-formula/src/functions/index.ts
Normal file
7
packages/base-formula/src/functions/index.ts
Normal 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";
|
||||
11
packages/base-formula/src/functions/logic.ts
Normal file
11
packages/base-formula/src/functions/logic.ts
Normal 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",
|
||||
});
|
||||
160
packages/base-formula/src/functions/math.ts
Normal file
160
packages/base-formula/src/functions/math.ts
Normal 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",
|
||||
});
|
||||
24
packages/base-formula/src/functions/registry.ts
Normal file
24
packages/base-formula/src/functions/registry.ts
Normal 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);
|
||||
}
|
||||
35
packages/base-formula/src/functions/string.ts
Normal file
35
packages/base-formula/src/functions/string.ts
Normal 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",
|
||||
});
|
||||
88
packages/base-formula/src/graph.ts
Normal file
88
packages/base-formula/src/graph.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
packages/base-formula/src/index.client.ts
Normal file
14
packages/base-formula/src/index.client.ts
Normal 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";
|
||||
15
packages/base-formula/src/index.server.ts
Normal file
15
packages/base-formula/src/index.server.ts
Normal 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";
|
||||
10
packages/base-formula/src/number.ts
Normal file
10
packages/base-formula/src/number.ts
Normal 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);
|
||||
}
|
||||
201
packages/base-formula/src/parser.ts
Normal file
201
packages/base-formula/src/parser.ts
Normal 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 },
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
packages/base-formula/src/resolver.ts
Normal file
68
packages/base-formula/src/resolver.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
158
packages/base-formula/src/tokenizer.ts
Normal file
158
packages/base-formula/src/tokenizer.ts
Normal 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;
|
||||
}
|
||||
89
packages/base-formula/src/typecheck.ts
Normal file
89
packages/base-formula/src/typecheck.ts
Normal 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 } }]);
|
||||
}
|
||||
74
packages/base-formula/src/types.ts
Normal file
74
packages/base-formula/src/types.ts
Normal 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;
|
||||
12
packages/base-formula/tsconfig.json
Normal file
12
packages/base-formula/tsconfig.json
Normal 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
37
packages/editor-ext/.gitignore
vendored
Normal 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
|
||||
4
packages/editor-ext/.prettierrc
Normal file
4
packages/editor-ext/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
3
packages/editor-ext/README.md
Normal file
3
packages/editor-ext/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# editor extensions
|
||||
|
||||
Tiptap editor extensions
|
||||
16
packages/editor-ext/package.json
Normal file
16
packages/editor-ext/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
packages/editor-ext/src/index.ts
Normal file
41
packages/editor-ext/src/index.ts
Normal 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";
|
||||
125
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal file
125
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal 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 };
|
||||
136
packages/editor-ext/src/lib/attachment/attachment.ts
Normal file
136
packages/editor-ext/src/lib/attachment/attachment.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Attachment } from "./attachment";
|
||||
export * from "./attachment-upload";
|
||||
139
packages/editor-ext/src/lib/audio/audio-upload.ts
Normal file
139
packages/editor-ext/src/lib/audio/audio-upload.ts
Normal 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 };
|
||||
134
packages/editor-ext/src/lib/audio/audio.ts
Normal file
134
packages/editor-ext/src/lib/audio/audio.ts
Normal 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 };
|
||||
};
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/audio/index.ts
Normal file
2
packages/editor-ext/src/lib/audio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TiptapAudio } from "./audio";
|
||||
export * from "./audio-upload";
|
||||
133
packages/editor-ext/src/lib/base-embed/base-embed.ts
Normal file
133
packages/editor-ext/src/lib/base-embed/base-embed.ts
Normal 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),
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/base-embed/index.ts
Normal file
2
packages/editor-ext/src/lib/base-embed/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BaseEmbed } from './base-embed';
|
||||
export type { BaseEmbedOptions } from './base-embed';
|
||||
296
packages/editor-ext/src/lib/callout/callout.ts
Normal file
296
packages/editor-ext/src/lib/callout/callout.ts
Normal 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]),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/callout/index.ts
Normal file
2
packages/editor-ext/src/lib/callout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Callout } from "./callout";
|
||||
export * from "./utils";
|
||||
21
packages/editor-ext/src/lib/callout/utils.ts
Normal file
21
packages/editor-ext/src/lib/callout/utils.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
127
packages/editor-ext/src/lib/columns/column.ts
Normal file
127
packages/editor-ext/src/lib/columns/column.ts
Normal 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 }),
|
||||
};
|
||||
},
|
||||
});
|
||||
237
packages/editor-ext/src/lib/columns/columns.ts
Normal file
237
packages/editor-ext/src/lib/columns/columns.ts
Normal 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" },
|
||||
),
|
||||
]);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
4
packages/editor-ext/src/lib/columns/index.ts
Normal file
4
packages/editor-ext/src/lib/columns/index.ts
Normal 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";
|
||||
35
packages/editor-ext/src/lib/comment/comment-decoration.ts
Normal file
35
packages/editor-ext/src/lib/comment/comment-decoration.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
217
packages/editor-ext/src/lib/comment/comment.ts
Normal file
217
packages/editor-ext/src/lib/comment/comment.ts
Normal 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()];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
1
packages/editor-ext/src/lib/custom-code-block/index.ts
Normal file
1
packages/editor-ext/src/lib/custom-code-block/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CustomCodeBlock } from "./custom-code-block";
|
||||
202
packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts
Normal file
202
packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts
Normal 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;
|
||||
}
|
||||
111
packages/editor-ext/src/lib/details/details-content.ts
Normal file
111
packages/editor-ext/src/lib/details/details-content.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
96
packages/editor-ext/src/lib/details/details-summary.ts
Normal file
96
packages/editor-ext/src/lib/details/details-summary.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
245
packages/editor-ext/src/lib/details/details.ts
Normal file
245
packages/editor-ext/src/lib/details/details.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
3
packages/editor-ext/src/lib/details/index.ts
Normal file
3
packages/editor-ext/src/lib/details/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Details } from "./details";
|
||||
export { DetailsSummary } from "./details-summary";
|
||||
export { DetailsContent } from "./details-content";
|
||||
364
packages/editor-ext/src/lib/drawio.ts
Normal file
364
packages/editor-ext/src/lib/drawio.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
146
packages/editor-ext/src/lib/embed-provider.ts
Normal file
146
packages/editor-ext/src/lib/embed-provider.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
138
packages/editor-ext/src/lib/embed.ts
Normal file
138
packages/editor-ext/src/lib/embed.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
364
packages/editor-ext/src/lib/excalidraw.ts
Normal file
364
packages/editor-ext/src/lib/excalidraw.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
189
packages/editor-ext/src/lib/footnotes/footnote.ts
Normal file
189
packages/editor-ext/src/lib/footnotes/footnote.ts
Normal 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;
|
||||
46
packages/editor-ext/src/lib/footnotes/footnotes.ts
Normal file
46
packages/editor-ext/src/lib/footnotes/footnotes.ts
Normal 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;
|
||||
4
packages/editor-ext/src/lib/footnotes/index.ts
Normal file
4
packages/editor-ext/src/lib/footnotes/index.ts
Normal 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";
|
||||
221
packages/editor-ext/src/lib/footnotes/reference.ts
Normal file
221
packages/editor-ext/src/lib/footnotes/reference.ts
Normal 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;
|
||||
90
packages/editor-ext/src/lib/footnotes/rules.ts
Normal file
90
packages/editor-ext/src/lib/footnotes/rules.ts
Normal 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;
|
||||
123
packages/editor-ext/src/lib/footnotes/utils.ts
Normal file
123
packages/editor-ext/src/lib/footnotes/utils.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
80
packages/editor-ext/src/lib/heading/heading.ts
Normal file
80
packages/editor-ext/src/lib/heading/heading.ts
Normal 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 = " ";
|
||||
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,
|
||||
];
|
||||
},
|
||||
});
|
||||
40
packages/editor-ext/src/lib/highlight.ts
Normal file
40
packages/editor-ext/src/lib/highlight.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
155
packages/editor-ext/src/lib/image/image-upload.ts
Normal file
155
packages/editor-ext/src/lib/image/image-upload.ts
Normal 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 };
|
||||
389
packages/editor-ext/src/lib/image/image.ts
Normal file
389
packages/editor-ext/src/lib/image/image.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
2
packages/editor-ext/src/lib/image/index.ts
Normal file
2
packages/editor-ext/src/lib/image/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TiptapImage } from "./image";
|
||||
export * from "./image-upload";
|
||||
225
packages/editor-ext/src/lib/indent.ts
Normal file
225
packages/editor-ext/src/lib/indent.ts
Normal 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;
|
||||
}
|
||||
96
packages/editor-ext/src/lib/link.ts
Normal file
96
packages/editor-ext/src/lib/link.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/markdown/index.ts
Normal file
2
packages/editor-ext/src/lib/markdown/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./utils/marked.utils";
|
||||
export * from "./utils/turndown.utils";
|
||||
29
packages/editor-ext/src/lib/markdown/utils/basename.ts
Normal file
29
packages/editor-ext/src/lib/markdown/utils/basename.ts
Normal 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);
|
||||
}
|
||||
41
packages/editor-ext/src/lib/markdown/utils/callout.marked.ts
Normal file
41
packages/editor-ext/src/lib/markdown/utils/callout.marked.ts
Normal 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>`;
|
||||
},
|
||||
};
|
||||
110
packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts
Normal file
110
packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts
Normal 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 '';
|
||||
},
|
||||
};
|
||||
71
packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
Normal file
71
packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
Normal 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();
|
||||
}
|
||||
@@ -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>`;
|
||||
},
|
||||
};
|
||||
@@ -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>`;
|
||||
},
|
||||
};
|
||||
12
packages/editor-ext/src/lib/markdown/utils/turndown.d.ts
vendored
Normal file
12
packages/editor-ext/src/lib/markdown/utils/turndown.d.ts
vendored
Normal 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;
|
||||
}
|
||||
272
packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
Normal file
272
packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
Normal 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 '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 + ')';
|
||||
},
|
||||
});
|
||||
}
|
||||
2
packages/editor-ext/src/lib/math/index.ts
Normal file
2
packages/editor-ext/src/lib/math/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MathInline } from "./math-inline";
|
||||
export { MathBlock } from "./math-block";
|
||||
97
packages/editor-ext/src/lib/math/math-block.ts
Normal file
97
packages/editor-ext/src/lib/math/math-block.ts
Normal 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("$", ""),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
97
packages/editor-ext/src/lib/math/math-inline.ts
Normal file
97
packages/editor-ext/src/lib/math/math-inline.ts
Normal 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("$", ""),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
43
packages/editor-ext/src/lib/media-utils.ts
Normal file
43
packages/editor-ext/src/lib/media-utils.ts
Normal 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>;
|
||||
}
|
||||
354
packages/editor-ext/src/lib/mention.ts
Normal file
354
packages/editor-ext/src/lib/mention.ts
Normal 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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
1
packages/editor-ext/src/lib/page-break/index.ts
Normal file
1
packages/editor-ext/src/lib/page-break/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./page-break";
|
||||
60
packages/editor-ext/src/lib/page-break/page-break.ts
Normal file
60
packages/editor-ext/src/lib/page-break/page-break.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/pdf/index.ts
Normal file
2
packages/editor-ext/src/lib/pdf/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TiptapPdf } from "./pdf";
|
||||
export * from "./pdf-upload";
|
||||
123
packages/editor-ext/src/lib/pdf/pdf-upload.ts
Normal file
123
packages/editor-ext/src/lib/pdf/pdf-upload.ts
Normal 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 };
|
||||
156
packages/editor-ext/src/lib/pdf/pdf.ts
Normal file
156
packages/editor-ext/src/lib/pdf/pdf.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
167
packages/editor-ext/src/lib/prosemirror-docx/README.md
Normal file
167
packages/editor-ext/src/lib/prosemirror-docx/README.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# `prosemirror-docx`
|
||||
|
||||
[](https://www.npmjs.com/package/prosemirror-docx)
|
||||
[](https://github.com/curvenote/prosemirror-docx)
|
||||
[
|
||||
[](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
|
||||

|
||||
|
||||
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
|
||||
|
||||

|
||||
|
||||
## 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!
|
||||
24
packages/editor-ext/src/lib/prosemirror-docx/index.ts
Normal file
24
packages/editor-ext/src/lib/prosemirror-docx/index.ts
Normal 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';
|
||||
47
packages/editor-ext/src/lib/prosemirror-docx/numbering.ts
Normal file
47
packages/editor-ext/src/lib/prosemirror-docx/numbering.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
315
packages/editor-ext/src/lib/prosemirror-docx/schema.ts
Normal file
315
packages/editor-ext/src/lib/prosemirror-docx/schema.ts
Normal 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);
|
||||
}
|
||||
948
packages/editor-ext/src/lib/prosemirror-docx/serializer.ts
Normal file
948
packages/editor-ext/src/lib/prosemirror-docx/serializer.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
34
packages/editor-ext/src/lib/prosemirror-docx/types.ts
Normal file
34
packages/editor-ext/src/lib/prosemirror-docx/types.ts
Normal 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;
|
||||
}
|
||||
91
packages/editor-ext/src/lib/prosemirror-docx/utils.ts
Normal file
91
packages/editor-ext/src/lib/prosemirror-docx/utils.ts
Normal 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;
|
||||
}
|
||||
3
packages/editor-ext/src/lib/recreate-transform/copy.ts
Normal file
3
packages/editor-ext/src/lib/recreate-transform/copy.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function copy<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
4
packages/editor-ext/src/lib/recreate-transform/index.ts
Normal file
4
packages/editor-ext/src/lib/recreate-transform/index.ts
Normal 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
Reference in New Issue
Block a user