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