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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,279 @@
import { Transform } from "@tiptap/pm/transform";
import { Node, Schema } from "@tiptap/pm/model";
import { applyPatch, createPatch, Operation } from "rfc6902";
import { diffWordsWithSpace, diffChars } from "diff";
import { AnyObject } from "./types";
import { getReplaceStep } from "./getReplaceStep";
import { simplifyTransform } from "./simplifyTransform";
import { removeMarks } from "./removeMarks";
import { getFromPath } from "./getFromPath";
import { copy } from "./copy";
export interface Options {
complexSteps?: boolean;
wordDiffs?: boolean;
simplifyDiff?: boolean;
}
export class RecreateTransform {
fromDoc: Node;
toDoc: Node;
complexSteps: boolean;
wordDiffs: boolean;
simplifyDiff: boolean;
schema: Schema;
tr: Transform;
/* current working document data, may get updated while recalculating node steps */
currentJSON: AnyObject;
/* final document as json data */
finalJSON: AnyObject;
ops: Array<Operation>;
constructor(fromDoc: Node, toDoc: Node, options: Options = {}) {
const o = {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
...options,
};
this.fromDoc = fromDoc;
this.toDoc = toDoc;
this.complexSteps = o.complexSteps; // Whether to return steps other than ReplaceSteps
this.wordDiffs = o.wordDiffs; // Whether to make text diffs cover entire words
this.simplifyDiff = o.simplifyDiff;
this.schema = fromDoc.type.schema;
this.tr = new Transform(fromDoc);
}
init() {
if (this.complexSteps) {
// For First steps: we create versions of the documents without marks as
// these will only confuse the diffing mechanism and marks won't cause
// any mapping changes anyway.
this.currentJSON = removeMarks(this.fromDoc).toJSON();
this.finalJSON = removeMarks(this.toDoc).toJSON();
this.ops = createPatch(this.currentJSON, this.finalJSON);
this.recreateChangeContentSteps();
this.recreateChangeMarkSteps();
} else {
// We don't differentiate between mark changes and other changes.
this.currentJSON = this.fromDoc.toJSON();
this.finalJSON = this.toDoc.toJSON();
this.ops = createPatch(this.currentJSON, this.finalJSON);
this.recreateChangeContentSteps();
}
if (this.simplifyDiff) {
this.tr = simplifyTransform(this.tr) || this.tr;
}
return this.tr;
}
/** convert json-diff to prosemirror steps */
recreateChangeContentSteps() {
// First step: find content changing steps.
let ops = [];
while (this.ops.length) {
// get next
let op = this.ops.shift();
ops.push(op);
let toDoc;
const afterStepJSON = copy(this.currentJSON); // working document receiving patches
const pathParts = op.path.split("/");
// collect operations until we receive a valid document:
// apply ops-patches until a valid prosemirror document is retrieved,
// then try to create a transformation step or retry with next operation
while (toDoc == null) {
applyPatch(afterStepJSON, [op]);
try {
toDoc = this.schema.nodeFromJSON(afterStepJSON);
toDoc.check();
} catch (error) {
toDoc = null;
if (this.ops.length > 0) {
op = this.ops.shift();
ops.push(op);
} else {
throw new Error(`No valid diff possible applying ${op.path}`);
}
}
}
// apply operation (ignoring afterStepJSON)
if (
this.complexSteps &&
ops.length === 1 &&
(pathParts.includes("attrs") || pathParts.includes("type"))
) {
// Node markup is changing
this.addSetNodeMarkup(); // a lost update is ignored
ops = [];
// console.log("%cop", logStyle, "- update node", ops);
} else if (
ops.length === 1 &&
op.op === "replace" &&
pathParts[pathParts.length - 1] === "text"
) {
// Text is being replaced, we apply text diffing to find the smallest possible diffs.
this.addReplaceTextSteps(op, afterStepJSON);
ops = [];
// console.log("%cop", logStyle, "- replace", ops);
} else if (this.addReplaceStep(toDoc, afterStepJSON)) {
// operations have been applied
ops = [];
// console.log("%cop", logStyle, "- other", ops);
}
}
}
/** update node with attrs and marks, may also change type */
addSetNodeMarkup() {
// first diff in document is supposed to be a node-change (in type and/or attributes)
// thus simply find the first change and apply a node change step, then recalculate the diff
// after updating the document
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
const toDoc = this.schema.nodeFromJSON(this.finalJSON);
const start = toDoc.content.findDiffStart(fromDoc.content);
// @note start is the same (first) position for current and target document
const fromNode = fromDoc.nodeAt(start);
const toNode = toDoc.nodeAt(start);
if (start != null) {
// @note this completly updates all attributes in one step, by completely replacing node
const nodeType = fromNode.type === toNode.type ? null : toNode.type;
try {
this.tr.setNodeMarkup(start, nodeType, toNode.attrs, toNode.marks);
} catch (e) {
// if nodetypes differ, the updated node-type and contents might not be compatible
// with schema and requires a replace
if (nodeType && e.message.includes("Invalid content")) {
// @todo add test-case for this scenario
this.tr.replaceWith(start, start + fromNode.nodeSize, toNode);
} else {
throw e;
}
}
this.currentJSON = removeMarks(this.tr.doc).toJSON();
// setting the node markup may have invalidated the following ops, so we calculate them again.
this.ops = createPatch(this.currentJSON, this.finalJSON);
return true;
}
return false;
}
recreateChangeMarkSteps() {
// Now the documents should be the same, except their marks, so everything should map 1:1.
// Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc
this.toDoc.descendants((tNode, tPos) => {
if (!tNode.isInline) {
return true;
}
this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => {
if (!fNode.isInline) {
return true;
}
const from = Math.max(tPos, fPos);
const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize);
fNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(tNode.marks)) {
this.tr.removeMark(from, to, nodeMark);
}
});
tNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(fNode.marks)) {
this.tr.addMark(from, to, nodeMark);
}
});
});
});
}
/**
* retrieve and possibly apply replace-step based from doc changes
* From http://prosemirror.net/examples/footnote/
*/
addReplaceStep(toDoc: Node, afterStepJSON: AnyObject) {
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
const step = getReplaceStep(fromDoc, toDoc);
if (!step) {
return false;
} else if (!this.tr.maybeStep(step).failed) {
this.currentJSON = afterStepJSON;
return true; // @change previously null
}
throw new Error("No valid step found.");
}
/** retrieve and possibly apply text replace-steps based from doc changes */
addReplaceTextSteps(op, afterStepJSON) {
// We find the position number of the first character in the string
const op1 = { ...op, value: "xx" };
const op2 = { ...op, value: "yy" };
const afterOP1JSON = copy(this.currentJSON);
const afterOP2JSON = copy(this.currentJSON);
applyPatch(afterOP1JSON, [op1]);
applyPatch(afterOP2JSON, [op2]);
const op1Doc = this.schema.nodeFromJSON(afterOP1JSON);
const op2Doc = this.schema.nodeFromJSON(afterOP2JSON);
// get text diffs
const finalText = op.value;
const currentText = getFromPath(this.currentJSON, op.path);
const textDiffs = this.wordDiffs
? diffWordsWithSpace(currentText, finalText)
: diffChars(currentText, finalText);
let offset = op1Doc.content.findDiffStart(op2Doc.content);
const marks = op1Doc.resolve(offset + 1).marks();
while (textDiffs.length) {
const diff = textDiffs.shift();
if (diff.added) {
const textNode = this.schema
.nodeFromJSON({ type: "text", text: diff.value })
.mark(marks);
if (textDiffs.length && textDiffs[0].removed) {
const nextDiff = textDiffs.shift();
this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode);
} else {
this.tr.insert(offset, textNode);
}
offset += diff.value.length;
} else if (diff.removed) {
if (textDiffs.length && textDiffs[0].added) {
const nextDiff = textDiffs.shift();
const textNode = this.schema
.nodeFromJSON({ type: "text", text: nextDiff.value })
.mark(marks);
this.tr.replaceWith(offset, offset + diff.value.length, textNode);
offset += nextDiff.value.length;
} else {
this.tr.delete(offset, offset + diff.value.length);
}
} else {
offset += diff.value.length;
}
}
this.currentJSON = afterStepJSON;
}
}
export function recreateTransform(
fromDoc: Node,
toDoc: Node,
options: Options = {},
): Transform {
const recreator = new RecreateTransform(fromDoc, toDoc, options);
return recreator.init();
}

View File

@@ -0,0 +1,8 @@
import { Transform } from "@tiptap/pm/transform";
import { Node } from "@tiptap/pm/model";
export function removeMarks(doc: Node) {
const tr = new Transform(doc);
tr.removeMark(0, doc.nodeSize - 2);
return tr.doc;
}

View File

@@ -0,0 +1,30 @@
import { Transform, ReplaceStep, Step } from "@tiptap/pm/transform";
import { getReplaceStep } from "./getReplaceStep";
// join adjacent ReplaceSteps
export function simplifyTransform(tr: Transform) {
if (!tr.steps.length) {
return undefined;
}
const newTr = new Transform(tr.docs[0]);
const oldSteps = tr.steps.slice();
while (oldSteps.length) {
let step = oldSteps.shift();
while (oldSteps.length && step.merge(oldSteps[0])) {
const addedStep = oldSteps.shift();
if (step instanceof ReplaceStep && addedStep instanceof ReplaceStep) {
step = getReplaceStep(
newTr.doc,
addedStep.apply(step.apply(newTr.doc).doc).doc,
// @ts-ignore
) as Step<any>;
} else {
step = step.merge(addedStep);
}
}
newTr.step(step);
}
return newTr;
}

View File

@@ -0,0 +1,3 @@
export interface AnyObject {
[p: string]: any;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
import { SearchAndReplace } from './search-and-replace'
export * from './search-and-replace'
export default SearchAndReplace

View File

@@ -0,0 +1,519 @@
/***
MIT License
Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of 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.
***/
import { Extension, Range, type Dispatch } from "@tiptap/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from "@tiptap/pm/state";
import { Node as PMNode, Mark } from "@tiptap/pm/model";
declare module "@tiptap/core" {
interface Storage {
searchAndReplace: SearchAndReplaceStorage;
}
interface Commands<ReturnType> {
search: {
/**
* @description Set search term in extension.
*/
setSearchTerms: (searchTerms: string[]) => ReturnType;
/**
* @description Set whole word search in extension.
*/
setWholeWord: (wholeWord: boolean) => ReturnType;
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
/**
* @description Set case sensitivity in extension.
*/
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
/**
* @description Reset current search result to first instance.
*/
resetIndex: () => ReturnType;
/**
* @description Find next instance of search result.
*/
nextSearchResult: () => ReturnType;
/**
* @description Find previous instance of search result.
*/
previousSearchResult: () => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
/**
* @description Find selected instance of search result.
*/
selectCurrentItem: () => ReturnType;
};
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (
searchTerms: string[],
disableRegex: boolean,
caseSensitive: boolean,
wholeWord: boolean,
): RegExp => {
const terms = searchTerms.filter(Boolean).sort((a, b) => b.length - a.length);
const pattern = terms
.map((term) =>
disableRegex ? term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : term,
)
.join("|");
const finalPattern = wholeWord
? `(?<![\\p{L}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{N}_])`
: pattern;
return new RegExp(
finalPattern,
caseSensitive ? "gu" : "giu",
);
};
interface ProcessedSearches {
decorationsToReturn: DecorationSet;
results: Range[];
}
function processSearches(
doc: PMNode,
searchTerm: RegExp,
searchResultClass: string,
resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
decorationsToReturn: DecorationSet.empty,
results: [],
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
const className =
i === resultIndex
? `${searchResultClass} ${searchResultClass}-current`
: searchResultClass;
const decoration: Decoration = Decoration.inline(r.from, r.to, {
class: className,
});
decorations.push(decoration);
}
return {
decorationsToReturn: DecorationSet.create(doc, decorations),
results,
};
}
const replace = (
replaceTerm: string,
results: Range[],
resultIndex: number,
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
) => {
const firstResult = results[resultIndex];
if (!firstResult) return;
const { from, to } = results[resultIndex];
if (dispatch) {
const tr = state.tr;
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
state.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach((mark) => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete the old text
tr.delete(from, to);
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
if (replaceTerm) {
tr.insert(from, state.schema.text(replaceTerm, marks));
}
dispatch(tr);
}
};
const replaceAll = (
replaceTerm: string,
results: Range[],
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
) => {
const resultsCopy = results.slice();
if (!resultsCopy.length) return;
// Process replacements in reverse order to avoid position shifting issues
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
const { from, to } = resultsCopy[i];
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach((mark) => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete the old text
tr.delete(from, to);
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
if (replaceTerm) {
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
}
}
dispatch(tr);
};
export const searchAndReplacePluginKey = new PluginKey(
"searchAndReplacePlugin",
);
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerms: string[];
replaceTerm: string;
results: Range[];
lastSearchTerms: string[];
caseSensitive: boolean;
lastCaseSensitive: boolean;
wholeWord: boolean;
lastWholeWord: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<
SearchAndReplaceOptions,
SearchAndReplaceStorage
>({
name: "searchAndReplace",
addOptions() {
return {
searchResultClass: "search-result",
disableRegex: true,
};
},
addStorage() {
return {
searchTerms: [],
replaceTerm: "",
results: [],
lastSearchTerms: [],
caseSensitive: false,
wholeWord: false,
lastWholeWord: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0,
};
},
addCommands() {
return {
setSearchTerms:
(searchTerms: string[]) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerms = searchTerms.filter(Boolean);
// clear whole word by default
// should remove if whole word toggle is added to search and replace dialog
editor.storage.searchAndReplace.wholeWord = false;
return false;
},
setWholeWord:
(wholeWord: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.wholeWord = wholeWord;
return false;
},
setReplaceTerm:
(replaceTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
return false;
},
setCaseSensitive:
(caseSensitive: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
return false;
},
resetIndex:
() =>
({ editor }) => {
editor.storage.searchAndReplace.resultIndex = 0;
return false;
},
nextSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const nextIndex = resultIndex + 1;
if (results[nextIndex]) {
editor.storage.searchAndReplace.resultIndex = nextIndex;
} else {
editor.storage.searchAndReplace.resultIndex = 0;
}
return false;
},
previousSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const prevIndex = resultIndex - 1;
if (results[prevIndex]) {
editor.storage.searchAndReplace.resultIndex = prevIndex;
} else {
editor.storage.searchAndReplace.resultIndex = results.length - 1;
}
return false;
},
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results, resultIndex } =
editor.storage.searchAndReplace;
replace(replaceTerm, results, resultIndex, { state, dispatch });
// After replace, adjust index if needed
// The results will be recalculated by the plugin, but we need to ensure
// the index doesn't exceed the new bounds
setTimeout(() => {
const newResultsLength =
editor.storage.searchAndReplace.results.length;
if (
newResultsLength > 0 &&
editor.storage.searchAndReplace.resultIndex >= newResultsLength
) {
// Keep the same position if possible, otherwise go to the last result
editor.storage.searchAndReplace.resultIndex = Math.min(
resultIndex,
newResultsLength - 1,
);
}
}, 0);
return false;
},
replaceAll:
() =>
({ editor, tr, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replaceAll(replaceTerm, results, { tr, dispatch });
return false;
},
selectCurrentItem:
() =>
({ editor }) => {
const { results } = editor.storage.searchAndReplace;
for (let i = 0; i < results.length; i++) {
if (
results[i].from == editor.state.selection.from &&
results[i].to == editor.state.selection.to
) {
editor.storage.searchAndReplace.resultIndex = i;
}
}
return false;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerms = (terms: string[]) =>
(editor.storage.searchAndReplace.lastSearchTerms = [...terms]);
const setLastWholeWord = (t: boolean) =>
(editor.storage.searchAndReplace.lastWholeWord = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) =>
(editor.storage.searchAndReplace.lastResultIndex = t);
return [
new Plugin({
key: searchAndReplacePluginKey,
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const storage = editor.storage.searchAndReplace;
if (!storage) return oldState;
const {
searchTerms,
lastCaseSensitive,
lastSearchTerms,
caseSensitive,
wholeWord,
lastWholeWord,
resultIndex,
lastResultIndex,
} = storage;
if (
!docChanged &&
searchTerms.length === lastSearchTerms.length &&
searchTerms.every((term, index) => term === lastSearchTerms[index]) &&
lastCaseSensitive === caseSensitive &&
lastWholeWord === wholeWord &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerms(searchTerms);
setLastCaseSensitive(caseSensitive);
setLastWholeWord(wholeWord);
setLastResultIndex(resultIndex);
if (searchTerms.length === 0) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(
searchTerms,
disableRegex,
caseSensitive,
wholeWord,
),
searchResultClass,
resultIndex,
);
editor.storage.searchAndReplace.results = results;
return decorationsToReturn;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
export default SearchAndReplace;

View File

@@ -0,0 +1,36 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
export const Selection = Extension.create({
name: "selection",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("selection"),
props: {
decorations(state) {
if (state.selection.empty) {
return null;
}
if (editor.isFocused === true) {
return null;
}
return DecorationSet.create(state.doc, [
Decoration.inline(state.selection.from, state.selection.to, {
class: "selection",
}),
]);
},
},
}),
];
},
});
export default Selection;

View File

@@ -0,0 +1 @@
export { SharedStorage } from "./shared-storage";

View File

@@ -0,0 +1,17 @@
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
interface Storage {
shared: Record<string, any>;
}
}
const SharedStorage = Extension.create({
name: "shared",
addStorage() {
return {};
},
});
export { SharedStorage };

View File

@@ -0,0 +1,108 @@
import { Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
export type StatusStorage = {
autoOpen: boolean;
};
declare module '@tiptap/core' {
interface Commands<ReturnType> {
status: {
setStatus: (attributes?: { text?: string; color?: string }) => ReturnType;
};
}
interface Storage {
status: StatusStorage;
}
}
export type StatusColor =
| 'gray'
| 'blue'
| 'green'
| 'yellow'
| 'red'
| 'purple';
export interface StatusOption {
HTMLAttributes: Record<string, any>;
view: any;
}
export const Status = Node.create<StatusOption, StatusStorage>({
name: 'status',
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addStorage() {
return {
autoOpen: false,
};
},
addAttributes() {
return {
text: {
default: '',
parseHTML: (element: HTMLElement) => element.textContent || '',
},
color: {
default: 'gray',
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-color') || 'gray',
},
};
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
{
'data-type': this.name,
'data-color': HTMLAttributes.color,
},
HTMLAttributes.text,
];
},
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addCommands() {
return {
setStatus:
(attributes) =>
({ commands }) => {
this.storage.autoOpen = true;
return commands.insertContent({
type: this.name,
attrs: {
text: attributes?.text ?? '',
color: attributes?.color || 'gray',
},
});
},
};
},
});

View File

@@ -0,0 +1,2 @@
export { Subpages } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";

View File

@@ -0,0 +1,72 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
export interface SubpagesOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface SubpagesAttributes {}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
subpages: {
insertSubpages: (attributes?: SubpagesAttributes) => ReturnType;
};
}
}
export const Subpages = Node.create<SubpagesOptions>({
name: "subpages",
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
group: "block",
atom: true,
draggable: true,
isolating: true,
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
];
},
addCommands() {
return {
insertSubpages:
(attributes) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});

View File

@@ -0,0 +1,43 @@
import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) =>
element.style.backgroundColor ||
element.getAttribute("data-background-color") ||
null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
"data-background-color": attributes.backgroundColor,
};
},
},
backgroundColorName: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-background-color-name") || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColorName) {
return {};
}
return {
"data-background-color-name":
attributes.backgroundColorName.toLowerCase(),
};
},
},
};
},
});

View File

@@ -0,0 +1,44 @@
function findDragOverElement(
elements: Element[],
pointer: number,
axis: 'x' | 'y',
): [Element, number] | undefined {
const startProp = axis === 'x' ? 'left' : 'top'
const endProp = axis === 'x' ? 'right' : 'bottom'
const lastIndex = elements.length - 1
const index = elements.findIndex((el, index) => {
const rect = el.getBoundingClientRect()
const boundaryStart = rect[startProp]
const boundaryEnd = rect[endProp]
// The pointer is within the boundary of the current element.
if (boundaryStart <= pointer && pointer <= boundaryEnd) return true
// The pointer is beyond the last element.
if (index === lastIndex && pointer > boundaryEnd) return true
// The pointer is before the first element.
if (index === 0 && pointer < boundaryStart) return true
return false
})
return index >= 0 ? [elements[index], index] : undefined
}
export function getDragOverColumn(
table: HTMLTableElement,
pointerX: number,
): [element: Element, index: number] | undefined {
const firstRow = table.querySelector('tr')
if (!firstRow) return
const cells = Array.from(firstRow.children)
return findDragOverElement(cells, pointerX, 'x')
}
export function getDragOverRow(
table: HTMLTableElement,
pointerY: number,
): [element: Element, index: number] | undefined {
const rows = Array.from(table.querySelectorAll('tr'))
return findDragOverElement(rows, pointerY, 'y')
}

View File

@@ -0,0 +1,393 @@
import { Editor, Extension } from "@tiptap/core";
import { PluginKey, Plugin, PluginSpec, TextSelection, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorProps, EditorView } from "@tiptap/pm/view";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { cellAround } from "@tiptap/pm/tables";
import {
cellInfoFromResolvedCell,
DraggingDOMs,
getDndRelatedDOMs,
getHoveringCell,
HoveringCellInfo,
} from "./utils";
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
import { findTable } from "../utils/query";
import { moveColumn, moveRow } from "../utils";
import { PreviewController } from "./preview/preview-controller";
import { DropIndicatorController } from "./preview/drop-indicator-controller";
export interface TableHandleState {
hoveringCell: HoveringCellInfo | null;
tableNode: ProseMirrorNode | null;
tablePos: number | null;
dragging: { orientation: "col" | "row"; index: number } | null;
frozen: boolean;
}
const INITIAL_STATE: TableHandleState = {
hoveringCell: null,
tableNode: null,
tablePos: null,
dragging: null,
frozen: false,
};
export const TableDndKey = new PluginKey<TableHandleState>("table-handles");
class TableHandlePluginSpec implements PluginSpec<TableHandleState> {
key = TableDndKey;
props: EditorProps<Plugin<TableHandleState>>;
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
private _hoveringCell?: HoveringCellInfo;
private _disposables: (() => void)[] = [];
private _draggingDirection: "col" | "row" = "col";
private _draggingIndex = -1;
private _droppingIndex = -1;
private _draggingDOMs?: DraggingDOMs;
private _startCoords = { x: 0, y: 0 };
private _dragging = false;
state = {
init: (): TableHandleState => INITIAL_STATE,
apply: (tr: Transaction, prev: TableHandleState): TableHandleState => {
const meta = tr.getMeta(TableDndKey) as Partial<TableHandleState> | null;
if (!meta) return prev;
let changed = false;
for (const key in meta) {
if (!Object.is(prev[key as keyof TableHandleState], meta[key as keyof TableHandleState])) {
changed = true;
break;
}
}
return changed ? { ...prev, ...meta } : prev;
},
};
constructor(public editor: Editor) {
this.props = {
handleDOMEvents: {
pointermove: this._pointerMove,
// Force-unfreeze on any pointerdown that lands on the editor.
// Mantine's `Menu.onClose` doesn't always fire on outside click
// (the dropdown vanishes visually but the callback is skipped),
// which would otherwise leave `frozen=true` permanently.
pointerdown: this._pointerDown,
},
};
this._previewController = new PreviewController();
this._dropIndicatorController = new DropIndicatorController();
}
view = () => {
const wrapper = this.editor.options.element;
// @ts-ignore
wrapper.appendChild(this._previewController.previewRoot);
// @ts-ignore
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
// Track the cursor cell so handles follow keyboard nav and clicks too.
this.editor.on("selectionUpdate", this._onSelectionUpdate);
this._disposables.push(() =>
this.editor.off("selectionUpdate", this._onSelectionUpdate),
);
return {
destroy: this.destroy,
};
};
destroy = () => {
this._previewController.destroy();
this._dropIndicatorController.destroy();
this._disposables.forEach((d) => d());
};
private _pointerDown = (view: EditorView, _event: PointerEvent): boolean => {
const current = TableDndKey.getState(view.state);
if (current?.frozen) this.editor.commands.unfreezeHandles();
return false;
};
private _pointerMove = (view: EditorView, event: PointerEvent) => {
const current = TableDndKey.getState(view.state);
if (current?.frozen || current?.dragging) return;
const resizeState = columnResizingPluginKey.getState(view.state);
if (resizeState?.dragging) return;
if (!this.editor.isEditable) {
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
return;
}
const hoveringCell = getHoveringCell(view, event);
if (hoveringCell) {
if (current?.hoveringCell?.cellPos === hoveringCell.cellPos) return;
this._hoveringCell = hoveringCell;
const $cell = view.state.doc.resolve(hoveringCell.cellPos);
const tableInfo = findTable($cell);
this._dispatchMeta({
hoveringCell,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
// Pointer isn't over a cell but may be transiting toward a handle that
// floats outside the cell — fall back to the selection's cell so the
// handles stay visible.
const $cellPos = cellAround(view.state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
this._hoveringCell = undefined;
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
};
private _onSelectionUpdate = () => {
if (!this.editor.isEditable) return;
const current = TableDndKey.getState(this.editor.state);
if (current?.frozen || current?.dragging) return;
const $cellPos = cellAround(this.editor.state.selection.$head);
if (!$cellPos) return;
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
};
private _dispatchMeta = (patch: Partial<TableHandleState>) => {
const tr = this.editor.state.tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
};
// ---- Public API for the React handle layer ----
// Returns true if the drag was set up successfully.
startDragFromHandle = (
orientation: "col" | "row",
clientX: number,
clientY: number,
): boolean => {
if (!this._hoveringCell) return false;
this._dragging = true;
this._draggingDirection = orientation;
this._startCoords = { x: clientX, y: clientY };
const draggingIndex =
(orientation === "col"
? this._hoveringCell.colIndex
: this._hoveringCell.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell.cellPos,
draggingIndex,
orientation,
);
if (!relatedDoms) {
this._dragging = false;
return false;
}
this._draggingDOMs = relatedDoms;
this._previewController.onDragStart(relatedDoms, draggingIndex, orientation);
this._dropIndicatorController.onDragStart(relatedDoms, orientation);
// Park the selection inside the dragged cell unless it's already in the
// same table. PM auto-maps `selection.from` through concurrent remote
// transactions, so commitDrop can resolve the table even if the doc
// shifted mid-drag — same trick the pre-pragmatic-dnd implementation
// relied on.
const state = this.editor.state;
const currentTable = findTable(state.selection.$from);
const hoverTable = (() => {
try {
return findTable(state.doc.resolve(this._hoveringCell.cellPos));
} catch {
return undefined;
}
})();
const tr = state.tr;
if (
hoverTable &&
(!currentTable || currentTable.pos !== hoverTable.pos)
) {
try {
const $inside = state.doc.resolve(this._hoveringCell.cellPos + 1);
tr.setSelection(TextSelection.near($inside, 1));
} catch {}
}
tr.setMeta(TableDndKey, {
dragging: { orientation, index: draggingIndex },
});
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
return true;
};
updateDragPosition = (clientX: number, clientY: number) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs || !this._dragging) return;
if (this._draggingDirection === "col") {
this._previewController.onDragging(
draggingDOMs,
clientX,
clientY,
"col",
);
const direction = this._startCoords.x > clientX ? "left" : "right";
const dragOverColumn = getDragOverColumn(draggingDOMs.table, clientX);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, "col");
return;
}
this._previewController.onDragging(draggingDOMs, clientX, clientY, "row");
const direction = this._startCoords.y > clientY ? "up" : "down";
const dragOverRow = getDragOverRow(draggingDOMs.table, clientY);
if (!dragOverRow) return;
const [row, index] = dragOverRow;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(row, direction, "row");
};
commitDrop = () => {
if (!this._dragging) return;
const direction = this._draggingDirection;
const from = this._draggingIndex;
const to = this._droppingIndex;
if (from < 0 || to < 0 || from === to) return;
// Use the live (auto-mapped) selection as the table anchor — PM has
// already mapped it through any concurrent remote transactions, so
// it's safe to resolve even if the doc shifted mid-drag.
const tr = this.editor.state.tr;
const pos = this.editor.state.selection.from;
if (direction === "col") {
if (moveColumn({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
this.editor.view.dispatch(tr);
}
return;
}
if (moveRow({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
this.editor.view.dispatch(tr);
}
};
endDrag = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._draggingDOMs = undefined;
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
this._dispatchMeta({ dragging: null });
};
}
export type { TableHandlePluginSpec };
// Resolve via plugin key, not a module singleton — survives StrictMode / HMR.
export function getTableHandlePluginSpec(
editor: Editor,
): TableHandlePluginSpec | null {
const plugin = TableDndKey.get(editor.state);
if (!plugin) return null;
return plugin.spec as unknown as TableHandlePluginSpec;
}
export const TableDndExtension = Extension.create({
name: "table-drag-and-drop",
addProseMirrorPlugins() {
const editor = this.editor;
const spec = new TableHandlePluginSpec(editor);
return [new Plugin(spec)];
},
});
export const TableHandleCommandsExtension = Extension.create({
name: "table-handle-commands",
addCommands() {
return {
freezeHandles:
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(TableDndKey, { frozen: true });
tr.setMeta("addToHistory", false);
}
return true;
},
unfreezeHandles:
() =>
({ tr, state, dispatch }) => {
if (dispatch) {
// Re-sync `hoveringCell` to the cursor's cell as we unfreeze:
// `selectionUpdate` was gated while frozen, so the stored
// hoveringCell may be stale.
const patch: Partial<TableHandleState> = { frozen: false };
const $cellPos = cellAround(state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
const tableInfo = findTable($cellPos);
patch.hoveringCell = cellInfo;
patch.tableNode = tableInfo?.node ?? null;
patch.tablePos = tableInfo?.pos ?? null;
} else {
patch.hoveringCell = null;
patch.tableNode = null;
patch.tablePos = null;
}
tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
}
return true;
},
};
},
});
declare module "@tiptap/core" {
interface Commands<ReturnType> {
tableHandleCommands: {
freezeHandles: () => ReturnType;
unfreezeHandles: () => ReturnType;
};
}
}

View File

@@ -0,0 +1,7 @@
export {
TableDndExtension,
TableHandleCommandsExtension,
TableDndKey,
getTableHandlePluginSpec,
} from "./dnd-extension";
export type { TableHandleState, TableHandlePluginSpec } from "./dnd-extension";

View File

@@ -0,0 +1,102 @@
import { computePosition, offset } from "@floating-ui/dom";
import { DraggingDOMs } from "../utils";
const DROP_INDICATOR_WIDTH = 2;
export class DropIndicatorController {
private _dropIndicator: HTMLElement;
constructor() {
this._dropIndicator = document.createElement('div');
this._dropIndicator.classList.add('table-dnd-drop-indicator');
Object.assign(this._dropIndicator.style, {
position: 'absolute',
pointerEvents: 'none'
});
}
get dropIndicatorRoot() {
return this._dropIndicator;
}
onDragStart = (relatedDoms: DraggingDOMs, type: 'col' | 'row') => {
this._initDropIndicatorStyle(relatedDoms.table, type);
this._initDropIndicatorPosition(relatedDoms.cell, type);
this._dropIndicator.dataset.dragging = 'true';
}
onDragEnd = () => {
Object.assign(this._dropIndicator.style, { display: 'none' });
this._dropIndicator.dataset.dragging = 'false';
}
onDragging = (target: Element, direction: 'left' | 'right' | 'up' | 'down', type: 'col' | 'row') => {
if (type === 'col') {
void computePosition(target, this._dropIndicator, {
placement: direction === 'left' ? 'left' : 'right',
middleware: [offset((direction === 'left' ? -1 * DROP_INDICATOR_WIDTH : 0))],
}).then(({ x }) => {
Object.assign(this._dropIndicator.style, { left: `${x}px` });
})
return;
}
if (type === 'row') {
void computePosition(target, this._dropIndicator, {
placement: direction === 'up' ? 'top' : 'bottom',
middleware: [offset((direction === 'up' ? -1 * DROP_INDICATOR_WIDTH : 0))],
}).then(({ y }) => {
Object.assign(this._dropIndicator.style, { top: `${y}px` });
})
return;
}
}
destroy = () => {
this._dropIndicator.remove();
}
private _initDropIndicatorStyle = (table: HTMLElement, type: 'col' | 'row') => {
const tableRect = table.getBoundingClientRect();
if (type === 'col') {
Object.assign(this._dropIndicator.style, {
display: 'block',
width: `${DROP_INDICATOR_WIDTH}px`,
height: `${tableRect.height}px`,
});
return;
}
if (type === 'row') {
Object.assign(this._dropIndicator.style, {
display: 'block',
width: `${tableRect.width}px`,
height: `${DROP_INDICATOR_WIDTH}px`,
});
}
}
private _initDropIndicatorPosition = (cell: HTMLElement, type: 'col' | 'row') => {
void computePosition(cell, this._dropIndicator, {
placement: type === 'row' ? 'right' : 'bottom',
middleware: [
offset(({ rects }) => {
if (type === 'col') {
return -rects.reference.height
}
return -rects.reference.width
}),
],
}).then(({ x, y }) => {
Object.assign(this._dropIndicator.style, {
left: `${x}px`,
top: `${y}px`,
})
});
}
}

View File

@@ -0,0 +1,132 @@
import { computePosition, offset, shift, ReferenceElement } from "@floating-ui/dom";
import { DraggingDOMs } from "../utils";
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
export class PreviewController {
private _preview: HTMLElement;
constructor() {
this._preview = document.createElement('div');
this._preview.classList.add('table-dnd-preview');
this._preview.classList.add('ProseMirror');
Object.assign(this._preview.style, {
position: 'absolute',
pointerEvents: 'none',
display: 'none',
});
}
get previewRoot(): HTMLElement {
return this._preview;
}
onDragStart = (relatedDoms: DraggingDOMs, index: number | undefined, type: 'col' | 'row') => {
this._initPreviewStyle(relatedDoms.table, relatedDoms.cell, type);
createPreviewDOM(relatedDoms.table, this._preview, index, type)
this._initPreviewPosition(relatedDoms.table, relatedDoms.cell, type);
}
onDragEnd = () => {
clearPreviewDOM(this._preview);
Object.assign(this._preview.style, { display: 'none' });
}
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
this._updatePreviewPosition(x, y, relatedDoms.table, relatedDoms.cell, type);
}
destroy = () => {
this._preview.remove();
}
private _initPreviewStyle(table: HTMLTableElement, cell: HTMLTableCellElement, type: 'col' | 'row') {
const tableRect = table.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
if (type === 'col') {
Object.assign(this._preview.style, {
display: 'block',
width: `${cellRect.width}px`,
height: `${tableRect.height}px`,
})
}
if (type === 'row') {
Object.assign(this._preview.style, {
display: 'block',
width: `${tableRect.width}px`,
height: `${cellRect.height}px`,
})
}
}
private _initPreviewPosition(table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
void computePosition(cell, this._preview, {
placement: type === 'row' ? 'right' : 'bottom',
middleware: [
offset(({ rects }) => {
if (type === 'col') {
return -rects.reference.height
}
return -rects.reference.width
}),
shift({ boundary: table, padding: 0 }),
],
}).then(({ x, y }) => {
Object.assign(this._preview.style, {
left: `${x}px`,
top: `${y}px`,
})
});
}
// Clamp the preview to within the table's bounds via `shift({ boundary })`
// so it can't track the cursor past the table edge. Without the clamp,
// dragging near the viewport edge pushes the preview's `left` (or `top`)
// beyond the document's natural width/height, the browser extends the
// page to contain it, and the auto-scroll plugin then has a wider area
// to keep scrolling into — a feedback loop that grows the page forever.
private _updatePreviewPosition(x: number, y: number, table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
computePosition(
getVirtualElement(cell, x, y),
this._preview,
{
placement: type === 'row' ? 'right' : 'bottom',
middleware: [shift({ boundary: table, padding: 0 })],
},
).then(({ x, y }) => {
if (type === 'row') {
Object.assign(this._preview.style, {
top: `${y}px`,
})
return
}
if (type === 'col') {
Object.assign(this._preview.style, {
left: `${x}px`,
})
return
}
})
}
}
function getVirtualElement(cell: HTMLElement, x: number, y: number): ReferenceElement {
return {
contextElement: cell,
getBoundingClientRect: () => {
const rect = cell.getBoundingClientRect()
return {
width: rect.width,
height: rect.height,
right: x + rect.width / 2,
bottom: y + rect.height / 2,
top: y - rect.height / 2,
left: x - rect.width / 2,
x: x - rect.width / 2,
y: y - rect.height / 2,
}
},
}
}

View File

@@ -0,0 +1,37 @@
export function clearPreviewDOM(previewRoot: HTMLElement): void {
while (previewRoot.firstChild) {
previewRoot.removeChild(previewRoot.firstChild)
}
}
export function createPreviewDOM(
table: HTMLTableElement,
previewRoot: HTMLElement,
index: number,
direction: 'row' | 'col',
): void {
clearPreviewDOM(previewRoot)
const previewTable = document.createElement('table')
const previewTableBody = document.createElement('tbody')
previewTable.appendChild(previewTableBody)
previewRoot.appendChild(previewTable)
const rows = table.querySelectorAll('tr')
if (direction === 'row') {
const row = rows[index]
const rowDOM = row.cloneNode(true)
previewTableBody.appendChild(rowDOM)
} else {
rows.forEach((row) => {
const rowDOM = row.cloneNode(false)
const cells = row.querySelectorAll('th,td')
if (cells[index]) {
const cellDOM = cells[index].cloneNode(true)
rowDOM.appendChild(cellDOM)
previewTableBody.appendChild(rowDOM)
}
})
}
}

View File

@@ -0,0 +1,119 @@
import { cellAround, TableMap } from "@tiptap/pm/tables"
import { ResolvedPos } from "@tiptap/pm/model"
import { EditorView } from "@tiptap/pm/view"
export function getHoveringCell(
view: EditorView,
event: MouseEvent,
): HoveringCellInfo | undefined {
const domCell = domCellAround(event.target as HTMLElement | null)
if (!domCell) return
// Resolve directly from the cell DOM rather than via coords. The previous
// center-coords approach broke on tall merged cells — their visual center
// can land in empty space whose closest PM position resolves to an
// adjacent cell. `posAtDOM(td, 0)` is always inside this cell, regardless
// of rowspan/colspan.
let pos: number
try {
pos = view.posAtDOM(domCell, 0)
} catch {
return
}
const $cellPos = cellAround(view.state.doc.resolve(pos))
if (!$cellPos) return
return cellInfoFromResolvedCell($cellPos)
}
/**
* Build HoveringCellInfo from a resolved position whose parent is a
* table cell (i.e. the result of `cellAround` on some inner position).
*/
export function cellInfoFromResolvedCell(
$cellPos: ResolvedPos,
): HoveringCellInfo {
const map = TableMap.get($cellPos.node(-1))
const tableStart = $cellPos.start(-1)
const cellRect = map.findCell($cellPos.pos - tableStart)
const rowIndex = cellRect.top
const colIndex = cellRect.left
return {
rowIndex,
colIndex,
cellPos: $cellPos.pos,
rowFirstCellPos: getCellPos(map, tableStart, rowIndex, 0),
colFirstCellPos: getCellPos(map, tableStart, 0, colIndex),
}
}
function domCellAround(target: HTMLElement | null): HTMLElement | null {
while (target && target.nodeName != 'TD' && target.nodeName != 'TH') {
target = target.classList?.contains('ProseMirror')
? null
: (target.parentNode as HTMLElement | null)
}
return target
}
export interface HoveringCellInfo {
rowIndex: number
colIndex: number
cellPos: number
rowFirstCellPos: number
colFirstCellPos: number
}
function getCellPos(
map: TableMap,
tableStart: number,
rowIndex: number,
colIndex: number,
) {
const cellIndex = getCellIndex(map, rowIndex, colIndex)
const posInTable = map.map[cellIndex]
return tableStart + posInTable
}
function getCellIndex(
map: TableMap,
rowIndex: number,
colIndex: number,
): number {
return map.width * rowIndex + colIndex
}
function getTableDOMByPos(view: EditorView, pos: number): HTMLTableElement | undefined {
const dom = view.domAtPos(pos).node
if (!dom) return
const element = dom instanceof HTMLElement ? dom : dom.parentElement
const table = element?.closest('table')
return table ?? undefined
}
function getTargetFirstCellDOM(table: HTMLTableElement, index: number, direction: 'row' | 'col'): HTMLTableCellElement | undefined {
if (direction === 'row') {
const row = table.querySelectorAll('tr')[index]
const cell = row?.querySelector<HTMLTableCellElement>('th,td')
return cell ?? undefined
} else {
const row = table.querySelector('tr')
const cell = row?.querySelectorAll<HTMLTableCellElement>('th,td')[index]
return cell ?? undefined
}
}
export type DraggingDOMs = {
table: HTMLTableElement
cell: HTMLTableCellElement
}
export function getDndRelatedDOMs(view: EditorView, cellPos: number | undefined, draggingIndex: number, direction: 'row' | 'col'): DraggingDOMs | undefined {
if (cellPos == null) return
const table = getTableDOMByPos(view, cellPos)
if (!table) return
const cell = getTargetFirstCellDOM(table, draggingIndex, direction)
if (!cell) return
return { table, cell }
}

View File

@@ -0,0 +1,186 @@
// Per-table header-pin controller: native sticky when table fits its wrapper, transform fallback when it doesn't.
import { computePinTop, pinOffsetWatcher } from './offset';
const WRAPPER_NO_OVERFLOW = 'tableWrapperNoOverflow';
const HEADER_PINNED = 'tableHeaderPinned';
const PIN_OFFSET_VAR = '--table-pin-offset';
type PinMode = 'off' | 'native' | 'fallback';
function firstRowIsAllHeaders(row: HTMLTableRowElement | null): boolean {
if (!row) return false;
const cells = Array.from(row.cells);
return cells.length > 0 && cells.every((c) => c.tagName === 'TH');
}
function isNestedTable(wrapper: HTMLElement): boolean {
return wrapper.closest('table .tableWrapper') !== null;
}
function isLayoutInert(rect: DOMRectReadOnly): boolean {
return rect.width === 0 && rect.height === 0;
}
const fallbackControllers = new Set<TablePinController>();
let fallbackScrollListener: (() => void) | null = null;
let fallbackRafPending = false;
function ensureFallbackListener() {
if (fallbackScrollListener) return;
fallbackScrollListener = () => {
if (fallbackRafPending) return;
fallbackRafPending = true;
requestAnimationFrame(() => {
fallbackRafPending = false;
for (const ctrl of fallbackControllers) ctrl.updateFallbackOffset();
});
};
document.addEventListener('scroll', fallbackScrollListener, {
passive: true,
capture: true,
});
}
function maybeTeardownFallbackListener() {
if (!fallbackScrollListener || fallbackControllers.size > 0) return;
document.removeEventListener('scroll', fallbackScrollListener, {
capture: true,
});
fallbackScrollListener = null;
fallbackRafPending = false;
}
export class TablePinController {
private wrapper: HTMLElement;
private table: HTMLTableElement;
private fitsObserver?: IntersectionObserver;
private mode: PinMode = 'off';
private cachedHeaderRow: HTMLTableRowElement | null = null;
constructor(wrapper: HTMLElement, table: HTMLTableElement) {
this.wrapper = wrapper;
this.table = table;
pinOffsetWatcher.acquire();
this.fitsObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) this.evaluateFit(entry);
},
{ root: this.wrapper, threshold: 1 },
);
this.fitsObserver.observe(this.table);
}
private getHeaderRow(): HTMLTableRowElement | null {
if (this.cachedHeaderRow && this.table.contains(this.cachedHeaderRow)) {
return this.cachedHeaderRow;
}
this.cachedHeaderRow = this.table.querySelector('tr');
return this.cachedHeaderRow;
}
private evaluateFit(entry: IntersectionObserverEntry) {
if (!this.isEligible()) {
this.apply('off');
return;
}
if (isLayoutInert(entry.boundingClientRect)) return;
this.apply(entry.isIntersecting ? 'native' : 'fallback');
}
private isEligible(): boolean {
return (
!isNestedTable(this.wrapper) && firstRowIsAllHeaders(this.getHeaderRow())
);
}
private apply(next: PinMode) {
if (next === this.mode) return;
if (this.mode === 'fallback' && next !== 'fallback') {
fallbackControllers.delete(this);
maybeTeardownFallbackListener();
}
this.mode = next;
const cls = this.wrapper.classList;
if (next === 'off') {
cls.remove(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'native') {
cls.add(HEADER_PINNED);
cls.add(WRAPPER_NO_OVERFLOW);
// Native mode reads --editor-pin-offset from :root; clear stale per-wrapper var from fallback.
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'fallback') {
cls.add(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
fallbackControllers.add(this);
ensureFallbackListener();
// Avoid one stale-frame paint under translateY.
this.updateFallbackOffset();
}
}
updateFallbackOffset() {
const pinTop = computePinTop();
const tableRect = this.table.getBoundingClientRect();
const headerRow = this.getHeaderRow();
if (!headerRow) return;
const rowHeight = headerRow.getBoundingClientRect().height;
const active = tableRect.top < pinTop && tableRect.bottom > pinTop + rowHeight;
if (active) {
const offset = Math.min(pinTop - tableRect.top, tableRect.height - rowHeight);
this.wrapper.style.setProperty(PIN_OFFSET_VAR, `${offset}px`);
} else {
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
}
}
refresh() {
// The header <tr> may have been replaced by a PM transaction; drop
// the cached reference before checking eligibility.
this.cachedHeaderRow = null;
if (!this.isEligible()) {
this.apply('off');
return;
}
if (this.mode === 'off') {
// Eligibility just flipped back on; re-trigger the observer so it
// emits the current intersection state.
this.fitsObserver?.unobserve(this.table);
this.fitsObserver?.observe(this.table);
}
}
destroy() {
this.fitsObserver?.disconnect();
this.fitsObserver = undefined;
this.apply('off');
pinOffsetWatcher.release();
}
}
const controllers = new WeakMap<HTMLElement, TablePinController>();
export function attach(wrapper: HTMLElement) {
if (controllers.has(wrapper)) return;
const table = wrapper.querySelector(':scope > table') as HTMLTableElement | null;
if (!table) return;
controllers.set(wrapper, new TablePinController(wrapper, table));
}
export function detach(wrapper: HTMLElement) {
const ctrl = controllers.get(wrapper);
if (!ctrl) return;
ctrl.destroy();
controllers.delete(wrapper);
}
export function getController(wrapper: HTMLElement): TablePinController | undefined {
return controllers.get(wrapper);
}

View File

@@ -0,0 +1,78 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { attach, detach, getController } from './controller';
const tableHeaderPinKey = new PluginKey('tableHeaderPin');
export const TableHeaderPin = Extension.create({
name: 'tableHeaderPin',
addProseMirrorPlugins() {
let editorRoot: HTMLElement | null = null;
let domObserver: MutationObserver | null = null;
const tracked = new Set<HTMLElement>();
let rafHandle: number | null = null;
const reconcile = () => {
rafHandle = null;
if (!editorRoot) return;
const current = new Set(
editorRoot.querySelectorAll<HTMLElement>('.tableWrapper'),
);
for (const w of tracked) {
if (!current.has(w)) {
detach(w);
tracked.delete(w);
}
}
for (const w of current) {
if (!tracked.has(w)) {
attach(w);
tracked.add(w);
}
}
};
const schedule = () => {
if (rafHandle !== null) return;
rafHandle = requestAnimationFrame(reconcile);
};
return [
new Plugin({
key: tableHeaderPinKey,
view(editorView) {
editorRoot = editorView.dom as HTMLElement;
schedule();
domObserver = new MutationObserver(schedule);
domObserver.observe(editorRoot, { subtree: true, childList: true });
return {
update(view, prevState) {
if (!editorRoot) return;
if (view.state.doc === prevState.doc) return;
editorRoot
.querySelectorAll<HTMLElement>('.tableWrapper')
.forEach((w) => getController(w)?.refresh());
},
destroy() {
if (rafHandle !== null) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
domObserver?.disconnect();
domObserver = null;
for (const w of tracked) detach(w);
tracked.clear();
editorRoot = null;
},
};
},
}),
];
},
});

View File

@@ -0,0 +1,2 @@
export { TableHeaderPin } from './extension';
export { pinOffsetWatcher, EDITOR_PIN_OFFSET_VAR, computePinTop } from './offset';

View File

@@ -0,0 +1,65 @@
// Pin-offset measurement and watcher used by the table header-pin controller.
// Fallback app-bar height (px) when no fixed surface is mounted; matches global-app-shell.tsx.
const APP_BAR_FALLBACK_HEIGHT = 45;
export const EDITOR_PIN_OFFSET_VAR = '--editor-pin-offset';
// Selectors for fixed surfaces between viewport top and editor content. Use data attributes —
// CSS module classes are build-time hashed and won't match.
const PIN_ANCHOR_SELECTORS = [
'[data-page-header]',
'[data-fixed-toolbar]',
] as const;
export function computePinTop(): number {
let bottom = APP_BAR_FALLBACK_HEIGHT;
for (const sel of PIN_ANCHOR_SELECTORS) {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.height > 0 && rect.bottom > bottom) bottom = rect.bottom;
}
return bottom;
}
// Reference-counted watcher that publishes the editor's top offset to a CSS custom property.
export const pinOffsetWatcher = {
refs: 0,
resizeObserver: null as ResizeObserver | null,
rafPending: false,
lastValue: -1,
acquire() {
if (this.refs++ > 0) return;
this.publish();
const schedule = () => {
if (this.rafPending) return;
this.rafPending = true;
requestAnimationFrame(() => {
this.rafPending = false;
this.publish();
});
};
this.resizeObserver = new ResizeObserver(schedule);
this.resizeObserver.observe(document.body);
},
release() {
if (--this.refs > 0) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
document.documentElement.style.removeProperty(EDITOR_PIN_OFFSET_VAR);
this.lastValue = -1;
},
publish() {
const top = computePinTop();
if (top === this.lastValue) return;
this.lastValue = top;
document.documentElement.style.setProperty(
EDITOR_PIN_OFFSET_VAR,
`${top}px`,
);
},
};

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