snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)
This commit is contained in:
37
packages/editor-ext/.gitignore
vendored
Normal file
37
packages/editor-ext/.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
.env
|
||||
package-lock.json
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
4
packages/editor-ext/.prettierrc
Normal file
4
packages/editor-ext/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
3
packages/editor-ext/README.md
Normal file
3
packages/editor-ext/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# editor extensions
|
||||
|
||||
Tiptap editor extensions
|
||||
16
packages/editor-ext/package.json
Normal file
16
packages/editor-ext/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@docmost/editor-ext",
|
||||
"homepage": "https://docmost.com",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "./src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"marked": "17.0.5"
|
||||
}
|
||||
}
|
||||
41
packages/editor-ext/src/index.ts
Normal file
41
packages/editor-ext/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export * from "./lib/trailing-node";
|
||||
export * from "./lib/comment/comment";
|
||||
export * from "./lib/utils";
|
||||
export * from "./lib/math";
|
||||
export * from "./lib/details";
|
||||
export * from "./lib/table";
|
||||
export * from "./lib/image";
|
||||
export * from "./lib/video";
|
||||
export * from "./lib/callout";
|
||||
export * from "./lib/media-utils";
|
||||
export * from "./lib/link";
|
||||
export * from "./lib/selection";
|
||||
export * from "./lib/attachment";
|
||||
export * from "./lib/audio";
|
||||
export * from "./lib/custom-code-block";
|
||||
export * from "./lib/drawio";
|
||||
export * from "./lib/excalidraw";
|
||||
export * from "./lib/embed";
|
||||
export * from "./lib/mention";
|
||||
export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/embed-provider";
|
||||
export * from "./lib/subpages";
|
||||
export * from "./lib/transclusion";
|
||||
export * from "./lib/highlight";
|
||||
export * from "./lib/indent";
|
||||
export * from "./lib/heading/heading";
|
||||
export * from "./lib/unique-id";
|
||||
export * from "./lib/shared-storage";
|
||||
export * from "./lib/recreate-transform";
|
||||
export * from "./lib/columns";
|
||||
export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/footnotes";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export {
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from "./lib/prosemirror-docx";
|
||||
export * from "./lib/base-embed";
|
||||
125
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal file
125
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { Command } from "@tiptap/core";
|
||||
|
||||
const findAttachmentNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
if (
|
||||
node.type.name === "attachment" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
const handleAttachmentUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, editor, pos, pageId, allowMedia) => {
|
||||
const validated = validateFn?.(file, allowMedia);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
|
||||
const placeholderId = generateNodeId();
|
||||
|
||||
let placeholderInserted = false;
|
||||
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
},
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (isEmptyTextBlock) {
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const replacePlaceholderWithAttachment = (
|
||||
attachment: IAttachment,
|
||||
): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
// If the placeholder is not found or attachment is missing, abort the process
|
||||
if (currentPos === null || !attachment) return false;
|
||||
|
||||
// Update the placeholder node with the actual attachment data
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
url: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
name: attachment.fileName,
|
||||
mime: attachment.mimeType,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
// Only show the placeholder if the upload takes more than 250ms
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithAttachment(attachment));
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithAttachment(attachment))
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
}
|
||||
};
|
||||
|
||||
export { handleAttachmentUpload };
|
||||
136
packages/editor-ext/src/lib/attachment/attachment.ts
Normal file
136
packages/editor-ext/src/lib/attachment/attachment.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { sanitizeUrl } from "../utils";
|
||||
|
||||
export interface AttachmentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface AttachmentAttributes {
|
||||
url?: string;
|
||||
name?: string;
|
||||
mime?: string; // e.g. application/zip
|
||||
size?: number;
|
||||
attachmentId?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
attachment: {
|
||||
setAttachment: (attributes: AttachmentAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Attachment = Node.create<AttachmentOptions>({
|
||||
name: "attachment",
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
url: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const url = element.getAttribute("data-attachment-url");
|
||||
return sanitizeUrl(url);
|
||||
},
|
||||
renderHTML: (attributes) => ({
|
||||
"data-attachment-url": sanitizeUrl(attributes.url),
|
||||
}),
|
||||
},
|
||||
name: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-name"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-name": attributes.name,
|
||||
}),
|
||||
},
|
||||
mime: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-mime"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-mime": attributes.mime,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-size"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
[
|
||||
"a",
|
||||
{
|
||||
href: sanitizeUrl(HTMLAttributes["data-attachment-url"]),
|
||||
class: "attachment",
|
||||
target: "blank",
|
||||
},
|
||||
`${HTMLAttributes["data-attachment-name"]}`,
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setAttachment:
|
||||
(attrs: AttachmentAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "attachment",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||
this.editor.isInitialized = true;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Attachment } from "./attachment";
|
||||
export * from "./attachment-upload";
|
||||
139
packages/editor-ext/src/lib/audio/audio-upload.ts
Normal file
139
packages/editor-ext/src/lib/audio/audio-upload.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { Command } from "@tiptap/core";
|
||||
|
||||
const findAudioNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
|
||||
if (
|
||||
node.type.name === "audio" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleAudioUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, editor, pos, pageId) => {
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const placeholderId = generateNodeId();
|
||||
|
||||
let placeholderInserted = false;
|
||||
|
||||
editor.storage.shared.audioPreviews =
|
||||
editor.storage.shared.audioPreviews || {};
|
||||
editor.storage.shared.audioPreviews[placeholderId] = objectUrl;
|
||||
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.audio?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
name: file.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (isEmptyTextBlock) {
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const replacePlaceholderWithAudio = (attachment: IAttachment): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null || !attachment) return;
|
||||
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
attachmentId: attachment.id,
|
||||
size: attachment.fileSize,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
|
||||
const disposePreviewFile = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
if (editor.storage.shared.audioPreviews) {
|
||||
delete editor.storage.shared.audioPreviews[placeholderId];
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithAudio(attachment));
|
||||
disposePreviewFile();
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithAudio(attachment))
|
||||
.run();
|
||||
disposePreviewFile();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
disposePreviewFile();
|
||||
}
|
||||
};
|
||||
|
||||
export { handleAudioUpload };
|
||||
134
packages/editor-ext/src/lib/audio/audio.ts
Normal file
134
packages/editor-ext/src/lib/audio/audio.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl } from "../media-utils";
|
||||
import { sanitizeUrl, isInternalFileUrl } from "../utils";
|
||||
|
||||
export interface AudioOptions {
|
||||
view: any;
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AudioAttributes {
|
||||
src?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
placeholder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
audioBlock: {
|
||||
setAudio: (attributes: AudioAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TiptapAudio = Node.create<AudioOptions>({
|
||||
name: "audio",
|
||||
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
view: null,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute("src");
|
||||
const sanitized = sanitizeUrl(src);
|
||||
return isInternalFileUrl(sanitized) ? sanitized : "";
|
||||
},
|
||||
renderHTML: (attributes) => ({
|
||||
src: isInternalFileUrl(attributes.src)
|
||||
? sanitizeUrl(attributes.src)
|
||||
: "",
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: AudioAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: AudioAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "audio",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"audio",
|
||||
mergeAttributes(
|
||||
{ controls: "true", preload: "metadata" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
["source", { src: HTMLAttributes.src }],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setAudio:
|
||||
(attrs: AudioAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "audio",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
if (this.options.view) {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
}
|
||||
|
||||
return ({ node, HTMLAttributes }) => {
|
||||
const dom = document.createElement("div");
|
||||
const audio = document.createElement("audio");
|
||||
const src = node.attrs.src;
|
||||
if (src && isInternalFileUrl(src)) {
|
||||
audio.src = normalizeFileUrl(src);
|
||||
}
|
||||
audio.controls = true;
|
||||
audio.preload = "metadata";
|
||||
audio.style.width = "100%";
|
||||
dom.append(audio);
|
||||
return { dom };
|
||||
};
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/audio/index.ts
Normal file
2
packages/editor-ext/src/lib/audio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TiptapAudio } from "./audio";
|
||||
export * from "./audio-upload";
|
||||
133
packages/editor-ext/src/lib/base-embed/base-embed.ts
Normal file
133
packages/editor-ext/src/lib/base-embed/base-embed.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { EditorState, NodeSelection, Plugin } from '@tiptap/pm/state';
|
||||
|
||||
export interface BaseEmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
baseEmbed: {
|
||||
insertBaseEmbed: (attrs: {
|
||||
pageId: string | null;
|
||||
pendingKey?: string | null;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const BaseEmbed = Node.create<BaseEmbedOptions>({
|
||||
name: 'base',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return { HTMLAttributes: {} };
|
||||
},
|
||||
|
||||
// prosemirror-dropcursor draws a block-boundary indicator on every
|
||||
// `dragover` it sees. Pragmatic-dnd (used for column / choice reorder
|
||||
// inside the embed) fires native `dragstart`/`dragover`, which bubble
|
||||
// up to the editor and trigger dropcursor — visible as a stray blue
|
||||
// line above or below the embed during an internal drag. The cursor
|
||||
// event lands over the atom node, so dropcursor consults
|
||||
// `disableDropCursor` on this node spec; returning true suppresses
|
||||
// the indicator while still letting pragmatic-dnd handle the drag.
|
||||
extendNodeSchema(extension) {
|
||||
return extension.name === 'base'
|
||||
? { disableDropCursor: true }
|
||||
: {};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
pageId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('data-page-id'),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
|
||||
},
|
||||
// Transient marker set when the slash command inserts the embed
|
||||
// before the server has assigned a pageId. The view renders a
|
||||
// skeleton in this state. Cleared once the API responds and the
|
||||
// real pageId is patched in. Not serialized — embeds saved with
|
||||
// a pendingKey would orphan if the page were closed mid-request.
|
||||
pendingKey: {
|
||||
default: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-type="base-embed"]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': 'base-embed',
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertBaseEmbed:
|
||||
(attrs) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({
|
||||
type: this.name,
|
||||
attrs,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
// Block Backspace / Delete when the base embed itself is the
|
||||
// current selection — the "click on the embed and hit delete"
|
||||
// accidental-delete path. Returning true tells TipTap we've
|
||||
// handled the key, preventing the default removal. Range
|
||||
// selections covering the node and programmatic deletes still
|
||||
// work normally.
|
||||
const isThisNodeSelected = (): boolean => {
|
||||
const { selection } = this.editor.state;
|
||||
return (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === this.name
|
||||
);
|
||||
};
|
||||
return {
|
||||
Backspace: () => isThisNodeSelected(),
|
||||
Delete: () => isThisNodeSelected(),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
// Same idea as the Backspace/Delete shortcuts above, but for the
|
||||
// other accidental-delete path: when the embed is the selection,
|
||||
// a typed character or paste would replace the whole node. These
|
||||
// hooks return true (handled, no-op) so the node stays put. The
|
||||
// user can still press an arrow key to deselect and then type.
|
||||
const nodeName = this.name;
|
||||
const isThisNodeSelected = (state: EditorState): boolean => {
|
||||
const { selection } = state;
|
||||
return (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === nodeName
|
||||
);
|
||||
};
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleTextInput: (view) => isThisNodeSelected(view.state),
|
||||
handlePaste: (view) => isThisNodeSelected(view.state),
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/base-embed/index.ts
Normal file
2
packages/editor-ext/src/lib/base-embed/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BaseEmbed } from './base-embed';
|
||||
export type { BaseEmbedOptions } from './base-embed';
|
||||
296
packages/editor-ext/src/lib/callout/callout.ts
Normal file
296
packages/editor-ext/src/lib/callout/callout.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
findParentNode,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
wrappingInputRule,
|
||||
} from "@tiptap/core";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { CalloutType, getValidCalloutType } from "./utils";
|
||||
|
||||
export interface CalloutOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface CalloutAttributes {
|
||||
/**
|
||||
* The type of callout.
|
||||
*/
|
||||
type: CalloutType;
|
||||
/**
|
||||
* The custom icon name for the callout.
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
callout: {
|
||||
setCallout: (attributes?: CalloutAttributes) => ReturnType;
|
||||
unsetCallout: () => ReturnType;
|
||||
toggleCallout: (attributes?: CalloutAttributes) => ReturnType;
|
||||
updateCalloutType: (type: CalloutType) => ReturnType;
|
||||
updateCalloutIcon: (icon: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Matches a callout to a `:::` as input.
|
||||
*/
|
||||
export const inputRegex = /^:::([a-z]+)?[\s\n]$/;
|
||||
|
||||
export const Callout = Node.create<CalloutOptions>({
|
||||
name: "callout",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
type: {
|
||||
default: "info",
|
||||
parseHTML: (element) => element.getAttribute("data-callout-type"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-callout-type": attributes.type,
|
||||
}),
|
||||
},
|
||||
icon: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-callout-icon"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-callout-icon": attributes.icon,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCallout:
|
||||
(attributes) =>
|
||||
({ commands }) => {
|
||||
return commands.setNode(this.name, attributes);
|
||||
},
|
||||
|
||||
unsetCallout:
|
||||
() =>
|
||||
({ commands }) => {
|
||||
return commands.lift(this.name);
|
||||
},
|
||||
|
||||
toggleCallout:
|
||||
(attributes) =>
|
||||
({ commands }) => {
|
||||
return commands.toggleWrap(this.name, attributes);
|
||||
},
|
||||
|
||||
updateCalloutType:
|
||||
(type: string) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("callout", {
|
||||
type: getValidCalloutType(type),
|
||||
}),
|
||||
|
||||
updateCalloutIcon:
|
||||
(icon: string) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("callout", {
|
||||
icon: icon || null,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||
this.editor.isInitialized = true;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
//"Mod-Shift-c": () => this.editor.commands.toggleCallout(),
|
||||
|
||||
/**
|
||||
* Handle the backspace key when deleting content.
|
||||
* Aims to stop merging callouts when deleting content in between.
|
||||
*/
|
||||
Backspace: ({ editor }) => {
|
||||
const { state, view } = editor;
|
||||
const { selection } = state;
|
||||
|
||||
// If the selection is not empty, return false
|
||||
// and let other extension handle the deletion.
|
||||
if (!selection.empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { $from } = selection;
|
||||
|
||||
// If not at the start of current node, no joining will happen
|
||||
if ($from.parentOffset !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Empty callout: delete the whole node so Backspace inside it isn't
|
||||
// a no-op (isolating: true blocks the default join with the block
|
||||
// above).
|
||||
const calloutDepth = $from.depth - 1;
|
||||
if (calloutDepth >= 0) {
|
||||
const calloutNode = $from.node(calloutDepth);
|
||||
if (
|
||||
calloutNode.type === this.type &&
|
||||
calloutNode.childCount === 1 &&
|
||||
calloutNode.firstChild?.content.size === 0
|
||||
) {
|
||||
const calloutPos = $from.before(calloutDepth);
|
||||
const { tr } = state;
|
||||
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(calloutPos), -1),
|
||||
);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const previousPosition = $from.before($from.depth) - 1;
|
||||
|
||||
// If nothing above to join with
|
||||
if (previousPosition < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousPos = state.doc.resolve(previousPosition);
|
||||
|
||||
// If resolving previous position fails, bail out
|
||||
if (!previousPos?.parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousNode = previousPos.parent;
|
||||
const parentNode = findParentNode(() => true)(selection);
|
||||
|
||||
if (!parentNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { node, pos, depth } = parentNode;
|
||||
|
||||
// If current node is nested
|
||||
if (depth !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If previous node is a callout, cut current node's content into it
|
||||
if (node.type !== this.type && previousNode.type === this.type) {
|
||||
const { content, nodeSize } = node;
|
||||
const { tr } = state;
|
||||
|
||||
tr.delete(pos, pos + nodeSize);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(previousPosition - 1))
|
||||
);
|
||||
tr.insert(previousPosition - 1, content);
|
||||
|
||||
view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Exit the callout into a fresh paragraph below when the cursor sits
|
||||
// in an empty trailing child. An empty callout (single empty
|
||||
// paragraph) exits on the first Enter and keeps the empty callout
|
||||
// intact; a callout with content needs the double-Enter pattern
|
||||
// (first Enter splits, second Enter on the new trailing empty exits
|
||||
// and removes that trailing paragraph).
|
||||
Enter: ({ editor }) => {
|
||||
const { state, view } = editor;
|
||||
const { selection } = state;
|
||||
if (!selection.empty) return false;
|
||||
|
||||
const { $from } = selection;
|
||||
const calloutDepth = $from.depth - 1;
|
||||
if (calloutDepth < 0) return false;
|
||||
|
||||
const calloutNode = $from.node(calloutDepth);
|
||||
if (calloutNode.type !== this.type) return false;
|
||||
if ($from.parent.content.size !== 0) return false;
|
||||
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paragraphType = state.schema.nodes.paragraph;
|
||||
const containerDepth = calloutDepth - 1;
|
||||
const container = $from.node(containerDepth);
|
||||
const indexAfter = $from.indexAfter(containerDepth);
|
||||
if (
|
||||
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const calloutEnd = $from.after(calloutDepth);
|
||||
const paragraph = paragraphType.create();
|
||||
const { tr } = state;
|
||||
|
||||
if (calloutNode.childCount === 1) {
|
||||
tr.insert(calloutEnd, paragraph);
|
||||
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
|
||||
} else {
|
||||
tr.delete($from.before(), $from.after());
|
||||
const insertPos = tr.mapping.map(calloutEnd);
|
||||
tr.insert(insertPos, paragraph);
|
||||
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
wrappingInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
type: getValidCalloutType(match[1]),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/callout/index.ts
Normal file
2
packages/editor-ext/src/lib/callout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Callout } from "./callout";
|
||||
export * from "./utils";
|
||||
21
packages/editor-ext/src/lib/callout/utils.ts
Normal file
21
packages/editor-ext/src/lib/callout/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type CalloutType =
|
||||
| 'default'
|
||||
| 'info'
|
||||
| 'note'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger';
|
||||
const validCalloutTypes = [
|
||||
'default',
|
||||
'info',
|
||||
'note',
|
||||
'success',
|
||||
'warning',
|
||||
'danger',
|
||||
];
|
||||
|
||||
export function getValidCalloutType(value: string): string {
|
||||
if (value) {
|
||||
return validCalloutTypes.includes(value) ? value : 'info';
|
||||
}
|
||||
}
|
||||
127
packages/editor-ext/src/lib/columns/column.ts
Normal file
127
packages/editor-ext/src/lib/columns/column.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
export interface ColumnOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ColumnAttributes {
|
||||
width?: number | null;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
column: {
|
||||
setColumnWidth: (width: number | null) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Column = Node.create<ColumnOptions>({
|
||||
name: "column",
|
||||
group: "block",
|
||||
content: "block+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const value = element.getAttribute("data-width");
|
||||
return value ? parseFloat(value) : null;
|
||||
},
|
||||
renderHTML: (attributes: ColumnAttributes) => {
|
||||
if (!attributes.width) return {};
|
||||
return {
|
||||
"data-width": attributes.width,
|
||||
style: `flex: ${attributes.width}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const jumpToColumn = (direction: 1 | -1) => () => {
|
||||
const { state, dispatch } = this.editor.view;
|
||||
|
||||
const columns = findParentNode(
|
||||
(node) => node.type.name === "columns",
|
||||
)(state.selection);
|
||||
if (!columns) return false;
|
||||
|
||||
const column = findParentNode(
|
||||
(node) => node.type.name === "column",
|
||||
)(state.selection);
|
||||
if (!column) return false;
|
||||
|
||||
let currentIndex = -1;
|
||||
columns.node.forEach((_child, offset, index) => {
|
||||
if (columns.pos + 1 + offset === column.pos) {
|
||||
currentIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
const targetIndex = currentIndex + direction;
|
||||
if (targetIndex < 0 || targetIndex >= columns.node.childCount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
for (let j = 0; j < targetIndex; j++) {
|
||||
offset += columns.node.child(j).nodeSize;
|
||||
}
|
||||
|
||||
const targetPos = columns.pos + 1 + offset + 1 + 1;
|
||||
if (dispatch) {
|
||||
dispatch(
|
||||
state.tr.setSelection(TextSelection.create(state.doc, targetPos)),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
Tab: jumpToColumn(1),
|
||||
"Shift-Tab": jumpToColumn(-1),
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setColumnWidth:
|
||||
(width) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("column", { width }),
|
||||
};
|
||||
},
|
||||
});
|
||||
237
packages/editor-ext/src/lib/columns/columns.ts
Normal file
237
packages/editor-ext/src/lib/columns/columns.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
|
||||
import { Fragment, Node as PMNode } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
export type ColumnsLayout =
|
||||
| "two_equal"
|
||||
| "two_left_sidebar"
|
||||
| "two_right_sidebar"
|
||||
| "three_equal"
|
||||
| "three_left_wide"
|
||||
| "three_right_wide"
|
||||
| "three_with_sidebars"
|
||||
| "four_equal"
|
||||
| "five_equal";
|
||||
|
||||
export interface ColumnsOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export type WidthMode = "normal" | "wide";
|
||||
|
||||
export interface ColumnsAttributes {
|
||||
layout?: ColumnsLayout;
|
||||
widthMode?: WidthMode;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
columns: {
|
||||
insertColumns: (attributes?: ColumnsAttributes) => ReturnType;
|
||||
setColumnsWidthMode: (widthMode: WidthMode) => ReturnType;
|
||||
setColumnCount: (count: number) => ReturnType;
|
||||
setColumnsLayout: (layout: ColumnsLayout) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function columnCountFromLayout(layout: string): number {
|
||||
if (layout.startsWith("five")) return 5;
|
||||
if (layout.startsWith("four")) return 4;
|
||||
if (layout.startsWith("three")) return 3;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function defaultLayoutForCount(count: number): ColumnsLayout {
|
||||
if (count === 3) return "three_equal";
|
||||
if (count === 4) return "four_equal";
|
||||
if (count === 5) return "five_equal";
|
||||
return "two_equal";
|
||||
}
|
||||
|
||||
export const Columns = Node.create<ColumnsOptions>({
|
||||
name: "columns",
|
||||
group: "block",
|
||||
content: "column+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
layout: {
|
||||
default: "two_equal",
|
||||
parseHTML: (element) => element.getAttribute("data-layout"),
|
||||
renderHTML: (attributes: ColumnsAttributes) => ({
|
||||
"data-layout": attributes.layout,
|
||||
}),
|
||||
},
|
||||
widthMode: {
|
||||
default: "normal",
|
||||
parseHTML: (element) =>
|
||||
element.getAttribute("data-width-mode") || "normal",
|
||||
renderHTML: (attributes: ColumnsAttributes) => {
|
||||
if (!attributes.widthMode || attributes.widthMode === "normal")
|
||||
return {};
|
||||
return { "data-width-mode": attributes.widthMode };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertColumns:
|
||||
(attributes) =>
|
||||
({ tr, state, dispatch }) => {
|
||||
const layout = attributes?.layout || "two_equal";
|
||||
const count = columnCountFromLayout(layout);
|
||||
|
||||
const columnType = state.schema.nodes.column;
|
||||
const paraType = state.schema.nodes.paragraph;
|
||||
const children = Array.from({ length: count }, () =>
|
||||
columnType.create(null, paraType.create()),
|
||||
);
|
||||
const columnsNode = this.type.create(
|
||||
attributes,
|
||||
Fragment.from(children),
|
||||
);
|
||||
|
||||
const stepsBefore = tr.steps.length;
|
||||
tr.replaceSelectionWith(columnsNode);
|
||||
|
||||
if (tr.steps.length > stepsBefore) {
|
||||
const stepMap = tr.steps[tr.steps.length - 1].getMap();
|
||||
let insertStart = 0;
|
||||
stepMap.forEach((_from, _to, newFrom) => {
|
||||
insertStart = newFrom;
|
||||
});
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(insertStart + 1), 1),
|
||||
);
|
||||
}
|
||||
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
setColumnsWidthMode:
|
||||
(widthMode) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("columns", { widthMode }),
|
||||
|
||||
setColumnCount:
|
||||
(count: number) =>
|
||||
({ tr, state }) => {
|
||||
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||
const parent = findParentNode(predicate)(state.selection);
|
||||
if (!parent) return false;
|
||||
|
||||
const { node: columnsNode, pos: parentPos } = parent;
|
||||
const currentCount = columnsNode.childCount;
|
||||
if (count === currentCount || count < 2 || count > 5) return false;
|
||||
|
||||
const columnType = state.schema.nodes.column;
|
||||
const paraType = state.schema.nodes.paragraph;
|
||||
const newChildren: PMNode[] = [];
|
||||
|
||||
if (count > currentCount) {
|
||||
for (let i = 0; i < currentCount; i++) {
|
||||
newChildren.push(columnsNode.child(i));
|
||||
}
|
||||
for (let i = currentCount; i < count; i++) {
|
||||
newChildren.push(columnType.create(null, paraType.create()));
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < count - 1; i++) {
|
||||
newChildren.push(columnsNode.child(i));
|
||||
}
|
||||
let mergedContent = columnsNode.child(count - 1).content;
|
||||
for (let j = count; j < currentCount; j++) {
|
||||
const col = columnsNode.child(j);
|
||||
const nonEmpty: PMNode[] = [];
|
||||
col.content.forEach((child) => {
|
||||
if (
|
||||
child.type.name !== "paragraph" ||
|
||||
child.content.size > 0
|
||||
) {
|
||||
nonEmpty.push(child);
|
||||
}
|
||||
});
|
||||
if (nonEmpty.length > 0) {
|
||||
mergedContent = mergedContent.append(
|
||||
Fragment.from(nonEmpty),
|
||||
);
|
||||
}
|
||||
}
|
||||
newChildren.push(columnType.create(null, mergedContent));
|
||||
}
|
||||
|
||||
const newLayout = defaultLayoutForCount(count);
|
||||
const newNode = columnsNode.type.create(
|
||||
{ ...columnsNode.attrs, layout: newLayout },
|
||||
Fragment.from(newChildren),
|
||||
);
|
||||
tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(parentPos + 1), 1),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
setColumnsLayout:
|
||||
(layout) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("columns", { layout }),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("columnsFocus"),
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const parent = findParentNode(
|
||||
(node) => node.type.name === "columns",
|
||||
)(state.selection);
|
||||
if (!parent) return DecorationSet.empty;
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.node(
|
||||
parent.pos,
|
||||
parent.pos + parent.node.nodeSize,
|
||||
{ class: "has-focus" },
|
||||
),
|
||||
]);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
4
packages/editor-ext/src/lib/columns/index.ts
Normal file
4
packages/editor-ext/src/lib/columns/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Columns } from "./columns";
|
||||
export type { ColumnsOptions, ColumnsAttributes, ColumnsLayout, WidthMode } from "./columns";
|
||||
export { Column } from "./column";
|
||||
export type { ColumnOptions, ColumnAttributes } from "./column";
|
||||
35
packages/editor-ext/src/lib/comment/comment-decoration.ts
Normal file
35
packages/editor-ext/src/lib/comment/comment-decoration.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { commentDecorationMetaKey, commentMarkClass } from './comment';
|
||||
|
||||
export function commentDecoration(): Plugin {
|
||||
const commentDecorationPlugin = new PluginKey('commentDecoration');
|
||||
|
||||
return new Plugin({
|
||||
key: commentDecorationPlugin,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, oldSet) {
|
||||
const decorationMeta = tr.getMeta(commentDecorationMetaKey);
|
||||
|
||||
if (decorationMeta) {
|
||||
const { from, to } = tr.selection;
|
||||
const decoration = Decoration.inline(from, to, { class: commentMarkClass });
|
||||
return DecorationSet.create(tr.doc, [decoration]);
|
||||
} else if (decorationMeta === false) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
return oldSet.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
return commentDecorationPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
217
packages/editor-ext/src/lib/comment/comment.ts
Normal file
217
packages/editor-ext/src/lib/comment/comment.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
import { commentDecoration } from "./comment-decoration";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
|
||||
export interface ICommentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ICommentStorage {
|
||||
activeCommentId: string | null;
|
||||
}
|
||||
|
||||
export const commentMarkClass = "comment-mark";
|
||||
export const commentDecorationMetaKey = "decorateComment";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
comment: {
|
||||
setCommentDecoration: () => ReturnType;
|
||||
unsetCommentDecoration: () => ReturnType;
|
||||
setComment: (commentId: string) => ReturnType;
|
||||
unsetComment: (commentId: string) => ReturnType;
|
||||
setCommentResolved: (commentId: string, resolved: boolean) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
name: "comment",
|
||||
exitable: true,
|
||||
inclusive: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
activeCommentId: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
commentId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-comment-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.commentId) return;
|
||||
|
||||
return {
|
||||
"data-comment-id": attributes.commentId,
|
||||
};
|
||||
},
|
||||
},
|
||||
resolved: {
|
||||
default: false,
|
||||
parseHTML: (element) => element.hasAttribute("data-resolved"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.resolved) return {};
|
||||
|
||||
return {
|
||||
"data-resolved": "true",
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "span[data-comment-id]",
|
||||
getAttrs: (el) => {
|
||||
const element = el as HTMLSpanElement;
|
||||
const commentId = element.getAttribute("data-comment-id")?.trim();
|
||||
const resolved = element.hasAttribute("data-resolved");
|
||||
|
||||
if (!commentId) return false;
|
||||
|
||||
return {
|
||||
commentId,
|
||||
resolved,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCommentDecoration:
|
||||
() =>
|
||||
({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, true);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
unsetCommentDecoration:
|
||||
() =>
|
||||
({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, false);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
setComment:
|
||||
(commentId) =>
|
||||
({ commands }) => {
|
||||
if (!commentId) return false;
|
||||
// Just add the new mark, do not remove existing ones
|
||||
return commands.setMark(this.name, { commentId, resolved: false });
|
||||
},
|
||||
unsetComment:
|
||||
(commentId) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (!commentId) return false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
|
||||
const commentMark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type.name === this.name &&
|
||||
mark.attrs.commentId === commentId
|
||||
);
|
||||
|
||||
if (commentMark) {
|
||||
tr = tr.removeMark(from, to, commentMark);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
setCommentResolved:
|
||||
(commentId, resolved) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (!commentId) return false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
|
||||
const commentMark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type.name === this.name &&
|
||||
mark.attrs.commentId === commentId
|
||||
);
|
||||
|
||||
if (commentMark) {
|
||||
// Remove the existing mark and add a new one with updated resolved state
|
||||
tr = tr.removeMark(from, to, commentMark);
|
||||
tr = tr.addMark(
|
||||
from,
|
||||
to,
|
||||
this.type.create({
|
||||
commentId: commentMark.attrs.commentId,
|
||||
resolved: resolved,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const commentId = HTMLAttributes?.["data-comment-id"] || null;
|
||||
const resolved = HTMLAttributes?.["data-resolved"] || false;
|
||||
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
class: resolved ? "comment-mark resolved" : "comment-mark",
|
||||
"data-comment-id": commentId,
|
||||
...(resolved && { "data-resolved": "true" }),
|
||||
}),
|
||||
0,
|
||||
];
|
||||
}
|
||||
|
||||
const elem = document.createElement("span");
|
||||
|
||||
Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
|
||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
||||
|
||||
// Add resolved class if the comment is resolved
|
||||
if (resolved) {
|
||||
elem.classList.add("resolved");
|
||||
}
|
||||
|
||||
elem.addEventListener("click", (e) => {
|
||||
const selection = document.getSelection();
|
||||
if (selection.type === "Range") return;
|
||||
|
||||
this.storage.activeCommentId = commentId;
|
||||
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
|
||||
bubbles: true,
|
||||
detail: { commentId, resolved },
|
||||
});
|
||||
|
||||
elem.dispatchEvent(commentEventClick);
|
||||
});
|
||||
|
||||
return elem;
|
||||
},
|
||||
|
||||
addProseMirrorPlugins(): Plugin[] {
|
||||
return [commentDecoration()];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
|
||||
import CodeBlock from '@tiptap/extension-code-block';
|
||||
import { Plugin, Selection, TextSelection } from '@tiptap/pm/state';
|
||||
import { GapCursor } from '@tiptap/pm/gapcursor';
|
||||
|
||||
import { LowlightPlugin } from './lowlight-plugin.js';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
|
||||
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
|
||||
/**
|
||||
* The lowlight instance.
|
||||
*/
|
||||
lowlight: any;
|
||||
view: any;
|
||||
}
|
||||
|
||||
const TAB_CHAR = '\u00A0\u00A0';
|
||||
|
||||
/**
|
||||
* This extension allows you to highlight code blocks with lowlight.
|
||||
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
||||
*/
|
||||
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
// Run ahead of Gapcursor (100) so the mermaid arrow-into-source plugin
|
||||
// can intercept before gapcursor takes over.
|
||||
priority: 101,
|
||||
selectable: true,
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lowlight: {},
|
||||
languageClassPrefix: 'language-',
|
||||
exitOnTripleEnter: true,
|
||||
exitOnArrowDown: true,
|
||||
defaultLanguage: null,
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const isMermaid = (node: any) =>
|
||||
node?.type === this.type && node.attrs.language === 'mermaid';
|
||||
|
||||
return {
|
||||
...this.parent?.(),
|
||||
// Stop at the gap (or enter mermaid source) instead of jumping
|
||||
// straight into the next block, so the user can place a cursor
|
||||
// between two adjacent isolating blocks.
|
||||
ArrowDown: ({ editor }) => {
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) return false;
|
||||
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
||||
|
||||
const after = $from.after();
|
||||
if (after >= doc.content.size) {
|
||||
return editor.commands.exitCode();
|
||||
}
|
||||
|
||||
const $after = doc.resolve(after);
|
||||
const nodeAfter = $after.nodeAfter;
|
||||
|
||||
if (isMermaid(nodeAfter)) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
nodeAfter?.type.spec.isolating &&
|
||||
!nodeAfter.type.spec.atom
|
||||
) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(new GapCursor(tr.doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
},
|
||||
// Mirror of ArrowDown; upstream has no ArrowUp handler.
|
||||
ArrowUp: ({ editor }) => {
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) return false;
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
|
||||
const before = $from.before();
|
||||
if (before <= 0) return false;
|
||||
|
||||
const $before = doc.resolve(before);
|
||||
const nodeBefore = $before.nodeBefore;
|
||||
|
||||
if (isMermaid(nodeBefore)) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(TextSelection.create(tr.doc, before - 1));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
nodeBefore?.type.spec.isolating &&
|
||||
!nodeBefore.type.spec.atom
|
||||
) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(new GapCursor(tr.doc.resolve(before)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
'Mod-a': () => {
|
||||
if (this.editor.isActive('codeBlock')) {
|
||||
const { state } = this.editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
let codeBlockNode = null;
|
||||
let codeBlockPos = null;
|
||||
let depth = 0;
|
||||
|
||||
for (depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === 'codeBlock') {
|
||||
codeBlockNode = node;
|
||||
codeBlockPos = $from.start(depth) - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeBlockNode && codeBlockPos !== null) {
|
||||
const codeBlockStart = codeBlockPos;
|
||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
||||
|
||||
const contentStart = codeBlockStart + 1;
|
||||
const contentEnd = codeBlockEnd - 1;
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from: contentStart,
|
||||
to: contentEnd,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||
this.editor.isInitialized = true;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const codeBlockType = this.type;
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
LowlightPlugin({
|
||||
name: this.name,
|
||||
lowlight: this.options.lowlight,
|
||||
defaultLanguage: this.options.defaultLanguage,
|
||||
}),
|
||||
// Mermaid hides its <pre> when unselected, so the browser's native
|
||||
// vertical caret movement skips past it. Land the cursor inside the
|
||||
// source explicitly.
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
|
||||
return false;
|
||||
}
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
if (
|
||||
!selection.empty ||
|
||||
!(selection instanceof TextSelection)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const { $from } = selection;
|
||||
if ($from.depth === 0 || $from.parent.type === codeBlockType) {
|
||||
return false;
|
||||
}
|
||||
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
|
||||
if (!view.endOfTextblock(dir)) return false;
|
||||
|
||||
const isMermaid = (node: any) =>
|
||||
node?.type === codeBlockType && node.attrs.language === 'mermaid';
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
const beforePos = $from.before();
|
||||
const prev = state.doc.resolve(beforePos).nodeBefore;
|
||||
if (!isMermaid(prev)) return false;
|
||||
const endPos = beforePos - 1;
|
||||
view.dispatch(
|
||||
state.tr.setSelection(
|
||||
TextSelection.create(state.doc, endPos),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
||||
const afterPos = $from.after();
|
||||
const next = state.doc.resolve(afterPos).nodeAfter;
|
||||
if (!isMermaid(next)) return false;
|
||||
const startPos = afterPos + 1;
|
||||
view.dispatch(
|
||||
state.tr.setSelection(
|
||||
TextSelection.create(state.doc, startPos),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
1
packages/editor-ext/src/lib/custom-code-block/index.ts
Normal file
1
packages/editor-ext/src/lib/custom-code-block/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CustomCodeBlock } from "./custom-code-block";
|
||||
202
packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts
Normal file
202
packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { findChildren } from '@tiptap/core';
|
||||
import type { Node as ProsemirrorNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
// @ts-ignore
|
||||
import highlight from 'highlight.js/lib/core';
|
||||
|
||||
function parseNodes(
|
||||
nodes: any[],
|
||||
className: string[] = [],
|
||||
): { text: string; classes: string[] }[] {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const classes = [
|
||||
...className,
|
||||
...(node.properties ? node.properties.className : []),
|
||||
];
|
||||
|
||||
if (node.children) {
|
||||
return parseNodes(node.children, classes);
|
||||
}
|
||||
|
||||
return {
|
||||
text: node.value,
|
||||
classes,
|
||||
};
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function getHighlightNodes(result: any) {
|
||||
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||
return result.value || result.children || [];
|
||||
}
|
||||
|
||||
function registered(aliasOrLanguage: string) {
|
||||
return Boolean(highlight.getLanguage(aliasOrLanguage));
|
||||
}
|
||||
|
||||
// Max characters to sample for auto-detection to avoid performance issues with large code blocks
|
||||
const AUTO_DETECT_SAMPLE_SIZE = 3000;
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
doc: ProsemirrorNode;
|
||||
name: string;
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
|
||||
let from = block.pos + 1;
|
||||
const language = block.node.attrs.language || defaultLanguage;
|
||||
const languages = lowlight.listLanguages();
|
||||
const textContent = block.node.textContent;
|
||||
|
||||
let nodes;
|
||||
if (
|
||||
language &&
|
||||
(languages.includes(language) ||
|
||||
registered(language) ||
|
||||
lowlight.registered?.(language))
|
||||
) {
|
||||
nodes = getHighlightNodes(lowlight.highlight(language, textContent));
|
||||
} else {
|
||||
// For auto-detection, sample a limited portion to detect the language,
|
||||
// then highlight the full content with the detected language
|
||||
const sample =
|
||||
textContent.length > AUTO_DETECT_SAMPLE_SIZE
|
||||
? textContent.slice(0, AUTO_DETECT_SAMPLE_SIZE)
|
||||
: textContent;
|
||||
const autoResult = lowlight.highlightAuto(sample);
|
||||
const detectedLanguage = autoResult.data?.language;
|
||||
if (detectedLanguage && textContent.length > AUTO_DETECT_SAMPLE_SIZE) {
|
||||
nodes = getHighlightNodes(
|
||||
lowlight.highlight(detectedLanguage, textContent),
|
||||
);
|
||||
} else {
|
||||
nodes = getHighlightNodes(autoResult);
|
||||
}
|
||||
}
|
||||
|
||||
parseNodes(nodes).forEach((node) => {
|
||||
const to = from + node.text.length;
|
||||
|
||||
if (node.classes.length) {
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: node.classes.join(' '),
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
}
|
||||
|
||||
from = to;
|
||||
});
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
function isFunction(param: any): param is Function {
|
||||
return typeof param === 'function';
|
||||
}
|
||||
|
||||
export function LowlightPlugin({
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
name: string;
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
if (
|
||||
!['highlight', 'highlightAuto', 'listLanguages'].every((api) =>
|
||||
isFunction(lowlight[api]),
|
||||
)
|
||||
) {
|
||||
throw Error(
|
||||
'You should provide an instance of lowlight to use the code-block-lowlight extension',
|
||||
);
|
||||
}
|
||||
|
||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||
key: new PluginKey('lowlight'),
|
||||
|
||||
state: {
|
||||
init: (_, { doc }) =>
|
||||
getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}),
|
||||
apply: (transaction, decorationSet, oldState, newState) => {
|
||||
const oldNodeName = oldState.selection.$head.parent.type.name;
|
||||
const newNodeName = newState.selection.$head.parent.type.name;
|
||||
const oldNodes = findChildren(
|
||||
oldState.doc,
|
||||
(node) => node.type.name === name,
|
||||
);
|
||||
const newNodes = findChildren(
|
||||
newState.doc,
|
||||
(node) => node.type.name === name,
|
||||
);
|
||||
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
// Apply decorations if:
|
||||
// selection includes named node,
|
||||
([oldNodeName, newNodeName].includes(name) ||
|
||||
// OR transaction adds/removes named node,
|
||||
newNodes.length !== oldNodes.length ||
|
||||
// OR transaction has changes that completely encapsulte a node
|
||||
// (for example, a transaction that affects the entire document).
|
||||
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
||||
transaction.steps.some((step) => {
|
||||
// @ts-ignore
|
||||
return (
|
||||
// @ts-ignore
|
||||
step.from !== undefined &&
|
||||
// @ts-ignore
|
||||
step.to !== undefined &&
|
||||
oldNodes.some((node) => {
|
||||
// @ts-ignore
|
||||
return (
|
||||
// @ts-ignore
|
||||
node.pos >= step.from &&
|
||||
// @ts-ignore
|
||||
node.pos + node.node.nodeSize <= step.to
|
||||
);
|
||||
})
|
||||
);
|
||||
}))
|
||||
) {
|
||||
return getDecorations({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
});
|
||||
}
|
||||
|
||||
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return lowlightPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return lowlightPlugin;
|
||||
}
|
||||
111
packages/editor-ext/src/lib/details/details-content.ts
Normal file
111
packages/editor-ext/src/lib/details/details-content.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Node,
|
||||
defaultBlockAt,
|
||||
findParentNode,
|
||||
mergeAttributes,
|
||||
} from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
|
||||
export interface DetailsContentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const DetailsContent = Node.create<DetailsContentOptions>({
|
||||
name: "detailsContent",
|
||||
group: "block",
|
||||
content: "block*",
|
||||
defining: true,
|
||||
selectable: false,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
const view = editor.view;
|
||||
const state = editor.state;
|
||||
const selection = state.selection;
|
||||
|
||||
const findNode = findParentNode((node) => node.type.name === this.name)(
|
||||
selection,
|
||||
);
|
||||
if (!selection.empty || !findNode || !findNode.node.childCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const childCount = findNode.node.childCount;
|
||||
if (!(childCount === selection.$from.index(findNode.depth) + 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fillNode =
|
||||
findNode.node.type.contentMatch.defaultType?.createAndFill();
|
||||
if (!fillNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastNode = findNode.node.child(childCount - 1);
|
||||
if (!lastNode.eq(fillNode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootNode = selection.$from.node(-3);
|
||||
if (!rootNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const indexAfter = selection.$from.indexAfter(-3);
|
||||
const nodeType = defaultBlockAt(rootNode.contentMatchAt(indexAfter));
|
||||
if (
|
||||
!nodeType ||
|
||||
!rootNode.canReplaceWith(indexAfter, indexAfter, nodeType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const defaultNode = nodeType.createAndFill();
|
||||
if (!defaultNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tr = state.tr;
|
||||
const after = selection.$from.after(-2);
|
||||
tr.replaceWith(after, after, defaultNode);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(after), 1));
|
||||
|
||||
const from = state.doc
|
||||
.resolve(findNode.pos + 1)
|
||||
.posAtIndex(childCount - 1, findNode.depth);
|
||||
const to = from + lastNode.nodeSize;
|
||||
tr.delete(from, to);
|
||||
tr.scrollIntoView();
|
||||
view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
96
packages/editor-ext/src/lib/details/details-summary.ts
Normal file
96
packages/editor-ext/src/lib/details/details-summary.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Node, defaultBlockAt, mergeAttributes } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
|
||||
export interface DetailsSummaryOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const DetailsSummary = Node.create<DetailsSummaryOptions>({
|
||||
name: "detailsSummary",
|
||||
group: "block",
|
||||
content: "inline*",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "summary",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"summary",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const state = editor.state;
|
||||
const selection = state.selection;
|
||||
if (selection.$anchor.parent.type.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
if (selection.$anchor.parentOffset !== 0) {
|
||||
return false;
|
||||
}
|
||||
return editor.chain().unsetDetails().focus().run();
|
||||
},
|
||||
Enter: ({ editor }) => {
|
||||
const view = editor.view;
|
||||
const state = editor.state;
|
||||
|
||||
const head = state.selection.$head;
|
||||
if (head.parent.type.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasOffset =
|
||||
// @ts-ignore
|
||||
view.domAtPos(head.after() + 1).node.offsetParent !== null;
|
||||
const findNode = hasOffset
|
||||
? state.doc.nodeAt(head.after())
|
||||
: head.node(-2);
|
||||
if (!findNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const indexAfter = hasOffset ? 0 : head.indexAfter(-1);
|
||||
const nodeType = defaultBlockAt(findNode.contentMatchAt(indexAfter));
|
||||
if (
|
||||
!nodeType ||
|
||||
!findNode.canReplaceWith(indexAfter, indexAfter, nodeType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const defaultNode = nodeType.createAndFill();
|
||||
if (!defaultNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tr = state.tr;
|
||||
const after = hasOffset ? head.after() + 1 : head.after(-1);
|
||||
tr.replaceWith(after, after, defaultNode);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(after), 1));
|
||||
|
||||
tr.scrollIntoView();
|
||||
view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
245
packages/editor-ext/src/lib/details/details.ts
Normal file
245
packages/editor-ext/src/lib/details/details.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
Node,
|
||||
findChildren,
|
||||
findParentNode,
|
||||
mergeAttributes,
|
||||
wrappingInputRule,
|
||||
} from "@tiptap/core";
|
||||
import { icon, setAttributes } from "../utils";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
details: {
|
||||
setDetails: () => ReturnType;
|
||||
unsetDetails: () => ReturnType;
|
||||
toggleDetails: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface DetailsOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const Details = Node.create<DetailsOptions>({
|
||||
name: "details",
|
||||
group: "block",
|
||||
content: "detailsSummary detailsContent",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
// @ts-ignore
|
||||
allowGapCursor: false,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
open: {
|
||||
default: false,
|
||||
parseHTML: (e) => e.getAttribute("open"),
|
||||
renderHTML: (a) => (a.open ? { open: "" } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "details",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"details",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => {
|
||||
const dom = document.createElement("div");
|
||||
const btn = document.createElement("button");
|
||||
const ico = document.createElement("div");
|
||||
const div = document.createElement("div");
|
||||
|
||||
for (const [key, value] of Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes),
|
||||
)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
dom.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
dom.setAttribute("data-type", this.name);
|
||||
btn.setAttribute("data-type", `${this.name}Button`);
|
||||
div.setAttribute("data-type", `${this.name}Container`);
|
||||
|
||||
if (editor.isEditable) {
|
||||
if (node.attrs.open) {
|
||||
dom.setAttribute("open", "true");
|
||||
} else {
|
||||
dom.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
|
||||
ico.innerHTML = icon("right-line");
|
||||
btn.addEventListener("click", () => {
|
||||
const open = !dom.hasAttribute("open");
|
||||
|
||||
if (!editor.isEditable) {
|
||||
// In readonly mode, toggle the 'open' attribute without updating the document state.
|
||||
if (open) {
|
||||
dom.setAttribute("open", "true");
|
||||
} else {
|
||||
dom.removeAttribute("open");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAttributes(editor, getPos, { ...node.attrs, open });
|
||||
});
|
||||
|
||||
btn.append(ico);
|
||||
dom.append(btn);
|
||||
dom.append(div);
|
||||
return {
|
||||
dom,
|
||||
contentDOM: div,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
if (!editor.isEditable) return true;
|
||||
if (updatedNode.attrs.open) {
|
||||
dom.setAttribute("open", "true");
|
||||
} else {
|
||||
dom.removeAttribute("open");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setDetails: () => {
|
||||
return ({ state, chain }) => {
|
||||
const range = state.selection.$from.blockRange(state.selection.$to);
|
||||
if (!range) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = state.doc.slice(range.start, range.end);
|
||||
|
||||
if (slice.content.firstChild.type.name === "detailsSummary")
|
||||
return false;
|
||||
|
||||
if (
|
||||
!state.schema.nodes.detailsContent.contentMatch.matchFragment(
|
||||
slice.content,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return chain()
|
||||
.insertContentAt(
|
||||
{
|
||||
from: range.start,
|
||||
to: range.end,
|
||||
},
|
||||
{
|
||||
type: this.name,
|
||||
attrs: {
|
||||
open: true,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "detailsSummary",
|
||||
},
|
||||
{
|
||||
type: "detailsContent",
|
||||
content: slice.toJSON()?.content ?? [],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.setTextSelection(range.start + 2)
|
||||
.run();
|
||||
};
|
||||
},
|
||||
|
||||
unsetDetails: () => {
|
||||
return ({ state, chain }) => {
|
||||
const parent = findParentNode((node) => node.type === this.type)(
|
||||
state.selection,
|
||||
);
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = findChildren(
|
||||
parent.node,
|
||||
(node) => node.type.name === "detailsSummary",
|
||||
);
|
||||
const content = findChildren(
|
||||
parent.node,
|
||||
(node) => node.type.name === "detailsContent",
|
||||
);
|
||||
if (!summary.length || !content.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = {
|
||||
from: parent.pos,
|
||||
to: parent.pos + parent.node.nodeSize,
|
||||
};
|
||||
const defaultType = state.doc.resolve(range.from).parent.type
|
||||
.contentMatch.defaultType;
|
||||
return chain()
|
||||
.insertContentAt(range, [
|
||||
defaultType?.create(null, summary[0].node.content).toJSON(),
|
||||
...(content[0].node.content.toJSON() ?? []),
|
||||
])
|
||||
.setTextSelection(range.from + 1)
|
||||
.run();
|
||||
};
|
||||
},
|
||||
|
||||
toggleDetails: () => {
|
||||
return ({ state, chain }) => {
|
||||
const node = findParentNode((node) => node.type === this.type)(
|
||||
state.selection,
|
||||
);
|
||||
if (node) {
|
||||
return chain().unsetDetails().run();
|
||||
} else {
|
||||
return chain().setDetails().run();
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
wrappingInputRule({
|
||||
find: /^:::details\s$/,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Alt-d": () => this.editor.commands.toggleDetails(),
|
||||
};
|
||||
},
|
||||
});
|
||||
3
packages/editor-ext/src/lib/details/index.ts
Normal file
3
packages/editor-ext/src/lib/details/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Details } from "./details";
|
||||
export { DetailsSummary } from "./details-summary";
|
||||
export { DetailsContent } from "./details-content";
|
||||
364
packages/editor-ext/src/lib/drawio.ts
Normal file
364
packages/editor-ext/src/lib/drawio.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ResizableNodeView } from "./resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
|
||||
|
||||
export type DrawioResizeOptions = {
|
||||
enabled: boolean;
|
||||
directions?: ResizableNodeViewDirection[];
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
alwaysPreserveAspectRatio?: boolean;
|
||||
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||
className?: {
|
||||
container?: string;
|
||||
wrapper?: string;
|
||||
handle?: string;
|
||||
resizing?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface DrawioOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
resize: DrawioResizeOptions | false;
|
||||
}
|
||||
|
||||
export interface DrawioAttributes {
|
||||
src?: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
width?: number | string;
|
||||
height?: number;
|
||||
aspectRatio?: number;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
drawio: {
|
||||
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
||||
setDrawioAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setDrawioSize: (width: number, height: number) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Drawio = Node.create<DrawioOptions>({
|
||||
name: "drawio",
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
resize: false,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-src"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-src": attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-title": attributes.title,
|
||||
}),
|
||||
},
|
||||
alt: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-alt"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-alt": attributes.alt,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("data-width");
|
||||
if (!raw) return null;
|
||||
if (raw.endsWith("%")) return raw;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("data-height");
|
||||
if (!raw) return null;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-height": attributes.height,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-aspect-ratio": attributes.aspectRatio,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
[
|
||||
"img",
|
||||
{
|
||||
src: HTMLAttributes["data-src"],
|
||||
alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
|
||||
width: HTMLAttributes["data-width"],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setDrawio:
|
||||
(attrs: DrawioAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "drawio",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
|
||||
setDrawioAlign:
|
||||
(align) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("drawio", { align }),
|
||||
|
||||
setDrawioSize:
|
||||
(width, height) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("drawio", { width, height }),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
const resize = this.options.resize;
|
||||
|
||||
if (!resize || !resize.enabled) {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
}
|
||||
|
||||
const {
|
||||
directions,
|
||||
minWidth,
|
||||
minHeight,
|
||||
alwaysPreserveAspectRatio,
|
||||
createCustomHandle,
|
||||
className,
|
||||
} = resize;
|
||||
|
||||
return (props) => {
|
||||
const { node, getPos, HTMLAttributes, editor } = props;
|
||||
|
||||
if (!node.attrs.src) {
|
||||
editor.isInitialized = true;
|
||||
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||
const view = reactView(props);
|
||||
|
||||
const originalUpdate = view.update?.bind(view);
|
||||
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||
return false;
|
||||
}
|
||||
if (originalUpdate) {
|
||||
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
const el = document.createElement("img");
|
||||
el.src = normalizeFileUrl(node.attrs.src);
|
||||
el.alt = node.attrs.alt || node.attrs.title || "";
|
||||
el.style.display = "block";
|
||||
el.style.maxWidth = "100%";
|
||||
el.style.borderRadius = "8px";
|
||||
|
||||
let currentNode = node;
|
||||
|
||||
const nodeView = new ResizableNodeView({
|
||||
element: el,
|
||||
editor,
|
||||
node,
|
||||
getPos,
|
||||
onResize: (w, h) => {
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
},
|
||||
onCommit: () => {
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
|
||||
this.editor
|
||||
.chain()
|
||||
.setNodeSelection(pos)
|
||||
.updateAttributes(this.name, {
|
||||
width: Math.round(el.offsetWidth),
|
||||
height: Math.round(el.offsetHeight),
|
||||
})
|
||||
.run();
|
||||
},
|
||||
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||
if (updatedNode.type !== currentNode.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||
el.src = normalizeFileUrl(updatedNode.attrs.src);
|
||||
}
|
||||
|
||||
if (
|
||||
updatedNode.attrs.alt !== currentNode.attrs.alt ||
|
||||
updatedNode.attrs.title !== currentNode.attrs.title
|
||||
) {
|
||||
el.alt =
|
||||
updatedNode.attrs.alt || updatedNode.attrs.title || "";
|
||||
}
|
||||
|
||||
const w = updatedNode.attrs.width;
|
||||
const h = updatedNode.attrs.height;
|
||||
if (w != null) {
|
||||
el.style.width = `${w}px`;
|
||||
}
|
||||
if (h != null) {
|
||||
el.style.height = `${h}px`;
|
||||
}
|
||||
|
||||
const align = updatedNode.attrs.align || "center";
|
||||
const container = nodeView.dom as HTMLElement;
|
||||
applyAlignment(container, align);
|
||||
|
||||
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
|
||||
|
||||
currentNode = updatedNode;
|
||||
return true;
|
||||
},
|
||||
options: {
|
||||
directions,
|
||||
min: {
|
||||
width: minWidth,
|
||||
height: minHeight,
|
||||
},
|
||||
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||
createCustomHandle,
|
||||
className,
|
||||
},
|
||||
});
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
syncAltBadge(nodeView.wrapper, node.attrs.alt);
|
||||
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
// Handle percentage width backward compat
|
||||
const widthAttr = node.attrs.width;
|
||||
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||
requestAnimationFrame(() => {
|
||||
const parentEl = dom.parentElement;
|
||||
if (parentEl) {
|
||||
const containerWidth = parentEl.clientWidth;
|
||||
const pctValue = parseInt(widthAttr, 10);
|
||||
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||
const pxWidth = Math.round(
|
||||
containerWidth * (pctValue / 100),
|
||||
);
|
||||
el.style.width = `${pxWidth}px`;
|
||||
if (node.attrs.aspectRatio) {
|
||||
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
dom.style.visibility = "";
|
||||
dom.style.pointerEvents = "";
|
||||
});
|
||||
}
|
||||
|
||||
// Show skeleton background while image loads from server
|
||||
dom.style.pointerEvents = "none";
|
||||
el.classList.add("media-pulse");
|
||||
|
||||
el.onload = () => {
|
||||
dom.style.pointerEvents = "";
|
||||
el.classList.remove("media-pulse");
|
||||
};
|
||||
|
||||
return nodeView;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function applyAlignment(container: HTMLElement, align: string) {
|
||||
if (align === "left") {
|
||||
container.style.justifyContent = "flex-start";
|
||||
} else if (align === "right") {
|
||||
container.style.justifyContent = "flex-end";
|
||||
} else {
|
||||
container.style.justifyContent = "center";
|
||||
}
|
||||
}
|
||||
146
packages/editor-ext/src/lib/embed-provider.ts
Normal file
146
packages/editor-ext/src/lib/embed-provider.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
export interface IEmbedProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
getEmbedUrl: (match: RegExpMatchArray, url?: string) => string;
|
||||
}
|
||||
|
||||
export const embedProviders: IEmbedProvider[] = [
|
||||
{
|
||||
id: "loom",
|
||||
name: "Loom",
|
||||
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if (url.includes("/embed/")) {
|
||||
return url;
|
||||
}
|
||||
return `https://loom.com/embed/${match[1]}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "airtable",
|
||||
name: "Airtable",
|
||||
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
const path = url.split("airtable.com/");
|
||||
if (url.includes("/embed/")) {
|
||||
return url;
|
||||
}
|
||||
return `https://airtable.com/embed/${path[1]}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "figma",
|
||||
name: "Figma",
|
||||
regex:
|
||||
/^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "typeform",
|
||||
name: "Typeform",
|
||||
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "miro",
|
||||
name: "Miro",
|
||||
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if (url.includes("/live-embed/")) {
|
||||
return url;
|
||||
}
|
||||
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "youtube",
|
||||
name: "YouTube",
|
||||
regex:
|
||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if (url.includes("/embed/")) {
|
||||
return url;
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vimeo",
|
||||
name: "Vimeo",
|
||||
regex:
|
||||
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)(?:\/([\da-zA-Z]+))?/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
// preserve ?h= hash for unlisted videos
|
||||
const hash =
|
||||
match[5] ?? new URL(url, "https://vimeo.com").searchParams.get("h");
|
||||
const base = `https://player.vimeo.com/video/${match[4]}`;
|
||||
return hash ? `${base}?h=${hash}` : base;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "framer",
|
||||
name: "Framer",
|
||||
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gdrive",
|
||||
name: "Google Drive",
|
||||
regex:
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||
getEmbedUrl: (match) => {
|
||||
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gsheets",
|
||||
name: "Google Sheets",
|
||||
regex:
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iframe",
|
||||
name: "Iframe",
|
||||
regex: /any-iframe/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
return url;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getEmbedProviderById(id: string) {
|
||||
return embedProviders.find(
|
||||
(provider) => provider.id.toLowerCase() === id.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
export interface IEmbedResult {
|
||||
embedUrl: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export function getEmbedUrlAndProvider(url: string): IEmbedResult {
|
||||
for (const provider of embedProviders) {
|
||||
const match = url.match(provider.regex);
|
||||
if (match) {
|
||||
return {
|
||||
embedUrl: provider.getEmbedUrl(match, url),
|
||||
provider: provider.name.toLowerCase(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
embedUrl: url,
|
||||
provider: "iframe",
|
||||
};
|
||||
}
|
||||
138
packages/editor-ext/src/lib/embed.ts
Normal file
138
packages/editor-ext/src/lib/embed.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { sanitizeUrl } from "./utils";
|
||||
|
||||
export interface EmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
export interface EmbedAttributes {
|
||||
src?: string;
|
||||
provider: string;
|
||||
align?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
embeds: {
|
||||
setEmbed: (attributes?: EmbedAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Embed = Node.create<EmbedOptions>({
|
||||
name: "embed",
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute("data-src");
|
||||
return sanitizeUrl(src);
|
||||
},
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
"data-src": sanitizeUrl(attributes.src),
|
||||
}),
|
||||
},
|
||||
provider: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
"data-provider": attributes.provider,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: 800,
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 600,
|
||||
parseHTML: (element) => element.getAttribute("data-height"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
"data-height": attributes.height,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const src = HTMLAttributes["data-src"];
|
||||
const safeHref = sanitizeUrl(src);
|
||||
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
[
|
||||
"a",
|
||||
{
|
||||
href: safeHref,
|
||||
target: "blank",
|
||||
},
|
||||
safeHref,
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setEmbed:
|
||||
(attrs: EmbedAttributes) =>
|
||||
({ commands }) => {
|
||||
// Validate the URL before inserting
|
||||
const validatedAttrs = {
|
||||
...attrs,
|
||||
src: sanitizeUrl(attrs.src),
|
||||
};
|
||||
|
||||
return commands.insertContent({
|
||||
type: "embed",
|
||||
attrs: validatedAttrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||
this.editor.isInitialized = true;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
364
packages/editor-ext/src/lib/excalidraw.ts
Normal file
364
packages/editor-ext/src/lib/excalidraw.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ResizableNodeView } from "./resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
|
||||
|
||||
export type ExcalidrawResizeOptions = {
|
||||
enabled: boolean;
|
||||
directions?: ResizableNodeViewDirection[];
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
alwaysPreserveAspectRatio?: boolean;
|
||||
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||
className?: {
|
||||
container?: string;
|
||||
wrapper?: string;
|
||||
handle?: string;
|
||||
resizing?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ExcalidrawOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
resize: ExcalidrawResizeOptions | false;
|
||||
}
|
||||
|
||||
export interface ExcalidrawAttributes {
|
||||
src?: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
width?: number | string;
|
||||
height?: number;
|
||||
aspectRatio?: number;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
excalidraw: {
|
||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||
setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setExcalidrawSize: (width: number, height: number) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
name: "excalidraw",
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
resize: false,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-src"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-src": attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-title": attributes.title,
|
||||
}),
|
||||
},
|
||||
alt: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-alt"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-alt": attributes.alt,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("data-width");
|
||||
if (!raw) return null;
|
||||
if (raw.endsWith("%")) return raw;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("data-height");
|
||||
if (!raw) return null;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-height": attributes.height,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-aspect-ratio": attributes.aspectRatio,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
[
|
||||
"img",
|
||||
{
|
||||
src: HTMLAttributes["data-src"],
|
||||
alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
|
||||
width: HTMLAttributes["data-width"],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setExcalidraw:
|
||||
(attrs: ExcalidrawAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "excalidraw",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
|
||||
setExcalidrawAlign:
|
||||
(align) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("excalidraw", { align }),
|
||||
|
||||
setExcalidrawSize:
|
||||
(width, height) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("excalidraw", { width, height }),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
const resize = this.options.resize;
|
||||
|
||||
if (!resize || !resize.enabled) {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
}
|
||||
|
||||
const {
|
||||
directions,
|
||||
minWidth,
|
||||
minHeight,
|
||||
alwaysPreserveAspectRatio,
|
||||
createCustomHandle,
|
||||
className,
|
||||
} = resize;
|
||||
|
||||
return (props) => {
|
||||
const { node, getPos, HTMLAttributes, editor } = props;
|
||||
|
||||
if (!node.attrs.src) {
|
||||
editor.isInitialized = true;
|
||||
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||
const view = reactView(props);
|
||||
|
||||
const originalUpdate = view.update?.bind(view);
|
||||
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||
return false;
|
||||
}
|
||||
if (originalUpdate) {
|
||||
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
const el = document.createElement("img");
|
||||
el.src = normalizeFileUrl(node.attrs.src);
|
||||
el.alt = node.attrs.alt || node.attrs.title || "";
|
||||
el.style.display = "block";
|
||||
el.style.maxWidth = "100%";
|
||||
el.style.borderRadius = "8px";
|
||||
|
||||
let currentNode = node;
|
||||
|
||||
const nodeView = new ResizableNodeView({
|
||||
element: el,
|
||||
editor,
|
||||
node,
|
||||
getPos,
|
||||
onResize: (w, h) => {
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
},
|
||||
onCommit: () => {
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
|
||||
this.editor
|
||||
.chain()
|
||||
.setNodeSelection(pos)
|
||||
.updateAttributes(this.name, {
|
||||
width: Math.round(el.offsetWidth),
|
||||
height: Math.round(el.offsetHeight),
|
||||
})
|
||||
.run();
|
||||
},
|
||||
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||
if (updatedNode.type !== currentNode.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||
el.src = normalizeFileUrl(updatedNode.attrs.src);
|
||||
}
|
||||
|
||||
if (
|
||||
updatedNode.attrs.alt !== currentNode.attrs.alt ||
|
||||
updatedNode.attrs.title !== currentNode.attrs.title
|
||||
) {
|
||||
el.alt =
|
||||
updatedNode.attrs.alt || updatedNode.attrs.title || "";
|
||||
}
|
||||
|
||||
const w = updatedNode.attrs.width;
|
||||
const h = updatedNode.attrs.height;
|
||||
if (w != null) {
|
||||
el.style.width = `${w}px`;
|
||||
}
|
||||
if (h != null) {
|
||||
el.style.height = `${h}px`;
|
||||
}
|
||||
|
||||
const align = updatedNode.attrs.align || "center";
|
||||
const container = nodeView.dom as HTMLElement;
|
||||
applyAlignment(container, align);
|
||||
|
||||
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
|
||||
|
||||
currentNode = updatedNode;
|
||||
return true;
|
||||
},
|
||||
options: {
|
||||
directions,
|
||||
min: {
|
||||
width: minWidth,
|
||||
height: minHeight,
|
||||
},
|
||||
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||
createCustomHandle,
|
||||
className,
|
||||
},
|
||||
});
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
syncAltBadge(nodeView.wrapper, node.attrs.alt);
|
||||
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
// Handle percentage width backward compat
|
||||
const widthAttr = node.attrs.width;
|
||||
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||
requestAnimationFrame(() => {
|
||||
const parentEl = dom.parentElement;
|
||||
if (parentEl) {
|
||||
const containerWidth = parentEl.clientWidth;
|
||||
const pctValue = parseInt(widthAttr, 10);
|
||||
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||
const pxWidth = Math.round(
|
||||
containerWidth * (pctValue / 100),
|
||||
);
|
||||
el.style.width = `${pxWidth}px`;
|
||||
if (node.attrs.aspectRatio) {
|
||||
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
dom.style.visibility = "";
|
||||
dom.style.pointerEvents = "";
|
||||
});
|
||||
}
|
||||
|
||||
// Show skeleton background while image loads from server
|
||||
dom.style.pointerEvents = "none";
|
||||
el.classList.add("media-pulse");
|
||||
|
||||
el.onload = () => {
|
||||
dom.style.pointerEvents = "";
|
||||
el.classList.remove("media-pulse");
|
||||
};
|
||||
|
||||
return nodeView;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function applyAlignment(container: HTMLElement, align: string) {
|
||||
if (align === "left") {
|
||||
container.style.justifyContent = "flex-start";
|
||||
} else if (align === "right") {
|
||||
container.style.justifyContent = "flex-end";
|
||||
} else {
|
||||
container.style.justifyContent = "center";
|
||||
}
|
||||
}
|
||||
189
packages/editor-ext/src/lib/footnotes/footnote.ts
Normal file
189
packages/editor-ext/src/lib/footnotes/footnote.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import ListItem, { ListItemOptions } from "@tiptap/extension-list-item";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
footnote: {
|
||||
/**
|
||||
* scrolls to & sets the text selection at the end of the footnote with the given id
|
||||
* @param id the id of the footote (i.e. the `data-id` attribute value of the footnote)
|
||||
* @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50")
|
||||
*/
|
||||
focusFootnote: (id: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface FootnoteOptions extends ListItemOptions {
|
||||
/**
|
||||
* Content expression for this node
|
||||
* @default "paragraph+"
|
||||
*/
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Footnote = ListItem.extend<FootnoteOptions>({
|
||||
name: "footnote",
|
||||
content() {
|
||||
return this.options.content;
|
||||
},
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
bulletListTypeName: 'bulletList',
|
||||
orderedListTypeName: 'orderedList',
|
||||
...this.parent?.(),
|
||||
content: "paragraph+",
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
isRequired: true,
|
||||
},
|
||||
// the data-id field should match the data-id field of a footnote reference.
|
||||
// it's used to link footnotes and references together.
|
||||
"data-id": {
|
||||
isRequired: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "li",
|
||||
getAttrs(node) {
|
||||
const id = node.getAttribute("data-id");
|
||||
if (id) {
|
||||
return {
|
||||
"data-id": node.getAttribute("data-id"),
|
||||
};
|
||||
}
|
||||
return false;
|
||||
},
|
||||
priority: 1000,
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"li",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
focusFootnote:
|
||||
(id: string) =>
|
||||
({ editor, chain }) => {
|
||||
const matchedFootnote = editor.$node("footnote", {
|
||||
"data-id": id,
|
||||
});
|
||||
if (matchedFootnote) {
|
||||
// sets the text selection to the end of the footnote definition and scroll to it.
|
||||
chain()
|
||||
.focus()
|
||||
.setTextSelection(
|
||||
matchedFootnote.from + matchedFootnote.content.size
|
||||
)
|
||||
.run();
|
||||
|
||||
matchedFootnote.element.scrollIntoView();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// when inside a footnote, Mod-a should select only the footnote content
|
||||
"Mod-a": ({ editor }) => {
|
||||
try {
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
|
||||
for (let depth = $from.depth; depth >= 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "footnote") {
|
||||
const start = $from.start(depth);
|
||||
const end = $from.end(depth);
|
||||
|
||||
editor.commands.setTextSelection({
|
||||
from: start + 1,
|
||||
to: end - 1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// when the user presses tab, adjust the text selection to be at the end of the next footnote
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { selection } = editor.state;
|
||||
const pos = editor.$pos(selection.anchor);
|
||||
if (!pos.after) return false;
|
||||
// if the next node is "footnotes", place the text selection at the end of the first footnote
|
||||
if (pos.after.node.type.name == "footnotes") {
|
||||
const firstChild = pos.after.node.child(0);
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(pos.after.from + firstChild.content.size)
|
||||
.scrollIntoView()
|
||||
.run();
|
||||
return true;
|
||||
} else {
|
||||
const startPos = selection.$from.start(2);
|
||||
if (Number.isNaN(startPos)) return false;
|
||||
const parent = editor.$pos(startPos);
|
||||
if (parent.node.type.name != "footnote" || !parent.after) {
|
||||
return false;
|
||||
}
|
||||
// if the next node is a footnote, place the text selection at the end of it
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(parent.after.to - 1)
|
||||
.scrollIntoView()
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// inverse of the tab command - place the text selection at the end of the previous footnote
|
||||
"Shift-Tab": ({ editor }) => {
|
||||
const { selection } = editor.state;
|
||||
const startPos = selection.$from.start(2);
|
||||
if (Number.isNaN(startPos)) return false;
|
||||
const parent = editor.$pos(startPos);
|
||||
if (parent.node.type.name != "footnote" || !parent.before) {
|
||||
return false;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(parent.before.to - 1)
|
||||
.scrollIntoView()
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default Footnote;
|
||||
46
packages/editor-ext/src/lib/footnotes/footnotes.ts
Normal file
46
packages/editor-ext/src/lib/footnotes/footnotes.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import FootnoteRules from "./rules";
|
||||
|
||||
const Footnotes = OrderedList.extend({
|
||||
name: "footnotes",
|
||||
group: "", // removed the default group of the ordered list extension
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: false,
|
||||
|
||||
content() {
|
||||
return "footnote*";
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
class: {
|
||||
default: "footnotes",
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "ol.footnotes",
|
||||
priority: 1000,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {};
|
||||
},
|
||||
addCommands() {
|
||||
return {};
|
||||
},
|
||||
addInputRules() {
|
||||
return [];
|
||||
},
|
||||
|
||||
addExtensions() {
|
||||
return [FootnoteRules];
|
||||
},
|
||||
});
|
||||
|
||||
export default Footnotes;
|
||||
4
packages/editor-ext/src/lib/footnotes/index.ts
Normal file
4
packages/editor-ext/src/lib/footnotes/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Footnotes } from "./footnotes";
|
||||
export { default as Footnote } from "./footnote";
|
||||
export type { FootnoteOptions } from "./footnote";
|
||||
export { default as FootnoteReference } from "./reference";
|
||||
221
packages/editor-ext/src/lib/footnotes/reference.ts
Normal file
221
packages/editor-ext/src/lib/footnotes/reference.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import {
|
||||
Fragment as PMFragment,
|
||||
Node as PMNode,
|
||||
Slice,
|
||||
} from "@tiptap/pm/model";
|
||||
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { generateNodeId } from "../utils";
|
||||
|
||||
|
||||
const REFNUM_ATTR = "data-reference-number";
|
||||
const REF_CLASS = "footnote-ref";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
footnoteReference: {
|
||||
/**
|
||||
* add a new footnote reference
|
||||
* @example editor.commands.addFootnote()
|
||||
*/
|
||||
addFootnote: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const FootnoteReference = Node.create({
|
||||
name: "footnoteReference",
|
||||
inline: true,
|
||||
content: "text*",
|
||||
group: "inline",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `sup`,
|
||||
priority: 1000,
|
||||
getAttrs(node) {
|
||||
const anchor = node.querySelector<HTMLAnchorElement>(
|
||||
`a.${REF_CLASS}:first-child`
|
||||
);
|
||||
|
||||
if (!anchor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const id = anchor.getAttribute("data-id");
|
||||
const ref = anchor.getAttribute(REFNUM_ATTR);
|
||||
|
||||
return {
|
||||
"data-id": id ?? generateNodeId(),
|
||||
referenceNumber: ref ?? anchor.innerText,
|
||||
};
|
||||
},
|
||||
contentElement(node) {
|
||||
return node.firstChild as HTMLElement;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
class: {
|
||||
default: REF_CLASS,
|
||||
},
|
||||
"data-id": {
|
||||
renderHTML(attributes) {
|
||||
return {
|
||||
"data-id": attributes["data-id"] || generateNodeId(),
|
||||
};
|
||||
},
|
||||
},
|
||||
referenceNumber: {},
|
||||
|
||||
href: {
|
||||
renderHTML(attributes) {
|
||||
return {
|
||||
href: `#fn:${attributes["referenceNumber"]}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { referenceNumber, ...attributes } = HTMLAttributes;
|
||||
const attrs = mergeAttributes(this.options.HTMLAttributes, attributes);
|
||||
attrs[REFNUM_ATTR] = referenceNumber;
|
||||
|
||||
return [
|
||||
"sup",
|
||||
{ id: `fnref:${referenceNumber}` },
|
||||
["a", attrs, HTMLAttributes.referenceNumber],
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
// Ensures pasted footnote references get unique IDs.
|
||||
const mapNode = (node: PMNode): PMNode => {
|
||||
if (node.type.name === this.name) {
|
||||
const newAttrs = { ...node.attrs, "data-id": generateNodeId() };
|
||||
return node.type.create(newAttrs, node.content, node.marks);
|
||||
}
|
||||
|
||||
if (node.content && node.content.size > 0) {
|
||||
const newChildren: PMNode[] = [];
|
||||
let changed = false;
|
||||
|
||||
node.content.forEach((child) => {
|
||||
const mapped = mapNode(child);
|
||||
if (mapped !== child) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
newChildren.push(mapped);
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
return node.copy(PMFragment.from(newChildren));
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("footnotePasteHandler"),
|
||||
props: {
|
||||
transformPasted(slice) {
|
||||
const mappedNodes: PMNode[] = [];
|
||||
let changed = false;
|
||||
|
||||
slice.content.forEach((node) => {
|
||||
const mapped = mapNode(node);
|
||||
if (mapped !== node) {
|
||||
changed = true;
|
||||
}
|
||||
mappedNodes.push(mapped);
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return slice;
|
||||
}
|
||||
|
||||
return new Slice(
|
||||
PMFragment.from(mappedNodes),
|
||||
slice.openStart,
|
||||
slice.openEnd
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Plugin({
|
||||
key: new PluginKey("footnoteRefClick"),
|
||||
|
||||
props: {
|
||||
// on double-click, focus on the footnote
|
||||
handleDoubleClickOn(view, pos, node, nodePos, event) {
|
||||
if (node.type.name != "footnoteReference") return false;
|
||||
event.preventDefault();
|
||||
const id = node.attrs["data-id"];
|
||||
return editor.commands.focusFootnote(id);
|
||||
},
|
||||
// click the footnote reference once to get focus, click twice to scroll to the footnote
|
||||
handleClickOn(view, pos, node, nodePos, event) {
|
||||
if (node.type.name != "footnoteReference") return false;
|
||||
event.preventDefault();
|
||||
const { selection } = editor.state.tr;
|
||||
if (selection instanceof NodeSelection && selection.node.eq(node)) {
|
||||
const id = node.attrs["data-id"];
|
||||
return editor.commands.focusFootnote(id);
|
||||
} else {
|
||||
editor.chain().setNodeSelection(nodePos).run();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
addFootnote:
|
||||
() =>
|
||||
({ state, tr }) => {
|
||||
const node = this.type.create({
|
||||
"data-id": generateNodeId(),
|
||||
});
|
||||
tr.insert(state.selection.anchor, node);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
// when a user types [^text], add a new footnote
|
||||
return [
|
||||
{
|
||||
find: /\[\^(.*?)\]/,
|
||||
type: this.type,
|
||||
undoable: true,
|
||||
handler({ range, match, chain }) {
|
||||
const start = range.from;
|
||||
let end = range.to;
|
||||
if (match[1]) {
|
||||
chain().deleteRange({ from: start, to: end }).addFootnote().run();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default FootnoteReference;
|
||||
90
packages/editor-ext/src/lib/footnotes/rules.ts
Normal file
90
packages/editor-ext/src/lib/footnotes/rules.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { updateFootnotesList } from "./utils";
|
||||
|
||||
const FootnoteRules = Extension.create({
|
||||
name: "footnoteRules",
|
||||
priority: 1000,
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("footnoteRules"),
|
||||
filterTransaction(tr) {
|
||||
const { from, to } = tr.selection;
|
||||
|
||||
// Allow full document selections (Mod-a/Ctrl-a)
|
||||
if (from === 0 && to === tr.doc.content.size) return true;
|
||||
|
||||
let selectedFootnotes = false;
|
||||
let selectedContent = false;
|
||||
let footnoteCount = 0;
|
||||
tr.doc.nodesBetween(from, to, (node, _, parent) => {
|
||||
if (parent?.type.name == "doc" && node.type.name != "footnotes") {
|
||||
selectedContent = true;
|
||||
} else if (node.type.name == "footnote") {
|
||||
footnoteCount += 1;
|
||||
} else if (node.type.name == "footnotes") {
|
||||
selectedFootnotes = true;
|
||||
}
|
||||
});
|
||||
const overSelected = selectedContent && selectedFootnotes;
|
||||
/*
|
||||
* Here, we don't allow any transaction that spans between the "content" nodes and the "footnotes" node. This also rejects any transaction that spans between more than 1 footnote.
|
||||
*/
|
||||
return !overSelected && footnoteCount <= 1;
|
||||
},
|
||||
|
||||
// if there are some to the footnote references (added/deleted/dragged), append a transaction that updates the footnotes list accordingly
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
let newTr = newState.tr;
|
||||
let refsChanged = false; // true if the footnote references have been changed, false otherwise
|
||||
for (let tr of transactions) {
|
||||
if (!tr.docChanged) continue;
|
||||
if (refsChanged) break;
|
||||
|
||||
for (let step of tr.steps) {
|
||||
if (!(step instanceof ReplaceStep)) continue;
|
||||
if (refsChanged) break;
|
||||
|
||||
const isDelete = step.from != step.to; // the user deleted items from the document (from != to & the step is a replace step)
|
||||
const isInsert = step.slice.size > 0;
|
||||
|
||||
// check if any footnote references have been inserted
|
||||
if (isInsert) {
|
||||
step.slice.content.descendants((node) => {
|
||||
if (node?.type.name == "footnoteReference") {
|
||||
refsChanged = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (isDelete && !refsChanged) {
|
||||
// check if any footnote references have been deleted
|
||||
tr.before.nodesBetween(
|
||||
step.from,
|
||||
Math.min(tr.before.content.size, step.to), // make sure to not go over the old document's limit
|
||||
(node) => {
|
||||
if (node.type.name == "footnoteReference") {
|
||||
refsChanged = true;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (refsChanged) {
|
||||
updateFootnotesList(newTr, newState);
|
||||
return newTr;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
export default FootnoteRules;
|
||||
123
packages/editor-ext/src/lib/footnotes/utils.ts
Normal file
123
packages/editor-ext/src/lib/footnotes/utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import { EditorState, Transaction } from "@tiptap/pm/state";
|
||||
import { Fragment, Node } from "@tiptap/pm/model";
|
||||
|
||||
// update the reference number of all the footnote references in the document
|
||||
export function updateFootnoteReferences(tr: Transaction) {
|
||||
let count = 1;
|
||||
|
||||
const nodes: any[] = [];
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
if (node.type.name == "footnoteReference") {
|
||||
tr.setNodeAttribute(pos, "referenceNumber", `${count}`);
|
||||
|
||||
nodes.push(node);
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
// return the updated footnote references (in the order that they appear in the document)
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function getFootnotes(tr: Transaction) {
|
||||
let footnotesRange: { from: number; to: number } | undefined;
|
||||
const footnotes: Node[] = [];
|
||||
tr.doc.descendants((node, pos) => {
|
||||
if (node.type.name == "footnote") {
|
||||
footnotes.push(node);
|
||||
} else if (node.type.name == "footnotes") {
|
||||
footnotesRange = { from: pos, to: pos + node.nodeSize };
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return { footnotesRange, footnotes };
|
||||
}
|
||||
|
||||
// update the "footnotes" ordered list based on the footnote references in the document
|
||||
export function updateFootnotesList(tr: Transaction, state: EditorState) {
|
||||
const footnoteReferences = updateFootnoteReferences(tr);
|
||||
|
||||
const footnoteType = state.schema.nodes.footnote;
|
||||
const footnotesType = state.schema.nodes.footnotes;
|
||||
|
||||
const emptyParagraph = state.schema.nodeFromJSON({
|
||||
type: "paragraph",
|
||||
content: [],
|
||||
});
|
||||
|
||||
const { footnotesRange, footnotes } = getFootnotes(tr);
|
||||
|
||||
// a mapping of footnote id -> footnote node
|
||||
const footnoteIds: { [key: string]: Node } = footnotes.reduce(
|
||||
(obj, footnote) => {
|
||||
obj[footnote.attrs["data-id"]] = footnote;
|
||||
return obj;
|
||||
},
|
||||
{} as any,
|
||||
);
|
||||
|
||||
const newFootnotes: Node[] = [];
|
||||
|
||||
let footnoteRefIds = new Set(
|
||||
footnoteReferences.map((ref) => ref.attrs["data-id"]),
|
||||
);
|
||||
const deleteFootnoteIds: Set<string> = new Set();
|
||||
for (let footnote of footnotes) {
|
||||
const id = footnote.attrs["data-id"];
|
||||
if (!footnoteRefIds.has(id) || deleteFootnoteIds.has(id)) {
|
||||
deleteFootnoteIds.add(id);
|
||||
// we traverse through this footnote's content because it may contain footnote references.
|
||||
// we want to delete the footnotes associated with these references, so we add them to the delete set.
|
||||
footnote.content.descendants((node) => {
|
||||
if (node.type.name == "footnoteReference")
|
||||
deleteFootnoteIds.add(node.attrs["data-id"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < footnoteReferences.length; i++) {
|
||||
let refId = footnoteReferences[i].attrs["data-id"];
|
||||
|
||||
if (deleteFootnoteIds.has(refId)) continue;
|
||||
// if there is a footnote w/ the same id as this `ref`, we preserve its content and update its id attribute
|
||||
if (refId in footnoteIds) {
|
||||
let footnote = footnoteIds[refId];
|
||||
newFootnotes.push(
|
||||
footnoteType.create(
|
||||
{ ...footnote.attrs, id: `fn:${i + 1}` },
|
||||
footnote.content,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
let newNode = footnoteType.create(
|
||||
{
|
||||
"data-id": refId,
|
||||
id: `fn:${i + 1}`,
|
||||
},
|
||||
[emptyParagraph],
|
||||
);
|
||||
newFootnotes.push(newNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (newFootnotes.length == 0) {
|
||||
// no footnotes in the doc, delete the "footnotes" node
|
||||
if (footnotesRange) {
|
||||
tr.delete(footnotesRange.from, footnotesRange.to);
|
||||
}
|
||||
} else if (!footnotesRange) {
|
||||
// there is no footnotes node present in the doc, add it
|
||||
tr.insert(
|
||||
tr.doc.content.size,
|
||||
footnotesType.create(undefined, Fragment.from(newFootnotes)),
|
||||
);
|
||||
} else {
|
||||
tr.replaceWith(
|
||||
footnotesRange!.from + 1, // add 1 to point at the position after the opening ol tag
|
||||
footnotesRange!.to - 1, // substract 1 to point to the position before the closing ol tag
|
||||
Fragment.from(newFootnotes),
|
||||
);
|
||||
}
|
||||
}
|
||||
80
packages/editor-ext/src/lib/heading/heading.ts
Normal file
80
packages/editor-ext/src/lib/heading/heading.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import TiptapHeading, {
|
||||
HeadingOptions as TiptapHeadingOptions,
|
||||
} from "@tiptap/extension-heading";
|
||||
import { mergeAttributes } from "@tiptap/react";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { copyToClipboard } from "../utils";
|
||||
|
||||
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
|
||||
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`;
|
||||
|
||||
export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
|
||||
// @ts-ignore
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
const decorations: Decoration[] = [];
|
||||
const { doc } = state;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === "heading" && node.content.size > 1) {
|
||||
const deco = Decoration.widget(
|
||||
pos + node.nodeSize - 1,
|
||||
() => {
|
||||
const icon = document.createElement("span");
|
||||
icon.classList.add("link-btn");
|
||||
icon.innerHTML = " ";
|
||||
icon.contentEditable = "false";
|
||||
|
||||
const linkBtnContent = document.createElement("span");
|
||||
linkBtnContent.classList.add("link-btn-content");
|
||||
linkBtnContent.innerHTML = copyIcon;
|
||||
icon.appendChild(linkBtnContent);
|
||||
|
||||
icon.addEventListener("mousedown", (e) =>
|
||||
e.preventDefault(),
|
||||
);
|
||||
icon.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const id = node.attrs.id;
|
||||
const baseUrl = window.location.href.split('#')[0];
|
||||
const url = `${baseUrl}#${id}`;
|
||||
copyToClipboard(url);
|
||||
linkBtnContent.innerHTML = successIcon;
|
||||
setTimeout(
|
||||
() => (linkBtnContent.innerHTML = copyIcon),
|
||||
2000,
|
||||
);
|
||||
});
|
||||
|
||||
return icon;
|
||||
},
|
||||
{ side: 1 }, // render after node content
|
||||
);
|
||||
decorations.push(deco);
|
||||
}
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const hasLevel = this.options.levels.includes(node.attrs.level);
|
||||
const level = hasLevel ? node.attrs.level : this.options.levels[0];
|
||||
|
||||
return [
|
||||
`h${level}`,
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
id: node.attrs.id,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
});
|
||||
40
packages/editor-ext/src/lib/highlight.ts
Normal file
40
packages/editor-ext/src/lib/highlight.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Highlight as TiptapHighlight,
|
||||
type HighlightOptions,
|
||||
} from "@tiptap/extension-highlight";
|
||||
|
||||
export const Highlight = TiptapHighlight.extend<HighlightOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
color: {
|
||||
default: null,
|
||||
parseHTML: (element) =>
|
||||
element.getAttribute("data-color") || element.style.backgroundColor,
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.color) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-color": attributes.color,
|
||||
style: `background-color: ${attributes.color}; color: inherit`,
|
||||
};
|
||||
},
|
||||
},
|
||||
colorName: {
|
||||
default: null,
|
||||
parseHTML: (element) =>
|
||||
element.getAttribute("data-highlight-color-name") || null,
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.colorName) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
"data-highlight-color-name": attributes.colorName.toLowerCase(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
155
packages/editor-ext/src/lib/image/image-upload.ts
Normal file
155
packages/editor-ext/src/lib/image/image-upload.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { imageDimensionsFromData } from 'image-dimensions';
|
||||
import { MediaUploadOptions, UploadFn } from '../media-utils';
|
||||
import { IAttachment } from '../types';
|
||||
import { generateNodeId } from '../utils';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { Command } from '@tiptap/core';
|
||||
|
||||
const findImageNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
if (
|
||||
node.type.name === 'image' &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
const handleImageUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, editor, pos, pageId) => {
|
||||
// check if the file is an image
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
const imageDimensions = imageDimensionsFromData(
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
);
|
||||
|
||||
const placeholderId = generateNodeId();
|
||||
const width = imageDimensions?.width ?? undefined;
|
||||
const height = imageDimensions?.height ?? undefined;
|
||||
const aspectRatio = imageDimensions
|
||||
? imageDimensions.width / imageDimensions.height
|
||||
: undefined;
|
||||
|
||||
let placeholderInserted = false;
|
||||
|
||||
editor.storage.shared.imagePreviews =
|
||||
editor.storage.shared.imagePreviews || {};
|
||||
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
|
||||
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.image?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
name: file.name,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (isEmptyTextBlock) {
|
||||
// Replace e.g. empty paragraph with the image
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
// If the placeholder is not found or attachment is missing, abort the process
|
||||
if (currentPos === null || !attachment) return false;
|
||||
|
||||
// Update the placeholder node with the actual image data
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
attachmentId: attachment.id,
|
||||
size: attachment.fileSize,
|
||||
width,
|
||||
height,
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
// Remove the placeholder node
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
// Only show the placeholder if the upload takes more than 250ms
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
const disposePreviewFile = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
if (editor.storage.shared.imagePreviews) {
|
||||
delete editor.storage.shared.imagePreviews[placeholderId];
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithImage(attachment));
|
||||
disposePreviewFile();
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithImage(attachment))
|
||||
.run();
|
||||
disposePreviewFile();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
disposePreviewFile();
|
||||
}
|
||||
};
|
||||
|
||||
export { handleImageUpload };
|
||||
389
packages/editor-ext/src/lib/image/image.ts
Normal file
389
packages/editor-ext/src/lib/image/image.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import {
|
||||
mergeAttributes,
|
||||
Range,
|
||||
} from "@tiptap/core";
|
||||
import { ResizableNodeView } from "../resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
|
||||
import { normalizeFileUrl, syncAltBadge } from "../media-utils";
|
||||
|
||||
export type ImageResizeOptions = {
|
||||
enabled: boolean;
|
||||
directions?: ResizableNodeViewDirection[];
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
alwaysPreserveAspectRatio?: boolean;
|
||||
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||
className?: {
|
||||
container?: string;
|
||||
wrapper?: string;
|
||||
handle?: string;
|
||||
resizing?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ImageOptions extends DefaultImageOptions {
|
||||
view: any;
|
||||
resize: ImageResizeOptions | false;
|
||||
}
|
||||
|
||||
export interface ImageAttributes {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number | string;
|
||||
height?: number;
|
||||
aspectRatio?: number;
|
||||
placeholder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
imageBlock: {
|
||||
setImage: (attributes: ImageAttributes) => ReturnType;
|
||||
setImageAt: (
|
||||
attributes: ImageAttributes & { pos: number | Range },
|
||||
) => ReturnType;
|
||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setImageWidth: (width: number) => ReturnType;
|
||||
setImageSize: (width: number, height: number) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TiptapImage = Image.extend<ImageOptions>({
|
||||
name: "image",
|
||||
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
view: null,
|
||||
resize: false,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("src"),
|
||||
renderHTML: (attributes) => ({
|
||||
src: attributes.src,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("width");
|
||||
if (!raw) return null;
|
||||
if (raw.endsWith("%")) return raw;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
width: attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("height");
|
||||
if (!raw) return null;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
height: attributes.height,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
alt: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("alt"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
alt: attributes.alt,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-aspect-ratio": attributes.aspectRatio,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"img",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setImage:
|
||||
(attrs: ImageAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "image",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
|
||||
setImageAt:
|
||||
(attrs) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContentAt(attrs.pos, {
|
||||
type: "image",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
|
||||
setImageAlign:
|
||||
(align) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("image", { align }),
|
||||
|
||||
setImageWidth:
|
||||
(width) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("image", { width }),
|
||||
|
||||
setImageSize:
|
||||
(width, height) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("image", { width, height }),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
const resize = this.options.resize;
|
||||
|
||||
if (!resize || !resize.enabled) {
|
||||
// Fallback to React node view (existing behavior)
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
}
|
||||
|
||||
const {
|
||||
directions,
|
||||
minWidth,
|
||||
minHeight,
|
||||
alwaysPreserveAspectRatio,
|
||||
createCustomHandle,
|
||||
className,
|
||||
} = resize;
|
||||
|
||||
return (props) => {
|
||||
const { node, getPos, HTMLAttributes, editor } = props;
|
||||
|
||||
// If no src yet (placeholder/uploading), use React view for loading UI
|
||||
if (!HTMLAttributes.src) {
|
||||
editor.isInitialized = true;
|
||||
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||
const view = reactView(props);
|
||||
|
||||
// When the node gets a src, return false from update to force rebuild
|
||||
const originalUpdate = view.update?.bind(view);
|
||||
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||
return false;
|
||||
}
|
||||
if (originalUpdate) {
|
||||
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
// Has src — use ResizableNodeView
|
||||
const el = document.createElement("img");
|
||||
|
||||
Object.entries(HTMLAttributes).forEach(([key, value]) => {
|
||||
if (value != null) {
|
||||
switch (key) {
|
||||
case "width":
|
||||
case "height":
|
||||
break;
|
||||
default:
|
||||
el.setAttribute(key, String(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
el.src = normalizeFileUrl(HTMLAttributes.src);
|
||||
el.style.display = "block";
|
||||
el.style.maxWidth = "100%";
|
||||
el.style.borderRadius = "8px";
|
||||
|
||||
if (typeof node.attrs.width === "number" && node.attrs.width > 0) {
|
||||
el.style.width = `${node.attrs.width}px`;
|
||||
if (typeof node.attrs.height === "number" && node.attrs.height > 0) {
|
||||
el.style.height = `${node.attrs.height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
let currentNode = node;
|
||||
|
||||
const nodeView = new ResizableNodeView({
|
||||
element: el,
|
||||
editor,
|
||||
node,
|
||||
getPos,
|
||||
onResize: (w, h) => {
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
},
|
||||
onCommit: () => {
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
|
||||
this.editor
|
||||
.chain()
|
||||
.setNodeSelection(pos)
|
||||
.updateAttributes(this.name, {
|
||||
width: Math.round(el.offsetWidth),
|
||||
height: Math.round(el.offsetHeight),
|
||||
})
|
||||
.run();
|
||||
},
|
||||
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||
if (updatedNode.type !== currentNode.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||
el.src = normalizeFileUrl(updatedNode.attrs.src);
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
|
||||
el.alt = updatedNode.attrs.alt || "";
|
||||
}
|
||||
|
||||
const w = updatedNode.attrs.width;
|
||||
const h = updatedNode.attrs.height;
|
||||
if (w != null) {
|
||||
el.style.width = `${w}px`;
|
||||
}
|
||||
if (h != null) {
|
||||
el.style.height = `${h}px`;
|
||||
}
|
||||
|
||||
// Update alignment on container
|
||||
const align = updatedNode.attrs.align || "center";
|
||||
const container = nodeView.dom as HTMLElement;
|
||||
applyAlignment(container, align);
|
||||
|
||||
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
|
||||
|
||||
currentNode = updatedNode;
|
||||
return true;
|
||||
},
|
||||
options: {
|
||||
directions,
|
||||
min: {
|
||||
width: minWidth,
|
||||
height: minHeight,
|
||||
},
|
||||
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||
createCustomHandle,
|
||||
className,
|
||||
},
|
||||
});
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
syncAltBadge(nodeView.wrapper, node.attrs.alt);
|
||||
|
||||
// Apply initial alignment
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
// Handle percentage width backward compat
|
||||
const widthAttr = node.attrs.width;
|
||||
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||
// Defer conversion until we can measure the container
|
||||
requestAnimationFrame(() => {
|
||||
const parentEl = dom.parentElement;
|
||||
if (parentEl) {
|
||||
const containerWidth = parentEl.clientWidth;
|
||||
const pctValue = parseInt(widthAttr, 10);
|
||||
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||
const pxWidth = Math.round(
|
||||
containerWidth * (pctValue / 100),
|
||||
);
|
||||
el.style.width = `${pxWidth}px`;
|
||||
if (node.attrs.aspectRatio) {
|
||||
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
dom.style.visibility = "";
|
||||
dom.style.pointerEvents = "";
|
||||
});
|
||||
}
|
||||
|
||||
// Show skeleton background while image loads from server
|
||||
dom.style.pointerEvents = "none";
|
||||
el.classList.add("media-pulse");
|
||||
|
||||
el.onload = () => {
|
||||
dom.style.pointerEvents = "";
|
||||
el.classList.remove("media-pulse");
|
||||
};
|
||||
|
||||
return nodeView;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function applyAlignment(container: HTMLElement, align: string) {
|
||||
if (align === "left") {
|
||||
container.style.justifyContent = "flex-start";
|
||||
} else if (align === "right") {
|
||||
container.style.justifyContent = "flex-end";
|
||||
} else {
|
||||
container.style.justifyContent = "center";
|
||||
}
|
||||
}
|
||||
2
packages/editor-ext/src/lib/image/index.ts
Normal file
2
packages/editor-ext/src/lib/image/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TiptapImage } from "./image";
|
||||
export * from "./image-upload";
|
||||
225
packages/editor-ext/src/lib/indent.ts
Normal file
225
packages/editor-ext/src/lib/indent.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import {
|
||||
Plugin,
|
||||
PluginKey,
|
||||
type EditorState,
|
||||
type Transaction,
|
||||
} from '@tiptap/pm/state';
|
||||
|
||||
export type IndentOptions = {
|
||||
types: string[];
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
indent: {
|
||||
indent: () => ReturnType;
|
||||
outdent: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Containers whose descendants must never carry an `indent` attribute. These
|
||||
// nodes own their own Tab semantics (list nesting, cell navigation, literal
|
||||
// tab) and visually conflict with our indent padding, so paragraphs and
|
||||
// headings inside them stay flat
|
||||
const NON_INDENTABLE_ANCESTORS = new Set([
|
||||
'listItem',
|
||||
'taskItem',
|
||||
'tableCell',
|
||||
'tableHeader',
|
||||
'codeBlock',
|
||||
]);
|
||||
|
||||
const clampIndent = (value: number, min: number, max: number): number => {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||
};
|
||||
|
||||
const hasNonIndentableAncestor = (
|
||||
doc: EditorState['doc'],
|
||||
pos: number,
|
||||
): boolean => {
|
||||
const $pos = doc.resolve(pos);
|
||||
for (let depth = $pos.depth; depth >= 0; depth--) {
|
||||
if (NON_INDENTABLE_ANCESTORS.has($pos.node(depth).type.name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const Indent = Extension.create<IndentOptions>({
|
||||
name: 'indent',
|
||||
|
||||
priority: 1000,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
types: ['paragraph', 'heading'],
|
||||
min: 0,
|
||||
max: 8,
|
||||
};
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
indent: {
|
||||
default: this.options.min,
|
||||
keepOnSplit: true,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute('data-indent');
|
||||
if (raw === null) return this.options.min;
|
||||
return clampIndent(
|
||||
parseInt(raw, 10),
|
||||
this.options.min,
|
||||
this.options.max,
|
||||
);
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
const value = attributes.indent;
|
||||
if (value <= this.options.min) return {};
|
||||
return { 'data-indent': String(value) };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
indent:
|
||||
() =>
|
||||
({ state, tr, dispatch }) => {
|
||||
return updateIndent(state, tr, dispatch, this.options, +1);
|
||||
},
|
||||
outdent:
|
||||
() =>
|
||||
({ state, tr, dispatch }) => {
|
||||
return updateIndent(state, tr, dispatch, this.options, -1);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const isInIndentableBlock = (): boolean => {
|
||||
const { $from } = this.editor.state.selection;
|
||||
if (!this.options.types.includes($from.parent.type.name)) return false;
|
||||
for (let depth = $from.depth - 1; depth >= 0; depth--) {
|
||||
if (NON_INDENTABLE_ANCESTORS.has($from.node(depth).type.name)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
// Return the command's result so Tab falls through to the browser
|
||||
// (moving focus out of the editor) once the user has reached max
|
||||
// indent. Without this Tab stays trapped at max depth, failing
|
||||
// WCAG 2.1.2.
|
||||
Tab: () => {
|
||||
if (!isInIndentableBlock()) return false;
|
||||
return this.editor.commands.indent();
|
||||
},
|
||||
'Shift-Tab': () => {
|
||||
if (!isInIndentableBlock()) return false;
|
||||
return this.editor.commands.outdent();
|
||||
},
|
||||
Backspace: () => {
|
||||
const { $from, empty } = this.editor.state.selection;
|
||||
if (!empty) return false;
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
if (!isInIndentableBlock()) return false;
|
||||
if ($from.parent.attrs.indent <= this.options.min) return false;
|
||||
this.editor.commands.outdent();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const types = new Set(this.options.types);
|
||||
const min = this.options.min;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('indentNormalizer'),
|
||||
appendTransaction: (transactions, _oldState, newState) => {
|
||||
if (!transactions.some((tr) => tr.docChanged)) return null;
|
||||
|
||||
const tr = newState.tr;
|
||||
let modified = false;
|
||||
|
||||
newState.doc.descendants((node, pos) => {
|
||||
// Containers: descend so we can find paragraph/heading children.
|
||||
if (!types.has(node.type.name)) return true;
|
||||
|
||||
if (node.attrs.indent <= min) return false;
|
||||
|
||||
if (hasNonIndentableAncestor(newState.doc, pos)) {
|
||||
tr.setNodeMarkup(
|
||||
pos,
|
||||
undefined,
|
||||
{ ...node.attrs, indent: min },
|
||||
node.marks,
|
||||
);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// paragraph/heading don't contain other paragraphs/headings —
|
||||
// never descend into their inline content.
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!modified) return null;
|
||||
// Normalisation must not show up as a separate undo step;
|
||||
// otherwise undo would re-introduce the illegal indent.
|
||||
return tr.setMeta('addToHistory', false);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function updateIndent(
|
||||
state: EditorState,
|
||||
tr: Transaction,
|
||||
dispatch: ((tr: Transaction) => void) | undefined,
|
||||
options: IndentOptions,
|
||||
delta: number,
|
||||
): boolean {
|
||||
const { selection } = state;
|
||||
const { from, to } = selection;
|
||||
const types = new Set(options.types);
|
||||
let updated = false;
|
||||
|
||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
// Skip non-block nodes (text, inline atoms) up front.
|
||||
if (!node.type.isBlock) return false;
|
||||
|
||||
// Don't descend into containers whose children must stay flat — handles
|
||||
// multi-block selections that span across e.g. a list-item or table-cell.
|
||||
if (NON_INDENTABLE_ANCESTORS.has(node.type.name)) return false;
|
||||
|
||||
if (!types.has(node.type.name)) return true;
|
||||
|
||||
const current = node.attrs.indent as number;
|
||||
const next = clampIndent(current + delta, options.min, options.max);
|
||||
if (next === current) return false;
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next });
|
||||
updated = true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!updated) return false;
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
96
packages/editor-ext/src/lib/link.ts
Normal file
96
packages/editor-ext/src/lib/link.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import TiptapLink from '@tiptap/extension-link';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { EditorView } from '@tiptap/pm/view';
|
||||
|
||||
export const LinkExtension = TiptapLink.extend({
|
||||
inclusive: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
internal: {
|
||||
default: false,
|
||||
parseHTML: (element: HTMLElement) =>
|
||||
element.getAttribute('data-internal') === 'true',
|
||||
renderHTML: (attributes) =>
|
||||
attributes.internal ? { 'data-internal': 'true' } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
|
||||
const { selection } = editor.state;
|
||||
|
||||
if (event.key === 'Escape' && selection.empty !== true) {
|
||||
editor.commands.focus(selection.to, { scrollIntoView: false });
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Fix for Firefox: when the cursor is at a boundary of a link,
|
||||
// Firefox's contenteditable inserts new text *inside* the <a> element.
|
||||
// ProseMirror then rejects the mutation because inclusive is false,
|
||||
// causing keystrokes to be silently swallowed. Firefox also does not
|
||||
// fire handleTextInput in this state, so we intercept at handleKeyDown.
|
||||
// This handles both:
|
||||
// - right boundary: cursor just after a link (typing appends to link)
|
||||
// - left boundary: cursor just before a link, e.g. at the start of a
|
||||
// line (#1748), where Firefox places new text inside the link node
|
||||
new Plugin({
|
||||
key: new PluginKey('linkBoundaryInput'),
|
||||
props: {
|
||||
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
|
||||
// Only handle single printable characters
|
||||
if (event.key.length !== 1) return false;
|
||||
// Don't handle modified keys (shortcuts) or composing (IME)
|
||||
if (
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.isComposing
|
||||
)
|
||||
return false;
|
||||
|
||||
const { state } = view;
|
||||
const linkType = state.schema.marks.link;
|
||||
if (!linkType) return false;
|
||||
|
||||
// Don't interfere if the user has explicitly set storedMarks
|
||||
if (state.storedMarks !== null) return false;
|
||||
|
||||
const { from, to } = state.selection;
|
||||
const $from = state.doc.resolve(from);
|
||||
const nodeBefore = $from.nodeBefore;
|
||||
const nodeAfter = $from.nodeAfter;
|
||||
|
||||
const linkBefore = nodeBefore && linkType.isInSet(nodeBefore.marks);
|
||||
const linkAfter = nodeAfter && linkType.isInSet(nodeAfter.marks);
|
||||
|
||||
// If both sides have link marks we're in the middle — don't interfere
|
||||
if (linkBefore && linkAfter) return false;
|
||||
|
||||
// Not at any link boundary — nothing to do
|
||||
if (!linkBefore && !linkAfter) return false;
|
||||
|
||||
// We're at a link boundary (left or right).
|
||||
// Prevent native input and insert text without the link mark.
|
||||
event.preventDefault();
|
||||
const tr = state.tr.insertText(event.key, from, to);
|
||||
tr.removeMark(from, from + event.key.length, linkType);
|
||||
view.dispatch(tr.scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/markdown/index.ts
Normal file
2
packages/editor-ext/src/lib/markdown/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./utils/marked.utils";
|
||||
export * from "./utils/turndown.utils";
|
||||
29
packages/editor-ext/src/lib/markdown/utils/basename.ts
Normal file
29
packages/editor-ext/src/lib/markdown/utils/basename.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Flexible `basename` implementation for node and the browser
|
||||
* @see https://stackoverflow.com/a/59907288/2228771
|
||||
*/
|
||||
export function getBasename(path: string) {
|
||||
// make sure the basename is not empty, if string ends with separator
|
||||
let end = path.length - 1;
|
||||
while (path[end] === '/' || path[end] === '\\') {
|
||||
--end;
|
||||
}
|
||||
|
||||
// support mixing of Win + Unix path separators
|
||||
const i1 = path.lastIndexOf('/', end);
|
||||
const i2 = path.lastIndexOf('\\', end);
|
||||
|
||||
let start: number;
|
||||
if (i1 === -1) {
|
||||
if (i2 === -1) {
|
||||
// no separator in the whole thing
|
||||
return path;
|
||||
}
|
||||
start = i2;
|
||||
} else if (i2 === -1) {
|
||||
start = i1;
|
||||
} else {
|
||||
start = Math.max(i1, i2);
|
||||
}
|
||||
return path.substring(start + 1, end + 1);
|
||||
}
|
||||
41
packages/editor-ext/src/lib/markdown/utils/callout.marked.ts
Normal file
41
packages/editor-ext/src/lib/markdown/utils/callout.marked.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Token, marked } from 'marked';
|
||||
|
||||
interface CalloutToken {
|
||||
type: 'callout';
|
||||
calloutType: string;
|
||||
text: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export const calloutExtension = {
|
||||
name: 'callout',
|
||||
level: 'block',
|
||||
start(src: string) {
|
||||
return src.match(/:::/)?.index ?? -1;
|
||||
},
|
||||
tokenizer(src: string): CalloutToken | undefined {
|
||||
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
|
||||
const match = rule.exec(src);
|
||||
|
||||
const validCalloutTypes = ['info', 'success', 'warning', 'danger'];
|
||||
|
||||
if (match) {
|
||||
let type = match[1];
|
||||
if (!validCalloutTypes.includes(type)) {
|
||||
type = 'info';
|
||||
}
|
||||
return {
|
||||
type: 'callout',
|
||||
calloutType: type,
|
||||
raw: match[0],
|
||||
text: match[2].trim(),
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token: Token) {
|
||||
const calloutToken = token as CalloutToken;
|
||||
const body = marked.parse(calloutToken.text);
|
||||
|
||||
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
|
||||
},
|
||||
};
|
||||
110
packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts
Normal file
110
packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Token, marked } from 'marked';
|
||||
import { generateNodeId } from '../../utils';
|
||||
|
||||
interface FootnoteRefToken {
|
||||
type: 'footnoteRef';
|
||||
label: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface FootnoteDefToken {
|
||||
type: 'footnoteDef';
|
||||
label: string;
|
||||
text: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// Parse-scoped state: markdownToHtml resets before the top-level parse and
|
||||
// appends the collected list after it. Nested marked.parse calls (callout,
|
||||
// footnote definitions) share this state, so hooks cannot be used here.
|
||||
let footnoteRefs: { label: string; id: string; number: number }[] = [];
|
||||
let footnoteDefs = new Map<string, string>();
|
||||
|
||||
export function resetFootnotes() {
|
||||
footnoteRefs = [];
|
||||
footnoteDefs = new Map();
|
||||
}
|
||||
|
||||
export function renderFootnotesList(): string {
|
||||
if (!footnoteRefs.length) return '';
|
||||
const items = footnoteRefs.map(({ label, id, number }) => {
|
||||
const body = footnoteDefs.get(label) || '<p></p>';
|
||||
return `<li id="fn:${number}" data-id="${id}">${body}</li>`;
|
||||
});
|
||||
return `<ol class="footnotes">\n${items.join('\n')}\n</ol>\n`;
|
||||
}
|
||||
|
||||
export const footnoteRefExtension = {
|
||||
name: 'footnoteRef',
|
||||
level: 'inline',
|
||||
start(src: string) {
|
||||
return src.indexOf('[^');
|
||||
},
|
||||
tokenizer(src: string): FootnoteRefToken | undefined {
|
||||
const match = /^\[\^([^\]\s]+)\]/.exec(src);
|
||||
if (match) {
|
||||
return {
|
||||
type: 'footnoteRef',
|
||||
raw: match[0],
|
||||
label: match[1].toLowerCase(),
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token: Token) {
|
||||
const refToken = token as FootnoteRefToken;
|
||||
const number = footnoteRefs.length + 1;
|
||||
const id = generateNodeId();
|
||||
footnoteRefs.push({ label: refToken.label, id, number });
|
||||
return `<sup id="fnref:${number}"><a class="footnote-ref" data-id="${id}" data-reference-number="${number}" href="#fn:${number}">${number}</a></sup>`;
|
||||
},
|
||||
};
|
||||
|
||||
export const footnoteDefExtension = {
|
||||
name: 'footnoteDef',
|
||||
level: 'block',
|
||||
start(src: string) {
|
||||
return src.match(/^\[\^[^\]\s]+\]:/m)?.index ?? -1;
|
||||
},
|
||||
tokenizer(src: string): FootnoteDefToken | undefined {
|
||||
const firstLine = /^\[\^([^\]\s]+)\]:[ \t]*/.exec(src);
|
||||
if (!firstLine) return undefined;
|
||||
|
||||
const lines = src.split('\n');
|
||||
const contentLines = [lines[0].slice(firstLine[0].length)];
|
||||
let consumed = 1;
|
||||
while (consumed < lines.length) {
|
||||
const line = lines[consumed];
|
||||
if (/^[ \t]{2,}\S/.test(line)) {
|
||||
contentLines.push(line.replace(/^[ \t]{1,4}/, ''));
|
||||
consumed += 1;
|
||||
} else if (
|
||||
/^[ \t]*$/.test(line) &&
|
||||
consumed + 1 < lines.length &&
|
||||
/^[ \t]{2,}\S/.test(lines[consumed + 1])
|
||||
) {
|
||||
contentLines.push('');
|
||||
consumed += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const raw =
|
||||
lines.slice(0, consumed).join('\n') +
|
||||
(consumed < lines.length ? '\n' : '');
|
||||
return {
|
||||
type: 'footnoteDef',
|
||||
raw,
|
||||
label: firstLine[1].toLowerCase(),
|
||||
text: contentLines.join('\n').trim(),
|
||||
};
|
||||
},
|
||||
renderer(token: Token) {
|
||||
const defToken = token as FootnoteDefToken;
|
||||
const body = defToken.text
|
||||
? marked.parse(defToken.text).toString()
|
||||
: '<p></p>';
|
||||
footnoteDefs.set(defToken.label, body);
|
||||
return '';
|
||||
},
|
||||
};
|
||||
71
packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
Normal file
71
packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { marked } from "marked";
|
||||
import { calloutExtension } from "./callout.marked";
|
||||
import { mathBlockExtension } from "./math-block.marked";
|
||||
import { mathInlineExtension } from "./math-inline.marked";
|
||||
import {
|
||||
footnoteDefExtension,
|
||||
footnoteRefExtension,
|
||||
renderFootnotesList,
|
||||
resetFootnotes,
|
||||
} from "./footnotes.marked";
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
list({ ordered, start, items }) {
|
||||
let body = "";
|
||||
for (const item of items) {
|
||||
body += this.listitem(item);
|
||||
}
|
||||
|
||||
if (ordered) {
|
||||
const startAttr = start !== 1 ? ` start="${start}"` : "";
|
||||
return `<ol${startAttr}>\n${body}</ol>\n`;
|
||||
}
|
||||
|
||||
const isTaskList = items.some((item) => item.task);
|
||||
const dataType = isTaskList ? ' data-type="taskList"' : "";
|
||||
return `<ul${dataType}>\n${body}</ul>\n`;
|
||||
},
|
||||
listitem({ tokens, task: isTask, checked: isChecked }) {
|
||||
const text = this.parser.parse(tokens);
|
||||
if (!isTask) {
|
||||
return `<li>${text}</li>\n`;
|
||||
}
|
||||
const checkedAttr = isChecked
|
||||
? 'data-checked="true"'
|
||||
: 'data-checked="false"';
|
||||
return `<li data-type="taskItem" ${checkedAttr}>${text}</li>\n`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
marked.use({
|
||||
extensions: [
|
||||
calloutExtension,
|
||||
mathBlockExtension,
|
||||
mathInlineExtension,
|
||||
footnoteDefExtension,
|
||||
footnoteRefExtension,
|
||||
],
|
||||
});
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
|
||||
export function markdownToHtml(
|
||||
markdownInput: string,
|
||||
): string | Promise<string> {
|
||||
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
|
||||
|
||||
const markdown = markdownInput
|
||||
.replace(YAML_FONT_MATTER_REGEX, "")
|
||||
.trimStart();
|
||||
|
||||
resetFootnotes();
|
||||
// marked always closes fenced code with a newline that the editor keeps as
|
||||
// an empty trailing line inside the code block.
|
||||
const html = marked
|
||||
.parse(markdown)
|
||||
.toString()
|
||||
.replace(/\n<\/code><\/pre>/g, "</code></pre>");
|
||||
return html + renderFootnotesList();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Token, marked } from 'marked';
|
||||
|
||||
interface MathBlockToken {
|
||||
type: 'mathBlock';
|
||||
text: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export const mathBlockExtension = {
|
||||
name: 'mathBlock',
|
||||
level: 'block',
|
||||
start(src: string) {
|
||||
return src.match(/\$\$/)?.index ?? -1;
|
||||
},
|
||||
tokenizer(src: string): MathBlockToken | undefined {
|
||||
const rule = /^\$\$(?!(\$))([\s\S]+?)\$\$/;
|
||||
const match = rule.exec(src);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
type: 'mathBlock',
|
||||
raw: match[0],
|
||||
text: match[2]?.trim(),
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token: Token) {
|
||||
const mathBlockToken = token as MathBlockToken;
|
||||
// parse to prevent escaping slashes
|
||||
const latex = marked
|
||||
.parse(mathBlockToken.text)
|
||||
.toString()
|
||||
.replace(/<(\/)?p>/g, '');
|
||||
|
||||
return `<div data-type="${mathBlockToken.type}" data-katex="true">${latex}</div>`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Token, marked } from 'marked';
|
||||
|
||||
interface MathInlineToken {
|
||||
type: 'mathInline';
|
||||
text: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const inlineMathRegex = /^\$(?!\s)(.+?)(?<!\s)\$(?!\d)/;
|
||||
|
||||
export const mathInlineExtension = {
|
||||
name: 'mathInline',
|
||||
level: 'inline',
|
||||
start(src: string) {
|
||||
let index: number;
|
||||
let indexSrc = src;
|
||||
|
||||
while (indexSrc) {
|
||||
index = indexSrc.indexOf('$');
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
const f = index === 0 || indexSrc.charAt(index - 1) === ' ';
|
||||
if (f) {
|
||||
const possibleKatex = indexSrc.substring(index);
|
||||
if (possibleKatex.match(inlineMathRegex)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
|
||||
}
|
||||
},
|
||||
tokenizer(src: string): MathInlineToken | undefined {
|
||||
const match = inlineMathRegex.exec(src);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
type: 'mathInline',
|
||||
raw: match[0],
|
||||
text: match[1]?.trim(),
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token: Token) {
|
||||
const mathInlineToken = token as MathInlineToken;
|
||||
// parse to prevent escaping slashes
|
||||
const latex = marked
|
||||
.parse(mathInlineToken.text)
|
||||
.toString()
|
||||
.replace(/<(\/)?p>/g, '');
|
||||
|
||||
return `<span data-type="${mathInlineToken.type}" data-katex="true">${latex}</span>`;
|
||||
},
|
||||
};
|
||||
12
packages/editor-ext/src/lib/markdown/utils/turndown.d.ts
vendored
Normal file
12
packages/editor-ext/src/lib/markdown/utils/turndown.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Map @joplin/turndown types to @types/turndown
|
||||
declare module "@joplin/turndown" {
|
||||
import TurndownService from "turndown";
|
||||
export = TurndownService;
|
||||
}
|
||||
|
||||
declare module "@joplin/turndown-plugin-gfm" {
|
||||
import TurndownService from "turndown";
|
||||
export const tables: TurndownService.Plugin;
|
||||
export const strikethrough: TurndownService.Plugin;
|
||||
export const highlightedCodeBlock: TurndownService.Plugin;
|
||||
}
|
||||
272
packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
Normal file
272
packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import * as _TurndownService from '@joplin/turndown';
|
||||
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||
import { getBasename } from './basename';
|
||||
|
||||
// CJS/ESM interop: .default exists in Vite, not in NestJS
|
||||
const TurndownService = (_TurndownService as any).default || _TurndownService;
|
||||
|
||||
function sanitizeMdLinkText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/([\[\]!])/g, '\\$1')
|
||||
.replace(/[\r\n]+/g, ' ');
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
hr: '---',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
|
||||
turndownService.use([
|
||||
TurndownPluginGfm.tables,
|
||||
TurndownPluginGfm.strikethrough,
|
||||
TurndownPluginGfm.highlightedCodeBlock,
|
||||
taskList,
|
||||
callout,
|
||||
preserveDetail,
|
||||
listParagraph,
|
||||
orderedListItem,
|
||||
mathInline,
|
||||
mathBlock,
|
||||
iframeEmbed,
|
||||
image,
|
||||
video,
|
||||
footnoteRef,
|
||||
footnotesList,
|
||||
]);
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: _TurndownService) {
|
||||
turndownService.addRule('paragraph', {
|
||||
filter: ['p'],
|
||||
replacement: (content: string, node: HTMLInputElement) => {
|
||||
if (node.parentElement?.nodeName === 'LI') {
|
||||
return content;
|
||||
}
|
||||
return `\n\n${content}\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function orderedListItem(turndownService: _TurndownService) {
|
||||
turndownService.addRule('orderedListItem', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
|
||||
},
|
||||
replacement: (content: string, node: HTMLInputElement, options: any) => {
|
||||
const parent = node.parentNode as HTMLElement;
|
||||
if (parent.nodeName !== 'OL' && parent.nodeName !== 'UL') {
|
||||
return content;
|
||||
}
|
||||
|
||||
content = content
|
||||
.replace(/^\n+/, '')
|
||||
.replace(/\n+$/, '\n')
|
||||
.replace(/\n/gm, '\n ');
|
||||
|
||||
let prefix: string;
|
||||
if (parent.nodeName === 'OL') {
|
||||
const start = parseInt(parent.getAttribute('start') || '1', 10);
|
||||
const index = Array.prototype.indexOf.call(parent.children, node);
|
||||
prefix = `${start + index}. `;
|
||||
} else {
|
||||
prefix = `${options.bulletListMarker} `;
|
||||
}
|
||||
|
||||
return (
|
||||
prefix +
|
||||
content +
|
||||
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function callout(turndownService: _TurndownService) {
|
||||
turndownService.addRule('callout', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
||||
);
|
||||
},
|
||||
replacement: function (content: string, node: HTMLInputElement) {
|
||||
const calloutType = node.getAttribute('data-callout-type');
|
||||
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function taskList(turndownService: _TurndownService) {
|
||||
turndownService.addRule('taskListItem', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.getAttribute('data-type') === 'taskItem' &&
|
||||
node.parentNode.nodeName === 'UL'
|
||||
);
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const isChecked = node.getAttribute('data-checked') === 'true';
|
||||
const div = node.querySelector('div');
|
||||
const text = div ? div.textContent.trim() : node.textContent.trim();
|
||||
|
||||
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
||||
|
||||
return (
|
||||
prefix +
|
||||
text +
|
||||
(node.nextSibling && !/\n$/.test(text) ? '\n' : '')
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function preserveDetail(turndownService: _TurndownService) {
|
||||
turndownService.addRule('preserveDetail', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.nodeName === 'DETAILS';
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const summary = node.querySelector(':scope > summary');
|
||||
let detailSummary = '';
|
||||
|
||||
if (summary) {
|
||||
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
|
||||
}
|
||||
|
||||
const detailsContent = Array.from(node.childNodes)
|
||||
.filter((child) => child.nodeName !== 'SUMMARY')
|
||||
.map((child) =>
|
||||
child.nodeType === 1
|
||||
? turndownService.turndown((child as HTMLElement).outerHTML)
|
||||
: child.textContent,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mathInline(turndownService: _TurndownService) {
|
||||
turndownService.addRule('mathInline', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'SPAN' &&
|
||||
node.getAttribute('data-type') === 'mathInline'
|
||||
);
|
||||
},
|
||||
replacement: function (content: string) {
|
||||
return `$${content}$`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mathBlock(turndownService: _TurndownService) {
|
||||
turndownService.addRule('mathBlock', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'DIV' &&
|
||||
node.getAttribute('data-type') === 'mathBlock'
|
||||
);
|
||||
},
|
||||
replacement: function (content: string) {
|
||||
return `\n$$\n${content}\n$$\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function iframeEmbed(turndownService: _TurndownService) {
|
||||
turndownService.addRule('iframeEmbed', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.nodeName === 'IFRAME';
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src');
|
||||
return '[' + src + '](' + src + ')';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function image(turndownService: _TurndownService) {
|
||||
turndownService.addRule('image', {
|
||||
filter: 'img',
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src') || '';
|
||||
if (!src) return '';
|
||||
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
|
||||
const title = node.getAttribute('title') || '';
|
||||
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';
|
||||
return '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getFootnoteAnchor(node: HTMLElement): HTMLElement | null {
|
||||
const child = node.firstElementChild as HTMLElement | null;
|
||||
return child?.nodeName === 'A' && child.classList.contains('footnote-ref')
|
||||
? child
|
||||
: null;
|
||||
}
|
||||
|
||||
function footnoteRef(turndownService: _TurndownService) {
|
||||
turndownService.addRule('footnoteRef', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.nodeName === 'SUP' && !!getFootnoteAnchor(node);
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const anchor = getFootnoteAnchor(node);
|
||||
const number =
|
||||
anchor.getAttribute('data-reference-number') || anchor.textContent;
|
||||
return `[^${number}]`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function footnotesList(turndownService: _TurndownService) {
|
||||
turndownService.addRule('footnotesList', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.nodeName === 'OL' && node.classList.contains('footnotes');
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const items = Array.from(node.children).filter(
|
||||
(child) => child.nodeName === 'LI',
|
||||
);
|
||||
const definitions = items.map((li, index) => {
|
||||
const number =
|
||||
(li.getAttribute('id') || '').replace('fn:', '') ||
|
||||
String(index + 1);
|
||||
const markdown = turndownService
|
||||
.turndown((li as HTMLElement).innerHTML)
|
||||
.trim();
|
||||
// continuation lines need a 4-space indent to stay in the footnote
|
||||
const [first, ...rest] = markdown.split('\n');
|
||||
const body = [
|
||||
first,
|
||||
...rest.map((line: string) => (line.trim() ? ` ${line}` : line)),
|
||||
].join('\n');
|
||||
return `[^${number}]: ${body}`;
|
||||
});
|
||||
return `\n\n${definitions.join('\n')}\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function video(turndownService: _TurndownService) {
|
||||
turndownService.addRule('video', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.tagName === 'VIDEO';
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src') || '';
|
||||
const ariaLabel = node.getAttribute('aria-label');
|
||||
const name = sanitizeMdLinkText(
|
||||
ariaLabel || getBasename(src) || src,
|
||||
);
|
||||
return '[' + name + '](' + src + ')';
|
||||
},
|
||||
});
|
||||
}
|
||||
2
packages/editor-ext/src/lib/math/index.ts
Normal file
2
packages/editor-ext/src/lib/math/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MathInline } from "./math-inline";
|
||||
export { MathBlock } from "./math-block";
|
||||
97
packages/editor-ext/src/lib/math/math-block.ts
Normal file
97
packages/editor-ext/src/lib/math/math-block.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Node, nodeInputRule } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
mathBlock: {
|
||||
setMathBlock: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface MathBlockOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface MathBlockAttributes {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const inputRegex = /(?:^|\s)((?:\$\$\$)((?:[^$]+))(?:\$\$\$))$/;
|
||||
|
||||
export const MathBlock = Node.create({
|
||||
name: "mathBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
text: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
return element.innerHTML;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
getAttrs: (node: HTMLElement) => {
|
||||
return node.hasAttribute("data-katex") ? {} : false;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
{ "data-type": this.name, "data-katex": true },
|
||||
`${HTMLAttributes.text}`,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||
this.editor.isInitialized = true;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setMathBlock:
|
||||
(attributes?: Record<string, any>) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
text: match[1].replaceAll("$", ""),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
97
packages/editor-ext/src/lib/math/math-inline.ts
Normal file
97
packages/editor-ext/src/lib/math/math-inline.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Node, nodeInputRule } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
mathInline: {
|
||||
setMathInline: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface MathInlineOption {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface MathInlineAttributes {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const inputRegex = /(?:^|\s)((?:\$\$)((?:[^$]+))(?:\$\$))$/;
|
||||
|
||||
export const MathInline = Node.create<MathInlineOption>({
|
||||
name: "mathInline",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
text: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
return element.innerHTML;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
getAttrs: (node: HTMLElement) => {
|
||||
return node.hasAttribute("data-katex") ? {} : false;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
{ "data-type": this.name, "data-katex": true },
|
||||
`${HTMLAttributes.text}`,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||
this.editor.isInitialized = true;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setMathInline:
|
||||
(attributes?: Record<string, any>) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
text: match[1].replaceAll("$", ""),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
43
packages/editor-ext/src/lib/media-utils.ts
Normal file
43
packages/editor-ext/src/lib/media-utils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
|
||||
export function normalizeFileUrl(src: string): string {
|
||||
if (src && src.startsWith("/files/")) {
|
||||
return "/api" + src;
|
||||
}
|
||||
return src || "";
|
||||
}
|
||||
|
||||
export function syncAltBadge(wrapper: HTMLElement, alt: unknown): void {
|
||||
const existing = wrapper.querySelector<HTMLElement>(
|
||||
":scope > .media-alt-badge",
|
||||
);
|
||||
|
||||
if (typeof alt !== "string" || !alt.trim()) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const badge = existing ?? document.createElement("span");
|
||||
badge.dataset.alt = alt;
|
||||
|
||||
if (!existing) {
|
||||
badge.className = "media-alt-badge";
|
||||
badge.textContent = "ALT";
|
||||
badge.setAttribute("aria-hidden", "true");
|
||||
wrapper.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
export type UploadFn = (
|
||||
file: File,
|
||||
editor: Editor,
|
||||
pos: number,
|
||||
pageId: string,
|
||||
// only applicable to file attachments
|
||||
allowMedia?: boolean,
|
||||
) => void;
|
||||
|
||||
export interface MediaUploadOptions {
|
||||
validateFn?: (file: File, allowMedia?: boolean) => void;
|
||||
onUpload: (file: File, pageId: string) => Promise<any>;
|
||||
}
|
||||
354
packages/editor-ext/src/lib/mention.ts
Normal file
354
packages/editor-ext/src/lib/mention.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
|
||||
export interface MentionNodeAttrs {
|
||||
/**
|
||||
* unique mention node id (uuidv7)
|
||||
*/
|
||||
id: string | null;
|
||||
/**
|
||||
* The label to be rendered by the editor as the displayed text for this mentioned
|
||||
* item, if provided.
|
||||
*/
|
||||
label?: string | null;
|
||||
|
||||
/**
|
||||
* the entity type - user or page
|
||||
*/
|
||||
entityType: "user" | "page";
|
||||
|
||||
/**
|
||||
* the entity id - userId or pageId
|
||||
*/
|
||||
entityId?: string | null;
|
||||
|
||||
/**
|
||||
* page slugId
|
||||
*/
|
||||
slugId?: string | null;
|
||||
|
||||
/**
|
||||
* the id of the user who initiated the mention
|
||||
*/
|
||||
creatorId?: string;
|
||||
|
||||
/**
|
||||
* the anchor hash for page mentions (e.g., "heading-1")
|
||||
*/
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
export type MentionOptions<
|
||||
SuggestionItem = any,
|
||||
Attrs extends Record<string, any> = MentionNodeAttrs,
|
||||
> = {
|
||||
/**
|
||||
* The HTML attributes for a mention node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
|
||||
/**
|
||||
* A function to render the text of a mention.
|
||||
* @param props The render props
|
||||
* @returns The text
|
||||
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
|
||||
*/
|
||||
renderText: (props: {
|
||||
options: MentionOptions<SuggestionItem, Attrs>;
|
||||
node: ProseMirrorNode;
|
||||
}) => string;
|
||||
|
||||
/**
|
||||
* A function to render the HTML of a mention.
|
||||
* @param props The render props
|
||||
* @returns The HTML as a ProseMirror DOM Output Spec
|
||||
* @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
|
||||
*/
|
||||
renderHTML: (props: {
|
||||
options: MentionOptions<SuggestionItem, Attrs>;
|
||||
node: ProseMirrorNode;
|
||||
}) => DOMOutputSpec;
|
||||
|
||||
/**
|
||||
* Whether to delete the trigger character with backspace.
|
||||
* @default false
|
||||
*/
|
||||
deleteTriggerWithBackspace: boolean;
|
||||
|
||||
/**
|
||||
* The suggestion options.
|
||||
* @default {}
|
||||
* @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
|
||||
*/
|
||||
suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, "editor">;
|
||||
};
|
||||
|
||||
/**
|
||||
* The plugin key for the mention plugin.
|
||||
* @default 'mention'
|
||||
*/
|
||||
export const MentionPluginKey = new PluginKey("mention");
|
||||
|
||||
/**
|
||||
* This extension allows you to insert mentions into the editor.
|
||||
* @see https://www.tiptap.dev/api/extensions/mention
|
||||
*/
|
||||
export const Mention = Node.create<MentionOptions>({
|
||||
name: "mention",
|
||||
|
||||
priority: 101,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderText({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
deleteTriggerWithBackspace: false,
|
||||
renderHTML({ options, node }) {
|
||||
const isUserMention = node.attrs.entityType === "user";
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
|
||||
`${isUserMention ? options.suggestion.char : ""}${node.attrs.label ?? node.attrs.entityId}`,
|
||||
];
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
pluginKey: MentionPluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(" ");
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: props,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " ",
|
||||
},
|
||||
])
|
||||
.run();
|
||||
|
||||
// get reference to `window` object from editor element, to support cross-frame JS usage
|
||||
editor.view.dom.ownerDocument.defaultView
|
||||
?.getSelection()
|
||||
?.collapseToEnd();
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const type = state.schema.nodes[this.name];
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||||
|
||||
return allow;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
group: "inline",
|
||||
inline: true,
|
||||
selectable: true,
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-id": attributes.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-label"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.label) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-label": attributes.label,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
entityType: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-entity-type"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.entityType) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-entity-type": attributes.entityType,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
entityId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-entity-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.entityId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-entity-id": attributes.entityId,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
slugId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-slug-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.slugId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-slug-id": attributes.slugId,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
creatorId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-creator-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.creatorId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-creator-id": attributes.creatorId,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
anchorId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-anchor-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.anchorId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-anchor-id": attributes.anchorId,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const mergedOptions = { ...this.options };
|
||||
|
||||
mergedOptions.HTMLAttributes = mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
);
|
||||
const html = this.options.renderHTML({
|
||||
options: mergedOptions,
|
||||
node,
|
||||
});
|
||||
|
||||
if (typeof html === "string") {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
html,
|
||||
];
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return this.options.renderText({
|
||||
options: this.options,
|
||||
node,
|
||||
});
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () =>
|
||||
this.editor.commands.command(({ tr, state }) => {
|
||||
let isMention = false;
|
||||
const { selection } = state;
|
||||
const { empty, anchor } = selection;
|
||||
|
||||
if (!empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||
if (node.type.name === this.name) {
|
||||
isMention = true;
|
||||
tr.insertText(
|
||||
this.options.deleteTriggerWithBackspace
|
||||
? ""
|
||||
: this.options.suggestion.char || "",
|
||||
pos,
|
||||
pos + node.nodeSize,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return isMention;
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
1
packages/editor-ext/src/lib/page-break/index.ts
Normal file
1
packages/editor-ext/src/lib/page-break/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./page-break";
|
||||
60
packages/editor-ext/src/lib/page-break/page-break.ts
Normal file
60
packages/editor-ext/src/lib/page-break/page-break.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export interface PageBreakOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
pageBreak: {
|
||||
setPageBreak: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const PageBreak = Node.create<PageBreakOptions>({
|
||||
name: "pageBreak",
|
||||
|
||||
group: "block",
|
||||
|
||||
atom: true,
|
||||
|
||||
selectable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name, class: "page-break" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setPageBreak:
|
||||
() =>
|
||||
({ chain }) =>
|
||||
chain()
|
||||
.insertContent({ type: this.name })
|
||||
.focus()
|
||||
.run(),
|
||||
};
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/pdf/index.ts
Normal file
2
packages/editor-ext/src/lib/pdf/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TiptapPdf } from "./pdf";
|
||||
export * from "./pdf-upload";
|
||||
123
packages/editor-ext/src/lib/pdf/pdf-upload.ts
Normal file
123
packages/editor-ext/src/lib/pdf/pdf-upload.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { Command } from "@tiptap/core";
|
||||
|
||||
const findPdfNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
|
||||
if (
|
||||
node.type.name === "pdf" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handlePdfUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, editor, pos, pageId) => {
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
|
||||
const placeholderId = generateNodeId();
|
||||
|
||||
let placeholderInserted = false;
|
||||
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.pdf?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
name: file.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (isEmptyTextBlock) {
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const replacePlaceholderWithPdf = (attachment: IAttachment): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null || !attachment) return;
|
||||
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
name: attachment.fileName,
|
||||
attachmentId: attachment.id,
|
||||
size: attachment.fileSize,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithPdf(attachment));
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithPdf(attachment))
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
editor.commands.command(removePlaceholder());
|
||||
}
|
||||
};
|
||||
|
||||
export { handlePdfUpload };
|
||||
156
packages/editor-ext/src/lib/pdf/pdf.ts
Normal file
156
packages/editor-ext/src/lib/pdf/pdf.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { sanitizeUrl, isInternalFileUrl } from "../utils";
|
||||
|
||||
export type PdfOptions = {
|
||||
view: any;
|
||||
HTMLAttributes: Record<string, any>;
|
||||
};
|
||||
|
||||
export type PdfAttributes = {
|
||||
src?: string;
|
||||
name?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
placeholder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
pdfBlock: {
|
||||
setPdf: (attributes: PdfAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TiptapPdf = Node.create<PdfOptions>({
|
||||
name: "pdf",
|
||||
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
view: null,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute("src");
|
||||
const sanitized = sanitizeUrl(src);
|
||||
return isInternalFileUrl(sanitized) ? sanitized : "";
|
||||
},
|
||||
renderHTML: (attributes) => ({
|
||||
src: isInternalFileUrl(attributes.src) ? sanitizeUrl(attributes.src) : "",
|
||||
}),
|
||||
},
|
||||
name: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-name"),
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
"data-name": attributes.name,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: 800,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("width");
|
||||
if (!raw) return null;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
width: attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 600,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("height");
|
||||
if (!raw) return null;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
height: attributes.height,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
[
|
||||
"iframe",
|
||||
{
|
||||
src: isInternalFileUrl(HTMLAttributes.src) ? sanitizeUrl(HTMLAttributes.src) : "",
|
||||
width: HTMLAttributes.width || 800,
|
||||
height: HTMLAttributes.height || 600,
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setPdf:
|
||||
(attrs: PdfAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "pdf",
|
||||
attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
167
packages/editor-ext/src/lib/prosemirror-docx/README.md
Normal file
167
packages/editor-ext/src/lib/prosemirror-docx/README.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# `prosemirror-docx`
|
||||
|
||||
[](https://www.npmjs.com/package/prosemirror-docx)
|
||||
[](https://github.com/curvenote/prosemirror-docx)
|
||||
[
|
||||
[](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
|
||||

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

|
||||
|
||||
## Overview
|
||||
|
||||
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but can’t read from `*.docx`), and has most of the basic nodes covered (see below).
|
||||
|
||||
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
|
||||
|
||||
## Basic usage
|
||||
|
||||
```ts
|
||||
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs'; // Or some other way to write a file
|
||||
|
||||
// Set up your prosemirror state/document as you normally do
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
getImageBuffer(src: string) {
|
||||
return anImageBuffer;
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced usage
|
||||
|
||||
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
|
||||
|
||||
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
|
||||
|
||||
```ts
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
|
||||
return {
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
fonts: [], // embed fonts,
|
||||
styles: {
|
||||
paragraphStyles,
|
||||
default: {
|
||||
heading1: paragraphStyles[1],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
|
||||
|
||||
## Extended usage
|
||||
|
||||
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
|
||||
|
||||
```ts
|
||||
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
|
||||
|
||||
const nodeSerializer = {
|
||||
...defaultNodes,
|
||||
my_paragraph(state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
};
|
||||
|
||||
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
|
||||
```
|
||||
|
||||
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
|
||||
|
||||
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
|
||||
|
||||
```ts
|
||||
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
export const docxSerializer = new DocxSerializerAsync(
|
||||
{
|
||||
...defaultAsyncNodes,
|
||||
async image(state, node) {
|
||||
const { src } = node.attrs;
|
||||
await state.image(src, 70, 'center', undefined, 'png');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
defaultMarks,
|
||||
);
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
async getImageBuffer(src: string) {
|
||||
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
|
||||
return new Uint8Array(arrayBuffer);
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
## Supported Nodes
|
||||
|
||||
- text
|
||||
- paragraph
|
||||
- heading (levels)
|
||||
- TODO: Support numbering of headings
|
||||
- blockquote
|
||||
- code_block
|
||||
- TODO: No styles supported
|
||||
- horizontal_rule
|
||||
- hard_break
|
||||
- ordered_list
|
||||
- unordered_list
|
||||
- list_item
|
||||
- image
|
||||
- math
|
||||
- equations (numbered & unnumbered)
|
||||
- tables
|
||||
|
||||
Planned:
|
||||
|
||||
- Internal References (e.g. see Table 1)
|
||||
|
||||
## Supported Marks
|
||||
|
||||
- em
|
||||
- strong
|
||||
- link
|
||||
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
|
||||
- code
|
||||
- subscript
|
||||
- superscript
|
||||
- strikethrough
|
||||
- underline
|
||||
- smallcaps
|
||||
- allcaps
|
||||
|
||||
## Resources
|
||||
|
||||
- [Prosemirror Docs](https://prosemirror.net/docs/)
|
||||
- [docx](https://docx.js.org/)
|
||||
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!
|
||||
24
packages/editor-ext/src/lib/prosemirror-docx/index.ts
Normal file
24
packages/editor-ext/src/lib/prosemirror-docx/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// MIT - https://github.com/curvenote/prosemirror-docx/
|
||||
export type { SectionConfig, SerializationState } from './types';
|
||||
export type {
|
||||
MarkSerializer,
|
||||
NodeSerializer,
|
||||
NodeSerializerAsync,
|
||||
Options,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
|
||||
export {
|
||||
DocxSerializerStateAsync,
|
||||
DocxSerializerAsync,
|
||||
DocxSerializerState,
|
||||
DocxSerializer,
|
||||
MAX_IMAGE_WIDTH,
|
||||
} from './serializer';
|
||||
export {
|
||||
defaultAsyncNodes,
|
||||
defaultMarks,
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from './schema';
|
||||
export { writeDocx, createDocFromState, buildDoc } from './utils';
|
||||
47
packages/editor-ext/src/lib/prosemirror-docx/numbering.ts
Normal file
47
packages/editor-ext/src/lib/prosemirror-docx/numbering.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
|
||||
import { INumbering } from './types';
|
||||
|
||||
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
|
||||
return {
|
||||
alignment: AlignmentType.START,
|
||||
style: {
|
||||
paragraph: {
|
||||
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const numbered = Array(3)
|
||||
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
|
||||
.flat()
|
||||
.map((format, level) => ({
|
||||
level,
|
||||
format,
|
||||
text: `%${level + 1}.`,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const bullets = Array(3)
|
||||
.fill(['●', '○', '■'])
|
||||
.flat()
|
||||
.map((text, level) => ({
|
||||
level,
|
||||
format: LevelFormat.BULLET,
|
||||
text,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const styles = {
|
||||
numbered,
|
||||
bullets,
|
||||
};
|
||||
|
||||
export type NumberingStyles = keyof typeof styles;
|
||||
|
||||
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
|
||||
return {
|
||||
reference,
|
||||
levels: styles[style],
|
||||
};
|
||||
}
|
||||
315
packages/editor-ext/src/lib/prosemirror-docx/schema.ts
Normal file
315
packages/editor-ext/src/lib/prosemirror-docx/schema.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx';
|
||||
import { Node } from 'prosemirror-model';
|
||||
import {
|
||||
DocxSerializerAsync,
|
||||
MarkSerializer,
|
||||
NodeSerializerAsync,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
import { writeDocx } from './utils';
|
||||
|
||||
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
|
||||
|
||||
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
|
||||
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
|
||||
// (named colors, hsl, etc.) so the caller omits the color rather than letting
|
||||
// docx throw "Invalid hex value".
|
||||
function toDocxColor(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const value = input.trim().toLowerCase();
|
||||
const hex = value.startsWith('#') ? value.slice(1) : value;
|
||||
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
|
||||
if (/^[0-9a-f]{3}$/.test(hex)) {
|
||||
return hex
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('');
|
||||
}
|
||||
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (rgb) {
|
||||
const channel = (n: string) =>
|
||||
Math.max(0, Math.min(255, parseInt(n, 10)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Images and diagrams embed via the image resolver; the URL (with its file
|
||||
// extension) is passed through so docx can infer the image type.
|
||||
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
|
||||
const src = node.attrs?.src || node.attrs?.attachmentId;
|
||||
if (src) {
|
||||
try {
|
||||
await state.image(src, 100);
|
||||
} catch {
|
||||
// Unrenderable/missing image: skip rather than fail the whole export.
|
||||
}
|
||||
}
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
// Non-embeddable media render as a labelled line.
|
||||
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label =
|
||||
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label = node.attrs?.src || node.attrs?.url || 'embed';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||
text(state, node) {
|
||||
state.text(node.text ?? '');
|
||||
},
|
||||
async paragraph(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
async heading(state, node) {
|
||||
await state.renderInline(node);
|
||||
const heading = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
][(node.attrs.level ?? 1) - 1];
|
||||
state.closeBlock(node, { heading });
|
||||
},
|
||||
async blockquote(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async codeBlock(state, node) {
|
||||
await state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
horizontalRule(state, node) {
|
||||
state.closeBlock(node, { thematicBreak: true });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
hardBreak(state) {
|
||||
state.addRunOptions({ break: 1 });
|
||||
},
|
||||
async bulletList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async orderedList(state, node) {
|
||||
await state.renderList(node, 'numbered');
|
||||
},
|
||||
async listItem(state, node) {
|
||||
await state.renderListItem(node);
|
||||
},
|
||||
async taskList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async taskItem(state, node) {
|
||||
if (state.currentNumbering) {
|
||||
state.addParagraphOptions({ numbering: state.currentNumbering });
|
||||
}
|
||||
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async table(state, node) {
|
||||
await state.table(node);
|
||||
},
|
||||
// Docmost stores LaTeX in attrs.text.
|
||||
mathInline(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: true });
|
||||
},
|
||||
mathBlock(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
image: renderImage,
|
||||
drawio: renderImage,
|
||||
excalidraw: renderImage,
|
||||
video: renderFileLine,
|
||||
audio: renderFileLine,
|
||||
pdf: renderFileLine,
|
||||
attachment: renderFileLine,
|
||||
embed: renderEmbedLine,
|
||||
youtube: renderEmbedLine,
|
||||
async callout(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async details(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async detailsSummary(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
|
||||
},
|
||||
async detailsContent(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async columns(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async column(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async transclusionSource(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
mention(state, node) {
|
||||
state.text(`@${node.attrs?.label ?? ''}`);
|
||||
},
|
||||
status(state, node) {
|
||||
state.text(`[${node.attrs?.text ?? ''}]`);
|
||||
},
|
||||
pageBreak(state, node) {
|
||||
state.closeBlock(node, { pageBreakBefore: true });
|
||||
},
|
||||
footnoteReference(state, node) {
|
||||
const number =
|
||||
Number(node.attrs?.referenceNumber) || state.$footnoteCounter + 1;
|
||||
state.$footnoteCounter = Math.max(state.$footnoteCounter, number);
|
||||
// seed an empty body so the reference stays valid even if the trailing
|
||||
// footnotes list is missing; the footnotes node overwrites it with content
|
||||
if (!state.footnotes[number]) {
|
||||
state.footnotes[number] = { children: [new Paragraph('')] };
|
||||
}
|
||||
state.current.push(new FootnoteReferenceRun(number));
|
||||
},
|
||||
async footnotes(state, node) {
|
||||
for (let i = 0; i < node.childCount; i += 1) {
|
||||
const item = node.child(i);
|
||||
const number =
|
||||
Number(String(item.attrs?.id ?? '').replace('fn:', '')) || i + 1;
|
||||
await state.footnoteDefinition(item, number);
|
||||
}
|
||||
},
|
||||
// items are consumed by the footnotes handler above
|
||||
footnote() {},
|
||||
// No usable static export representation: skip without failing.
|
||||
subpages() {},
|
||||
transclusionReference() {},
|
||||
base() {},
|
||||
};
|
||||
|
||||
export const defaultMarks: MarkSerializer = {
|
||||
bold() {
|
||||
return { bold: true };
|
||||
},
|
||||
italic() {
|
||||
return { italics: true };
|
||||
},
|
||||
strike() {
|
||||
return { strike: true };
|
||||
},
|
||||
underline() {
|
||||
return { underline: {} };
|
||||
},
|
||||
code() {
|
||||
return {
|
||||
font: { name: 'Monospace' },
|
||||
color: '000000',
|
||||
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
|
||||
};
|
||||
},
|
||||
superscript() {
|
||||
return { superScript: true };
|
||||
},
|
||||
subscript() {
|
||||
return { subScript: true };
|
||||
},
|
||||
link() {
|
||||
// Handled specifically in the serializer; Word treats links as nodes.
|
||||
return {};
|
||||
},
|
||||
highlight(_state, _node, mark) {
|
||||
const fill = toDocxColor(mark.attrs?.color);
|
||||
return fill
|
||||
? { shading: { type: ShadingType.CLEAR, fill } }
|
||||
: { highlight: 'yellow' };
|
||||
},
|
||||
// @tiptap/extension-color stores the color on the textStyle mark.
|
||||
textStyle(_state, _node, mark) {
|
||||
const color = toDocxColor(mark.attrs?.color);
|
||||
return color ? { color } : {};
|
||||
},
|
||||
// Comments are editor-only; drop the annotation in the export.
|
||||
comment() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
export async function pageNodeToDocxBuffer(
|
||||
doc: Node,
|
||||
getImageBuffer: DocxImageResolver,
|
||||
): Promise<Buffer> {
|
||||
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
|
||||
const wordDoc = await serializer.serializeAsync(
|
||||
doc,
|
||||
{ getImageBuffer },
|
||||
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
|
||||
// has no heading color, so override the default heading run colors to the
|
||||
// normal text color. Sizes/italics mirror docx's own defaults so only the
|
||||
// color changes.
|
||||
() =>
|
||||
({
|
||||
styles: {
|
||||
default: {
|
||||
document: { paragraph: { spacing: { after: 160 } } },
|
||||
heading1: {
|
||||
run: { color: '000000', size: 32 },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 240, after: 0 },
|
||||
},
|
||||
},
|
||||
heading2: {
|
||||
run: { color: '000000', size: 26 },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading3: {
|
||||
run: { color: '000000', size: 24 },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading4: {
|
||||
run: { color: '000000', italics: true },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading5: {
|
||||
run: { color: '000000' },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
heading6: {
|
||||
run: { color: '000000' },
|
||||
paragraph: {
|
||||
keepNext: true,
|
||||
keepLines: true,
|
||||
spacing: { before: 40, after: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
return writeDocx(wordDoc);
|
||||
}
|
||||
948
packages/editor-ext/src/lib/prosemirror-docx/serializer.ts
Normal file
948
packages/editor-ext/src/lib/prosemirror-docx/serializer.ts
Normal file
@@ -0,0 +1,948 @@
|
||||
import { Node, Mark } from 'prosemirror-model';
|
||||
import {
|
||||
IParagraphOptions,
|
||||
IRunOptions,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
ExternalHyperlink,
|
||||
ParagraphChild,
|
||||
MathRun,
|
||||
Math,
|
||||
TabStopType,
|
||||
TabStopPosition,
|
||||
SequentialIdentifier,
|
||||
Bookmark,
|
||||
ImageRun,
|
||||
AlignmentType,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
ITableCellOptions,
|
||||
InternalHyperlink,
|
||||
SimpleField,
|
||||
FootnoteReferenceRun,
|
||||
IImageOptions,
|
||||
Document,
|
||||
ITableOptions,
|
||||
ITableRowOptions,
|
||||
IPropertiesOptions,
|
||||
} from 'docx';
|
||||
import { imageDimensionsFromData } from 'image-dimensions';
|
||||
import { createNumbering, NumberingStyles } from './numbering';
|
||||
import { buildDoc, createShortId } from './utils';
|
||||
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
|
||||
|
||||
// This is duplicated from @curvenote/schema
|
||||
export type AlignOptions = 'left' | 'center' | 'right';
|
||||
|
||||
export type NodeSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
|
||||
>;
|
||||
|
||||
export type NodeSerializerAsync = Record<
|
||||
string,
|
||||
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
|
||||
>;
|
||||
|
||||
export type MarkSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
|
||||
>;
|
||||
|
||||
export type Options = {
|
||||
getImageBuffer: (src: string) => Uint8Array;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type OptionsAsync = {
|
||||
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type IMathOpts = {
|
||||
inline?: boolean;
|
||||
id?: string | null;
|
||||
numbered?: boolean;
|
||||
};
|
||||
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
|
||||
|
||||
export const MAX_IMAGE_WIDTH = 600;
|
||||
|
||||
function createReferenceBookmark(
|
||||
id: string,
|
||||
kind: 'Equation' | 'Figure' | 'Table',
|
||||
before?: string,
|
||||
after?: string,
|
||||
) {
|
||||
const textBefore = before ? [new TextRun(before)] : [];
|
||||
const textAfter = after ? [new TextRun(after)] : [];
|
||||
return new Bookmark({
|
||||
id,
|
||||
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
|
||||
});
|
||||
}
|
||||
|
||||
export class DocxSerializerState {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
options: Options;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as Options);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
parent.forEach((node, _, i) => {
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
this.render(node, parent, i);
|
||||
});
|
||||
}
|
||||
|
||||
render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
this.nodes[node.type.name](this, node, parent, index);
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
parent.forEach(progress);
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = this.options.getImageBuffer(src);
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
node.content.forEach((row) => {
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
row.content.forEach((cell) => {
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
});
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
row.content.forEach((cell) => {
|
||||
this.children = [];
|
||||
this.renderContent(cell);
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
});
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this seperates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializer {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
serialize(
|
||||
content: Node,
|
||||
options: Options,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
): Document {
|
||||
const state = new DocxSerializerState(this.nodes, this.marks, options);
|
||||
state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerStateAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
options: OptionsAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as OptionsAsync);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
async renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
const node = parent.child(i);
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.render(node, parent, i);
|
||||
}
|
||||
}
|
||||
|
||||
async render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
async renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = async (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
await this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
// Process nodes sequentially to maintain order
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await progress(parent.child(i), 0, i);
|
||||
}
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
async renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
await this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
async renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
await this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
async image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
async table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
|
||||
const row = node.content.child(rowIndex);
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
|
||||
// Check if all cells in the row are headers
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
}
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
|
||||
// Iterate through cells and ensure order
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
this.children = [];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.renderContent(cell); // Ensure order
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
}
|
||||
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this separates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
async footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
await this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
// Fills the footnote body for an already-referenced footnote number from a
|
||||
// node holding block content (Docmost keeps footnote text in a trailing
|
||||
// list, separate from the inline reference).
|
||||
async footnoteDefinition(node: Node, number: number) {
|
||||
const { current, children, nextRunOpts, nextParentParagraphOpts } = this;
|
||||
this.current = [];
|
||||
this.children = [];
|
||||
delete this.nextRunOpts;
|
||||
delete this.nextParentParagraphOpts;
|
||||
|
||||
await this.renderContent(node);
|
||||
this.footnotes[number] = {
|
||||
children: this.children.filter(
|
||||
(child): child is Paragraph => child instanceof Paragraph,
|
||||
),
|
||||
};
|
||||
|
||||
this.current = current;
|
||||
this.children = children;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.nextParentParagraphOpts = nextParentParagraphOpts;
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
async serializeAsync(
|
||||
content: Node,
|
||||
options: OptionsAsync,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
) {
|
||||
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
|
||||
await state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
34
packages/editor-ext/src/lib/prosemirror-docx/types.ts
Normal file
34
packages/editor-ext/src/lib/prosemirror-docx/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [k in keyof T]: T[k];
|
||||
};
|
||||
|
||||
export type IFootnotes = Mutable<
|
||||
Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
readonly children: readonly Paragraph[];
|
||||
}
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type INumbering = INumberingOptions['config'][0];
|
||||
|
||||
export interface SectionConfig {
|
||||
properties?: ISectionOptions['properties'];
|
||||
headers?: ISectionOptions['headers'];
|
||||
footers?: ISectionOptions['footers'];
|
||||
}
|
||||
|
||||
export interface SerializationState {
|
||||
numbering: INumberingOptions['config'];
|
||||
sections?: Array<{
|
||||
config: SectionConfig;
|
||||
children: ISectionOptions['children'];
|
||||
}>;
|
||||
children?: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}
|
||||
91
packages/editor-ext/src/lib/prosemirror-docx/utils.ts
Normal file
91
packages/editor-ext/src/lib/prosemirror-docx/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Document,
|
||||
INumberingOptions,
|
||||
IPropertiesOptions,
|
||||
ISectionOptions,
|
||||
Packer,
|
||||
SectionType,
|
||||
} from 'docx';
|
||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||
import { IFootnotes, SerializationState } from './types';
|
||||
|
||||
export function createShortId() {
|
||||
return Math.random().toString(36).slice(2, 11);
|
||||
}
|
||||
|
||||
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
|
||||
let sections = state?.sections?.length
|
||||
? state.sections.map((section) => ({
|
||||
properties: section.config.properties || {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
headers: section.config.headers,
|
||||
footers: section.config.footers,
|
||||
children: section.children,
|
||||
}))
|
||||
: undefined;
|
||||
if (!sections) {
|
||||
sections = [
|
||||
{
|
||||
headers: undefined,
|
||||
footers: undefined,
|
||||
properties: {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
children: state?.children || [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
footnotes: state.footnotes,
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
sections,
|
||||
...(opts || {}),
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - use `buildDoc` instead
|
||||
* Creates a docx document from the given state.
|
||||
* */
|
||||
export function createDocFromState(state: {
|
||||
numbering: INumberingOptions['config'];
|
||||
children: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}) {
|
||||
return buildDoc({
|
||||
numbering: state.numbering,
|
||||
sections: [
|
||||
{
|
||||
config: {},
|
||||
children: state.children,
|
||||
},
|
||||
],
|
||||
footnotes: state.footnotes,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeDocx(
|
||||
doc: Document,
|
||||
/**
|
||||
* @deprecated use `.then()` or `await` instead
|
||||
*/
|
||||
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
|
||||
) {
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
await write?.(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function getLatexFromNode(node: ProsemirrorNode): string {
|
||||
let math = '';
|
||||
node.forEach((child) => {
|
||||
if (child.isText) math += child.text;
|
||||
// TODO: improve this as we may have other things in the future
|
||||
});
|
||||
return math;
|
||||
}
|
||||
3
packages/editor-ext/src/lib/recreate-transform/copy.ts
Normal file
3
packages/editor-ext/src/lib/recreate-transform/copy.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function copy<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AnyObject } from "./types";
|
||||
|
||||
/**
|
||||
* get target value from json-pointer (e.g. /content/0/content)
|
||||
* @param {AnyObject} obj object to resolve path into
|
||||
* @param {string} path json-pointer
|
||||
* @return {any} target value
|
||||
*/
|
||||
export function getFromPath(obj: AnyObject, path: string): any {
|
||||
const pathParts = path.split("/");
|
||||
pathParts.shift(); // remove root-entry
|
||||
while (pathParts.length) {
|
||||
const property = pathParts.shift();
|
||||
obj = obj[property];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
|
||||
export function getReplaceStep(fromDoc: Node, toDoc: Node) {
|
||||
let start = toDoc.content.findDiffStart(fromDoc.content);
|
||||
if (start === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// @ts-ignore property access to content
|
||||
let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content);
|
||||
const overlap = start - Math.min(endA, endB);
|
||||
if (overlap > 0) {
|
||||
// If there is an overlap, there is some freedom of choice in how to calculate the
|
||||
// start/end boundary. for an inserted/removed slice. We choose the extreme with
|
||||
// the lowest depth value.
|
||||
if (
|
||||
fromDoc.resolve(start - overlap).depth <
|
||||
toDoc.resolve(endA + overlap).depth
|
||||
) {
|
||||
start -= overlap;
|
||||
} else {
|
||||
endA += overlap;
|
||||
endB += overlap;
|
||||
}
|
||||
}
|
||||
|
||||
return new ReplaceStep(start, endB, toDoc.slice(start, endA));
|
||||
}
|
||||
4
packages/editor-ext/src/lib/recreate-transform/index.ts
Normal file
4
packages/editor-ext/src/lib/recreate-transform/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
|
||||
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
|
||||
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
||||
export type { Options } from "./recreateTransform";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
packages/editor-ext/src/lib/recreate-transform/types.ts
Normal file
3
packages/editor-ext/src/lib/recreate-transform/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface AnyObject {
|
||||
[p: string]: any;
|
||||
}
|
||||
1098
packages/editor-ext/src/lib/resizable-nodeview.ts
Normal file
1098
packages/editor-ext/src/lib/resizable-nodeview.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
packages/editor-ext/src/lib/search-and-replace/index.ts
Normal file
3
packages/editor-ext/src/lib/search-and-replace/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SearchAndReplace } from './search-and-replace'
|
||||
export * from './search-and-replace'
|
||||
export default SearchAndReplace
|
||||
@@ -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;
|
||||
36
packages/editor-ext/src/lib/selection.ts
Normal file
36
packages/editor-ext/src/lib/selection.ts
Normal 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;
|
||||
1
packages/editor-ext/src/lib/shared-storage/index.ts
Normal file
1
packages/editor-ext/src/lib/shared-storage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SharedStorage } from "./shared-storage";
|
||||
17
packages/editor-ext/src/lib/shared-storage/shared-storage.ts
Normal file
17
packages/editor-ext/src/lib/shared-storage/shared-storage.ts
Normal 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 };
|
||||
108
packages/editor-ext/src/lib/status.ts
Normal file
108
packages/editor-ext/src/lib/status.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/subpages/index.ts
Normal file
2
packages/editor-ext/src/lib/subpages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Subpages } from "./subpages";
|
||||
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
|
||||
72
packages/editor-ext/src/lib/subpages/subpages.ts
Normal file
72
packages/editor-ext/src/lib/subpages/subpages.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
43
packages/editor-ext/src/lib/table/cell.ts
Normal file
43
packages/editor-ext/src/lib/table/cell.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
44
packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts
Normal file
44
packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts
Normal 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')
|
||||
}
|
||||
393
packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
Normal file
393
packages/editor-ext/src/lib/table/dnd/dnd-extension.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
7
packages/editor-ext/src/lib/table/dnd/index.ts
Normal file
7
packages/editor-ext/src/lib/table/dnd/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
TableDndExtension,
|
||||
TableHandleCommandsExtension,
|
||||
TableDndKey,
|
||||
getTableHandlePluginSpec,
|
||||
} from "./dnd-extension";
|
||||
export type { TableHandleState, TableHandlePluginSpec } from "./dnd-extension";
|
||||
@@ -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`,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
119
packages/editor-ext/src/lib/table/dnd/utils.ts
Normal file
119
packages/editor-ext/src/lib/table/dnd/utils.ts
Normal 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 }
|
||||
}
|
||||
186
packages/editor-ext/src/lib/table/header-pin/controller.ts
Normal file
186
packages/editor-ext/src/lib/table/header-pin/controller.ts
Normal 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);
|
||||
}
|
||||
78
packages/editor-ext/src/lib/table/header-pin/extension.ts
Normal file
78
packages/editor-ext/src/lib/table/header-pin/extension.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/table/header-pin/index.ts
Normal file
2
packages/editor-ext/src/lib/table/header-pin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TableHeaderPin } from './extension';
|
||||
export { pinOffsetWatcher, EDITOR_PIN_OFFSET_VAR, computePinTop } from './offset';
|
||||
65
packages/editor-ext/src/lib/table/header-pin/offset.ts
Normal file
65
packages/editor-ext/src/lib/table/header-pin/offset.ts
Normal 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
Reference in New Issue
Block a user