mirror of
https://github.com/docmost/docmost.git
synced 2026-08-23 23:52:14 +10:00
markdown export
This commit is contained in:
@@ -134,7 +134,7 @@ export default function TabsView(props: NodeViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clampIndex = (value: unknown, length = Number.MAX_SAFE_INTEGER) => {
|
const clampIndex = (value: unknown, length = Number.MAX_SAFE_INTEGER) => {
|
||||||
const parsed = typeof value === "number" ? value : Number(value ?? 0);
|
const parsed = Number(value ?? 0);
|
||||||
if (!Number.isFinite(parsed) || length <= 0) return 0;
|
if (!Number.isFinite(parsed) || length <= 0) return 0;
|
||||||
return Math.max(0, Math.min(Math.trunc(parsed), length - 1));
|
return Math.max(0, Math.min(Math.trunc(parsed), length - 1));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ export const tabsExtension = {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
HEADER_RE.lastIndex = 0;
|
HEADER_RE.lastIndex = 0;
|
||||||
let m: RegExpExecArray | null = null;
|
|
||||||
|
|
||||||
while ((m = HEADER_RE.exec(src)) !== null) {
|
for (let m = HEADER_RE.exec(src); m !== null; m = HEADER_RE.exec(src)) {
|
||||||
headers.push({
|
headers.push({
|
||||||
index: m.index,
|
index: m.index,
|
||||||
raw: m[0],
|
raw: m[0],
|
||||||
@@ -47,6 +46,7 @@ export const tabsExtension = {
|
|||||||
|
|
||||||
const tabs: MarkdownTab[] = [];
|
const tabs: MarkdownTab[] = [];
|
||||||
let consumed = 0;
|
let consumed = 0;
|
||||||
|
let breakOutOfTabSet = false;
|
||||||
|
|
||||||
for (let i = 0; i < headers.length; i++) {
|
for (let i = 0; i < headers.length; i++) {
|
||||||
const h = headers[i];
|
const h = headers[i];
|
||||||
@@ -56,15 +56,38 @@ export const tabsExtension = {
|
|||||||
const headerEnd = h.index + h.raw.length;
|
const headerEnd = h.index + h.raw.length;
|
||||||
const bodyStart =
|
const bodyStart =
|
||||||
src.charCodeAt(headerEnd) === 10 ? headerEnd + 1 : headerEnd;
|
src.charCodeAt(headerEnd) === 10 ? headerEnd + 1 : headerEnd;
|
||||||
|
|
||||||
const next = headers[i + 1];
|
const next = headers[i + 1];
|
||||||
const bodyEnd = next ? next.index : src.length;
|
const bodyLimit = next ? next.index : src.length;
|
||||||
|
let bodyEnd = bodyLimit;
|
||||||
|
let lineStart = bodyStart;
|
||||||
|
|
||||||
|
while (lineStart < bodyLimit) {
|
||||||
|
const newlineIndex = src.indexOf('\n', lineStart);
|
||||||
|
const lineEnd =
|
||||||
|
newlineIndex === -1 || newlineIndex > bodyLimit
|
||||||
|
? bodyLimit
|
||||||
|
: newlineIndex;
|
||||||
|
|
||||||
|
const line = src.slice(lineStart, lineEnd);
|
||||||
|
const isBlank = line.trim() === '';
|
||||||
|
const isIndented = /^( {2,4}|\t)/.test(line);
|
||||||
|
|
||||||
|
if (!isBlank && !isIndented) {
|
||||||
|
bodyEnd = lineStart;
|
||||||
|
breakOutOfTabSet = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
lineStart = lineEnd < bodyLimit ? lineEnd + 1 : bodyLimit;
|
||||||
|
}
|
||||||
|
|
||||||
let body = src.slice(bodyStart, bodyEnd).replace(/\n+$/, '');
|
let body = src.slice(bodyStart, bodyEnd).replace(/\n+$/, '');
|
||||||
|
|
||||||
if (body.length > 0) {
|
if (body.length > 0) {
|
||||||
body = body
|
body = body
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.replace(/^\s{0,4}/, ''))
|
.map((line) => line.replace(/^\s{2,4}/, ''))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +98,8 @@ export const tabsExtension = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
consumed = bodyEnd;
|
consumed = bodyEnd;
|
||||||
|
|
||||||
|
if (breakOutOfTabSet) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabs.length < 1) return;
|
if (tabs.length < 1) return;
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPreviousTabs = (node: HTMLElement) => {
|
||||||
|
// we want to make this as cheap as reasonable since it
|
||||||
|
// would be preferable to return a false positive
|
||||||
|
// than to make the editor noticably slower
|
||||||
|
let el = node.previousElementSibling;
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
while (el) {
|
||||||
|
if (el.getAttribute('data-type') === 'tabs') return true;
|
||||||
|
el = el.previousElementSibling;
|
||||||
|
checks += 1;
|
||||||
|
|
||||||
|
if (checks === 100) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
function tabs(turndownService: _TurndownService) {
|
function tabs(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('tabs', {
|
turndownService.addRule('tabs', {
|
||||||
filter: (node: HTMLInputElement) =>
|
filter: (node: HTMLInputElement) =>
|
||||||
@@ -49,7 +67,10 @@ function tabs(turndownService: _TurndownService) {
|
|||||||
);
|
);
|
||||||
if (tabNodes.length === 0) return content;
|
if (tabNodes.length === 0) return content;
|
||||||
|
|
||||||
const tabBlocks = tabNodes.map((tabNode) => {
|
const isNestedTabsNode =
|
||||||
|
node.parentElement?.closest('div[data-type="tabs"]') !== null;
|
||||||
|
|
||||||
|
const tabBlocks = tabNodes.map((tabNode, index) => {
|
||||||
const labelNode = tabNode.querySelector(
|
const labelNode = tabNode.querySelector(
|
||||||
':scope > div[data-type="tabLabel"]',
|
':scope > div[data-type="tabLabel"]',
|
||||||
);
|
);
|
||||||
@@ -62,7 +83,13 @@ function tabs(turndownService: _TurndownService) {
|
|||||||
? turndownService.turndown(panelNode.innerHTML).trim()
|
? turndownService.turndown(panelNode.innerHTML).trim()
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `=== "${label}"\n${indentMarkdownBlock(panelMarkdown)}`;
|
const isFirstTabInSet = index === 0;
|
||||||
|
const marker =
|
||||||
|
isFirstTabInSet && !isNestedTabsNode && hasPreviousTabs(node)
|
||||||
|
? '!'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `===${marker} "${label}"\n${indentMarkdownBlock(panelMarkdown)}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return `\n\n${tabBlocks.join('\n\n')}\n\n`;
|
return `\n\n${tabBlocks.join('\n\n')}\n\n`;
|
||||||
@@ -85,7 +112,9 @@ function listParagraph(turndownService: _TurndownService) {
|
|||||||
function orderedListItem(turndownService: _TurndownService) {
|
function orderedListItem(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('orderedListItem', {
|
turndownService.addRule('orderedListItem', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
|
return (
|
||||||
|
node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
replacement: (content: string, node: HTMLInputElement, options: any) => {
|
replacement: (content: string, node: HTMLInputElement, options: any) => {
|
||||||
const parent = node.parentNode as HTMLElement;
|
const parent = node.parentNode as HTMLElement;
|
||||||
@@ -146,9 +175,7 @@ function taskList(turndownService: _TurndownService) {
|
|||||||
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
prefix +
|
prefix + text + (node.nextSibling && !/\n$/.test(text) ? '\n' : '')
|
||||||
text +
|
|
||||||
(node.nextSibling && !/\n$/.test(text) ? '\n' : '')
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -243,9 +270,7 @@ function video(turndownService: _TurndownService) {
|
|||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
replacement: function (_content: string, node: HTMLInputElement) {
|
||||||
const src = node.getAttribute('src') || '';
|
const src = node.getAttribute('src') || '';
|
||||||
const ariaLabel = node.getAttribute('aria-label');
|
const ariaLabel = node.getAttribute('aria-label');
|
||||||
const name = sanitizeMdLinkText(
|
const name = sanitizeMdLinkText(ariaLabel || getBasename(src) || src);
|
||||||
ariaLabel || getBasename(src) || src,
|
|
||||||
);
|
|
||||||
return '[' + name + '](' + src + ')';
|
return '[' + name + '](' + src + ')';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { InputRule, Node, Range, mergeAttributes } from '@tiptap/core';
|
||||||
InputRule,
|
|
||||||
Node,
|
|
||||||
Range,
|
|
||||||
mergeAttributes,
|
|
||||||
} from '@tiptap/core';
|
|
||||||
import { Fragment, type Node as PMNode } from '@tiptap/pm/model';
|
import { Fragment, type Node as PMNode } from '@tiptap/pm/model';
|
||||||
import {
|
import {
|
||||||
TextSelection,
|
TextSelection,
|
||||||
@@ -13,6 +8,7 @@ import {
|
|||||||
import { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react';
|
import { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { generateNodeId } from '../utils';
|
import { generateNodeId } from '../utils';
|
||||||
|
import { findParentNode } from '../table/utils';
|
||||||
|
|
||||||
export interface TabsOptions {
|
export interface TabsOptions {
|
||||||
HTMLAttributes: Record<string, unknown>;
|
HTMLAttributes: Record<string, unknown>;
|
||||||
@@ -114,24 +110,6 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveTabs = (state: EditorState) => {
|
|
||||||
const { $from } = state.selection;
|
|
||||||
let depth = $from.depth;
|
|
||||||
|
|
||||||
while (depth >= 0) {
|
|
||||||
const node = $from.node(depth);
|
|
||||||
if (node.type.name === 'tabs') {
|
|
||||||
return {
|
|
||||||
node,
|
|
||||||
pos: $from.before(depth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
depth--;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTabPos = (doc: PMNode, tabsPos: number, tabIndex: number) => {
|
const getTabPos = (doc: PMNode, tabsPos: number, tabIndex: number) => {
|
||||||
const pos = doc.resolve(tabsPos + 1);
|
const pos = doc.resolve(tabsPos + 1);
|
||||||
return pos.posAtIndex(tabIndex, pos.depth);
|
return pos.posAtIndex(tabIndex, pos.depth);
|
||||||
@@ -243,7 +221,8 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
insertTab:
|
insertTab:
|
||||||
(pos: 'left' | 'right') =>
|
(pos: 'left' | 'right') =>
|
||||||
({ state, tr, dispatch }) => {
|
({ state, tr, dispatch }) => {
|
||||||
const tabs = resolveTabs(state);
|
const { $from } = state.selection;
|
||||||
|
const tabs = findParentNode((node) => node.type.name === this.name, $from);
|
||||||
if (!tabs || tabs.node.childCount <= 0) return false;
|
if (!tabs || tabs.node.childCount <= 0) return false;
|
||||||
|
|
||||||
const currentTabIndex = clampIndex(
|
const currentTabIndex = clampIndex(
|
||||||
@@ -285,7 +264,8 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
moveTab:
|
moveTab:
|
||||||
(pos: 'left' | 'right') =>
|
(pos: 'left' | 'right') =>
|
||||||
({ state, tr, dispatch }) => {
|
({ state, tr, dispatch }) => {
|
||||||
const tabs = resolveTabs(state);
|
const { $from } = state.selection;
|
||||||
|
const tabs = findParentNode((node) => node.type.name === this.name, $from);
|
||||||
if (!tabs || tabs.node.childCount <= 1) return false;
|
if (!tabs || tabs.node.childCount <= 1) return false;
|
||||||
|
|
||||||
const currentTabIndex = clampIndex(
|
const currentTabIndex = clampIndex(
|
||||||
@@ -375,7 +355,8 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
deleteTab:
|
deleteTab:
|
||||||
() =>
|
() =>
|
||||||
({ state, tr, dispatch }) => {
|
({ state, tr, dispatch }) => {
|
||||||
const tabs = resolveTabs(state);
|
const { $from } = state.selection;
|
||||||
|
const tabs = findParentNode((node) => node.type.name === this.name, $from);
|
||||||
if (!tabs) return false;
|
if (!tabs) return false;
|
||||||
|
|
||||||
if (tabs.node.childCount < 2) {
|
if (tabs.node.childCount < 2) {
|
||||||
@@ -416,7 +397,8 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
deleteTabs:
|
deleteTabs:
|
||||||
() =>
|
() =>
|
||||||
({ state, tr, dispatch }) => {
|
({ state, tr, dispatch }) => {
|
||||||
const tabs = resolveTabs(state);
|
const { $from } = state.selection;
|
||||||
|
const tabs = findParentNode((node) => node.type.name === this.name, $from);
|
||||||
if (!tabs || tabs.node.childCount < 0) return false;
|
if (!tabs || tabs.node.childCount < 0) return false;
|
||||||
|
|
||||||
tr.delete(tabs.pos, tabs.pos + tabs.node.nodeSize);
|
tr.delete(tabs.pos, tabs.pos + tabs.node.nodeSize);
|
||||||
@@ -426,10 +408,44 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
Enter: ({ editor }) => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { $from, empty } = state.selection;
|
||||||
|
|
||||||
|
if (!empty) return false;
|
||||||
|
if ($from.parent.content.size > 0) return false;
|
||||||
|
|
||||||
|
const tabsNode = findParentNode(
|
||||||
|
(node) => node.type.name === this.name,
|
||||||
|
$from,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tabsNode) return false;
|
||||||
|
return editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr, state }) => {
|
||||||
|
const posAfter = $from.after(tabsNode.depth);
|
||||||
|
tr.delete($from.before(), $from.after());
|
||||||
|
|
||||||
|
const targetPos = tr.mapping.map(posAfter);
|
||||||
|
const paragraph = state.schema.nodes.paragraph.create();
|
||||||
|
|
||||||
|
tr.insert(targetPos, paragraph);
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, targetPos + 1));
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.scrollIntoView()
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const clampIndex = (value: unknown, length = Number.MAX_SAFE_INTEGER) => {
|
const clampIndex = (value: unknown, length = Number.MAX_SAFE_INTEGER) => {
|
||||||
const parsed = typeof value === 'number' ? value : Number(value ?? 0);
|
const parsed = Number(value ?? 0);
|
||||||
if (!Number.isFinite(parsed) || length <= 0) return 0;
|
if (!Number.isFinite(parsed) || length <= 0) return 0;
|
||||||
return Math.max(0, Math.min(Math.trunc(parsed), length - 1));
|
return Math.max(0, Math.min(Math.trunc(parsed), length - 1));
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user