mirror of
https://github.com/docmost/docmost.git
synced 2026-08-15 08:41:38 +10:00
markdown import
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
const TabsMenu = React.memo(({ editor }: EditorMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
@@ -74,7 +75,7 @@ const TabsMenu = React.memo(({ editor }: EditorMenuProps) => {
|
||||
}, [editor]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.chain().focus().delete().run();
|
||||
editor.chain().focus().deleteTabs().run();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { marked } from "marked";
|
||||
import { calloutExtension } from "./callout.marked";
|
||||
import { mathBlockExtension } from "./math-block.marked";
|
||||
import { mathInlineExtension } from "./math-inline.marked";
|
||||
import { tabsExtension } from "./tabs.marked";
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
@@ -34,7 +35,12 @@ marked.use({
|
||||
});
|
||||
|
||||
marked.use({
|
||||
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
|
||||
extensions: [
|
||||
calloutExtension,
|
||||
mathBlockExtension,
|
||||
mathInlineExtension,
|
||||
tabsExtension,
|
||||
],
|
||||
});
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { marked, type Token } from 'marked';
|
||||
|
||||
interface MarkdownTab {
|
||||
label: string;
|
||||
text: string;
|
||||
forceActive: boolean;
|
||||
}
|
||||
|
||||
interface TabbedToken {
|
||||
type: 'tabbed';
|
||||
raw: string;
|
||||
tabs: MarkdownTab[];
|
||||
activeTabIndex: number;
|
||||
}
|
||||
|
||||
const HEADER_RE = /^===([!+])?\s*["'“”‘’]([^"'“”‘’\n]+)["'“”‘’]\s*$/gm;
|
||||
|
||||
export const tabsExtension = {
|
||||
name: 'tabbed',
|
||||
level: 'block',
|
||||
start(src: string) {
|
||||
return src.search(/^===(?:[!+])?\s*["'“”‘’]/m);
|
||||
},
|
||||
tokenizer(src: string): TabbedToken | undefined {
|
||||
if (src.indexOf('===') === -1) return;
|
||||
|
||||
const headers: Array<{
|
||||
index: number;
|
||||
raw: string;
|
||||
marker: string;
|
||||
label: string;
|
||||
}> = [];
|
||||
|
||||
HEADER_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null = null;
|
||||
|
||||
while ((m = HEADER_RE.exec(src)) !== null) {
|
||||
headers.push({
|
||||
index: m.index,
|
||||
raw: m[0],
|
||||
marker: m[1] ?? '',
|
||||
label: m[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
if (headers.length < 1 || headers[0].index !== 0) return;
|
||||
|
||||
const tabs: MarkdownTab[] = [];
|
||||
let consumed = 0;
|
||||
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const h = headers[i];
|
||||
|
||||
if (i > 0 && h.marker === '!') break;
|
||||
|
||||
const headerEnd = h.index + h.raw.length;
|
||||
const bodyStart =
|
||||
src.charCodeAt(headerEnd) === 10 ? headerEnd + 1 : headerEnd;
|
||||
const next = headers[i + 1];
|
||||
const bodyEnd = next ? next.index : src.length;
|
||||
|
||||
let body = src.slice(bodyStart, bodyEnd).replace(/\n+$/, '');
|
||||
|
||||
if (body.length > 0) {
|
||||
body = body
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^\s{0,4}/, ''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
label: h.label,
|
||||
text: body,
|
||||
forceActive: h.marker === '+',
|
||||
});
|
||||
|
||||
consumed = bodyEnd;
|
||||
}
|
||||
|
||||
if (tabs.length < 1) return;
|
||||
|
||||
const forcedActiveIndex = tabs.findIndex((t) => t.forceActive);
|
||||
|
||||
return {
|
||||
type: 'tabbed',
|
||||
raw: src.slice(0, consumed),
|
||||
tabs,
|
||||
activeTabIndex: Math.max(forcedActiveIndex, 0),
|
||||
};
|
||||
},
|
||||
renderer(token: Token) {
|
||||
const tabbedToken = token as TabbedToken;
|
||||
|
||||
const activeTabIndex = Math.max(
|
||||
0,
|
||||
Math.min(tabbedToken.activeTabIndex ?? 0, tabbedToken.tabs.length - 1),
|
||||
);
|
||||
|
||||
const sections = tabbedToken.tabs.map((tab, index) => {
|
||||
const label = escapeHtml(tab.label);
|
||||
const panel = marked.parse(tab.text || '').toString();
|
||||
const isActive = index === activeTabIndex;
|
||||
|
||||
const activeAttrs = isActive
|
||||
? 'data-tab-active="true"'
|
||||
: 'data-tab-active="false"';
|
||||
|
||||
return `<div data-type="tab" aria-hidden="true" ${activeAttrs}><div data-type="tabLabel">${label}</div><div data-type="tabPanel" >${panel}</div></div>`;
|
||||
});
|
||||
|
||||
return `<div data-type="tabs" data-active-tab="${activeTabIndex}">${sections.join('')}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function htmlToMarkdown(html: string): string {
|
||||
TurndownPluginGfm.tables,
|
||||
TurndownPluginGfm.strikethrough,
|
||||
TurndownPluginGfm.highlightedCodeBlock,
|
||||
tabs,
|
||||
taskList,
|
||||
callout,
|
||||
preserveDetail,
|
||||
@@ -38,6 +39,37 @@ export function htmlToMarkdown(html: string): string {
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
}
|
||||
|
||||
function tabs(turndownService: _TurndownService) {
|
||||
turndownService.addRule('tabs', {
|
||||
filter: (node: HTMLInputElement) =>
|
||||
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'tabs',
|
||||
replacement: (content: string, node: HTMLInputElement) => {
|
||||
const tabNodes = Array.from(
|
||||
node.querySelectorAll(':scope > div[data-type="tab"]'),
|
||||
);
|
||||
if (tabNodes.length === 0) return content;
|
||||
|
||||
const tabBlocks = tabNodes.map((tabNode) => {
|
||||
const labelNode = tabNode.querySelector(
|
||||
':scope > div[data-type="tabLabel"]',
|
||||
);
|
||||
const panelNode = tabNode.querySelector(
|
||||
':scope > div[data-type="tabPanel"]',
|
||||
);
|
||||
|
||||
const label = sanitizeTabLabel(labelNode?.textContent || 'Tab');
|
||||
const panelMarkdown = panelNode
|
||||
? turndownService.turndown(panelNode.innerHTML).trim()
|
||||
: '';
|
||||
|
||||
return `=== "${label}"\n${indentMarkdownBlock(panelMarkdown)}`;
|
||||
});
|
||||
|
||||
return `\n\n${tabBlocks.join('\n\n')}\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: _TurndownService) {
|
||||
turndownService.addRule('paragraph', {
|
||||
filter: ['p'],
|
||||
@@ -218,3 +250,19 @@ function video(turndownService: _TurndownService) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeTabLabel(value: string): string {
|
||||
return value
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/"/g, '\\"')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function indentMarkdownBlock(content: string): string {
|
||||
if (!content) return ' ';
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => (line.trim() ? ` ${line}` : ''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core';
|
||||
import { generateNodeId } from "../utils";
|
||||
|
||||
export interface TabOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
@@ -21,7 +22,7 @@ export const Tab = Node.create<TabOptions>({
|
||||
id: {
|
||||
default: '',
|
||||
parseHTML: (element: HTMLElement) =>
|
||||
element.getAttribute('data-tab-id') ?? '',
|
||||
element.getAttribute('data-tab-id') ?? generateNodeId(),
|
||||
renderHTML: (attributes: { id?: string }) => ({
|
||||
'data-tab-id': attributes.id ?? '',
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import {
|
||||
InputRule,
|
||||
Node,
|
||||
Range,
|
||||
mergeAttributes,
|
||||
} from '@tiptap/core';
|
||||
import { Fragment, type Node as PMNode } from '@tiptap/pm/model';
|
||||
import { TextSelection, Transaction, type EditorState } from '@tiptap/pm/state';
|
||||
import {
|
||||
TextSelection,
|
||||
type Transaction,
|
||||
type EditorState,
|
||||
} from '@tiptap/pm/state';
|
||||
import { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { generateNodeId } from '../utils';
|
||||
@@ -10,13 +19,16 @@ export interface TabsOptions {
|
||||
view: ComponentType<ReactNodeViewProps<HTMLElement>> | null;
|
||||
}
|
||||
|
||||
const TAB_INPUT_REGEX = /^\s*===\s*["'“”‘’]([^"'“”‘’\n]+)["'“”‘’]\s+$/;
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
tabs: {
|
||||
insertTabs: () => ReturnType;
|
||||
insertTabs: (tabName?: string, range?: Range) => ReturnType;
|
||||
insertTab: (pos: 'right' | 'left') => ReturnType;
|
||||
moveTab: (pos: 'right' | 'left') => ReturnType;
|
||||
setActiveTab: (index: number, tabsPos: number) => ReturnType;
|
||||
deleteTabs: () => ReturnType;
|
||||
updateTabLabel: (
|
||||
index: number,
|
||||
label: string,
|
||||
@@ -72,6 +84,21 @@ export const Tabs = Node.create<TabsOptions>({
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: TAB_INPUT_REGEX,
|
||||
handler: ({ range, match }) => {
|
||||
const label = (
|
||||
typeof match[1] === 'string' ? match[1] : 'Tab 1'
|
||||
).trim();
|
||||
|
||||
this.editor.commands.insertTabs(label, range);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
const createTab = (
|
||||
schema: EditorState['schema'],
|
||||
@@ -172,32 +199,42 @@ export const Tabs = Node.create<TabsOptions>({
|
||||
|
||||
return {
|
||||
insertTabs:
|
||||
() =>
|
||||
(tabName?: string, range?: Range) =>
|
||||
({ tr, state, dispatch }) => {
|
||||
const firstTab = createTab(state.schema, 'Tab 1', true);
|
||||
const secondTab = createTab(state.schema, 'Tab 2', false);
|
||||
if (!firstTab || !secondTab) return false;
|
||||
const firstTab = createTab(state.schema, tabName ?? 'Tab 1', true);
|
||||
if (!firstTab) return false;
|
||||
|
||||
const tabsNode = this.type.create(
|
||||
{
|
||||
activeTab: 0,
|
||||
},
|
||||
Fragment.fromArray([firstTab, secondTab]),
|
||||
Fragment.fromArray([firstTab]),
|
||||
);
|
||||
|
||||
const insertionPos = tr.selection.from;
|
||||
tr.replaceSelectionWith(tabsNode).scrollIntoView();
|
||||
|
||||
if (range) {
|
||||
tr.replaceRangeWith(
|
||||
range.from,
|
||||
range.to,
|
||||
tabsNode,
|
||||
).scrollIntoView();
|
||||
} else {
|
||||
tr.replaceSelectionWith(tabsNode).scrollIntoView();
|
||||
}
|
||||
|
||||
const firstTabPos = getTabPos(tr.doc, insertionPos, 0);
|
||||
const firstTabNode = tr.doc.nodeAt(firstTabPos);
|
||||
if (!firstTabNode) return false;
|
||||
|
||||
const labelSize = firstTabNode.child(0)?.nodeSize ?? 0;
|
||||
const panelContentPos = firstTabPos + 2 + labelSize + 2;
|
||||
if (!range) {
|
||||
const labelSize = firstTabNode.child(0)?.nodeSize ?? 0;
|
||||
const panelContentPos = firstTabPos + 2 + labelSize + 2;
|
||||
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(panelContentPos), 1),
|
||||
);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(panelContentPos), 1),
|
||||
);
|
||||
}
|
||||
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
@@ -339,7 +376,12 @@ export const Tabs = Node.create<TabsOptions>({
|
||||
() =>
|
||||
({ state, tr, dispatch }) => {
|
||||
const tabs = resolveTabs(state);
|
||||
if (!tabs || tabs.node.childCount <= 1) return false;
|
||||
if (!tabs) return false;
|
||||
|
||||
if (tabs.node.childCount < 2) {
|
||||
this.editor.commands.deleteTabs();
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentTabIndex = clampIndex(
|
||||
tabs.node.attrs.activeTab,
|
||||
@@ -371,11 +413,11 @@ export const Tabs = Node.create<TabsOptions>({
|
||||
return true;
|
||||
},
|
||||
|
||||
delete:
|
||||
deleteTabs:
|
||||
() =>
|
||||
({ state, tr, dispatch }) => {
|
||||
const tabs = resolveTabs(state);
|
||||
if (!tabs || tabs.node.childCount <= 1) return false;
|
||||
if (!tabs || tabs.node.childCount < 0) return false;
|
||||
|
||||
tr.delete(tabs.pos, tabs.pos + tabs.node.nodeSize);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user