|
| 1 | +import { Extension } from '@tiptap/core' |
| 2 | +import { Plugin, PluginKey } from '@tiptap/pm/state' |
| 3 | +import { DOMSerializer } from '@tiptap/pm/model' |
| 4 | +import type { Node as ProseMirrorNode, Schema, DOMOutputSpec, Slice } from '@tiptap/pm/model' |
| 5 | +import type { EditorView } from '@tiptap/pm/view' |
| 6 | +import { jsonContentToMarkdown } from '~/utils/markdown' |
| 7 | +import type { CopyFormat } from '~/types/settings' |
| 8 | + |
| 9 | +const CELL_STYLE = 'border:1px solid #ccc;padding:6px 12px' |
| 10 | + |
| 11 | +export function serializeCodeBlock(node: ProseMirrorNode): DOMOutputSpec { |
| 12 | + const lang = node.attrs.language |
| 13 | + const codeAttrs: Record<string, string> = {} |
| 14 | + if (lang) codeAttrs.class = `language-${lang}` |
| 15 | + return ['pre', ['code', codeAttrs, 0]] |
| 16 | +} |
| 17 | + |
| 18 | +export function serializeTableCell(node: ProseMirrorNode): DOMOutputSpec { |
| 19 | + const tag = node.type.name === 'tableHeader' ? 'th' : 'td' |
| 20 | + const attrs: Record<string, string> = { style: CELL_STYLE } |
| 21 | + if (node.attrs.colspan && node.attrs.colspan > 1) attrs.colspan = String(node.attrs.colspan) |
| 22 | + if (node.attrs.rowspan && node.attrs.rowspan > 1) attrs.rowspan = String(node.attrs.rowspan) |
| 23 | + return [tag, attrs, 0] |
| 24 | +} |
| 25 | + |
| 26 | +function serializeTable(): DOMOutputSpec { |
| 27 | + return ['table', { style: 'border-collapse:collapse' }, 0] |
| 28 | +} |
| 29 | + |
| 30 | +function serializeTableRow(): DOMOutputSpec { |
| 31 | + return ['tr', 0] |
| 32 | +} |
| 33 | + |
| 34 | +export function serializeTaskItem(node: ProseMirrorNode): DOMOutputSpec { |
| 35 | + const checked = node.attrs.checked |
| 36 | + if (checked) { |
| 37 | + return ['li', ['input', { type: 'checkbox', checked: '', disabled: '' }], 0] |
| 38 | + } |
| 39 | + return ['li', ['input', { type: 'checkbox', disabled: '' }], 0] |
| 40 | +} |
| 41 | + |
| 42 | +export function serializeTaskList(): DOMOutputSpec { |
| 43 | + return ['ul', { style: 'list-style:none;padding-left:0' }, 0] |
| 44 | +} |
| 45 | + |
| 46 | +export function createClipboardSerializer(schema: Schema): DOMSerializer { |
| 47 | + const base = DOMSerializer.fromSchema(schema) |
| 48 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 49 | + const nodes = { ...base.nodes } as Record<string, any> |
| 50 | + |
| 51 | + if (schema.nodes.codeBlock) nodes.codeBlock = serializeCodeBlock |
| 52 | + if (schema.nodes.table) nodes.table = serializeTable |
| 53 | + if (schema.nodes.tableRow) nodes.tableRow = serializeTableRow |
| 54 | + if (schema.nodes.tableHeader) nodes.tableHeader = serializeTableCell |
| 55 | + if (schema.nodes.tableCell) nodes.tableCell = serializeTableCell |
| 56 | + if (schema.nodes.taskItem) nodes.taskItem = serializeTaskItem |
| 57 | + if (schema.nodes.taskList) nodes.taskList = serializeTaskList |
| 58 | + |
| 59 | + return new DOMSerializer(nodes, base.marks) |
| 60 | +} |
| 61 | + |
| 62 | +function sliceToMarkdown(slice: Slice): string { |
| 63 | + const json = { type: 'doc', content: slice.content.toJSON() } |
| 64 | + return jsonContentToMarkdown(json) |
| 65 | +} |
| 66 | + |
| 67 | +export function escapeHtml(text: string): string { |
| 68 | + return text |
| 69 | + .replace(/&/g, '&') |
| 70 | + .replace(/</g, '<') |
| 71 | + .replace(/>/g, '>') |
| 72 | + .replace(/"/g, '"') |
| 73 | +} |
| 74 | + |
| 75 | +function serializeSliceToHTML(slice: Slice, serializer: DOMSerializer, view: EditorView): string { |
| 76 | + const fragment = serializer.serializeFragment(slice.content, { document: view.dom.ownerDocument }) |
| 77 | + const div = view.dom.ownerDocument.createElement('div') |
| 78 | + div.appendChild(fragment) |
| 79 | + |
| 80 | + // Post-process: wrap table rows in thead/tbody based on cell node types. |
| 81 | + // DOMSerializer serializes rows individually and can't group them, |
| 82 | + // so we detect header rows by checking for <th> elements and wrap accordingly. |
| 83 | + div.querySelectorAll('table').forEach((table) => { |
| 84 | + const rows = Array.from(table.querySelectorAll(':scope > tr')) |
| 85 | + if (rows.length === 0) return |
| 86 | + |
| 87 | + const thead = table.ownerDocument.createElement('thead') |
| 88 | + const tbody = table.ownerDocument.createElement('tbody') |
| 89 | + |
| 90 | + for (const row of rows) { |
| 91 | + if (row.querySelector('th')) { |
| 92 | + thead.appendChild(row) |
| 93 | + } |
| 94 | + else { |
| 95 | + tbody.appendChild(row) |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + while (table.firstChild) table.removeChild(table.firstChild) |
| 100 | + if (thead.hasChildNodes()) table.appendChild(thead) |
| 101 | + if (tbody.hasChildNodes()) table.appendChild(tbody) |
| 102 | + }) |
| 103 | + |
| 104 | + return div.innerHTML |
| 105 | +} |
| 106 | + |
| 107 | +function handleClipboard(getCopyFormat: () => CopyFormat, serializer: DOMSerializer) { |
| 108 | + return (view: EditorView, event: ClipboardEvent): boolean => { |
| 109 | + const { state } = view |
| 110 | + const { selection } = state |
| 111 | + if (selection.empty) return false |
| 112 | + |
| 113 | + const slice = selection.content() |
| 114 | + const markdown = sliceToMarkdown(slice) |
| 115 | + |
| 116 | + if (getCopyFormat() === 'html') { |
| 117 | + const html = serializeSliceToHTML(slice, serializer, view) |
| 118 | + event.clipboardData?.setData('text/html', html) |
| 119 | + event.clipboardData?.setData('text/plain', markdown) |
| 120 | + } |
| 121 | + else { |
| 122 | + event.clipboardData?.setData('text/html', `<pre>${escapeHtml(markdown)}</pre>`) |
| 123 | + event.clipboardData?.setData('text/plain', markdown) |
| 124 | + } |
| 125 | + |
| 126 | + event.preventDefault() |
| 127 | + return true |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +interface ClipboardCopyOptions { |
| 132 | + getCopyFormat: () => CopyFormat |
| 133 | +} |
| 134 | + |
| 135 | +export const ClipboardCopy = Extension.create<ClipboardCopyOptions>({ |
| 136 | + name: 'clipboardCopy', |
| 137 | + |
| 138 | + addOptions() { |
| 139 | + return { getCopyFormat: () => 'html' as CopyFormat } |
| 140 | + }, |
| 141 | + |
| 142 | + addProseMirrorPlugins() { |
| 143 | + const schema = this.editor.schema |
| 144 | + const serializer = createClipboardSerializer(schema) |
| 145 | + const { getCopyFormat } = this.options |
| 146 | + |
| 147 | + return [ |
| 148 | + new Plugin({ |
| 149 | + key: new PluginKey('clipboardCopy'), |
| 150 | + // handleCopy/handleCut are valid ProseMirror EditorProps but |
| 151 | + // @tiptap/pm re-exports narrowed types that omit them. Cast to |
| 152 | + // satisfy TS while keeping runtime correctness. |
| 153 | + |
| 154 | + props: { |
| 155 | + handleCopy(view: EditorView, event: Event) { |
| 156 | + return handleClipboard(getCopyFormat, serializer)(view, event as ClipboardEvent) |
| 157 | + }, |
| 158 | + handleCut(view: EditorView, event: Event) { |
| 159 | + const handled = handleClipboard(getCopyFormat, serializer)(view, event as ClipboardEvent) |
| 160 | + if (handled) { |
| 161 | + view.dispatch(view.state.tr.deleteSelection().scrollIntoView()) |
| 162 | + } |
| 163 | + return handled |
| 164 | + }, |
| 165 | + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any |
| 166 | + }), |
| 167 | + ] |
| 168 | + }, |
| 169 | +}) |
0 commit comments