a tool for shared writing and social publishing
at main 630 lines 19 kB view raw
1import { MutableRefObject, useCallback } from "react"; 2import { Fact, ReplicacheMutators, useReplicache } from "src/replicache"; 3import { EditorView } from "prosemirror-view"; 4import { setEditorState, useEditorStates } from "src/state/useEditorState"; 5import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; 6import { multiBlockSchema, schema } from "./schema"; 7import { generateKeyBetween } from "fractional-indexing"; 8import { addImage } from "src/utils/addImage"; 9import { BlockProps } from "../Block"; 10import { focusBlock } from "src/utils/focusBlock"; 11import { useEntitySetContext } from "components/EntitySetProvider"; 12import { v7 } from "uuid"; 13import { Replicache } from "replicache"; 14import { markdownToHtml } from "src/htmlMarkdownParsers"; 15import { betterIsUrl, isUrl } from "src/utils/isURL"; 16import { TextSelection } from "prosemirror-state"; 17import type { FilterAttributes } from "src/replicache/attributes"; 18import { addLinkBlock } from "src/utils/addLinkBlock"; 19import { UndoManager } from "src/undoManager"; 20 21const parser = ProsemirrorDOMParser.fromSchema(schema); 22const multilineParser = ProsemirrorDOMParser.fromSchema(multiBlockSchema); 23export const useHandlePaste = ( 24 entityID: string, 25 propsRef: MutableRefObject<BlockProps>, 26) => { 27 let { rep, undoManager } = useReplicache(); 28 let entity_set = useEntitySetContext(); 29 return useCallback( 30 (view: EditorView, e: ClipboardEvent) => { 31 if (!rep) return; 32 if (!e.clipboardData) return; 33 let textHTML = e.clipboardData.getData("text/html"); 34 let text = e.clipboardData.getData("text"); 35 let editorState = useEditorStates.getState().editorStates[entityID]; 36 if (!editorState) return; 37 if (text && betterIsUrl(text)) { 38 let selection = view.state.selection as TextSelection; 39 let tr = view.state.tr; 40 let { from, to } = selection; 41 if (selection.empty) { 42 tr.insertText(text, selection.from); 43 tr.addMark( 44 from, 45 from + text.length, 46 schema.marks.link.create({ href: text }), 47 ); 48 } else { 49 tr.addMark(from, to, schema.marks.link.create({ href: text })); 50 } 51 let oldState = view.state; 52 let newState = view.state.apply(tr); 53 undoManager.add({ 54 undo: () => { 55 if (!view?.hasFocus()) view?.focus(); 56 setEditorState(entityID, { 57 editor: oldState, 58 }); 59 }, 60 redo: () => { 61 if (!view?.hasFocus()) view?.focus(); 62 setEditorState(entityID, { 63 editor: newState, 64 }); 65 }, 66 }); 67 setEditorState(entityID, { 68 editor: newState, 69 }); 70 return true; 71 } 72 // if there is no html, but there is text, convert the text to markdown 73 // 74 let xml = new DOMParser().parseFromString(textHTML, "text/html"); 75 if ((!textHTML || !xml.children.length) && text) { 76 textHTML = markdownToHtml(text); 77 } 78 // if thre is html 79 if (textHTML) { 80 let xml = new DOMParser().parseFromString(textHTML, "text/html"); 81 let currentPosition = propsRef.current.position; 82 let children = flattenHTMLToTextBlocks(xml.body); 83 let hasImage = false; 84 for (let item of e.clipboardData.items) { 85 if (item.type.includes("image")) hasImage = true; 86 } 87 if ( 88 !(children.length === 1 && children[0].tagName === "IMG" && hasImage) 89 ) { 90 children.forEach((child, index) => { 91 createBlockFromHTML(child, { 92 undoManager, 93 parentType: propsRef.current.pageType, 94 first: index === 0, 95 activeBlockProps: propsRef, 96 entity_set, 97 rep, 98 parent: propsRef.current.listData 99 ? propsRef.current.listData.parent 100 : propsRef.current.parent, 101 getPosition: () => { 102 currentPosition = generateKeyBetween( 103 currentPosition || null, 104 propsRef.current.nextPosition, 105 ); 106 return currentPosition; 107 }, 108 last: index === children.length - 1, 109 }); 110 }); 111 } 112 } 113 114 for (let item of e.clipboardData.items) { 115 if (item?.type.includes("image")) { 116 let file = item.getAsFile(); 117 if (file) { 118 let entity: string; 119 if (editorState.editor.doc.textContent.length === 0) { 120 entity = propsRef.current.entityID; 121 rep.mutate.assertFact({ 122 entity: propsRef.current.entityID, 123 attribute: "block/type", 124 data: { type: "block-type-union", value: "image" }, 125 }); 126 rep.mutate.retractAttribute({ 127 entity: propsRef.current.entityID, 128 attribute: "block/text", 129 }); 130 } else { 131 entity = v7(); 132 rep.mutate.addBlock({ 133 permission_set: entity_set.set, 134 factID: v7(), 135 type: "image", 136 newEntityID: entity, 137 parent: propsRef.current.parent, 138 position: generateKeyBetween( 139 propsRef.current.position, 140 propsRef.current.nextPosition, 141 ), 142 }); 143 } 144 addImage(file, rep, { 145 attribute: "block/image", 146 entityID: entity, 147 }); 148 } 149 return; 150 } 151 } 152 e.preventDefault(); 153 e.stopPropagation(); 154 return true; 155 }, 156 [rep, entity_set, entityID, propsRef], 157 ); 158}; 159 160const createBlockFromHTML = ( 161 child: Element, 162 { 163 first, 164 last, 165 activeBlockProps, 166 rep, 167 undoManager, 168 entity_set, 169 getPosition, 170 parent, 171 parentType, 172 }: { 173 parentType: "canvas" | "doc"; 174 parent: string; 175 first: boolean; 176 last: boolean; 177 activeBlockProps?: MutableRefObject<BlockProps>; 178 rep: Replicache<ReplicacheMutators>; 179 undoManager: UndoManager; 180 entity_set: { set: string }; 181 getPosition: () => string; 182 }, 183) => { 184 let type: Fact<"block/type">["data"]["value"] | null; 185 let headingLevel: number | null = null; 186 let hasChildren = false; 187 188 if (child.tagName === "UL") { 189 let children = Array.from(child.children); 190 if (children.length > 0) hasChildren = true; 191 for (let c of children) { 192 createBlockFromHTML(c, { 193 first: first && c === children[0], 194 last: last && c === children[children.length - 1], 195 activeBlockProps, 196 rep, 197 undoManager, 198 entity_set, 199 getPosition, 200 parent, 201 parentType, 202 }); 203 } 204 } 205 switch (child.tagName) { 206 case "BLOCKQUOTE": { 207 type = "blockquote"; 208 break; 209 } 210 case "LI": 211 case "SPAN": { 212 type = "text"; 213 break; 214 } 215 case "PRE": { 216 type = "code"; 217 break; 218 } 219 case "P": { 220 type = "text"; 221 break; 222 } 223 case "H1": { 224 headingLevel = 1; 225 type = "heading"; 226 break; 227 } 228 case "H2": { 229 headingLevel = 2; 230 type = "heading"; 231 break; 232 } 233 case "H3": { 234 headingLevel = 3; 235 type = "heading"; 236 break; 237 } 238 case "DIV": { 239 type = "card"; 240 break; 241 } 242 case "IMG": { 243 type = "image"; 244 break; 245 } 246 case "A": { 247 type = "link"; 248 break; 249 } 250 case "HR": { 251 type = "horizontal-rule"; 252 break; 253 } 254 default: 255 type = null; 256 } 257 let content = parser.parse(child); 258 if (!type) return; 259 260 let entityID: string; 261 let position: string; 262 if ( 263 (parentType === "canvas" && activeBlockProps?.current) || 264 (first && 265 (activeBlockProps?.current.type === "heading" || 266 activeBlockProps?.current.type === "blockquote" || 267 type === activeBlockProps?.current.type)) 268 ) 269 entityID = activeBlockProps.current.entityID; 270 else { 271 entityID = v7(); 272 if (parentType === "doc") { 273 position = getPosition(); 274 rep.mutate.addBlock({ 275 permission_set: entity_set.set, 276 factID: v7(), 277 newEntityID: entityID, 278 parent: parent, 279 type: type, 280 position, 281 }); 282 } 283 if (type === "heading" && headingLevel) { 284 rep.mutate.assertFact({ 285 entity: entityID, 286 attribute: "block/heading-level", 287 data: { type: "number", value: headingLevel }, 288 }); 289 } 290 } 291 let alignment = child.getAttribute("data-alignment"); 292 if (alignment && ["right", "left", "center"].includes(alignment)) { 293 rep.mutate.assertFact({ 294 entity: entityID, 295 attribute: "block/text-alignment", 296 data: { 297 type: "text-alignment-type-union", 298 value: alignment as "right" | "left" | "center", 299 }, 300 }); 301 } 302 let textSize = child.getAttribute("data-text-size"); 303 if (textSize && ["default", "small", "large"].includes(textSize)) { 304 rep.mutate.assertFact({ 305 entity: entityID, 306 attribute: "block/text-size", 307 data: { 308 type: "text-size-union", 309 value: textSize as "default" | "small" | "large", 310 }, 311 }); 312 } 313 if (child.tagName === "A") { 314 let href = child.getAttribute("href"); 315 let dataType = child.getAttribute("data-type"); 316 if (href) { 317 if (dataType === "button") { 318 rep.mutate.assertFact([ 319 { 320 entity: entityID, 321 attribute: "block/type", 322 data: { type: "block-type-union", value: "button" }, 323 }, 324 { 325 entity: entityID, 326 attribute: "button/text", 327 data: { type: "string", value: child.textContent || "" }, 328 }, 329 { 330 entity: entityID, 331 attribute: "button/url", 332 data: { type: "string", value: href }, 333 }, 334 ]); 335 } else { 336 addLinkBlock(href, entityID, rep); 337 } 338 } 339 } 340 if (child.tagName === "PRE") { 341 let lang = child.getAttribute("data-language") || "plaintext"; 342 if (child.firstElementChild && child.firstElementChild.className) { 343 let className = child.firstElementChild.className; 344 let match = className.match(/language-(\w+)/); 345 if (match) { 346 lang = match[1]; 347 } 348 } 349 if (child.textContent) { 350 rep.mutate.assertFact([ 351 { 352 entity: entityID, 353 attribute: "block/type", 354 data: { type: "block-type-union", value: "code" }, 355 }, 356 { 357 entity: entityID, 358 attribute: "block/code-language", 359 data: { type: "string", value: lang }, 360 }, 361 { 362 entity: entityID, 363 attribute: "block/code", 364 data: { type: "string", value: child.textContent }, 365 }, 366 ]); 367 } 368 } 369 if (child.tagName === "IMG") { 370 let src = child.getAttribute("src"); 371 if (src) { 372 fetch(src) 373 .then((res) => res.blob()) 374 .then((Blob) => { 375 const file = new File([Blob], "image.png", { type: Blob.type }); 376 addImage(file, rep, { 377 attribute: "block/image", 378 entityID: entityID, 379 }); 380 }); 381 } 382 } 383 if (child.tagName === "DIV" && child.getAttribute("data-tex")) { 384 let tex = child.getAttribute("data-tex"); 385 rep.mutate.assertFact([ 386 { 387 entity: entityID, 388 attribute: "block/type", 389 data: { type: "block-type-union", value: "math" }, 390 }, 391 { 392 entity: entityID, 393 attribute: "block/math", 394 data: { type: "string", value: tex || "" }, 395 }, 396 ]); 397 } 398 399 if (child.tagName === "DIV" && child.getAttribute("data-bluesky-post")) { 400 let postData = child.getAttribute("data-bluesky-post"); 401 if (postData) { 402 rep.mutate.assertFact([ 403 { 404 entity: entityID, 405 attribute: "block/type", 406 data: { type: "block-type-union", value: "bluesky-post" }, 407 }, 408 { 409 entity: entityID, 410 attribute: "block/bluesky-post", 411 data: { type: "bluesky-post", value: JSON.parse(postData) }, 412 }, 413 ]); 414 } 415 } 416 417 if (child.tagName === "DIV" && child.getAttribute("data-entityid")) { 418 let oldEntityID = child.getAttribute("data-entityid") as string; 419 let factsData = child.getAttribute("data-facts"); 420 if (factsData) { 421 let facts = JSON.parse(factsData) as Fact<any>[]; 422 423 let oldEntityIDToNewID = {} as { [k: string]: string }; 424 let oldEntities = facts.reduce((acc, f) => { 425 if (!acc.includes(f.entity)) acc.push(f.entity); 426 return acc; 427 }, [] as string[]); 428 let newEntities = [] as string[]; 429 for (let oldEntity of oldEntities) { 430 let newEntity = v7(); 431 oldEntityIDToNewID[oldEntity] = newEntity; 432 newEntities.push(newEntity); 433 } 434 435 let newFacts = [] as Array< 436 Pick<Fact<any>, "entity" | "attribute" | "data"> 437 >; 438 for (let fact of facts) { 439 let entity = oldEntityIDToNewID[fact.entity]; 440 let data = fact.data; 441 if ( 442 data.type === "ordered-reference" || 443 data.type == "spatial-reference" || 444 data.type === "reference" 445 ) { 446 data.value = oldEntityIDToNewID[data.value]; 447 } 448 if (data.type === "image") { 449 //idk get it from the clipboard maybe? 450 } 451 newFacts.push({ entity, attribute: fact.attribute, data }); 452 } 453 rep.mutate.createEntity( 454 newEntities.map((e) => ({ 455 entityID: e, 456 permission_set: entity_set.set, 457 })), 458 ); 459 rep.mutate.assertFact(newFacts.filter((f) => f.data.type !== "image")); 460 let newCardEntity = oldEntityIDToNewID[oldEntityID]; 461 rep.mutate.assertFact({ 462 entity: entityID, 463 attribute: "block/card", 464 data: { type: "reference", value: newCardEntity }, 465 }); 466 let images: Pick< 467 Fact<keyof FilterAttributes<{ type: "image" }>>, 468 "entity" | "data" | "attribute" 469 >[] = newFacts.filter((f) => f.data.type === "image"); 470 for (let image of images) { 471 fetch(image.data.src) 472 .then((res) => res.blob()) 473 .then((Blob) => { 474 const file = new File([Blob], "image.png", { type: Blob.type }); 475 addImage(file, rep, { 476 attribute: image.attribute, 477 entityID: image.entity, 478 }); 479 }); 480 } 481 } 482 } 483 484 if (child.tagName === "LI") { 485 let ul = Array.from(child.children) 486 .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement)) 487 .find((f) => f.tagName === "UL"); 488 let checked = child.getAttribute("data-checked"); 489 if (checked !== null) { 490 rep.mutate.assertFact({ 491 entity: entityID, 492 attribute: "block/check-list", 493 data: { type: "boolean", value: checked === "true" ? true : false }, 494 }); 495 } 496 rep.mutate.assertFact({ 497 entity: entityID, 498 attribute: "block/is-list", 499 data: { type: "boolean", value: true }, 500 }); 501 if (ul) { 502 hasChildren = true; 503 let currentPosition: string | null = null; 504 createBlockFromHTML(ul, { 505 parentType, 506 first: false, 507 last: last, 508 activeBlockProps, 509 rep, 510 undoManager, 511 entity_set, 512 getPosition: () => { 513 currentPosition = generateKeyBetween(currentPosition, null); 514 return currentPosition; 515 }, 516 parent: entityID, 517 }); 518 } 519 } 520 521 setTimeout(() => { 522 let block = useEditorStates.getState().editorStates[entityID]; 523 if (block) { 524 let tr = block.editor.tr; 525 if ( 526 block.editor.selection.from !== undefined && 527 block.editor.selection.to !== undefined 528 ) 529 tr.delete(block.editor.selection.from, block.editor.selection.to); 530 tr.replaceSelectionWith(content); 531 let newState = block.editor.apply(tr); 532 setEditorState(entityID, { 533 editor: newState, 534 }); 535 536 undoManager.add({ 537 redo: () => { 538 useEditorStates.setState((oldState) => { 539 let view = oldState.editorStates[entityID]?.view; 540 if (!view?.hasFocus()) view?.focus(); 541 return { 542 editorStates: { 543 ...oldState.editorStates, 544 [entityID]: { 545 ...oldState.editorStates[entityID]!, 546 editor: newState, 547 }, 548 }, 549 }; 550 }); 551 }, 552 undo: () => { 553 useEditorStates.setState((oldState) => { 554 let view = oldState.editorStates[entityID]?.view; 555 if (!view?.hasFocus()) view?.focus(); 556 return { 557 editorStates: { 558 ...oldState.editorStates, 559 [entityID]: { 560 ...oldState.editorStates[entityID]!, 561 editor: block.editor, 562 }, 563 }, 564 }; 565 }); 566 }, 567 }); 568 } 569 if (last && !hasChildren && !first) { 570 focusBlock( 571 { 572 value: entityID, 573 type: type, 574 parent: parent, 575 }, 576 { type: "end" }, 577 ); 578 } 579 }, 10); 580}; 581 582function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] { 583 // Function to recursively collect HTML from nodes 584 function collectHTML(node: Node, htmlBlocks: HTMLElement[]): void { 585 if (node.nodeType === Node.TEXT_NODE) { 586 if (node.textContent && node.textContent.trim() !== "") { 587 let newElement = document.createElement("p"); 588 newElement.textContent = node.textContent; 589 htmlBlocks.push(newElement); 590 } 591 } 592 if (node.nodeType === Node.ELEMENT_NODE) { 593 const elementNode = node as HTMLElement; 594 // Collect outer HTML for paragraph-like elements 595 if ( 596 [ 597 "BLOCKQUOTE", 598 "P", 599 "PRE", 600 "H1", 601 "H2", 602 "H3", 603 "H4", 604 "H5", 605 "H6", 606 "LI", 607 "UL", 608 "IMG", 609 "A", 610 "SPAN", 611 "HR", 612 ].includes(elementNode.tagName) || 613 elementNode.getAttribute("data-entityid") || 614 elementNode.getAttribute("data-tex") || 615 elementNode.getAttribute("data-bluesky-post") 616 ) { 617 htmlBlocks.push(elementNode); 618 } else { 619 // Recursively collect HTML from child nodes 620 for (let child of node.childNodes) { 621 collectHTML(child, htmlBlocks); 622 } 623 } 624 } 625 } 626 627 const htmlBlocks: HTMLElement[] = []; 628 collectHTML(element, htmlBlocks); 629 return htmlBlocks; 630}