hololake-platform/src/components/editorRichCopy.ts

259 lines
8.4 KiB
TypeScript
Raw Normal View History

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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
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 ?? ''
}