Skip to content

Commit c9db1ef

Browse files
committed
feat: add ClipboardCopy extension with patched serializer
1 parent a9c6f88 commit c9db1ef

2 files changed

Lines changed: 292 additions & 0 deletions

File tree

app/extensions/ClipboardCopy.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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, '&amp;')
70+
.replace(/</g, '&lt;')
71+
.replace(/>/g, '&gt;')
72+
.replace(/"/g, '&quot;')
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+
})

test/unit/clipboardCopy.spec.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
serializeCodeBlock,
4+
serializeTableCell,
5+
serializeTaskItem,
6+
serializeTaskList,
7+
escapeHtml,
8+
} from '~/extensions/ClipboardCopy'
9+
import { jsonContentToMarkdown } from '~/utils/markdown'
10+
11+
// Minimal mock for ProseMirror node shape used by serializers
12+
function mockNode(typeName: string, attrs: Record<string, unknown> = {}, children?: { type: { name: string } }[]) {
13+
const node = {
14+
type: { name: typeName },
15+
attrs,
16+
forEach: (cb: (child: { type: { name: string } }) => void) => {
17+
children?.forEach(cb)
18+
},
19+
}
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21+
return node as any
22+
}
23+
24+
describe('serializeCodeBlock', () => {
25+
it('produces <pre><code class="language-X"> with language', () => {
26+
const node = mockNode('codeBlock', { language: 'javascript' })
27+
const result = serializeCodeBlock(node)
28+
expect(result).toEqual(['pre', ['code', { class: 'language-javascript' }, 0]])
29+
})
30+
31+
it('produces <pre><code> without class when no language', () => {
32+
const node = mockNode('codeBlock', { language: '' })
33+
const result = serializeCodeBlock(node)
34+
expect(result).toEqual(['pre', ['code', {}, 0]])
35+
})
36+
37+
it('produces <pre><code> without class when language is null', () => {
38+
const node = mockNode('codeBlock', { language: null })
39+
const result = serializeCodeBlock(node)
40+
expect(result).toEqual(['pre', ['code', {}, 0]])
41+
})
42+
})
43+
44+
describe('serializeTableCell', () => {
45+
it('produces <th> for tableHeader nodes', () => {
46+
const node = mockNode('tableHeader', {})
47+
const result = serializeTableCell(node)
48+
expect(result).toEqual(['th', { style: 'border:1px solid #ccc;padding:6px 12px' }, 0])
49+
})
50+
51+
it('produces <td> for tableCell nodes', () => {
52+
const node = mockNode('tableCell', {})
53+
const result = serializeTableCell(node)
54+
expect(result).toEqual(['td', { style: 'border:1px solid #ccc;padding:6px 12px' }, 0])
55+
})
56+
57+
it('includes colspan when > 1', () => {
58+
const node = mockNode('tableHeader', { colspan: 2, rowspan: 1 })
59+
const result = serializeTableCell(node)
60+
expect(result).toEqual(['th', { style: 'border:1px solid #ccc;padding:6px 12px', colspan: '2' }, 0])
61+
})
62+
63+
it('includes rowspan when > 1', () => {
64+
const node = mockNode('tableCell', { colspan: 1, rowspan: 3 })
65+
const result = serializeTableCell(node)
66+
expect(result).toEqual(['td', { style: 'border:1px solid #ccc;padding:6px 12px', rowspan: '3' }, 0])
67+
})
68+
69+
it('includes both colspan and rowspan', () => {
70+
const node = mockNode('tableCell', { colspan: 2, rowspan: 2 })
71+
const result = serializeTableCell(node)
72+
expect(result).toEqual(['td', { style: 'border:1px solid #ccc;padding:6px 12px', colspan: '2', rowspan: '2' }, 0])
73+
})
74+
})
75+
76+
describe('serializeTaskItem', () => {
77+
it('produces checked checkbox when checked', () => {
78+
const node = mockNode('taskItem', { checked: true })
79+
const result = serializeTaskItem(node)
80+
expect(result).toEqual(['li', ['input', { type: 'checkbox', checked: '', disabled: '' }], 0])
81+
})
82+
83+
it('produces unchecked checkbox when not checked', () => {
84+
const node = mockNode('taskItem', { checked: false })
85+
const result = serializeTaskItem(node)
86+
expect(result).toEqual(['li', ['input', { type: 'checkbox', disabled: '' }], 0])
87+
})
88+
})
89+
90+
describe('serializeTaskList', () => {
91+
it('produces ul with no list-style', () => {
92+
const result = serializeTaskList()
93+
expect(result).toEqual(['ul', { style: 'list-style:none;padding-left:0' }, 0])
94+
})
95+
})
96+
97+
describe('escapeHtml', () => {
98+
it('escapes HTML special characters', () => {
99+
expect(escapeHtml('<script>"alert"&</script>')).toBe('&lt;script&gt;&quot;alert&quot;&amp;&lt;/script&gt;')
100+
})
101+
102+
it('returns empty string unchanged', () => {
103+
expect(escapeHtml('')).toBe('')
104+
})
105+
})
106+
107+
describe('sliceToMarkdown (via jsonContentToMarkdown)', () => {
108+
it('converts a doc fragment with mixed content to Markdown', () => {
109+
const json = {
110+
type: 'doc',
111+
content: [
112+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] },
113+
{ type: 'paragraph', content: [{ type: 'text', text: 'Some text' }] },
114+
{ type: 'codeBlock', attrs: { language: 'ts' }, content: [{ type: 'text', text: 'const x = 1' }] },
115+
],
116+
}
117+
expect(jsonContentToMarkdown(json)).toBe('## Title\n\nSome text\n\n```ts\nconst x = 1\n```')
118+
})
119+
120+
it('handles empty content array', () => {
121+
expect(jsonContentToMarkdown({ type: 'doc', content: [] })).toBe('')
122+
})
123+
})

0 commit comments

Comments
 (0)