1401 lines
50 KiB
JavaScript
1401 lines
50 KiB
JavaScript
// src/Extension.ts
|
||
import {
|
||
commands,
|
||
Extension
|
||
} from "@tiptap/core";
|
||
|
||
// src/MarkdownManager.ts
|
||
import {
|
||
attrsEqual as attrsEqual2,
|
||
callOrReturn,
|
||
decodeHtmlEntities,
|
||
encodeHtmlEntities,
|
||
flattenExtensions,
|
||
generateJSON,
|
||
getExtensionField,
|
||
getSchema,
|
||
marksEqual,
|
||
sortExtensions
|
||
} from "@tiptap/core";
|
||
import { marked } from "marked";
|
||
|
||
// src/utils.ts
|
||
import { attrsEqual } from "@tiptap/core";
|
||
function wrapInMarkdownBlock(prefix, content) {
|
||
const lines = content.split("\n");
|
||
const output = lines.flatMap((line) => [line, ""]).map((line) => `${prefix}${line}`).join("\n");
|
||
return output.slice(0, output.length - 1);
|
||
}
|
||
function findMarksToClose(currentMarks, nextNode) {
|
||
const marksToClose = [];
|
||
Array.from(currentMarks.entries()).forEach(([markType, currentMark]) => {
|
||
if (!nextNode) {
|
||
marksToClose.push(markType);
|
||
return;
|
||
}
|
||
const nextMark = (nextNode.marks || []).find(
|
||
(mark) => mark.type === markType && attrsEqual(mark.attrs, currentMark.attrs)
|
||
);
|
||
if (!nextMark) {
|
||
marksToClose.push(markType);
|
||
}
|
||
});
|
||
return marksToClose;
|
||
}
|
||
function findMarksToOpen(activeMarks, currentMarks) {
|
||
const marksToOpen = [];
|
||
Array.from(currentMarks.entries()).forEach(([markType, mark]) => {
|
||
const activeMark = activeMarks.get(markType);
|
||
if (!activeMark || !attrsEqual(activeMark.attrs, mark.attrs)) {
|
||
marksToOpen.push({ type: markType, mark });
|
||
}
|
||
});
|
||
return marksToOpen;
|
||
}
|
||
function findMarksToCloseAtEnd(activeMarks, currentMarks, nextNode, markSetsEqual) {
|
||
const isLastNode = !nextNode;
|
||
const nextNodeHasNoMarks = nextNode && (!nextNode.marks || nextNode.marks.length === 0);
|
||
const nextNodeHasDifferentMarks = nextNode && nextNode.marks && !markSetsEqual(currentMarks, new Map(nextNode.marks.map((mark) => [mark.type, mark])));
|
||
const marksToCloseAtEnd = [];
|
||
if (isLastNode || nextNodeHasNoMarks || nextNodeHasDifferentMarks) {
|
||
if (nextNode && nextNode.marks) {
|
||
Array.from(activeMarks.entries()).reverse().forEach(([markType, activeMark]) => {
|
||
const nextMark = nextNode.marks.find(
|
||
(m) => m.type === markType && attrsEqual(m.attrs, activeMark.attrs)
|
||
);
|
||
if (!nextMark) {
|
||
marksToCloseAtEnd.push(markType);
|
||
}
|
||
});
|
||
} else if (isLastNode || nextNodeHasNoMarks) {
|
||
marksToCloseAtEnd.push(...Array.from(activeMarks.keys()).reverse());
|
||
}
|
||
}
|
||
return marksToCloseAtEnd;
|
||
}
|
||
function closeMarksBeforeNode(activeMarks, getMarkClosing) {
|
||
let beforeMarkdown = "";
|
||
Array.from(activeMarks.keys()).reverse().forEach((markType) => {
|
||
const mark = activeMarks.get(markType);
|
||
const closeMarkdown = getMarkClosing(markType, mark);
|
||
if (closeMarkdown) {
|
||
beforeMarkdown = closeMarkdown + beforeMarkdown;
|
||
}
|
||
});
|
||
activeMarks.clear();
|
||
return beforeMarkdown;
|
||
}
|
||
function reopenMarksAfterNode(marksToReopen, activeMarks, getMarkOpening) {
|
||
let afterMarkdown = "";
|
||
Array.from(marksToReopen.entries()).forEach(([markType, mark]) => {
|
||
const openMarkdown = getMarkOpening(markType, mark);
|
||
if (openMarkdown) {
|
||
afterMarkdown += openMarkdown;
|
||
}
|
||
activeMarks.set(markType, mark);
|
||
});
|
||
return afterMarkdown;
|
||
}
|
||
function isTaskItem(item) {
|
||
const raw = item.raw || item.text || "";
|
||
const match = raw.match(/^(\s*)[-+*]\s+\[([ xX])\]\s+/);
|
||
if (match) {
|
||
return { isTask: true, checked: match[2].toLowerCase() === "x", indentLevel: match[1].length };
|
||
}
|
||
return { isTask: false, indentLevel: 0 };
|
||
}
|
||
function assumeContentType(content, contentType) {
|
||
if (typeof content !== "string") {
|
||
return "json";
|
||
}
|
||
return contentType;
|
||
}
|
||
|
||
// src/MarkdownManager.ts
|
||
var isHtmlUnknownElement = (element) => {
|
||
const ctor = window.HTMLUnknownElement;
|
||
return typeof ctor === "function" && element instanceof ctor;
|
||
};
|
||
var MarkdownManager = class {
|
||
/**
|
||
* Create a MarkdownManager.
|
||
* @param options.marked Optional marked instance to use (injected).
|
||
* @param options.markedOptions Optional options to pass to marked.setOptions
|
||
* @param options.indentation Indentation settings (style and size).
|
||
* @param options.extensions An array of Tiptap extensions to register for markdown parsing and rendering.
|
||
*/
|
||
constructor(options) {
|
||
this.activeParseLexer = null;
|
||
/**
|
||
* Order in which extensions were registered. Used to resolve mark nesting
|
||
* deterministically when several marks open on the same text node.
|
||
*
|
||
* The flattened extensions passed to the manager are pre-sorted by Tiptap's
|
||
* extension priority (descending), which is also the order ProseMirror uses
|
||
* to assign mark ranks. Recording that index here lets the serializer place
|
||
* higher-priority / lower-rank marks (e.g. link with priority 1000) on the
|
||
* outside without inspecting any rendered markdown output.
|
||
*/
|
||
this.extensionRanks = /* @__PURE__ */ new Map();
|
||
this.baseExtensions = [];
|
||
this.extensions = [];
|
||
/** Set of extension names whose `code` spec property is truthy (nodes and marks). */
|
||
this.codeTypes = /* @__PURE__ */ new Set();
|
||
/** Lazy cache of tag names declared by the registered schema's parseDOM rules. */
|
||
this.schemaParseDomTagsCache = null;
|
||
this.lastParseResult = null;
|
||
var _a, _b, _c, _d, _e;
|
||
this.markedInstance = (_a = options == null ? void 0 : options.marked) != null ? _a : marked;
|
||
this.indentStyle = (_c = (_b = options == null ? void 0 : options.indentation) == null ? void 0 : _b.style) != null ? _c : "space";
|
||
this.indentSize = (_e = (_d = options == null ? void 0 : options.indentation) == null ? void 0 : _d.size) != null ? _e : 2;
|
||
this.baseExtensions = (options == null ? void 0 : options.extensions) || [];
|
||
if ((options == null ? void 0 : options.markedOptions) && typeof this.markedInstance.setOptions === "function") {
|
||
this.markedInstance.setOptions(options.markedOptions);
|
||
}
|
||
this.registry = /* @__PURE__ */ new Map();
|
||
this.nodeTypeRegistry = /* @__PURE__ */ new Map();
|
||
if (options == null ? void 0 : options.extensions) {
|
||
this.baseExtensions = options.extensions;
|
||
const flattened = sortExtensions(flattenExtensions(options.extensions));
|
||
flattened.forEach((ext) => this.registerExtension(ext));
|
||
}
|
||
}
|
||
/** Returns the underlying marked instance. */
|
||
get instance() {
|
||
return this.markedInstance;
|
||
}
|
||
/** Returns the correct indentCharacter (space or tab) */
|
||
get indentCharacter() {
|
||
return this.indentStyle === "space" ? " " : " ";
|
||
}
|
||
/** Returns the correct indentString repeated X times */
|
||
get indentString() {
|
||
return this.indentCharacter.repeat(this.indentSize);
|
||
}
|
||
/** Helper to quickly check whether a marked instance is available. */
|
||
hasMarked() {
|
||
return !!this.markedInstance;
|
||
}
|
||
/**
|
||
* Register a Tiptap extension (Node/Mark/Extension). This will read
|
||
* `markdownName`, `parseMarkdown`, `renderMarkdown` and `priority` from the
|
||
* extension config (using the same resolution used across the codebase).
|
||
*/
|
||
registerExtension(extension) {
|
||
var _a, _b;
|
||
this.extensions.push(extension);
|
||
const isCode = callOrReturn(getExtensionField(extension, "code"));
|
||
const name = extension.name;
|
||
if (isCode) {
|
||
this.codeTypes.add(name);
|
||
}
|
||
if (!this.extensionRanks.has(name)) {
|
||
this.extensionRanks.set(name, this.extensionRanks.size);
|
||
}
|
||
const tokenName = getExtensionField(
|
||
extension,
|
||
"markdownTokenName"
|
||
) || name;
|
||
const parseMarkdown = getExtensionField(extension, "parseMarkdown");
|
||
const renderMarkdown = getExtensionField(extension, "renderMarkdown");
|
||
const tokenizer = getExtensionField(extension, "markdownTokenizer");
|
||
const markdownCfg = (_a = getExtensionField(extension, "markdownOptions")) != null ? _a : null;
|
||
const isIndenting = (_b = markdownCfg == null ? void 0 : markdownCfg.indentsContent) != null ? _b : false;
|
||
const htmlReopen = markdownCfg == null ? void 0 : markdownCfg.htmlReopen;
|
||
const spec = {
|
||
tokenName,
|
||
nodeName: name,
|
||
parseMarkdown,
|
||
renderMarkdown,
|
||
isIndenting,
|
||
htmlReopen,
|
||
tokenizer
|
||
};
|
||
if (tokenName && parseMarkdown) {
|
||
const parseExisting = this.registry.get(tokenName) || [];
|
||
parseExisting.push(spec);
|
||
this.registry.set(tokenName, parseExisting);
|
||
}
|
||
if (renderMarkdown) {
|
||
const renderExisting = this.nodeTypeRegistry.get(name) || [];
|
||
renderExisting.push(spec);
|
||
this.nodeTypeRegistry.set(name, renderExisting);
|
||
}
|
||
if (tokenizer && this.hasMarked()) {
|
||
this.registerTokenizer(tokenizer);
|
||
}
|
||
}
|
||
createLexer() {
|
||
return new this.markedInstance.Lexer();
|
||
}
|
||
createTokenizerHelpers(lexer) {
|
||
return {
|
||
inlineTokens: (src) => lexer.inlineTokens(src),
|
||
blockTokens: (src) => lexer.blockTokens(src)
|
||
};
|
||
}
|
||
tokenizeInline(src) {
|
||
var _a;
|
||
return ((_a = this.activeParseLexer) != null ? _a : this.createLexer()).inlineTokens(src);
|
||
}
|
||
/**
|
||
* Register a custom tokenizer with marked.js for parsing non-standard markdown syntax.
|
||
*/
|
||
registerTokenizer(tokenizer) {
|
||
if (!this.hasMarked()) {
|
||
return;
|
||
}
|
||
const { name, start, level = "inline", tokenize } = tokenizer;
|
||
const createTokenizerHelpers = this.createTokenizerHelpers.bind(this);
|
||
const createLexer = this.createLexer.bind(this);
|
||
let startCb;
|
||
if (!start) {
|
||
startCb = (src) => {
|
||
const result = tokenize(src, [], this.createTokenizerHelpers(this.createLexer()));
|
||
if (result && result.raw) {
|
||
const index = src.indexOf(result.raw);
|
||
return index;
|
||
}
|
||
return -1;
|
||
};
|
||
} else {
|
||
startCb = typeof start === "function" ? start : (src) => src.indexOf(start);
|
||
}
|
||
const markedExtension = {
|
||
name,
|
||
level,
|
||
start: startCb,
|
||
tokenizer(src, tokens) {
|
||
const helper = this.lexer ? createTokenizerHelpers(this.lexer) : createTokenizerHelpers(createLexer());
|
||
const result = tokenize(src, tokens, helper);
|
||
if (result && result.type) {
|
||
return {
|
||
...result,
|
||
type: result.type || name,
|
||
raw: result.raw || "",
|
||
tokens: result.tokens || []
|
||
};
|
||
}
|
||
return void 0;
|
||
},
|
||
childTokens: []
|
||
};
|
||
this.markedInstance.use({
|
||
extensions: [markedExtension]
|
||
});
|
||
}
|
||
/** Get registered handlers for a token type and try each until one succeeds. */
|
||
getHandlersForToken(type) {
|
||
try {
|
||
return this.registry.get(type) || [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
/** Get the first handler for a token type (for backwards compatibility). */
|
||
getHandlerForToken(type) {
|
||
const markdownHandlers = this.getHandlersForToken(type);
|
||
if (markdownHandlers.length > 0) {
|
||
return markdownHandlers[0];
|
||
}
|
||
const nodeTypeHandlers = this.getHandlersForNodeType(type);
|
||
return nodeTypeHandlers.length > 0 ? nodeTypeHandlers[0] : void 0;
|
||
}
|
||
/** Get registered handlers for a node type (for rendering). */
|
||
getHandlersForNodeType(type) {
|
||
try {
|
||
return this.nodeTypeRegistry.get(type) || [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
/**
|
||
* Serialize a ProseMirror-like JSON document (or node array) to a Markdown string
|
||
* using registered renderers and fallback renderers.
|
||
*/
|
||
serialize(docOrContent) {
|
||
if (!docOrContent) {
|
||
return "";
|
||
}
|
||
const result = this.renderNodes(docOrContent, docOrContent);
|
||
return this.isEmptyOutput(result) ? "" : result;
|
||
}
|
||
/**
|
||
* Check if the markdown output represents an empty document.
|
||
* Empty documents may contain only entities or non-breaking space characters
|
||
* which are used by the Paragraph extension to preserve blank lines.
|
||
*/
|
||
isEmptyOutput(markdown) {
|
||
if (!markdown || markdown.trim() === "") {
|
||
return true;
|
||
}
|
||
const cleanedOutput = markdown.replace(/ /g, "").replace(/\u00A0/g, "").trim();
|
||
return cleanedOutput === "";
|
||
}
|
||
/**
|
||
* Parse markdown string into Tiptap JSON document using registered extension handlers.
|
||
*/
|
||
parse(markdown) {
|
||
if (!this.hasMarked()) {
|
||
throw new Error("No marked instance available for parsing");
|
||
}
|
||
const previousParseLexer = this.activeParseLexer;
|
||
const parseLexer = this.createLexer();
|
||
this.activeParseLexer = parseLexer;
|
||
try {
|
||
const tokens = parseLexer.lex(markdown);
|
||
const content = this.parseTokens(tokens, true);
|
||
return {
|
||
type: "doc",
|
||
content
|
||
};
|
||
} finally {
|
||
this.activeParseLexer = previousParseLexer;
|
||
}
|
||
}
|
||
/**
|
||
* Convert an array of marked tokens into Tiptap JSON nodes using registered extension handlers.
|
||
*/
|
||
parseTokens(tokens, parseImplicitEmptyParagraphs = false) {
|
||
const nonSpaceTokenIndexes = tokens.reduce((indexes, token, index) => {
|
||
if (token.type !== "space") {
|
||
indexes.push(index);
|
||
}
|
||
return indexes;
|
||
}, []);
|
||
let previousNonSpaceTokenIndex = -1;
|
||
let nextNonSpaceTokenPointer = 0;
|
||
return tokens.flatMap((token, index) => {
|
||
var _a;
|
||
while (nextNonSpaceTokenPointer < nonSpaceTokenIndexes.length && nonSpaceTokenIndexes[nextNonSpaceTokenPointer] < index) {
|
||
previousNonSpaceTokenIndex = nonSpaceTokenIndexes[nextNonSpaceTokenPointer];
|
||
nextNonSpaceTokenPointer += 1;
|
||
}
|
||
if (parseImplicitEmptyParagraphs && token.type === "space") {
|
||
const nextNonSpaceTokenIndex = (_a = nonSpaceTokenIndexes[nextNonSpaceTokenPointer]) != null ? _a : -1;
|
||
return this.createImplicitEmptyParagraphsFromSpace(
|
||
token,
|
||
previousNonSpaceTokenIndex,
|
||
nextNonSpaceTokenIndex
|
||
);
|
||
}
|
||
const parsed = this.parseToken(token, parseImplicitEmptyParagraphs);
|
||
if (parsed === null) {
|
||
return [];
|
||
}
|
||
return Array.isArray(parsed) ? parsed : [parsed];
|
||
});
|
||
}
|
||
createImplicitEmptyParagraphsFromSpace(token, previousNonSpaceTokenIndex, nextNonSpaceTokenIndex) {
|
||
const separatorCount = this.countParagraphSeparators(token.raw || "");
|
||
if (separatorCount === 0) {
|
||
return [];
|
||
}
|
||
const isBoundarySpace = previousNonSpaceTokenIndex === -1 || nextNonSpaceTokenIndex === -1;
|
||
const emptyParagraphCount = Math.max(separatorCount - (isBoundarySpace ? 0 : 1), 0);
|
||
return Array.from({ length: emptyParagraphCount }, () => ({ type: "paragraph", content: [] }));
|
||
}
|
||
countParagraphSeparators(raw) {
|
||
return (raw.replace(/\r\n/g, "\n").match(/\n\n/g) || []).length;
|
||
}
|
||
/**
|
||
* Parse a single token into Tiptap JSON using the appropriate registered handler.
|
||
*/
|
||
parseToken(token, parseImplicitEmptyParagraphs = false) {
|
||
if (!token.type) {
|
||
return null;
|
||
}
|
||
if (token.type === "list") {
|
||
return this.parseListToken(token);
|
||
}
|
||
const handlers = this.getHandlersForToken(token.type);
|
||
const helpers = this.createParseHelpers();
|
||
const result = handlers.find((handler) => {
|
||
if (!handler.parseMarkdown) {
|
||
return false;
|
||
}
|
||
const parseResult = handler.parseMarkdown(token, helpers);
|
||
const normalized = this.normalizeParseResult(parseResult);
|
||
if (normalized && (!Array.isArray(normalized) || normalized.length > 0)) {
|
||
this.lastParseResult = normalized;
|
||
return true;
|
||
}
|
||
return false;
|
||
});
|
||
if (result && this.lastParseResult) {
|
||
const toReturn = this.lastParseResult;
|
||
this.lastParseResult = null;
|
||
return toReturn;
|
||
}
|
||
return this.parseFallbackToken(token, parseImplicitEmptyParagraphs);
|
||
}
|
||
/**
|
||
* Parse a list token, handling mixed bullet and task list items by splitting them into separate lists.
|
||
* This ensures that consecutive task items and bullet items are grouped and parsed as separate list nodes.
|
||
*
|
||
* @param token The list token to parse
|
||
* @returns Array of parsed list nodes, or null if parsing fails
|
||
*/
|
||
parseListToken(token) {
|
||
if (!token.items || token.items.length === 0) {
|
||
return this.parseTokenWithHandlers(token);
|
||
}
|
||
const hasTask = token.items.some((item) => isTaskItem(item).isTask);
|
||
const hasNonTask = token.items.some((item) => !isTaskItem(item).isTask);
|
||
if (!hasTask || !hasNonTask || this.getHandlersForToken("taskList").length === 0) {
|
||
return this.parseTokenWithHandlers(token);
|
||
}
|
||
const groups = [];
|
||
let currentGroup = [];
|
||
let currentType = null;
|
||
for (let i = 0; i < token.items.length; i += 1) {
|
||
const item = token.items[i];
|
||
const { isTask, checked, indentLevel } = isTaskItem(item);
|
||
let processedItem = item;
|
||
if (isTask) {
|
||
const raw = item.raw || item.text || "";
|
||
const lines = raw.split("\n");
|
||
const firstLineMatch = lines[0].match(/^\s*[-+*]\s+\[([ xX])\]\s+(.*)$/);
|
||
const mainContent = firstLineMatch ? firstLineMatch[2] : "";
|
||
let nestedTokens = [];
|
||
if (lines.length > 1) {
|
||
const nestedRaw = lines.slice(1).join("\n");
|
||
if (nestedRaw.trim()) {
|
||
const nestedLines = lines.slice(1);
|
||
const nonEmptyLines = nestedLines.filter((line) => line.trim());
|
||
if (nonEmptyLines.length > 0) {
|
||
const minIndent = Math.min(
|
||
...nonEmptyLines.map((line) => line.length - line.trimStart().length)
|
||
);
|
||
const trimmedLines = nestedLines.map((line) => {
|
||
if (!line.trim()) {
|
||
return "";
|
||
}
|
||
return line.slice(minIndent);
|
||
});
|
||
const nestedContent = trimmedLines.join("\n").trim();
|
||
if (nestedContent) {
|
||
nestedTokens = this.markedInstance.lexer(`${nestedContent}
|
||
`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
processedItem = {
|
||
type: "taskItem",
|
||
raw: "",
|
||
mainContent,
|
||
indentLevel,
|
||
checked: checked != null ? checked : false,
|
||
text: mainContent,
|
||
tokens: this.tokenizeInline(mainContent),
|
||
nestedTokens
|
||
};
|
||
}
|
||
const itemType = isTask ? "taskList" : "list";
|
||
if (currentType !== itemType) {
|
||
if (currentGroup.length > 0) {
|
||
groups.push({ type: currentType, items: currentGroup });
|
||
}
|
||
currentGroup = [processedItem];
|
||
currentType = itemType;
|
||
} else {
|
||
currentGroup.push(processedItem);
|
||
}
|
||
}
|
||
if (currentGroup.length > 0) {
|
||
groups.push({ type: currentType, items: currentGroup });
|
||
}
|
||
const results = [];
|
||
for (let i = 0; i < groups.length; i += 1) {
|
||
const group = groups[i];
|
||
const subToken = { ...token, type: group.type, items: group.items };
|
||
const parsed = this.parseToken(subToken);
|
||
if (parsed) {
|
||
if (Array.isArray(parsed)) {
|
||
results.push(...parsed);
|
||
} else {
|
||
results.push(parsed);
|
||
}
|
||
}
|
||
}
|
||
return results.length > 0 ? results : null;
|
||
}
|
||
/**
|
||
* Parse a token using registered handlers (extracted for reuse).
|
||
*/
|
||
parseTokenWithHandlers(token) {
|
||
if (!token.type) {
|
||
return null;
|
||
}
|
||
const handlers = this.getHandlersForToken(token.type);
|
||
const helpers = this.createParseHelpers();
|
||
const result = handlers.find((handler) => {
|
||
if (!handler.parseMarkdown) {
|
||
return false;
|
||
}
|
||
const parseResult = handler.parseMarkdown(token, helpers);
|
||
const normalized = this.normalizeParseResult(parseResult);
|
||
if (normalized && (!Array.isArray(normalized) || normalized.length > 0)) {
|
||
this.lastParseResult = normalized;
|
||
return true;
|
||
}
|
||
return false;
|
||
});
|
||
if (result && this.lastParseResult) {
|
||
const toReturn = this.lastParseResult;
|
||
this.lastParseResult = null;
|
||
return toReturn;
|
||
}
|
||
return this.parseFallbackToken(token);
|
||
}
|
||
/**
|
||
* Creates helper functions for parsing markdown tokens.
|
||
* @returns An object containing helper functions for parsing.
|
||
*/
|
||
createParseHelpers() {
|
||
return {
|
||
parseInline: (tokens) => this.parseInlineTokens(tokens),
|
||
tokenizeInline: (src) => this.tokenizeInline(src),
|
||
parseChildren: (tokens) => this.parseTokens(tokens),
|
||
parseBlockChildren: (tokens) => this.parseTokens(tokens, true),
|
||
createTextNode: (text, marks) => {
|
||
const node = {
|
||
type: "text",
|
||
text,
|
||
marks: marks || void 0
|
||
};
|
||
return node;
|
||
},
|
||
createNode: (type, attrs, content) => {
|
||
const node = {
|
||
type,
|
||
attrs: attrs || void 0,
|
||
content: content || void 0
|
||
};
|
||
if (!attrs || Object.keys(attrs).length === 0) {
|
||
delete node.attrs;
|
||
}
|
||
return node;
|
||
},
|
||
applyMark: (markType, content, attrs) => ({
|
||
mark: markType,
|
||
content,
|
||
attrs: attrs && Object.keys(attrs).length > 0 ? attrs : void 0
|
||
})
|
||
};
|
||
}
|
||
/**
|
||
* Escape special regex characters in a string.
|
||
*/
|
||
escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
/**
|
||
* Parse inline tokens (bold, italic, links, etc.) into text nodes with marks.
|
||
* This is the complex part that handles mark nesting and boundaries.
|
||
*/
|
||
parseInlineTokens(tokens) {
|
||
var _a, _b, _c, _d;
|
||
const result = [];
|
||
for (let i = 0; i < tokens.length; i += 1) {
|
||
const token = tokens[i];
|
||
if (token.type === "text") {
|
||
result.push({
|
||
type: "text",
|
||
text: decodeHtmlEntities(token.text || "")
|
||
});
|
||
} else if (token.type === "escape") {
|
||
result.push({
|
||
type: "text",
|
||
text: token.text || ""
|
||
});
|
||
} else if (token.type === "html") {
|
||
const raw = ((_b = (_a = token.raw) != null ? _a : token.text) != null ? _b : "").toString();
|
||
const isClosing = /^<\/[\s]*[\w-]+/i.test(raw);
|
||
const openMatch = raw.match(/^<[\s]*([\w-]+)(\s|>|\/|$)/i);
|
||
if (!isClosing && openMatch && !/\/>$/.test(raw)) {
|
||
const tagName = openMatch[1];
|
||
const escapedTagName = this.escapeRegex(tagName);
|
||
const closingRegex = new RegExp(`^<\\/\\s*${escapedTagName}\\b`, "i");
|
||
let foundIndex = -1;
|
||
const parts = [raw];
|
||
for (let j = i + 1; j < tokens.length; j += 1) {
|
||
const t = tokens[j];
|
||
const tRaw = ((_d = (_c = t.raw) != null ? _c : t.text) != null ? _d : "").toString();
|
||
parts.push(tRaw);
|
||
if (t.type === "html" && closingRegex.test(tRaw)) {
|
||
foundIndex = j;
|
||
break;
|
||
}
|
||
}
|
||
if (foundIndex !== -1) {
|
||
const mergedRaw = parts.join("");
|
||
const mergedToken = {
|
||
type: "html",
|
||
raw: mergedRaw,
|
||
text: mergedRaw,
|
||
block: false
|
||
};
|
||
const parsed = this.parseHTMLToken(mergedToken);
|
||
if (parsed) {
|
||
const normalized = this.normalizeParseResult(parsed);
|
||
if (Array.isArray(normalized)) {
|
||
result.push(...normalized);
|
||
} else if (normalized) {
|
||
result.push(normalized);
|
||
}
|
||
}
|
||
i = foundIndex;
|
||
continue;
|
||
}
|
||
}
|
||
const parsedSingle = this.parseHTMLToken(token);
|
||
if (parsedSingle) {
|
||
const normalized = this.normalizeParseResult(parsedSingle);
|
||
if (Array.isArray(normalized)) {
|
||
result.push(...normalized);
|
||
} else if (normalized) {
|
||
result.push(normalized);
|
||
}
|
||
}
|
||
} else if (token.type) {
|
||
const markHandler = this.getHandlerForToken(token.type);
|
||
if (markHandler && markHandler.parseMarkdown) {
|
||
const helpers = this.createParseHelpers();
|
||
const parsed = markHandler.parseMarkdown(token, helpers);
|
||
if (this.isMarkResult(parsed)) {
|
||
const markedContent = this.applyMarkToContent(parsed.mark, parsed.content, parsed.attrs);
|
||
result.push(...markedContent);
|
||
} else {
|
||
const normalized = this.normalizeParseResult(parsed);
|
||
if (Array.isArray(normalized)) {
|
||
result.push(...normalized);
|
||
} else if (normalized) {
|
||
result.push(normalized);
|
||
}
|
||
}
|
||
} else if (token.tokens) {
|
||
result.push(...this.parseInlineTokens(token.tokens));
|
||
}
|
||
}
|
||
}
|
||
for (let i = result.length - 1; i > 0; i -= 1) {
|
||
const current = result[i];
|
||
const previous = result[i - 1];
|
||
if (current.type === "text" && previous.type === "text") {
|
||
const currentMarks = current.marks || [];
|
||
const previousMarks = previous.marks || [];
|
||
if (marksEqual(currentMarks, previousMarks)) {
|
||
previous.text = (previous.text || "") + (current.text || "");
|
||
result.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
/**
|
||
* Apply a mark to content nodes.
|
||
*/
|
||
applyMarkToContent(markType, content, attrs) {
|
||
return content.map((node) => {
|
||
if (node.type === "text") {
|
||
const existingMarks = node.marks || [];
|
||
const newMark = attrs ? { type: markType, attrs } : { type: markType };
|
||
return {
|
||
...node,
|
||
marks: [...existingMarks, newMark]
|
||
};
|
||
}
|
||
return {
|
||
...node,
|
||
content: node.content ? this.applyMarkToContent(markType, node.content, attrs) : void 0
|
||
};
|
||
});
|
||
}
|
||
/**
|
||
* Check if a parse result represents a mark to be applied.
|
||
*/
|
||
isMarkResult(result) {
|
||
return result && typeof result === "object" && "mark" in result;
|
||
}
|
||
/**
|
||
* Normalize parse results to ensure they're valid JSONContent.
|
||
*/
|
||
normalizeParseResult(result) {
|
||
if (!result) {
|
||
return null;
|
||
}
|
||
if (this.isMarkResult(result)) {
|
||
return result.content;
|
||
}
|
||
return result;
|
||
}
|
||
/**
|
||
* Fallback parsing for common tokens when no specific handler is registered.
|
||
*/
|
||
parseFallbackToken(token, parseImplicitEmptyParagraphs = false) {
|
||
switch (token.type) {
|
||
case "paragraph":
|
||
return {
|
||
type: "paragraph",
|
||
content: token.tokens ? this.parseInlineTokens(token.tokens) : []
|
||
};
|
||
case "heading":
|
||
return {
|
||
type: "heading",
|
||
attrs: { level: token.depth || 1 },
|
||
content: token.tokens ? this.parseInlineTokens(token.tokens) : []
|
||
};
|
||
case "text":
|
||
return {
|
||
type: "text",
|
||
text: decodeHtmlEntities(token.text || "")
|
||
};
|
||
case "html":
|
||
return this.parseHTMLToken(token);
|
||
// handle Marked escape tokens as literal text (e.g. backslash-escaped characters)
|
||
case "escape":
|
||
return {
|
||
type: "text",
|
||
text: token.text || ""
|
||
};
|
||
case "space":
|
||
return null;
|
||
default:
|
||
if (token.tokens) {
|
||
return this.parseTokens(token.tokens, parseImplicitEmptyParagraphs);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
/**
|
||
* Parse an HTML token from marked into JSONContent using the registered
|
||
* extensions' `parseHTML` rules. Falls back to literal text when the HTML
|
||
* has nothing for the schema to keep.
|
||
*
|
||
* @param token Marked HTML token (block or inline).
|
||
* @example
|
||
* parseHTMLToken({ type: 'html', raw: '<em>hi</em>', block: false })
|
||
* // → text node with an italic mark
|
||
*/
|
||
parseHTMLToken(token) {
|
||
const html = token.text || token.raw || "";
|
||
if (!html.trim()) {
|
||
return null;
|
||
}
|
||
if (typeof window === "undefined") {
|
||
if (token.block) {
|
||
return {
|
||
type: "paragraph",
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: html
|
||
}
|
||
]
|
||
};
|
||
}
|
||
return {
|
||
type: "text",
|
||
text: html
|
||
};
|
||
}
|
||
if (this.isUnrecognizedHtml(html)) {
|
||
return this.htmlAsLiteralText(html, !!token.block);
|
||
}
|
||
try {
|
||
const parsed = generateJSON(html, this.baseExtensions);
|
||
if (parsed.type === "doc" && parsed.content) {
|
||
if (token.block) {
|
||
return parsed.content;
|
||
}
|
||
if (parsed.content.length === 1 && parsed.content[0].type === "paragraph" && parsed.content[0].content) {
|
||
return parsed.content[0].content;
|
||
}
|
||
return parsed.content;
|
||
}
|
||
return parsed;
|
||
} catch (error) {
|
||
throw new Error(`Failed to parse HTML in markdown: ${error}`);
|
||
}
|
||
}
|
||
/**
|
||
* Returns true when the HTML contains an element the browser classifies as
|
||
* `HTMLUnknownElement` – unless a registered extension declares the tag
|
||
* name in its parseDOM rules, in which case it is treated as a known
|
||
* custom element.
|
||
*
|
||
* Recognized but empty elements such as `<em></em>` or `<span></span>`,
|
||
* and hyphenated custom elements like `<my-mention>`, are not considered
|
||
* unrecognized.
|
||
*
|
||
* @param html Raw HTML string from a marked token.
|
||
* @example
|
||
* isUnrecognizedHtml('<enter foo bar>') // → true
|
||
* isUnrecognizedHtml('<em></em>') // → false (empty, but real tag)
|
||
* isUnrecognizedHtml('<em>hi</em>') // → false
|
||
* isUnrecognizedHtml('<my-el></my-el>') // → false (valid custom element)
|
||
* isUnrecognizedHtml('<br>') // → false
|
||
*/
|
||
isUnrecognizedHtml(html) {
|
||
if (typeof window === "undefined" || typeof window.DOMParser === "undefined") {
|
||
return false;
|
||
}
|
||
const dom = new window.DOMParser().parseFromString(`<body>${html}</body>`, "text/html").body;
|
||
const elements = dom.querySelectorAll("*");
|
||
if (elements.length === 0) {
|
||
return false;
|
||
}
|
||
const schemaTags = this.getSchemaParseDomTags();
|
||
return Array.from(elements).some((el) => {
|
||
if (!isHtmlUnknownElement(el)) {
|
||
return false;
|
||
}
|
||
const tagName = el.tagName.toLowerCase();
|
||
return !schemaTags.has(tagName);
|
||
});
|
||
}
|
||
/**
|
||
* Collect the lower-cased tag names declared by the registered extensions'
|
||
* parseDOM rules, so custom node/mark elements that use non-hyphenated,
|
||
* non-standard tag names are treated as recognized HTML. Result is cached for the
|
||
* lifetime of the manager since extensions don't change after registration.
|
||
*
|
||
* @example
|
||
* // After registering an extension with parseDOM [{ tag: 'something' }]
|
||
* getSchemaParseDomTags().has('something') // → true
|
||
*/
|
||
getSchemaParseDomTags() {
|
||
if (this.schemaParseDomTagsCache) {
|
||
return this.schemaParseDomTagsCache;
|
||
}
|
||
const tags = /* @__PURE__ */ new Set();
|
||
try {
|
||
const schema = getSchema(this.baseExtensions);
|
||
const collect = (spec) => {
|
||
const parseDOM = spec == null ? void 0 : spec.parseDOM;
|
||
if (!Array.isArray(parseDOM)) {
|
||
return;
|
||
}
|
||
parseDOM.forEach((rule) => {
|
||
if (typeof (rule == null ? void 0 : rule.tag) === "string") {
|
||
const match = rule.tag.match(/^[a-zA-Z][\w-]*/);
|
||
if (match) {
|
||
tags.add(match[0].toLowerCase());
|
||
}
|
||
}
|
||
});
|
||
};
|
||
Object.values(schema.nodes).forEach((type) => collect(type.spec));
|
||
Object.values(schema.marks).forEach((type) => collect(type.spec));
|
||
} catch {
|
||
}
|
||
this.schemaParseDomTagsCache = tags;
|
||
return tags;
|
||
}
|
||
/**
|
||
* Build a JSONContent that preserves the original HTML markup as literal
|
||
* text. Used when the HTML would otherwise be silently dropped during
|
||
* schema-aware parsing.
|
||
*
|
||
* @param html Raw HTML string to preserve verbatim.
|
||
* @param isBlock Whether to wrap the text in a paragraph node (block tokens)
|
||
* or return it as a bare text node (inline tokens).
|
||
* @example
|
||
* htmlAsLiteralText('<enter foo>', true)
|
||
* // → { type: 'paragraph', content: [{ type: 'text', text: '<enter foo>' }] }
|
||
*/
|
||
htmlAsLiteralText(html, isBlock) {
|
||
const text = html.replace(/\s+$/, "");
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
if (isBlock) {
|
||
return {
|
||
type: "paragraph",
|
||
content: [{ type: "text", text }]
|
||
};
|
||
}
|
||
return { type: "text", text };
|
||
}
|
||
/**
|
||
* Encode HTML entities in text unless the node is inside a code context
|
||
* (code mark or code-block parent) where literal characters should be preserved.
|
||
* Also backslash-escape markdown-significant characters in non-code text to
|
||
* prevent them from being misinterpreted as formatting delimiters.
|
||
*/
|
||
encodeTextForMarkdown(text, node, parentNode) {
|
||
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
|
||
if (isInsideCode) {
|
||
return text;
|
||
}
|
||
return this.escapeMarkdownSyntax(encodeHtmlEntities(text));
|
||
}
|
||
/**
|
||
* Backslash-escape characters that have special meaning in markdown inline
|
||
* syntax. This prevents literal characters in text nodes from being
|
||
* misinterpreted as formatting delimiters when the output is parsed again.
|
||
*
|
||
* The set covers the most common inline markdown syntax characters.
|
||
* Characters inside code blocks/code marks are skipped by the caller
|
||
* (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
|
||
*/
|
||
escapeMarkdownSyntax(text) {
|
||
return text.replace(/([\\`*_[\]~])/g, "\\$1");
|
||
}
|
||
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
|
||
var _a;
|
||
if (node.type === "text") {
|
||
return this.encodeTextForMarkdown(node.text || "", node, parentNode);
|
||
}
|
||
if (!node.type) {
|
||
return "";
|
||
}
|
||
const handler = this.getHandlerForToken(node.type);
|
||
if (!handler) {
|
||
return "";
|
||
}
|
||
const previousNode = Array.isArray(parentNode == null ? void 0 : parentNode.content) && index > 0 ? parentNode.content[index - 1] : void 0;
|
||
const helpers = {
|
||
renderChildren: (nodes, separator) => {
|
||
const childLevel = handler.isIndenting ? level + 1 : level;
|
||
if (!Array.isArray(nodes) && nodes.content) {
|
||
return this.renderNodes(
|
||
nodes.content,
|
||
node,
|
||
separator || "",
|
||
index,
|
||
childLevel
|
||
);
|
||
}
|
||
return this.renderNodes(nodes, node, separator || "", index, childLevel);
|
||
},
|
||
renderChild: (childNode, childIndex) => {
|
||
const childLevel = handler.isIndenting ? level + 1 : level;
|
||
return this.renderNodeToMarkdown(childNode, node, childIndex, childLevel);
|
||
},
|
||
indent: (content) => {
|
||
return this.indentString + content;
|
||
},
|
||
wrapInBlock: wrapInMarkdownBlock
|
||
};
|
||
const context = {
|
||
index,
|
||
level,
|
||
parentType: parentNode == null ? void 0 : parentNode.type,
|
||
previousNode,
|
||
meta: {
|
||
parentAttrs: parentNode == null ? void 0 : parentNode.attrs,
|
||
...meta
|
||
}
|
||
};
|
||
const rendered = ((_a = handler.renderMarkdown) == null ? void 0 : _a.call(handler, node, helpers, context)) || "";
|
||
return rendered;
|
||
}
|
||
/**
|
||
* Render a node or an array of nodes. Parent type controls how children
|
||
* are joined (which determines newline insertion between children).
|
||
*/
|
||
renderNodes(nodeOrNodes, parentNode, separator = "", index = 0, level = 0) {
|
||
if (!Array.isArray(nodeOrNodes)) {
|
||
if (!nodeOrNodes.type) {
|
||
return "";
|
||
}
|
||
return this.renderNodeToMarkdown(nodeOrNodes, parentNode, index, level);
|
||
}
|
||
return this.renderNodesWithMarkBoundaries(nodeOrNodes, parentNode, separator, level);
|
||
}
|
||
/**
|
||
* Render an array of nodes while properly tracking mark boundaries.
|
||
* This handles cases where marks span across multiple text nodes.
|
||
*/
|
||
renderNodesWithMarkBoundaries(nodes, parentNode, separator = "", level = 0) {
|
||
const result = [];
|
||
const activeMarks = /* @__PURE__ */ new Map();
|
||
const reopenWithHtmlOnNextOpen = /* @__PURE__ */ new Set();
|
||
const markOpeningModes = /* @__PURE__ */ new Map();
|
||
nodes.forEach((node, i) => {
|
||
const nextNode = i < nodes.length - 1 ? nodes[i + 1] : null;
|
||
if (!node.type) {
|
||
return;
|
||
}
|
||
if (node.type === "text") {
|
||
let textContent = this.encodeTextForMarkdown(node.text || "", node, parentNode);
|
||
const currentMarks = new Map((node.marks || []).map((mark) => [mark.type, mark]));
|
||
const marksToOpen = this.getMarksToOpenForSerialization(activeMarks, currentMarks, nextNode);
|
||
const marksToClose = findMarksToClose(currentMarks, nextNode);
|
||
const activeMarksClosingHere = marksToClose.filter((markType) => activeMarks.has(markType));
|
||
const hasCrossedBoundary = activeMarksClosingHere.length > 0 && marksToOpen.length > 0;
|
||
let middleTrailingWhitespace = "";
|
||
if (marksToClose.length > 0 && !hasCrossedBoundary) {
|
||
const middleTrailingMatch = textContent.match(/(\s+)$/);
|
||
if (middleTrailingMatch) {
|
||
middleTrailingWhitespace = middleTrailingMatch[1];
|
||
textContent = textContent.slice(0, -middleTrailingWhitespace.length);
|
||
}
|
||
}
|
||
if (!hasCrossedBoundary) {
|
||
marksToClose.slice().reverse().forEach((markType) => {
|
||
if (!activeMarks.has(markType)) {
|
||
return;
|
||
}
|
||
const mark = currentMarks.get(markType);
|
||
const closeMarkdown = this.getMarkClosing(
|
||
markType,
|
||
mark,
|
||
markOpeningModes.get(markType)
|
||
);
|
||
if (closeMarkdown) {
|
||
textContent += closeMarkdown;
|
||
}
|
||
if (activeMarks.has(markType)) {
|
||
activeMarks.delete(markType);
|
||
markOpeningModes.delete(markType);
|
||
}
|
||
});
|
||
}
|
||
let leadingWhitespace = "";
|
||
if (marksToOpen.length > 0) {
|
||
const leadingMatch = textContent.match(/^(\s+)/);
|
||
if (leadingMatch) {
|
||
leadingWhitespace = leadingMatch[1];
|
||
textContent = textContent.slice(leadingWhitespace.length);
|
||
}
|
||
}
|
||
marksToOpen.forEach(({ type, mark }) => {
|
||
const openingMode = reopenWithHtmlOnNextOpen.has(type) ? "html" : "markdown";
|
||
const openMarkdown = this.getMarkOpening(type, mark, openingMode);
|
||
if (openMarkdown) {
|
||
textContent = openMarkdown + textContent;
|
||
}
|
||
markOpeningModes.set(type, openingMode);
|
||
reopenWithHtmlOnNextOpen.delete(type);
|
||
});
|
||
if (!hasCrossedBoundary) {
|
||
marksToOpen.slice().reverse().forEach(({ type, mark }) => {
|
||
activeMarks.set(type, mark);
|
||
});
|
||
}
|
||
textContent = leadingWhitespace + textContent;
|
||
let marksToCloseAtEnd;
|
||
if (hasCrossedBoundary) {
|
||
const nextMarkTypes = new Set(((nextNode == null ? void 0 : nextNode.marks) || []).map((mark) => mark.type));
|
||
marksToOpen.forEach(({ type }) => {
|
||
if (nextMarkTypes.has(type) && this.getHtmlReopenTags(type)) {
|
||
reopenWithHtmlOnNextOpen.add(type);
|
||
}
|
||
});
|
||
const activeMarkKeys = Array.from(activeMarks.keys());
|
||
const activeMarksClosingHereLifo = activeMarksClosingHere.slice().sort((a, b) => activeMarkKeys.indexOf(b) - activeMarkKeys.indexOf(a));
|
||
marksToCloseAtEnd = [
|
||
...marksToOpen.map((m) => m.type),
|
||
// inner (opened here) — close first
|
||
...activeMarksClosingHereLifo
|
||
// outer (were active before) — close last, LIFO
|
||
];
|
||
} else {
|
||
marksToCloseAtEnd = findMarksToCloseAtEnd(
|
||
activeMarks,
|
||
currentMarks,
|
||
nextNode,
|
||
this.markSetsEqual.bind(this)
|
||
);
|
||
}
|
||
let trailingWhitespace = "";
|
||
if (marksToCloseAtEnd.length > 0) {
|
||
const trailingMatch = textContent.match(/(\s+)$/);
|
||
if (trailingMatch) {
|
||
trailingWhitespace = trailingMatch[1];
|
||
textContent = textContent.slice(0, -trailingWhitespace.length);
|
||
}
|
||
}
|
||
marksToCloseAtEnd.forEach((markType) => {
|
||
var _a;
|
||
const mark = (_a = activeMarks.get(markType)) != null ? _a : currentMarks.get(markType);
|
||
const closeMarkdown = this.getMarkClosing(markType, mark, markOpeningModes.get(markType));
|
||
if (closeMarkdown) {
|
||
textContent += closeMarkdown;
|
||
}
|
||
activeMarks.delete(markType);
|
||
markOpeningModes.delete(markType);
|
||
});
|
||
textContent += trailingWhitespace;
|
||
textContent += middleTrailingWhitespace;
|
||
result.push(textContent);
|
||
} else {
|
||
const nodeMarkTypes = new Set((node.marks || []).map((mark) => mark.type));
|
||
const marksToReopen = /* @__PURE__ */ new Map();
|
||
const openingModesToReopen = /* @__PURE__ */ new Map();
|
||
activeMarks.forEach((mark, type) => {
|
||
var _a;
|
||
if (nodeMarkTypes.has(type)) {
|
||
marksToReopen.set(type, mark);
|
||
openingModesToReopen.set(type, (_a = markOpeningModes.get(type)) != null ? _a : "markdown");
|
||
}
|
||
});
|
||
const beforeMarkdown = closeMarksBeforeNode(activeMarks, (markType, mark) => {
|
||
return this.getMarkClosing(markType, mark, markOpeningModes.get(markType));
|
||
});
|
||
markOpeningModes.clear();
|
||
const nodeContent = this.renderNodeToMarkdown(node, parentNode, i, level);
|
||
const afterMarkdown = node.type === "hardBreak" ? "" : reopenMarksAfterNode(marksToReopen, activeMarks, (markType, mark) => {
|
||
var _a;
|
||
const openingMode = (_a = openingModesToReopen.get(markType)) != null ? _a : "markdown";
|
||
markOpeningModes.set(markType, openingMode);
|
||
return this.getMarkOpening(markType, mark, openingMode);
|
||
});
|
||
result.push(beforeMarkdown + nodeContent + afterMarkdown);
|
||
}
|
||
});
|
||
return result.join(separator);
|
||
}
|
||
/**
|
||
* Get the opening markdown syntax for a mark type.
|
||
*/
|
||
getMarkOpening(markType, mark, openingMode = "markdown") {
|
||
var _a;
|
||
if (openingMode === "html") {
|
||
return ((_a = this.getHtmlReopenTags(markType)) == null ? void 0 : _a.open) || "";
|
||
}
|
||
const handlers = this.getHandlersForNodeType(markType);
|
||
const handler = handlers.length > 0 ? handlers[0] : void 0;
|
||
if (!handler || !handler.renderMarkdown) {
|
||
return "";
|
||
}
|
||
const placeholder = "\uE000__TIPTAP_MARKDOWN_PLACEHOLDER__\uE001";
|
||
const syntheticNode = {
|
||
type: markType,
|
||
attrs: mark.attrs || {},
|
||
content: [{ type: "text", text: placeholder }]
|
||
};
|
||
try {
|
||
const rendered = handler.renderMarkdown(
|
||
syntheticNode,
|
||
{
|
||
renderChildren: () => placeholder,
|
||
renderChild: () => placeholder,
|
||
indent: (content) => content,
|
||
wrapInBlock: (prefix, content) => prefix + content
|
||
},
|
||
{ index: 0, level: 0, parentType: "text", meta: {} }
|
||
);
|
||
const placeholderIndex = rendered.indexOf(placeholder);
|
||
return placeholderIndex >= 0 ? rendered.substring(0, placeholderIndex) : "";
|
||
} catch (err) {
|
||
throw new Error(`Failed to get mark opening for ${markType}: ${err}`);
|
||
}
|
||
}
|
||
/**
|
||
* Get the closing markdown syntax for a mark type.
|
||
*/
|
||
getMarkClosing(markType, mark, openingMode = "markdown") {
|
||
var _a;
|
||
if (openingMode === "html") {
|
||
return ((_a = this.getHtmlReopenTags(markType)) == null ? void 0 : _a.close) || "";
|
||
}
|
||
const handlers = this.getHandlersForNodeType(markType);
|
||
const handler = handlers.length > 0 ? handlers[0] : void 0;
|
||
if (!handler || !handler.renderMarkdown) {
|
||
return "";
|
||
}
|
||
const placeholder = "\uE000__TIPTAP_MARKDOWN_PLACEHOLDER__\uE001";
|
||
const syntheticNode = {
|
||
type: markType,
|
||
attrs: mark.attrs || {},
|
||
content: [{ type: "text", text: placeholder }]
|
||
};
|
||
try {
|
||
const rendered = handler.renderMarkdown(
|
||
syntheticNode,
|
||
{
|
||
renderChildren: () => placeholder,
|
||
renderChild: () => placeholder,
|
||
indent: (content) => content,
|
||
wrapInBlock: (prefix, content) => prefix + content
|
||
},
|
||
{ index: 0, level: 0, parentType: "text", meta: {} }
|
||
);
|
||
const placeholderIndex = rendered.indexOf(placeholder);
|
||
const placeholderEnd = placeholderIndex + placeholder.length;
|
||
return placeholderIndex >= 0 ? rendered.substring(placeholderEnd) : "";
|
||
} catch (err) {
|
||
throw new Error(`Failed to get mark closing for ${markType}: ${err}`);
|
||
}
|
||
}
|
||
/**
|
||
* Returns the inline HTML tags an extension exposes for overlap-boundary
|
||
* reopen handling, if that mark explicitly opted into HTML reopen mode.
|
||
*/
|
||
getHtmlReopenTags(markType) {
|
||
const handlers = this.getHandlersForNodeType(markType);
|
||
const handler = handlers.length > 0 ? handlers[0] : void 0;
|
||
return handler == null ? void 0 : handler.htmlReopen;
|
||
}
|
||
/**
|
||
* Check if two mark sets are equal (same types and matching attributes).
|
||
*/
|
||
markSetsEqual(marks1, marks2) {
|
||
if (marks1.size !== marks2.size) {
|
||
return false;
|
||
}
|
||
return Array.from(marks1.entries()).every(([type, mark]) => {
|
||
const otherMark = marks2.get(type);
|
||
return otherMark && attrsEqual2(mark.attrs, otherMark.attrs);
|
||
});
|
||
}
|
||
/**
|
||
* Decide the order in which marks open on the current text node.
|
||
*
|
||
* The returned array is iterated head-first when prepending opening
|
||
* delimiters, so the first entry becomes the innermost mark in the emitted
|
||
* markdown and the last becomes the outermost. Two stable signals drive
|
||
* the order — neither one inspects any rendered markdown:
|
||
*
|
||
* 1. Marks that end on this node must be inner relative to marks that
|
||
* continue into the next node, otherwise the delimiters interleave
|
||
* instead of nesting.
|
||
* 2. Within each lifetime group, marks are sorted so that lower
|
||
* registration ranks (i.e. higher Tiptap extension priorities) end up
|
||
* outermost. ProseMirror assigns mark ranks in the same priority-aware
|
||
* order Tiptap uses when building the schema, so link (priority 1000)
|
||
* naturally wraps bold/italic without the serializer needing to peek
|
||
* at how any particular mark renders.
|
||
*/
|
||
getMarksToOpenForSerialization(activeMarks, currentMarks, nextNode) {
|
||
const marksToOpen = findMarksToOpen(activeMarks, currentMarks);
|
||
if (marksToOpen.length <= 1) {
|
||
return marksToOpen;
|
||
}
|
||
const nextMarks = (nextNode == null ? void 0 : nextNode.marks) || [];
|
||
const continuesInNextNode = (markType, attrs) => nextMarks.some((m) => m.type === markType && attrsEqual2(m.attrs, attrs));
|
||
const byRankInnerFirst = (a, b) => {
|
||
var _a, _b;
|
||
const rankA = (_a = this.extensionRanks.get(a.type)) != null ? _a : Number.MAX_SAFE_INTEGER;
|
||
const rankB = (_b = this.extensionRanks.get(b.type)) != null ? _b : Number.MAX_SAFE_INTEGER;
|
||
if (rankA !== rankB) {
|
||
return rankB - rankA;
|
||
}
|
||
return a.type.localeCompare(b.type);
|
||
};
|
||
const endingHere = marksToOpen.filter((mark) => !continuesInNextNode(mark.type, mark.mark.attrs)).sort(byRankInnerFirst);
|
||
const continuing = marksToOpen.filter((mark) => continuesInNextNode(mark.type, mark.mark.attrs)).sort(byRankInnerFirst);
|
||
return [...endingHere, ...continuing];
|
||
}
|
||
};
|
||
var MarkdownManager_default = MarkdownManager;
|
||
|
||
// src/Extension.ts
|
||
var Markdown = Extension.create({
|
||
name: "markdown",
|
||
addOptions() {
|
||
return {
|
||
indentation: { style: "space", size: 2 },
|
||
marked: void 0,
|
||
markedOptions: {}
|
||
};
|
||
},
|
||
addCommands() {
|
||
return {
|
||
setContent: (content, options) => {
|
||
if (!(options == null ? void 0 : options.contentType)) {
|
||
return commands.setContent(content, options);
|
||
}
|
||
const actualContentType = assumeContentType(content, options == null ? void 0 : options.contentType);
|
||
if (actualContentType !== "markdown" || !this.editor.markdown) {
|
||
return commands.setContent(content, options);
|
||
}
|
||
const mdContent = this.editor.markdown.parse(content);
|
||
return commands.setContent(mdContent, options);
|
||
},
|
||
insertContent: (value, options) => {
|
||
if (!(options == null ? void 0 : options.contentType)) {
|
||
return commands.insertContent(value, options);
|
||
}
|
||
const actualContentType = assumeContentType(value, options == null ? void 0 : options.contentType);
|
||
if (actualContentType !== "markdown" || !this.editor.markdown) {
|
||
return commands.insertContent(value, options);
|
||
}
|
||
const mdContent = this.editor.markdown.parse(value);
|
||
return commands.insertContent(mdContent, options);
|
||
},
|
||
insertContentAt: (position, value, options) => {
|
||
if (!(options == null ? void 0 : options.contentType)) {
|
||
return commands.insertContentAt(position, value, options);
|
||
}
|
||
const actualContentType = assumeContentType(value, options == null ? void 0 : options.contentType);
|
||
if (actualContentType !== "markdown" || !this.editor.markdown) {
|
||
return commands.insertContentAt(position, value, options);
|
||
}
|
||
const mdContent = this.editor.markdown.parse(value);
|
||
return commands.insertContentAt(position, mdContent, options);
|
||
}
|
||
};
|
||
},
|
||
addStorage() {
|
||
return {
|
||
manager: new MarkdownManager_default({
|
||
indentation: this.options.indentation,
|
||
marked: this.options.marked,
|
||
markedOptions: this.options.markedOptions,
|
||
extensions: []
|
||
})
|
||
};
|
||
},
|
||
onBeforeCreate() {
|
||
var _a;
|
||
if (this.editor.markdown) {
|
||
console.error(
|
||
"[tiptap][markdown]: There is already a `markdown` property on the editor instance. This might lead to unexpected behavior."
|
||
);
|
||
return;
|
||
}
|
||
this.storage.manager = new MarkdownManager_default({
|
||
indentation: this.options.indentation,
|
||
marked: this.options.marked,
|
||
markedOptions: this.options.markedOptions,
|
||
extensions: this.editor.extensionManager.baseExtensions
|
||
});
|
||
this.editor.markdown = this.storage.manager;
|
||
this.editor.getMarkdown = () => {
|
||
return this.storage.manager.serialize(this.editor.getJSON());
|
||
};
|
||
if (!this.editor.options.contentType) {
|
||
return;
|
||
}
|
||
const assumedType = assumeContentType(
|
||
this.editor.options.content,
|
||
this.editor.options.contentType
|
||
);
|
||
if (assumedType !== "markdown") {
|
||
return;
|
||
}
|
||
if (!this.editor.markdown) {
|
||
throw new Error(
|
||
'[tiptap][markdown]: The `contentType` option is set to "markdown", but the Markdown extension is not added to the editor. Please add the Markdown extension to use this feature.'
|
||
);
|
||
}
|
||
if (this.editor.options.content === void 0 || typeof this.editor.options.content !== "string") {
|
||
throw new Error(
|
||
'[tiptap][markdown]: The `contentType` option is set to "markdown", but the initial content is not a string. Please provide the initial content as a markdown string.'
|
||
);
|
||
}
|
||
const json = this.editor.markdown.parse(this.editor.options.content);
|
||
if ((_a = json.content) == null ? void 0 : _a.length) {
|
||
this.editor.options.content = json;
|
||
}
|
||
}
|
||
});
|
||
export {
|
||
Markdown,
|
||
MarkdownManager,
|
||
assumeContentType,
|
||
closeMarksBeforeNode,
|
||
findMarksToClose,
|
||
findMarksToCloseAtEnd,
|
||
findMarksToOpen,
|
||
isTaskItem,
|
||
reopenMarksAfterNode,
|
||
wrapInMarkdownBlock
|
||
};
|
||
//# sourceMappingURL=index.js.map
|