import { selectedFragmentToHTML } from '@blocknote/core' import type { useCreateBlockNote } from '@blocknote/react' export const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]' const CLIPBOARD_INLINE_FORMAT_SELECTOR = 'a, b, code, em, i, s, span, strong, u' const CLIPBOARD_WIKILINK_SELECTOR = [ '[data-inline-content-type="wikilink"][data-target]', '.wikilink[data-target]', '[data-wikilink-target]', ].join(',') const MARKDOWN_WIKILINK_RE = /\[\[[^\]]+\]\]/ type RichEditor = ReturnType type ClipboardWriter = Pick type ClipboardWikilink = { label: string target: string } export type RichEditorClipboardPayload = { blocknoteHtml: string html: string markdown: string } export function richEditorClipboardPayload(editor: RichEditor): RichEditorClipboardPayload | null { try { const selection = editor.prosemirrorState?.selection const view = editor.prosemirrorView if (!selection || selection.empty || !view) return null const { clipboardHTML, externalHTML, markdown } = selectedFragmentToHTML(view, editor) if (clipboardHTML.length === 0 && externalHTML.length === 0) return null return { blocknoteHtml: clipboardHTML, html: externalHTML, markdown: restoreWikilinkMarkdown(markdown, externalHTML, clipboardHTML), } } catch { return null } } export function writeRichEditorClipboardPayload( clipboardData: ClipboardWriter, payload: RichEditorClipboardPayload, ) { const wikilinkPlainText = plainTextForWikilinkMarkdown(payload.markdown) if (wikilinkPlainText !== null) { clipboardData.setData('text/plain', wikilinkPlainText) clipboardData.setData('text/markdown', payload.markdown) clipboardData.setData('text/html', plainTextHtml(wikilinkPlainText)) return } clipboardData.setData('blocknote/html', payload.blocknoteHtml) clipboardData.setData('text/html', payload.html) } function plainTextForWikilinkMarkdown(markdown: string): string | null { if (!MARKDOWN_WIKILINK_RE.test(markdown)) return null return withoutSyntheticTerminalNewline(markdown) } function withoutSyntheticTerminalNewline(text: string): string { return text.replace(/\r?\n$/, '') } function plainTextHtml(text: string): string { const escapedLines = text.split(/\r?\n/).map(escapeHtml) return `

${escapedLines.join('
')}

` } function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } function restoreWikilinkMarkdown( markdown: string, externalHTML: string, blocknoteHtml: string, ): string { const wikilinks = clipboardWikilinksFromHtml(externalHTML) ?? clipboardWikilinksFromHtml(blocknoteHtml) if (!wikilinks) return markdown return restoreWikilinksInText(markdown, wikilinks) } function restoreWikilinksInText(text: string, wikilinks: ClipboardWikilink[]): string { let restored = text let searchFrom = 0 for (const wikilink of wikilinks) { const index = restored.indexOf(wikilink.label, searchFrom) if (index === -1) continue const replacement = `[[${wikilink.target}]]` restored = restored.slice(0, index) + replacement + restored.slice(index + wikilink.label.length) searchFrom = index + replacement.length } return restored } function clipboardWikilinksFromHtml(html: string): ClipboardWikilink[] | null { const ownerDocument = globalThis.document if (!ownerDocument || html.length === 0) return null const container = ownerDocument.createElement('div') container.innerHTML = html return clipboardWikilinksFromContainer(container) } function clipboardWikilinksFromContainer(container: ParentNode): ClipboardWikilink[] | null { const wikilinks = Array.from(container.querySelectorAll(CLIPBOARD_WIKILINK_SELECTOR)) .map(clipboardWikilinkFromElement) .filter((wikilink): wikilink is ClipboardWikilink => wikilink !== null) return wikilinks.length > 0 ? wikilinks : null } function clipboardWikilinksFromRange(range: Range): ClipboardWikilink[] | null { return clipboardWikilinksFromContainer(range.cloneContents()) ?? closestWikilinkFromRange(range) } function closestWikilinkFromRange(range: Range): ClipboardWikilink[] | null { const wikilinkElement = nodeElement(range.commonAncestorContainer) ?.closest(CLIPBOARD_WIKILINK_SELECTOR) if (!wikilinkElement) return null const wikilink = clipboardWikilinkFromElement(wikilinkElement) return wikilink ? [wikilink] : null } function restoredWikilinkPlainText(range: Range, text: string): string | null { const wikilinks = clipboardWikilinksFromRange(range) return wikilinks ? restoreWikilinksInText(text, wikilinks) : null } function clipboardWikilinkFromElement(element: HTMLElement): ClipboardWikilink | null { const target = element.getAttribute('data-target') ?? element.getAttribute('data-wikilink-target') const label = element.textContent if (!target || !label) return null return { label, target } } function nodeElement(node: Node | null): HTMLElement | null { if (!node) return null if (node instanceof HTMLElement) return node return node.parentElement } export function eventTargetElement(target: EventTarget | null): HTMLElement | null { if (!(target instanceof Node)) return null return nodeElement(target) } function hasSingleActiveRange(selection: Selection | null): selection is Selection { return Boolean(selection && selection.rangeCount === 1 && !selection.isCollapsed) } function closestCodeBlockInContainer(options: { range: Range container: HTMLElement }): HTMLElement | null { const { range, container } = options const codeBlock = nodeElement(range.commonAncestorContainer) ?.closest(CODE_BLOCK_SELECTOR) return codeBlock && container.contains(codeBlock) ? codeBlock : null } function nodeBelongsToElement(node: Node, element: HTMLElement): boolean { const elementNode = nodeElement(node) return Boolean(elementNode && element.contains(elementNode)) } function rangeBelongsToElement(range: Range, element: HTMLElement): boolean { return nodeBelongsToElement(range.startContainer, element) && nodeBelongsToElement(range.endContainer, element) } function selectedCodeBlockRange(options: { selection: Selection | null container: HTMLElement }): Range | null { const { selection, container } = options if (!hasSingleActiveRange(selection)) return null const range = selection.getRangeAt(0) const codeBlock = closestCodeBlockInContainer({ range, container }) if (!codeBlock || !rangeBelongsToElement(range, codeBlock)) return null return range } export function selectedCodeBlockText(options: { selection: Selection | null container: HTMLElement }): string | null { const range = selectedCodeBlockRange(options) if (!range) return null return range.cloneContents().textContent || options.selection?.toString() || '' } export function selectedEditorRange( selection: Selection | null, container: HTMLElement, ): Range | null { if (!hasSingleActiveRange(selection)) return null const range = selection.getRangeAt(0) return rangeBelongsToElement(range, container) ? range : null } export function selectedEditorPlainText(selection: Selection, range: Range): string | null { const text = selection.toString() || range.cloneContents().textContent || '' if (text.length === 0) return null return withoutSyntheticTerminalNewline(restoredWikilinkPlainText(range, text) ?? text) } export function selectedEditorDomHtml(range: Range): string { const wrapper = document.createElement('div') const selectedContent = range.cloneContents() const commonElement = nodeElement(range.commonAncestorContainer) const selectedText = selectedContent.textContent || '' const wikilinkPlainText = selectedText.length > 0 ? restoredWikilinkPlainText(range, selectedText) : null if (wikilinkPlainText !== null) { return plainTextHtml(withoutSyntheticTerminalNewline(wikilinkPlainText)) } if (commonElement?.matches(CLIPBOARD_INLINE_FORMAT_SELECTOR)) { const inlineWrapper = commonElement.cloneNode(false) inlineWrapper.appendChild(selectedContent) wrapper.appendChild(inlineWrapper) return wrapper.innerHTML } wrapper.appendChild(selectedContent) return wrapper.innerHTML } export function codeBlockText(codeBlock: HTMLElement): string { const codeElement = codeBlock.querySelector('pre code') return codeElement?.textContent ?? '' }