import type { EntityID } from '@wovin/core/applog' import type { ThreadOnlyCurrentNoDeleted } from '@wovin/core/thread' import { action } from '@wovin/core/mobx' import { Logger } from 'besonders-logger' import { useContext } from 'solid-js' import { BlockContext } from '../components/BlockTree' import { htmlToTiptap, markdownExtensions } from '../components/TipTapExtentions' import { insertBlockInRelChain, removeBlockFromRelChain } 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) } export const handleBlockDrag = action(function handleBlockDrag( thread: ThreadOnlyCurrentNoDeleted, blockID: EntityID, sourceRelationID: EntityID | null, newParentID: EntityID, newAfterID: EntityID | null, ) { DEBUG(`[handleBlockDrag]`, { blockID, sourceRelationID, newParentID, newAfterID, thread }) if (sourceRelationID) { removeBlockFromRelChain(thread, blockID, sourceRelationID) } insertBlockInRelChain(thread, blockID, newParentID, newAfterID, sourceRelationID) }) export async function getClipboardPerms() { const permission = await navigator.permissions.query({ name: 'clipboard-read', }) if (permission.state === 'denied') { throw new Error('Not allowed to read clipboard.') } } export async function getClipboardContents() { await getClipboardPerms() const clipboardContents = await navigator.clipboard.read() return clipboardContents } export async function getValidURLFromClipboard() { const clipboardContents = await getClipboardContents() const maybeValidURL = await (await clipboardContents[0]?.getType('text/plain'))?.text() return isValidURL(maybeValidURL) ? maybeValidURL : null } 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 } 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 } export function isValidURL(url) { try { const _urlObj = new URL(url) return _urlObj.toString() } catch (e) { return false } } export function getURIfromClipboard(clipboard: DataTransfer) { const plainText = clipboard.getData('text') const uriString = isValidURL(plainText) || null return uriString } 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 TiptapNode { type: string content?: TiptapNode[] text?: string } 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 '' } // Recursive function to process bulletList nodes 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 } const exampleTreeChildren = [ { type: 'heading', depth: 1, children: [ { type: 'text', value: 'Chapter 1 – Re‑imagining Attachment for Polysecure Relating', position: { start: { line: 1, column: 3, offset: 2, }, end: { line: 1, column: 62, offset: 61, }, }, }, ], position: { start: { line: 1, column: 1, offset: 0, }, end: { line: 1, column: 62, offset: 61, }, }, }, { type: 'code', lang: null, meta: null, value: '- Consensual nonmonogamy can be a fertile context for deep security, where each attachment pattern is understood as a wise strategy that once kept love safe and can now be gently updated to allow more ease, trust, and mutual nourishment. [web:21][web:22]\n- This chapter invites the reader to see attachment not as fixed labels that limit love, but as living patterns that can soften and transform in relationships rooted in honesty, consent, and care. [web:21][web:22]\n- In multi‑partner love, people can experience security with more than one person, and all attachment patterns have strengths that can be welcomed and supported rather than shamed. [web:21][web:22]', position: { start: { line: 3, column: 1, offset: 63, }, end: { line: 5, column: 202, offset: 741, }, }, }, { type: 'heading', depth: 2, children: [ { type: 'text', value: 'Secure relating as a shared field', position: { start: { line: 7, column: 4, offset: 746, }, end: { line: 7, column: 37, offset: 779, }, }, }, ], position: { start: { line: 7, column: 1, offset: 743, }, end: { line: 7, column: 37, offset: 779, }, }, }, { type: 'code', lang: null, meta: null, value: ' - Security is presented as something co‑created: a felt sense that “love can move freely here,” supported by reliability, emotional responsiveness, and clear agreements between all partners. [web:21][web:22]\n - “Secure” is reframed as a relational climate everyone helps to build through consistency, empathy, and honest communication about needs and boundaries. [web:21][web:22]\n - In nonmonogamy, security grows when partners are transparent, follow through on commitments, and remain curious about each other’s inner worlds, especially when jealousy, fear, or uncertainty arise. [web:21][web:29]', position: { start: { line: 9, column: 1, offset: 785, }, end: { line: 11, column: 226, offset: 1405, }, }, }, ] 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] 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' && candidateHeading.attrs?.level && 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 } 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 { eachNode.txt = tiptapToPlaintext(eachNode.content?.[0]) eachNode.children = eachNode.children ?? [] eachNode.children.push(...newkidsforheading) newkidsforheading = [] nestedByHeadings.unshift(eachNode) } } } else { nestedByHeadings = [...markdownChildren] } return nestedByHeadings } 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 } 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) } 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 } export const parsePastedMarkdown = async (markdownText: string): Promise<{ logsToInsert: ApplogForInsertOptionalAgent[], rootID: EntityID }> => { if (!isMarkdown(markdownText)) return null VERBOSE('[parsePastedMarkdown] Detected markdown bullets', { markdownText }) const markDownWithTodosReplaced = markdownText.replaceAll('* [ ]', '+todo').replaceAll('* [x]', '* +todo/done') // HACK const htmlFromMarkdown = await htmlFromMarkdownRemark(markDownWithTodosReplaced) // const value = markdownText // const tree = fromMarkdown(value, { // extensions: [gfm()], // mdastExtensions: [gfmFromMarkdown()], // }) // const result = toMarkdown(tree, { extensions: [gfmToMarkdown()] }) // test reversibility // Parse markdown to Tiptap JSON using markdownExtensions (which includes bulletList support) const tiptapJson = generateJSON(htmlFromMarkdown, markdownExtensions) as TiptapNode const maybeNestedUnderHeadings = reorganizeTiptapNodesByTypeAndLevel(tiptapJson.content ?? []) DEBUG.force({ htmlFromMarkdown, maybeNestedUnderHeadings, tiptapJson }) if (!tiptapJson.content?.length) return null const logsToInsert: ApplogForInsertOptionalAgent[] = [] const rootBuilder = BlockVM.buildNew({ content: serializeTiptapToVl(htmlToTiptap('Pasted md')) }) logsToInsert.push(...rootBuilder.build()) const rootID = rootBuilder.en let lastTopLevel: EntityID | null = null let currentParent: EntityID = rootID let lastSiblingUnderCurrent: EntityID | null = null const recurseChildren = (maybeNested: any[], parentID: EntityID) => { for (const eachNode of maybeNested || []) { let thisID if (['heading', 'paragraph'].includes(eachNode.type as string)) { const content = getBlockContent(eachNode) VERBOSE({ content }) if (!content) continue const builder = BlockVM.buildNew({ content }) logsToInsert.push(...builder.build()) thisID = builder.en logsToInsert.push(...RelationVM.buildNew({ childOf: parentID, block: thisID, after: lastTopLevel }).build()) lastTopLevel = thisID currentParent = thisID lastSiblingUnderCurrent = null } else if (eachNode.type === 'bulletList') { lastSiblingUnderCurrent = processBulletList(eachNode, currentParent, logsToInsert, lastSiblingUnderCurrent) } if (eachNode.children?.length) recurseChildren(eachNode.children, thisID ?? parentID) } } recurseChildren(maybeNestedUnderHeadings, rootID) if (logsToInsert.length === 0) return null DEBUG.force('[parseMarkdownBulletList] Result', { logsToInsert, rootID }) return { logsToInsert, rootID } } export const getRelevancefromClipboard = async (clipboard: DataTransfer) => { 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 if (plainText) { const markdownResult = await parsePastedMarkdown(plainText) if (markdownResult) { return markdownResult } // If not markdown, fall through to let TipTap handle plain text insertion } imageInfo = getImagefromClipboard(clipboard) if (imageInfo) { return imageInfo } 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 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 { } /* 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 {} } 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) */ } } export function getCursorPositionInContentEditable(element) { let range let preCaretRange let textContent const caretOffset = 0 if (window.getSelection) { range = window?.getSelection()?.getRangeAt(0) if (!range) { return undefined } preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(element) } return caretOffset }