a tool for shared writing and social publishing
at update/delete-blocks 611 lines 18 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-entityid")) { 400 let oldEntityID = child.getAttribute("data-entityid") as string; 401 let factsData = child.getAttribute("data-facts"); 402 if (factsData) { 403 let facts = JSON.parse(factsData) as Fact<any>[]; 404 405 let oldEntityIDToNewID = {} as { [k: string]: string }; 406 let oldEntities = facts.reduce((acc, f) => { 407 if (!acc.includes(f.entity)) acc.push(f.entity); 408 return acc; 409 }, [] as string[]); 410 let newEntities = [] as string[]; 411 for (let oldEntity of oldEntities) { 412 let newEntity = v7(); 413 oldEntityIDToNewID[oldEntity] = newEntity; 414 newEntities.push(newEntity); 415 } 416 417 let newFacts = [] as Array< 418 Pick<Fact<any>, "entity" | "attribute" | "data"> 419 >; 420 for (let fact of facts) { 421 let entity = oldEntityIDToNewID[fact.entity]; 422 let data = fact.data; 423 if ( 424 data.type === "ordered-reference" || 425 data.type == "spatial-reference" || 426 data.type === "reference" 427 ) { 428 data.value = oldEntityIDToNewID[data.value]; 429 } 430 if (data.type === "image") { 431 //idk get it from the clipboard maybe? 432 } 433 newFacts.push({ entity, attribute: fact.attribute, data }); 434 } 435 rep.mutate.createEntity( 436 newEntities.map((e) => ({ 437 entityID: e, 438 permission_set: entity_set.set, 439 })), 440 ); 441 rep.mutate.assertFact(newFacts.filter((f) => f.data.type !== "image")); 442 let newCardEntity = oldEntityIDToNewID[oldEntityID]; 443 rep.mutate.assertFact({ 444 entity: entityID, 445 attribute: "block/card", 446 data: { type: "reference", value: newCardEntity }, 447 }); 448 let images: Pick< 449 Fact<keyof FilterAttributes<{ type: "image" }>>, 450 "entity" | "data" | "attribute" 451 >[] = newFacts.filter((f) => f.data.type === "image"); 452 for (let image of images) { 453 fetch(image.data.src) 454 .then((res) => res.blob()) 455 .then((Blob) => { 456 const file = new File([Blob], "image.png", { type: Blob.type }); 457 addImage(file, rep, { 458 attribute: image.attribute, 459 entityID: image.entity, 460 }); 461 }); 462 } 463 } 464 } 465 466 if (child.tagName === "LI") { 467 let ul = Array.from(child.children) 468 .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement)) 469 .find((f) => f.tagName === "UL"); 470 let checked = child.getAttribute("data-checked"); 471 if (checked !== null) { 472 rep.mutate.assertFact({ 473 entity: entityID, 474 attribute: "block/check-list", 475 data: { type: "boolean", value: checked === "true" ? true : false }, 476 }); 477 } 478 rep.mutate.assertFact({ 479 entity: entityID, 480 attribute: "block/is-list", 481 data: { type: "boolean", value: true }, 482 }); 483 if (ul) { 484 hasChildren = true; 485 let currentPosition: string | null = null; 486 createBlockFromHTML(ul, { 487 parentType, 488 first: false, 489 last: last, 490 activeBlockProps, 491 rep, 492 undoManager, 493 entity_set, 494 getPosition: () => { 495 currentPosition = generateKeyBetween(currentPosition, null); 496 return currentPosition; 497 }, 498 parent: entityID, 499 }); 500 } 501 } 502 503 setTimeout(() => { 504 let block = useEditorStates.getState().editorStates[entityID]; 505 if (block) { 506 let tr = block.editor.tr; 507 if ( 508 block.editor.selection.from !== undefined && 509 block.editor.selection.to !== undefined 510 ) 511 tr.delete(block.editor.selection.from, block.editor.selection.to); 512 tr.replaceSelectionWith(content); 513 let newState = block.editor.apply(tr); 514 setEditorState(entityID, { 515 editor: newState, 516 }); 517 518 undoManager.add({ 519 redo: () => { 520 useEditorStates.setState((oldState) => { 521 let view = oldState.editorStates[entityID]?.view; 522 if (!view?.hasFocus()) view?.focus(); 523 return { 524 editorStates: { 525 ...oldState.editorStates, 526 [entityID]: { 527 ...oldState.editorStates[entityID]!, 528 editor: newState, 529 }, 530 }, 531 }; 532 }); 533 }, 534 undo: () => { 535 useEditorStates.setState((oldState) => { 536 let view = oldState.editorStates[entityID]?.view; 537 if (!view?.hasFocus()) view?.focus(); 538 return { 539 editorStates: { 540 ...oldState.editorStates, 541 [entityID]: { 542 ...oldState.editorStates[entityID]!, 543 editor: block.editor, 544 }, 545 }, 546 }; 547 }); 548 }, 549 }); 550 } 551 if (last && !hasChildren && !first) { 552 focusBlock( 553 { 554 value: entityID, 555 type: type, 556 parent: parent, 557 }, 558 { type: "end" }, 559 ); 560 } 561 }, 10); 562}; 563 564function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] { 565 // Function to recursively collect HTML from nodes 566 function collectHTML(node: Node, htmlBlocks: HTMLElement[]): void { 567 if (node.nodeType === Node.TEXT_NODE) { 568 if (node.textContent && node.textContent.trim() !== "") { 569 let newElement = document.createElement("p"); 570 newElement.textContent = node.textContent; 571 htmlBlocks.push(newElement); 572 } 573 } 574 if (node.nodeType === Node.ELEMENT_NODE) { 575 const elementNode = node as HTMLElement; 576 // Collect outer HTML for paragraph-like elements 577 if ( 578 [ 579 "BLOCKQUOTE", 580 "P", 581 "PRE", 582 "H1", 583 "H2", 584 "H3", 585 "H4", 586 "H5", 587 "H6", 588 "LI", 589 "UL", 590 "IMG", 591 "A", 592 "SPAN", 593 "HR", 594 ].includes(elementNode.tagName) || 595 elementNode.getAttribute("data-entityid") || 596 elementNode.getAttribute("data-tex") 597 ) { 598 htmlBlocks.push(elementNode); 599 } else { 600 // Recursively collect HTML from child nodes 601 for (let child of node.childNodes) { 602 collectHTML(child, htmlBlocks); 603 } 604 } 605 } 606 } 607 608 const htmlBlocks: HTMLElement[] = []; 609 collectHTML(element, htmlBlocks); 610 return htmlBlocks; 611}