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 "H4": {
248 headingLevel = 4;
249 type = "heading";
250 break;
251 }
252 case "DIV": {
253 type = "card";
254 break;
255 }
256 case "IMG": {
257 type = "image";
258 break;
259 }
260 case "A": {
261 type = "link";
262 break;
263 }
264 case "HR": {
265 type = "horizontal-rule";
266 break;
267 }
268 default:
269 type = null;
270 }
271 let content = parser.parse(child);
272 if (!type) return;
273
274 let entityID: string;
275 let position: string;
276 if (
277 (parentType === "canvas" && activeBlockProps?.current) ||
278 (first &&
279 (activeBlockProps?.current.type === "heading" ||
280 activeBlockProps?.current.type === "blockquote" ||
281 type === activeBlockProps?.current.type))
282 )
283 entityID = activeBlockProps.current.entityID;
284 else {
285 entityID = v7();
286 if (parentType === "doc") {
287 position = getPosition();
288 rep.mutate.addBlock({
289 permission_set: entity_set.set,
290 factID: v7(),
291 newEntityID: entityID,
292 parent: parent,
293 type: type,
294 position,
295 });
296 }
297 if (type === "heading" && headingLevel) {
298 rep.mutate.assertFact({
299 entity: entityID,
300 attribute: "block/heading-level",
301 data: { type: "number", value: headingLevel },
302 });
303 }
304 }
305 let alignment = child.getAttribute("data-alignment");
306 if (alignment && ["right", "left", "center"].includes(alignment)) {
307 rep.mutate.assertFact({
308 entity: entityID,
309 attribute: "block/text-alignment",
310 data: {
311 type: "text-alignment-type-union",
312 value: alignment as "right" | "left" | "center",
313 },
314 });
315 }
316 let textSize = child.getAttribute("data-text-size");
317 if (textSize && ["default", "small", "large"].includes(textSize)) {
318 rep.mutate.assertFact({
319 entity: entityID,
320 attribute: "block/text-size",
321 data: {
322 type: "text-size-union",
323 value: textSize as "default" | "small" | "large",
324 },
325 });
326 }
327 if (child.tagName === "A") {
328 let href = child.getAttribute("href");
329 let dataType = child.getAttribute("data-type");
330 if (href) {
331 if (dataType === "button") {
332 rep.mutate.assertFact([
333 {
334 entity: entityID,
335 attribute: "block/type",
336 data: { type: "block-type-union", value: "button" },
337 },
338 {
339 entity: entityID,
340 attribute: "button/text",
341 data: { type: "string", value: child.textContent || "" },
342 },
343 {
344 entity: entityID,
345 attribute: "button/url",
346 data: { type: "string", value: href },
347 },
348 ]);
349 } else {
350 addLinkBlock(href, entityID, rep);
351 }
352 }
353 }
354 if (child.tagName === "PRE") {
355 let lang = child.getAttribute("data-language") || "plaintext";
356 if (child.firstElementChild && child.firstElementChild.className) {
357 let className = child.firstElementChild.className;
358 let match = className.match(/language-(\w+)/);
359 if (match) {
360 lang = match[1];
361 }
362 }
363 if (child.textContent) {
364 rep.mutate.assertFact([
365 {
366 entity: entityID,
367 attribute: "block/type",
368 data: { type: "block-type-union", value: "code" },
369 },
370 {
371 entity: entityID,
372 attribute: "block/code-language",
373 data: { type: "string", value: lang },
374 },
375 {
376 entity: entityID,
377 attribute: "block/code",
378 data: { type: "string", value: child.textContent },
379 },
380 ]);
381 }
382 }
383 if (child.tagName === "IMG") {
384 let src = child.getAttribute("src");
385 if (src) {
386 fetch(src)
387 .then((res) => res.blob())
388 .then((Blob) => {
389 const file = new File([Blob], "image.png", { type: Blob.type });
390 addImage(file, rep, {
391 attribute: "block/image",
392 entityID: entityID,
393 });
394 });
395 }
396 }
397 if (child.tagName === "DIV" && child.getAttribute("data-tex")) {
398 let tex = child.getAttribute("data-tex");
399 rep.mutate.assertFact([
400 {
401 entity: entityID,
402 attribute: "block/type",
403 data: { type: "block-type-union", value: "math" },
404 },
405 {
406 entity: entityID,
407 attribute: "block/math",
408 data: { type: "string", value: tex || "" },
409 },
410 ]);
411 }
412
413 if (child.tagName === "DIV" && child.getAttribute("data-bluesky-post")) {
414 let postData = child.getAttribute("data-bluesky-post");
415 if (postData) {
416 rep.mutate.assertFact([
417 {
418 entity: entityID,
419 attribute: "block/type",
420 data: { type: "block-type-union", value: "bluesky-post" },
421 },
422 {
423 entity: entityID,
424 attribute: "block/bluesky-post",
425 data: { type: "bluesky-post", value: JSON.parse(postData) },
426 },
427 ]);
428 }
429 }
430
431 if (child.tagName === "DIV" && child.getAttribute("data-entityid")) {
432 let oldEntityID = child.getAttribute("data-entityid") as string;
433 let factsData = child.getAttribute("data-facts");
434 if (factsData) {
435 let facts = JSON.parse(factsData) as Fact<any>[];
436
437 let oldEntityIDToNewID = {} as { [k: string]: string };
438 let oldEntities = facts.reduce((acc, f) => {
439 if (!acc.includes(f.entity)) acc.push(f.entity);
440 return acc;
441 }, [] as string[]);
442 let newEntities = [] as string[];
443 for (let oldEntity of oldEntities) {
444 let newEntity = v7();
445 oldEntityIDToNewID[oldEntity] = newEntity;
446 newEntities.push(newEntity);
447 }
448
449 let newFacts = [] as Array<
450 Pick<Fact<any>, "entity" | "attribute" | "data">
451 >;
452 for (let fact of facts) {
453 let entity = oldEntityIDToNewID[fact.entity];
454 let data = fact.data;
455 if (
456 data.type === "ordered-reference" ||
457 data.type == "spatial-reference" ||
458 data.type === "reference"
459 ) {
460 data.value = oldEntityIDToNewID[data.value];
461 }
462 if (data.type === "image") {
463 //idk get it from the clipboard maybe?
464 }
465 newFacts.push({ entity, attribute: fact.attribute, data });
466 }
467 rep.mutate.createEntity(
468 newEntities.map((e) => ({
469 entityID: e,
470 permission_set: entity_set.set,
471 })),
472 );
473 rep.mutate.assertFact(newFacts.filter((f) => f.data.type !== "image"));
474 let newCardEntity = oldEntityIDToNewID[oldEntityID];
475 rep.mutate.assertFact({
476 entity: entityID,
477 attribute: "block/card",
478 data: { type: "reference", value: newCardEntity },
479 });
480 let images: Pick<
481 Fact<keyof FilterAttributes<{ type: "image" }>>,
482 "entity" | "data" | "attribute"
483 >[] = newFacts.filter((f) => f.data.type === "image");
484 for (let image of images) {
485 fetch(image.data.src)
486 .then((res) => res.blob())
487 .then((Blob) => {
488 const file = new File([Blob], "image.png", { type: Blob.type });
489 addImage(file, rep, {
490 attribute: image.attribute,
491 entityID: image.entity,
492 });
493 });
494 }
495 }
496 }
497
498 if (child.tagName === "LI") {
499 // Look for nested UL or OL
500 let nestedList = Array.from(child.children)
501 .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement))
502 .find((f) => f.tagName === "UL" || f.tagName === "OL");
503 let checked = child.getAttribute("data-checked");
504 if (checked !== null) {
505 rep.mutate.assertFact({
506 entity: entityID,
507 attribute: "block/check-list",
508 data: { type: "boolean", value: checked === "true" ? true : false },
509 });
510 }
511 rep.mutate.assertFact({
512 entity: entityID,
513 attribute: "block/is-list",
514 data: { type: "boolean", value: true },
515 });
516 // Set list style if provided (from parent OL/UL)
517 if (listStyle) {
518 rep.mutate.assertFact({
519 entity: entityID,
520 attribute: "block/list-style",
521 data: { type: "list-style-union", value: listStyle },
522 });
523 }
524 if (nestedList) {
525 hasChildren = true;
526 let currentPosition: string | null = null;
527 createBlockFromHTML(nestedList, {
528 parentType,
529 first: false,
530 last: last,
531 activeBlockProps,
532 rep,
533 undoManager,
534 entity_set,
535 getPosition: () => {
536 currentPosition = generateKeyBetween(currentPosition, null);
537 return currentPosition;
538 },
539 parent: entityID,
540 depth: depth + 1,
541 });
542 }
543 }
544
545 setTimeout(() => {
546 let block = useEditorStates.getState().editorStates[entityID];
547 if (block) {
548 let tr = block.editor.tr;
549 if (
550 block.editor.selection.from !== undefined &&
551 block.editor.selection.to !== undefined
552 )
553 tr.delete(block.editor.selection.from, block.editor.selection.to);
554 tr.replaceSelectionWith(content);
555 let newState = block.editor.apply(tr);
556 setEditorState(entityID, {
557 editor: newState,
558 });
559
560 undoManager.add({
561 redo: () => {
562 useEditorStates.setState((oldState) => {
563 let view = oldState.editorStates[entityID]?.view;
564 if (!view?.hasFocus()) view?.focus();
565 return {
566 editorStates: {
567 ...oldState.editorStates,
568 [entityID]: {
569 ...oldState.editorStates[entityID]!,
570 editor: newState,
571 },
572 },
573 };
574 });
575 },
576 undo: () => {
577 useEditorStates.setState((oldState) => {
578 let view = oldState.editorStates[entityID]?.view;
579 if (!view?.hasFocus()) view?.focus();
580 return {
581 editorStates: {
582 ...oldState.editorStates,
583 [entityID]: {
584 ...oldState.editorStates[entityID]!,
585 editor: block.editor,
586 },
587 },
588 };
589 });
590 },
591 });
592 }
593 if (last && !hasChildren && !first) {
594 focusBlock(
595 {
596 value: entityID,
597 type: type,
598 parent: parent,
599 },
600 { type: "end" },
601 );
602 }
603 }, 10);
604};
605
606function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] {
607 // Function to recursively collect HTML from nodes
608 function collectHTML(node: Node, htmlBlocks: HTMLElement[]): void {
609 if (node.nodeType === Node.TEXT_NODE) {
610 if (node.textContent && node.textContent.trim() !== "") {
611 let newElement = document.createElement("p");
612 newElement.textContent = node.textContent;
613 htmlBlocks.push(newElement);
614 }
615 }
616 if (node.nodeType === Node.ELEMENT_NODE) {
617 const elementNode = node as HTMLElement;
618 // Collect outer HTML for paragraph-like elements
619 if (
620 [
621 "BLOCKQUOTE",
622 "P",
623 "PRE",
624 "H1",
625 "H2",
626 "H3",
627 "H4",
628 "H5",
629 "H6",
630 "LI",
631 "UL",
632 "OL",
633 "IMG",
634 "A",
635 "SPAN",
636 "HR",
637 ].includes(elementNode.tagName) ||
638 elementNode.getAttribute("data-entityid") ||
639 elementNode.getAttribute("data-tex") ||
640 elementNode.getAttribute("data-bluesky-post")
641 ) {
642 htmlBlocks.push(elementNode);
643 } else {
644 // Recursively collect HTML from child nodes
645 for (let child of node.childNodes) {
646 collectHTML(child, htmlBlocks);
647 }
648 }
649 }
650 }
651
652 const htmlBlocks: HTMLElement[] = [];
653 collectHTML(element, htmlBlocks);
654 return htmlBlocks;
655}