/** * @fileoverview Utilities for handling paste operations in a block-based UI system. * This module provides functions for processing clipboard data and converting it * into appropriate block structures for a Tiptap-based editor. */ import type { Slice } from '@tiptap/pm/model' import type { EditorProps, EditorView } from '@tiptap/pm/view' import type { ApplogForInsertOptionalAgent, EntityID } from '@wovin/core/applog' import { tiptapToPlaintext } from '@note3/utils' import { generateJSON } from '@tiptap/core' import { Logger } from 'besonders-logger' import { XMLParser } from 'fast-xml-parser' import rehypeFormat from 'rehype-format' import rehypeStringify from 'rehype-stringify' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import { useContext } from 'solid-js' import { unified } from 'unified' import { is as isLikelyMarkdown } from 'very-small-parser/lib/markdown/is' import { reporter } from 'vfile-reporter' import { BlockContext } from '../components/BlockTree' import { baseExtensions, htmlToTiptap, markdownExtensions } from '../components/TipTapExtentions' import { useFocus, useThreadFromContext } from '../ui/reactive' import { focusViewOnBlock, tryParseNote3URL, useLocationNavigate } from '../ui/utils-ui' import { getSubOrShare } from './agent/utils-agent' import { insertApplogs } from './ApplogDB' import { deleteAndReplaceBlock } from './block-utils' import { serializeTiptapToVl } from './block-utils-nowin' import { BlockVM } from './VMs/BlockVM' import { RelationVM } from './VMs/RelationVM' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) export function useBlockContext() { return useContext(BlockContext) } // tests /** * Tests if a string is a valid URL * @param {string} url - The string to test * @returns {string|boolean} The URL as a string if valid, false otherwise */ export function isValidURL(url) { try { const _urlObj = new URL(url) return _urlObj.toString() } catch (e) { return false } } /** * Determines if text appears to be markdown (specifically checking for bullet lists) * @param {string} maybeMarkdownText - Text to check * @returns {boolean} True if text appears to be markdown */ const isMarkdown = (maybeMarkdownText: string) => { // Check if it looks like markdown bullet list const lines = maybeMarkdownText.trim().split('\n') const hasMarkdownBullets = lines.some((line: string) => /^\s?[-*+#]\s/.test(line)) // HACKish VERBOSE({ lines, hasMarkdownBullets }) return hasMarkdownBullets } /** * Checks if the application has permission to read from the clipboard * @returns {Promise} Resolves if permission is granted, throws an error if denied */ export async function getClipboardPerms() { const permission = await navigator.permissions.query({ // @ts-ignore - clipboard-read is a valid permission name name: 'clipboard-read', } as PermissionDescriptor) if (permission.state === 'denied') { throw new Error('Not allowed to read clipboard.') } } /** * Retrieves the contents of the clipboard * @returns {Promise} The clipboard contents */ export async function getClipboardContents() { await getClipboardPerms() const clipboardContents = await navigator.clipboard.read() return clipboardContents } /** * Extracts and validates a URL from the clipboard * @returns {Promise} A valid URL string or null if none found */ export async function getValidURLFromClipboard() { const clipboardContents = await getClipboardContents() const maybeValidURL = await (await clipboardContents[0]?.getType('text/plain'))?.text() return isValidURL(maybeValidURL) ? maybeValidURL : null } /** * Retrieves plain text content from the clipboard * @returns {Promise} The plain text content or undefined */ export async function getPlainTextFromClipboard() { const clipboardContents = await getClipboardContents() const text = await (await clipboardContents[0]?.getType('text/plain'))?.text() if (text) DEBUG('plain text found', text) return text } /** * Retrieves HTML content from the clipboard * @returns {Promise} The HTML content or null if not available */ export async function getHTMLFromClipboard() { const clipboardContents = await getClipboardContents() if (!clipboardContents[0].types.includes('text/html')) { return null } const htmlText = await (await clipboardContents[0]?.getType('text/html'))?.text() DEBUG('html found', htmlText) return htmlText } /** * Extracts a URI from clipboard data * @param {DataTransfer} clipboard - The clipboard data transfer object * @returns {string|null} A valid URI string or null */ export function getURIfromClipboard(clipboard: DataTransfer) { const plainText = clipboard.getData('text') const uriString = isValidURL(plainText) || null return uriString } /** * Extracts image data from clipboard * @param {DataTransfer} clipboard - The clipboard data transfer object * @returns {object | null} An object containing image data (blob, URL, type) or null */ export function getImagefromClipboard(clipboard: DataTransfer) { const firstItem = clipboard.items[0] const isImage = firstItem.kind === 'file' && firstItem.type.includes('image/') if (!isImage) return null const imgBlob = firstItem.getAsFile() const URLObj = window.URL || window.webkitURL const imgURL = URLObj.createObjectURL(imgBlob) const imgType = firstItem.type DEBUG('[getImageFromClipboard]', { imgType, imgURL }) return { imgBlob, imgURL, imgType } } /** * Interface representing a Tiptap node structure */ interface TiptapNode { type: string content?: TiptapNode[] text?: string attrs?: { level?: number } children?: TiptapNode[] txt?: string } /** * Extracts content from a Tiptap node * @param {TiptapNode} node - The Tiptap node to extract content from * @returns {string} The serialized content */ const getBlockContent = (node: TiptapNode): string => { if (['heading', 'paragraph'].includes(node.type)) { const tempDoc: TiptapNode = { type: 'doc', content: [node] } return serializeTiptapToVl(tempDoc) } else if (node.type === 'listItem') { const firstPara = node.content?.find(n => n.type === 'paragraph') if (firstPara) { const tempDoc: TiptapNode = { type: 'doc', content: [firstPara] } return serializeTiptapToVl(tempDoc) } return '' } return '' } /** * Processes a bullet list node and creates corresponding blocks * @param {TiptapNode} bulletList - The bullet list node to process * @param {EntityID} parentID - The ID of the parent block * @param {ApplogForInsertOptionalAgent[]} logsArray - Array to push new applogs to * @param {EntityID | null} afterStart - ID of the block to insert after * @returns {EntityID | null} The ID of the last created block or null */ const processBulletList = ( bulletList: TiptapNode, parentID: EntityID, logsArray: ApplogForInsertOptionalAgent[], afterStart: EntityID | null = null, ): EntityID | null => { if (bulletList.type !== 'bulletList' || !bulletList.content) return null let after = afterStart let lastCreated = after for (const listItem of bulletList.content) { if (listItem.type !== 'listItem') continue const content = getBlockContent(listItem) if (!content) continue // Create block for this list item const blockBuilder = BlockVM.buildNew({ content }) logsArray.push(...blockBuilder.build()) logsArray.push(...(RelationVM.buildNew({ childOf: parentID, block: blockBuilder.en, after })).build()) after = blockBuilder.en lastCreated = after // Check for nested bulletList const nestedList = listItem.content?.find(node => node.type === 'bulletList') if (nestedList) { processBulletList(nestedList, blockBuilder.en, logsArray, null) } } return lastCreated } /** * Organizes headings by their level to create a nested structure * @param {Array} arrayOfTipTapHeadingsWithChildren - Array of Tiptap heading nodes with children * @returns {Array} The nested heading structure */ const nestHeadingsByLevel = (arrayOfTipTapHeadingsWithChildren: { children: TiptapNode['content'] } & TiptapNode['content']) => { const nestedByLevel = [...arrayOfTipTapHeadingsWithChildren] if (nestedByLevel.length > 1) { // Create a mapping of heading level to its index for quick lookup const headingIndices: number[] = [] for (let i = 0; i < nestedByLevel.length; i++) { if (nestedByLevel[i].type === 'heading') { headingIndices.push(i) } } // Process each heading to find its proper parent for (let eachIdx = nestedByLevel.length - 1; eachIdx >= 0; eachIdx--) { const thisHeading = nestedByLevel[eachIdx] // @ts-ignore - attrs property exists on Tiptap nodes if (thisHeading.type === 'heading' && thisHeading.attrs?.level && thisHeading.attrs.level > 1) { // Find the nearest preceding heading with a smaller level (closer to root) let parentHeading = null let parentIndex = -1 // Look backwards through the heading indices to find the closest larger heading for (let hIdx = headingIndices.length - 1; hIdx >= 0; hIdx--) { const idx = headingIndices[hIdx] if (idx < eachIdx) { const candidateHeading = nestedByLevel[idx] if ( candidateHeading.type === 'heading' // @ts-ignore - attrs property exists on Tiptap nodes && candidateHeading.attrs?.level // @ts-ignore - attrs property exists on Tiptap nodes && candidateHeading.attrs.level < thisHeading.attrs.level ) { parentHeading = candidateHeading parentIndex = idx break // Found the nearest larger heading } } } // Move this heading to be a child of its parent if (parentHeading) { // Remove from current position const [movedHeading] = nestedByLevel.splice(eachIdx, 1) // Add to parent's children (at the beginning to preserve order) parentHeading.children = parentHeading.children || [] parentHeading.children.push(movedHeading) // Update heading indices array since we removed an element headingIndices.splice(headingIndices.indexOf(eachIdx), 1) } } } } return nestedByLevel } /** * Reorganizes Tiptap nodes by type, grouping non-headings under headings * @param {TiptapNode['content']} markdownChildren - Array of Tiptap nodes * @returns {Array} Reorganized nodes */ export const reorganizeTiptapNodesByType = (markdownChildren: TiptapNode['content']) => { let nestedByHeadings = [] if (markdownChildren.find(n => n.type === 'heading')) { let newkidsforheading = [] for (let eachIdx = markdownChildren.length - 1; eachIdx >= 0; eachIdx--) { const eachNode = markdownChildren[eachIdx] if (eachNode.type !== 'heading') { // crawl back until a heading is found and const eachMaybeNonHeadingNode = markdownChildren[eachIdx] if (eachMaybeNonHeadingNode.type !== 'heading') { newkidsforheading.push(eachMaybeNonHeadingNode) // markdownChildren.splice(eachIdx, 1)[0]) } } else { // @ts-ignore - extending TiptapNode with custom properties eachNode.txt = tiptapToPlaintext(eachNode.content?.[0]) // @ts-ignore - extending TiptapNode with custom properties eachNode.children = eachNode.children ?? [] // @ts-ignore - extending TiptapNode with custom properties eachNode.children.push(...newkidsforheading) newkidsforheading = [] nestedByHeadings.unshift(eachNode) } } } else { nestedByHeadings = [...markdownChildren] } return nestedByHeadings } /** * Reorganizes Tiptap nodes by both type and heading level * @param {TiptapNode['content']} markdownChildren - Array of Tiptap nodes * @returns {Array} Reorganized nodes nested by heading level */ export const reorganizeTiptapNodesByTypeAndLevel = (markdownChildren: TiptapNode['content']) => { // reorganize the nested tree so that: // - lower levels are children of higher ones // - all non headings are nested within the headings const nestedByHeadings = reorganizeTiptapNodesByType(markdownChildren) const nestedWithinHeadingsAndByLevel = nestHeadingsByLevel(nestedByHeadings) // TODO make the nesting by heading optional VERBOSE.force({ nestedByHeadings, nestedWithinHeadingsAndByLevel }) return nestedWithinHeadingsAndByLevel } /** * Converts markdown to HTML using remark/rehype processors * @param {string} markDownWithTodosReplaced - Markdown text with todo items replaced * @returns {Promise} The resulting HTML string */ const htmlFromMarkdownRemark = async (markDownWithTodosReplaced) => { // TODO also catch - [ ] and other variants const file = await unified() .use(remarkParse) .use(remarkGfm) // ← enable GFM: tables, task lists, strikethrough, autolinks .use(remarkRehype) .use(rehypeFormat) .use(rehypeStringify) .process(markDownWithTodosReplaced) const htmlReporterResult = reporter(file) if (htmlReporterResult !== 'no issues found') WARN(htmlReporterResult) const htmlFromMarkdown = String(file) DEBUG({ htmlFromMarkdown, markDownWithTodosReplaced }) return htmlFromMarkdown } /** Parsed node structure from markdown */ interface ParsedNode { content: ReturnType children: ParsedNode[] } /** Result of parsing markdown into a structure (no applogs yet) */ interface ParsedMarkdown { roots: ParsedNode[] } /** * Parses markdown text into a structure without creating applogs. * @param {string} markdownText - The markdown text to parse * @returns {Promise} Parsed structure or null if not valid markdown */ export const parseMarkdownStructure = async ( markdownText: string, ): Promise => { if (!isLikelyMarkdown(markdownText)) return null VERBOSE('[parseMarkdownStructure] Detected markdown bullets', { markdownText }) const markDownWithTodosReplaced = markdownText.replaceAll('* [ ]', '+todo').replaceAll('* [x]', '* +todo/done') // HACK const htmlFromMarkdown = await htmlFromMarkdownRemark(markDownWithTodosReplaced) const tiptapJson = generateJSON(htmlFromMarkdown, markdownExtensions) as TiptapNode const maybeNestedUnderHeadings = reorganizeTiptapNodesByTypeAndLevel(tiptapJson.content ?? []) DEBUG.force({ htmlFromMarkdown, maybeNestedUnderHeadings, tiptapJson }) if (!tiptapJson.content?.length) return null // Recursively convert tiptap node to ParsedNode const convertChildren = (nodes: any[]): ParsedNode[] => { const result: ParsedNode[] = [] for (const node of nodes || []) { if (['heading', 'paragraph'].includes(node.type as string)) { const content = getBlockContent(node) if (!content) continue result.push({ content, children: [ ...convertChildren(node.children || []), ...convertBulletListChildren(node), ], }) } else if (node.type === 'bulletList') { result.push(...convertBulletList(node)) } } return result } // Convert bullet list items, handling nesting const convertBulletList = (bulletList: TiptapNode): ParsedNode[] => { if (bulletList.type !== 'bulletList' || !bulletList.content) return [] const result: ParsedNode[] = [] for (const listItem of bulletList.content) { if (listItem.type !== 'listItem') continue const content = getBlockContent(listItem) if (!content) continue const nestedList = listItem.content?.find(n => n.type === 'bulletList') result.push({ content, children: nestedList ? convertBulletList(nestedList) : [], }) } return result } // Get nested bullet list from a node (for headings/paragraphs with bullet children) const convertBulletListChildren = (node: any): ParsedNode[] => { const nestedList = node.content?.find(n => n.type === 'bulletList') return nestedList ? convertBulletList(nestedList) : [] } const roots = convertChildren(maybeNestedUnderHeadings) if (roots.length === 0) return null DEBUG.force('[parseMarkdownStructure] Result', { roots }) return { roots } } /** * Generates applogs for pasting parsed markdown into the tree. * Handles all placement logic based on target state and focus. * * A block is considered "empty" only if it has no content AND no children. * * Logic matrix: * | Target | Focused | Roots | Action | * |-----------|---------|--------|-------------------------------------------| * | Empty | No | any | Replace target relation, delete target | * | Empty | Yes | single | Reuse target (update content) | * | Empty | Yes | multi | Roots become children of target | * | Non-empty | any | any | Roots become children of target | */ export const generatePasteApplogs = ({ parsed, targetBlock, targetRelation, isFocused, }: { parsed: ParsedMarkdown targetBlock: BlockVM targetRelation: RelationVM | null isFocused: boolean }): { logs: ApplogForInsertOptionalAgent[]; newFocusID: EntityID | null } => { const logs: ApplogForInsertOptionalAgent[] = [] const isSingleRoot = parsed.roots.length === 1 const hasChildren = (targetBlock.kidRelations?.length ?? 0) > 0 // A block is considered empty only if it has no content AND no children const isEmpty = targetBlock.isEmpty && !hasChildren let newFocusID: EntityID | null = null // Helper: create a block and its children structure // Creates the block, then recursively creates children with relations to this block // The caller is responsible for creating the relation from this block to its parent const createBlockWithChildren = (node: ParsedNode): EntityID => { const builder = BlockVM.buildNew({ content: node.content }) logs.push(...builder.build()) const blockID = builder.en // Create children with relations to this block let lastChildID: EntityID | null = null for (const child of node.children) { const childID = createBlockWithChildren(child) logs.push(...RelationVM.buildNew({ childOf: blockID, block: childID, after: lastChildID, }).build()) lastChildID = childID } return blockID } // Helper: create blocks for roots and attach as children of a parent const createRootsAsChildren = (parentID: EntityID, afterExisting: EntityID | null) => { let afterID = afterExisting for (const root of parsed.roots) { const rootID = createBlockWithChildren(root) logs.push(...RelationVM.buildNew({ childOf: parentID, block: rootID, after: afterID, }).build()) afterID = rootID } } // Helper: create roots as siblings const createRootsAsSiblings = ( parentID: EntityID, afterID: EntityID | null, ): EntityID => { let lastID = afterID let firstRootID: EntityID | null = null for (const root of parsed.roots) { const rootID = createBlockWithChildren(root) logs.push(...RelationVM.buildNew({ childOf: parentID, block: rootID, after: lastID, }).build()) if (!firstRootID) firstRootID = rootID lastID = rootID } return firstRootID! } if (isEmpty && !isFocused) { // Empty (no content, no children) and not focused - can replace target if (targetRelation) { // Create first root separately const firstRoot = parsed.roots[0] const firstRootID = createBlockWithChildren(firstRoot) // Update existing relation to point to first root instead of target logs.push(...targetRelation.buildUpdate({ block: firstRootID }).build()) // Delete the orphaned empty target logs.push({ en: targetBlock.en, at: 'isDeleted', vl: true }) // Create remaining roots as siblings let afterID = firstRootID for (let i = 1; i < parsed.roots.length; i++) { const rootID = createBlockWithChildren(parsed.roots[i]) logs.push(...RelationVM.buildNew({ childOf: targetRelation.childOf, block: rootID, after: afterID, }).build()) afterID = rootID } newFocusID = firstRootID } else { // No parent - just create roots as children (target becomes container) createRootsAsChildren(targetBlock.en, null) } } else if (isEmpty && isFocused && isSingleRoot) { // Reuse target block - update its content const root = parsed.roots[0] logs.push(...targetBlock.buildUpdate({ content: root.content }).build()) // Create children under target let lastChildID: EntityID | null = null for (const child of root.children) { const childID = createBlockWithChildren(child) logs.push(...RelationVM.buildNew({ childOf: targetBlock.en, block: childID, after: lastChildID, }).build()) lastChildID = childID } // newFocusID stays null - already focused on target } else { // Empty + focused + multi OR non-empty: roots become children of target // Find last existing child for proper ordering const lastExistingChild = targetBlock.kidRelations?.at(-1)?.block ?? null createRootsAsChildren(targetBlock.en, lastExistingChild) } DEBUG.force('[generatePasteApplogs] Result', { logs, newFocusID, isEmpty, isFocused, isSingleRoot, hasChildren, }) return { logs, newFocusID } } /** * Determines the most relevant data type from clipboard and processes it accordingly * @param {DataTransfer} clipboard - The clipboard data transfer object * @returns {Promise} Processed data in various formats depending on clipboard content */ export const getRelevancefromClipboard = ( clipboard: DataTransfer, thread, blockVM, relationVM, locnav, focus: () => EntityID | null, ) => { let asParsedXML, uriString, parsedHTML, imageInfo const plainText = clipboard.getData('text/plain') VERBOSE.force({ plainText }) try { uriString = getURIfromClipboard(clipboard) if (uriString) { return { uriString } } // Try parsing as markdown bullet list // TODO: Promise false positive issue - if parseMarkdownStructure returns null after // we've returned { markdownResultPromise }, TipTap was told not to handle the paste // but we didn't actually paste anything. Content is silently dropped. if (plainText) { if (isLikelyMarkdown(plainText)) { const isFocused = focus() === blockVM.en const markdownResultPromise = parseMarkdownStructure(plainText).then( (parsed) => { if (!parsed) return false DEBUG.force('[markdown paste]', { parsed }) const { logs, newFocusID } = generatePasteApplogs({ parsed, targetBlock: blockVM, targetRelation: relationVM, isFocused, }) insertApplogs(thread, logs) // Navigate to new focus if needed if (newFocusID && focus() === blockVM.en) { focusViewOnBlock({ id: newFocusID, inputFocus: true }, locnav) } DEBUG('added', logs) return true // true indicates we handled the paste so prosemirror/tiptap won't try }, ) return { markdownResultPromise } } else { VERBOSE.force('likely not markdown:', plainText) } } imageInfo = getImagefromClipboard(clipboard) if (imageInfo) { return imageInfo // { imgBlob, imgURL, imgType } } VERBOSE.force({ plainText, imageInfo, uriString }) // if (!item.types.includes("image/png")) { // throw new Error("Clipboard contains non-image data."); // } // const blob = await item.getType("image/png"); // destinationImage.src = URL.createObjectURL(blob); for (const type of clipboard.types) { const data = clipboard.getData(type) DEBUG('clip', type, { data }) const options = { ignoreAttributes: false, attributeNamePrefix: '', allowBooleanAttributes: true, } const parser = new XMLParser(options) asParsedXML = parser.parse(data) DEBUG({ asParsedXML }) } } catch (error) { ERROR('Clip parse fail', (error as any).message || error) } const rootOutlineOrOutlineArray = asParsedXML?.opml?.body?.outline /** * Interface representing an OPML outline node */ interface OutlineNode { text: string tiptap?: string _note?: string outline?: OutlineNode } if (rootOutlineOrOutlineArray) { const getContentWithNote = (outlineNode: OutlineNode) => { let parsed if (outlineNode.tiptap) { parsed = JSON.parse(outlineNode.tiptap) } else { const content = `${outlineNode.text}${outlineNode._note ? `\n${outlineNode._note}` : ''}` // TODO: format // generateJSON will translate Woven - 0.1 to tiptap marks for underline and bold #1 parsed = htmlToTiptap(content) } DEBUG({ contentWithMaybeHTML: parsed }) return serializeTiptapToVl(parsed) } const addApplogsForNode = (logArray: ApplogForInsertOptionalAgent[], outlineNode: OutlineNode | OutlineNode[], parentEn: EntityID) => { if (!outlineNode[0]) { outlineNode = [outlineNode as OutlineNode] } let after = null for (const eachNode of outlineNode as OutlineNode[]) { const eachBuilder = BlockVM.buildNew({ content: getContentWithNote(eachNode) }) logArray.push(...eachBuilder.build()) logArray.push(...(RelationVM.buildNew({ childOf: parentEn, block: eachBuilder.en, after })).build()) after = eachBuilder.en if (eachNode.outline) { addApplogsForNode(logArray, eachNode.outline, eachBuilder.en) } } return logArray } const rootBuilder = BlockVM.buildNew({ content: Array.isArray(rootOutlineOrOutlineArray) ? serializeTiptapToVl(htmlToTiptap('Pasted blocks')) // HACK: creates a dummy root when pasting array of roots //TODO: use head>title if given : getContentWithNote(rootOutlineOrOutlineArray), }) const logsToInsert = rootBuilder.build() LOG('beforeLoop', { rootOutline: rootOutlineOrOutlineArray, logsToInsert }) if (rootOutlineOrOutlineArray.outline) { addApplogsForNode(logsToInsert, rootOutlineOrOutlineArray.outline, rootBuilder.en) } else if (rootOutlineOrOutlineArray.length) { rootOutlineOrOutlineArray.forEach(node => addApplogsForNode(logsToInsert, node.outline, rootBuilder.en)) } LOG('afterLoop', { logsToInsert }) if (!logsToInsert.length) WARN(`XML parse but no applogs`, { rootOutline: rootOutlineOrOutlineArray }) return logsToInsert?.length ? { logsToInsert, rootID: rootBuilder.en } : null } else { parsedHTML = generateJSON(plainText, baseExtensions) return { parsedHTML } } /* else { const content = clipboard.getData('text') // TODO deal with awkward html via: await getHTMLFromClipboard() // eg with escaped html within styled divs (thanks vscode) // copy this: Woven - 0.1 // paste this bogus html: // const _bogusHTML = `
//
// <b><u>Woven - 0.1</u></b> //
` parsedHTML = generateJSON(content, baseExtensions) return { parsedHTML } } */ return {} } /** * Creates a paste event handler for tree items in the editor * @param {RelationVM | null} relationVM - The relation view model (null for root blocks) * @param {BlockVM} blockVM - The block view model (must exist when pasting) * @returns {EditorProps['handlePaste']} A function to handle paste events */ export function createTreeItemPasteHandler( relationVM: RelationVM | null, blockVM: BlockVM, ): EditorProps['handlePaste'] { const { setPanel } = useBlockContext() const thread = useThreadFromContext() const focus = useFocus() const { locnav } = useLocationNavigate() /** * Handles paste events in the editor * @param {EditorView} view - The editor view * @param {ClipboardEvent} event - The clipboard event * @param {Slice} slice - The content slice * @returns {Promise} True if the paste was handled, false otherwise */ return function handlePasteEvent(view: EditorView, event: ClipboardEvent, slice: Slice) { const cbData = event.clipboardData const asText = cbData.getData('text/plain') const cbItems = event.clipboardData.items DEBUG.force('[pasteHandler] onPaste', { asText, cbData, cbItems }) const relevantClipboardInfo = getRelevancefromClipboard(event.clipboardData, thread, blockVM, relationVM, locnav, focus) const { logsToInsert, rootID, uriString, markdownResultPromise /* , parsedHTML */ } = relevantClipboardInfo const { schema } = view.state DEBUG.force('[pasteHandler] with relevantClipboardInfo', { relevantClipboardInfo, schema, view, event, slice, relation: relationVM, }) if (markdownResultPromise) { DEBUG.force('markdownDetected should be handled async so we tell tiptap we got this') return true } if (uriString) { if (!blockVM.isEmpty) { DEBUG(`[handlePaste] block is not empty`, { blockVM }) return false // handled by tiptap } const url = tryParseNote3URL(uriString) if (!url) { VERBOSE('Pasted is not a note3 URL:', uriString) return false } DEBUG(`[pasteHandler] found note3 url:`, url) if (!url.focus) { console.error('TODO: Not sure what to do without root:', url) return false } if (url.publication && !getSubOrShare(url.publication)) { setPanel({ type: 'paste', publication: url.publication, block: url.focus }) } // otherwise, we have everything we need void deleteAndReplaceBlock(relationVM.en, url.focus, true) // also add a rel/mirror prop // relationVM.isMirror = true return true } else { const { imgURL, imgType, imgBlob /* , parsedHTML */ } = relevantClipboardInfo if (imgBlob) { const currentPos = view.state.selection?.anchor || 0 const reader = new FileReader() let isPasted = false // HACK to avoid infinite editing pasting loop reader.onloadend = async (ev) => { if (ev.lengthComputable && ev.loaded === ev.total && !isPasted) { const imageSrcB64 = reader.result // `` // https://github.com/ueberdosis/tiptap/blob/main/packages/extension-image/src/image.ts#L47 const node = schema.nodes.image.create({ src: imageSrcB64, alt: null, title: null, }) /* TODO ai to describe the image and set alt tag and title (and/or editable ui) */ reader.readAsDataURL(imgBlob) DEBUG('handlePaste', { ev, node, imgType, imageSrcB64, imgURL }) isPasted = true const transaction = view.state.tr.insert(currentPos, node) view.dispatch(transaction) } } reader.readAsDataURL(imgBlob) return true } } // } else if (parsedHTML) { // blockVM.content = parsedHTML // // HACK this works but throws: // // Did you intend to update an _observable_ (setting _content directly also does not work) // // value, instead of the computed property? // // didn't work: // // blockVM.markNotEditing() // // await blockVM.persistContent(parsedHTML) // // focusBlockAsInput({ id: blockVM.en, end: true }) // return true DEBUG.force('[pasteHandler] not handled, passing back to tiptap') return false } } /** * Copies text to the clipboard * @param {string} text - The text to copy to the clipboard * @returns {Promise} */ export async function copyToClipboard(text) { try { await navigator.clipboard.writeText(text) /* ✅ Copied successfully */ } catch (e) { DEBUG(`Failed to copy`, e) /* ❌ Failed to copy (insufficient permissions) */ } } const workFlowyExampleHTML = `
  • Setup your agent
    • Login to web3
    • Create encryption keys
    • Push public hello world thread
    • Send us your link with thread ID
`