mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 01:02:22 +10:00
markdown import
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
|
|
||||||
const TabsMenu = React.memo(({ editor }: EditorMenuProps) => {
|
const TabsMenu = React.memo(({ editor }: EditorMenuProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -74,7 +75,7 @@ const TabsMenu = React.memo(({ editor }: EditorMenuProps) => {
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
editor.chain().focus().delete().run();
|
editor.chain().focus().deleteTabs().run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { marked } from "marked";
|
|||||||
import { calloutExtension } from "./callout.marked";
|
import { calloutExtension } from "./callout.marked";
|
||||||
import { mathBlockExtension } from "./math-block.marked";
|
import { mathBlockExtension } from "./math-block.marked";
|
||||||
import { mathInlineExtension } from "./math-inline.marked";
|
import { mathInlineExtension } from "./math-inline.marked";
|
||||||
|
import { tabsExtension } from "./tabs.marked";
|
||||||
|
|
||||||
marked.use({
|
marked.use({
|
||||||
renderer: {
|
renderer: {
|
||||||
@@ -34,7 +35,12 @@ marked.use({
|
|||||||
});
|
});
|
||||||
|
|
||||||
marked.use({
|
marked.use({
|
||||||
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
|
extensions: [
|
||||||
|
calloutExtension,
|
||||||
|
mathBlockExtension,
|
||||||
|
mathInlineExtension,
|
||||||
|
tabsExtension,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
marked.setOptions({ breaks: true });
|
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.tables,
|
||||||
TurndownPluginGfm.strikethrough,
|
TurndownPluginGfm.strikethrough,
|
||||||
TurndownPluginGfm.highlightedCodeBlock,
|
TurndownPluginGfm.highlightedCodeBlock,
|
||||||
|
tabs,
|
||||||
taskList,
|
taskList,
|
||||||
callout,
|
callout,
|
||||||
preserveDetail,
|
preserveDetail,
|
||||||
@@ -38,6 +39,37 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
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) {
|
function listParagraph(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('paragraph', {
|
turndownService.addRule('paragraph', {
|
||||||
filter: ['p'],
|
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 { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
import { generateNodeId } from "../utils";
|
||||||
|
|
||||||
export interface TabOptions {
|
export interface TabOptions {
|
||||||
HTMLAttributes: Record<string, unknown>;
|
HTMLAttributes: Record<string, unknown>;
|
||||||
@@ -21,7 +22,7 @@ export const Tab = Node.create<TabOptions>({
|
|||||||
id: {
|
id: {
|
||||||
default: '',
|
default: '',
|
||||||
parseHTML: (element: HTMLElement) =>
|
parseHTML: (element: HTMLElement) =>
|
||||||
element.getAttribute('data-tab-id') ?? '',
|
element.getAttribute('data-tab-id') ?? generateNodeId(),
|
||||||
renderHTML: (attributes: { id?: string }) => ({
|
renderHTML: (attributes: { id?: string }) => ({
|
||||||
'data-tab-id': attributes.id ?? '',
|
'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 { 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 { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { generateNodeId } from '../utils';
|
import { generateNodeId } from '../utils';
|
||||||
@@ -10,13 +19,16 @@ export interface TabsOptions {
|
|||||||
view: ComponentType<ReactNodeViewProps<HTMLElement>> | null;
|
view: ComponentType<ReactNodeViewProps<HTMLElement>> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAB_INPUT_REGEX = /^\s*===\s*["'“”‘’]([^"'“”‘’\n]+)["'“”‘’]\s+$/;
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
tabs: {
|
tabs: {
|
||||||
insertTabs: () => ReturnType;
|
insertTabs: (tabName?: string, range?: Range) => ReturnType;
|
||||||
insertTab: (pos: 'right' | 'left') => ReturnType;
|
insertTab: (pos: 'right' | 'left') => ReturnType;
|
||||||
moveTab: (pos: 'right' | 'left') => ReturnType;
|
moveTab: (pos: 'right' | 'left') => ReturnType;
|
||||||
setActiveTab: (index: number, tabsPos: number) => ReturnType;
|
setActiveTab: (index: number, tabsPos: number) => ReturnType;
|
||||||
|
deleteTabs: () => ReturnType;
|
||||||
updateTabLabel: (
|
updateTabLabel: (
|
||||||
index: number,
|
index: number,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -72,6 +84,21 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
return ReactNodeViewRenderer(this.options.view);
|
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() {
|
addCommands() {
|
||||||
const createTab = (
|
const createTab = (
|
||||||
schema: EditorState['schema'],
|
schema: EditorState['schema'],
|
||||||
@@ -172,32 +199,42 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
insertTabs:
|
insertTabs:
|
||||||
() =>
|
(tabName?: string, range?: Range) =>
|
||||||
({ tr, state, dispatch }) => {
|
({ tr, state, dispatch }) => {
|
||||||
const firstTab = createTab(state.schema, 'Tab 1', true);
|
const firstTab = createTab(state.schema, tabName ?? 'Tab 1', true);
|
||||||
const secondTab = createTab(state.schema, 'Tab 2', false);
|
if (!firstTab) return false;
|
||||||
if (!firstTab || !secondTab) return false;
|
|
||||||
|
|
||||||
const tabsNode = this.type.create(
|
const tabsNode = this.type.create(
|
||||||
{
|
{
|
||||||
activeTab: 0,
|
activeTab: 0,
|
||||||
},
|
},
|
||||||
Fragment.fromArray([firstTab, secondTab]),
|
Fragment.fromArray([firstTab]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertionPos = tr.selection.from;
|
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 firstTabPos = getTabPos(tr.doc, insertionPos, 0);
|
||||||
const firstTabNode = tr.doc.nodeAt(firstTabPos);
|
const firstTabNode = tr.doc.nodeAt(firstTabPos);
|
||||||
if (!firstTabNode) return false;
|
if (!firstTabNode) return false;
|
||||||
|
|
||||||
const labelSize = firstTabNode.child(0)?.nodeSize ?? 0;
|
if (!range) {
|
||||||
const panelContentPos = firstTabPos + 2 + labelSize + 2;
|
const labelSize = firstTabNode.child(0)?.nodeSize ?? 0;
|
||||||
|
const panelContentPos = firstTabPos + 2 + labelSize + 2;
|
||||||
|
|
||||||
tr.setSelection(
|
tr.setSelection(
|
||||||
TextSelection.near(tr.doc.resolve(panelContentPos), 1),
|
TextSelection.near(tr.doc.resolve(panelContentPos), 1),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (dispatch) dispatch(tr);
|
if (dispatch) dispatch(tr);
|
||||||
return true;
|
return true;
|
||||||
@@ -339,7 +376,12 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
() =>
|
() =>
|
||||||
({ state, tr, dispatch }) => {
|
({ state, tr, dispatch }) => {
|
||||||
const tabs = resolveTabs(state);
|
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(
|
const currentTabIndex = clampIndex(
|
||||||
tabs.node.attrs.activeTab,
|
tabs.node.attrs.activeTab,
|
||||||
@@ -371,11 +413,11 @@ export const Tabs = Node.create<TabsOptions>({
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete:
|
deleteTabs:
|
||||||
() =>
|
() =>
|
||||||
({ state, tr, dispatch }) => {
|
({ state, tr, dispatch }) => {
|
||||||
const tabs = resolveTabs(state);
|
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);
|
tr.delete(tabs.pos, tabs.pos + tabs.node.nodeSize);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user