259 lines
8.4 KiB
TypeScript
259 lines
8.4 KiB
TypeScript
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<typeof useCreateBlockNote>
|
|
type ClipboardWriter = Pick<DataTransfer, 'setData'>
|
|
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 `<p>${escapedLines.join('<br>')}</p>`
|
|
}
|
|
|
|
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<HTMLElement>(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<HTMLElement>(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<HTMLElement>(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<HTMLElement>('pre code')
|
|
return codeElement?.textContent ?? ''
|
|
}
|