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 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 if (child.tagName === "A") {
303 let href = child.getAttribute("href");
304 let dataType = child.getAttribute("data-type");
305 if (href) {
306 if (dataType === "button") {
307 rep.mutate.assertFact([
308 {
309 entity: entityID,
310 attribute: "block/type",
311 data: { type: "block-type-union", value: "button" },
312 },
313 {
314 entity: entityID,
315 attribute: "button/text",
316 data: { type: "string", value: child.textContent || "" },
317 },
318 {
319 entity: entityID,
320 attribute: "button/url",
321 data: { type: "string", value: href },
322 },
323 ]);
324 } else {
325 addLinkBlock(href, entityID, rep);
326 }
327 }
328 }
329 if (child.tagName === "PRE") {
330 let lang = child.getAttribute("data-language") || "plaintext";
331 if (child.firstElementChild && child.firstElementChild.className) {
332 let className = child.firstElementChild.className;
333 let match = className.match(/language-(\w+)/);
334 if (match) {
335 lang = match[1];
336 }
337 }
338 if (child.textContent) {
339 rep.mutate.assertFact([
340 {
341 entity: entityID,
342 attribute: "block/type",
343 data: { type: "block-type-union", value: "code" },
344 },
345 {
346 entity: entityID,
347 attribute: "block/code-language",
348 data: { type: "string", value: lang },
349 },
350 {
351 entity: entityID,
352 attribute: "block/code",
353 data: { type: "string", value: child.textContent },
354 },
355 ]);
356 }
357 }
358 if (child.tagName === "IMG") {
359 let src = child.getAttribute("src");
360 if (src) {
361 fetch(src)
362 .then((res) => res.blob())
363 .then((Blob) => {
364 const file = new File([Blob], "image.png", { type: Blob.type });
365 addImage(file, rep, {
366 attribute: "block/image",
367 entityID: entityID,
368 });
369 });
370 }
371 }
372 if (child.tagName === "DIV" && child.getAttribute("data-tex")) {
373 let tex = child.getAttribute("data-tex");
374 rep.mutate.assertFact([
375 {
376 entity: entityID,
377 attribute: "block/type",
378 data: { type: "block-type-union", value: "math" },
379 },
380 {
381 entity: entityID,
382 attribute: "block/math",
383 data: { type: "string", value: tex || "" },
384 },
385 ]);
386 }
387
388 if (child.tagName === "DIV" && child.getAttribute("data-entityid")) {
389 let oldEntityID = child.getAttribute("data-entityid") as string;
390 let factsData = child.getAttribute("data-facts");
391 if (factsData) {
392 let facts = JSON.parse(factsData) as Fact<any>[];
393
394 let oldEntityIDToNewID = {} as { [k: string]: string };
395 let oldEntities = facts.reduce((acc, f) => {
396 if (!acc.includes(f.entity)) acc.push(f.entity);
397 return acc;
398 }, [] as string[]);
399 let newEntities = [] as string[];
400 for (let oldEntity of oldEntities) {
401 let newEntity = v7();
402 oldEntityIDToNewID[oldEntity] = newEntity;
403 newEntities.push(newEntity);
404 }
405
406 let newFacts = [] as Array<
407 Pick<Fact<any>, "entity" | "attribute" | "data">
408 >;
409 for (let fact of facts) {
410 let entity = oldEntityIDToNewID[fact.entity];
411 let data = fact.data;
412 if (
413 data.type === "ordered-reference" ||
414 data.type == "spatial-reference" ||
415 data.type === "reference"
416 ) {
417 data.value = oldEntityIDToNewID[data.value];
418 }
419 if (data.type === "image") {
420 //idk get it from the clipboard maybe?
421 }
422 newFacts.push({ entity, attribute: fact.attribute, data });
423 }
424 rep.mutate.createEntity(
425 newEntities.map((e) => ({
426 entityID: e,
427 permission_set: entity_set.set,
428 })),
429 );
430 rep.mutate.assertFact(newFacts.filter((f) => f.data.type !== "image"));
431 let newCardEntity = oldEntityIDToNewID[oldEntityID];
432 rep.mutate.assertFact({
433 entity: entityID,
434 attribute: "block/card",
435 data: { type: "reference", value: newCardEntity },
436 });
437 let images: Pick<
438 Fact<keyof FilterAttributes<{ type: "image" }>>,
439 "entity" | "data" | "attribute"
440 >[] = newFacts.filter((f) => f.data.type === "image");
441 for (let image of images) {
442 fetch(image.data.src)
443 .then((res) => res.blob())
444 .then((Blob) => {
445 const file = new File([Blob], "image.png", { type: Blob.type });
446 addImage(file, rep, {
447 attribute: image.attribute,
448 entityID: image.entity,
449 });
450 });
451 }
452 }
453 }
454
455 if (child.tagName === "LI") {
456 let ul = Array.from(child.children)
457 .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement))
458 .find((f) => f.tagName === "UL");
459 let checked = child.getAttribute("data-checked");
460 if (checked !== null) {
461 rep.mutate.assertFact({
462 entity: entityID,
463 attribute: "block/check-list",
464 data: { type: "boolean", value: checked === "true" ? true : false },
465 });
466 }
467 rep.mutate.assertFact({
468 entity: entityID,
469 attribute: "block/is-list",
470 data: { type: "boolean", value: true },
471 });
472 if (ul) {
473 hasChildren = true;
474 let currentPosition: string | null = null;
475 createBlockFromHTML(ul, {
476 parentType,
477 first: false,
478 last: last,
479 activeBlockProps,
480 rep,
481 undoManager,
482 entity_set,
483 getPosition: () => {
484 currentPosition = generateKeyBetween(currentPosition, null);
485 return currentPosition;
486 },
487 parent: entityID,
488 });
489 }
490 }
491
492 setTimeout(() => {
493 let block = useEditorStates.getState().editorStates[entityID];
494 if (block) {
495 let tr = block.editor.tr;
496 if (
497 block.editor.selection.from !== undefined &&
498 block.editor.selection.to !== undefined
499 )
500 tr.delete(block.editor.selection.from, block.editor.selection.to);
501 tr.replaceSelectionWith(content);
502 let newState = block.editor.apply(tr);
503 setEditorState(entityID, {
504 editor: newState,
505 });
506
507 undoManager.add({
508 redo: () => {
509 useEditorStates.setState((oldState) => {
510 let view = oldState.editorStates[entityID]?.view;
511 if (!view?.hasFocus()) view?.focus();
512 return {
513 editorStates: {
514 ...oldState.editorStates,
515 [entityID]: {
516 ...oldState.editorStates[entityID]!,
517 editor: newState,
518 },
519 },
520 };
521 });
522 },
523 undo: () => {
524 useEditorStates.setState((oldState) => {
525 let view = oldState.editorStates[entityID]?.view;
526 if (!view?.hasFocus()) view?.focus();
527 return {
528 editorStates: {
529 ...oldState.editorStates,
530 [entityID]: {
531 ...oldState.editorStates[entityID]!,
532 editor: block.editor,
533 },
534 },
535 };
536 });
537 },
538 });
539 }
540 if (last && !hasChildren && !first) {
541 focusBlock(
542 {
543 value: entityID,
544 type: type,
545 parent: parent,
546 },
547 { type: "end" },
548 );
549 }
550 }, 10);
551};
552
553function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] {
554 // Function to recursively collect HTML from nodes
555 function collectHTML(node: Node, htmlBlocks: HTMLElement[]): void {
556 if (node.nodeType === Node.TEXT_NODE) {
557 if (node.textContent && node.textContent.trim() !== "") {
558 let newElement = document.createElement("p");
559 newElement.textContent = node.textContent;
560 htmlBlocks.push(newElement);
561 }
562 }
563 if (node.nodeType === Node.ELEMENT_NODE) {
564 const elementNode = node as HTMLElement;
565 // Collect outer HTML for paragraph-like elements
566 if (
567 [
568 "BLOCKQUOTE",
569 "P",
570 "PRE",
571 "H1",
572 "H2",
573 "H3",
574 "H4",
575 "H5",
576 "H6",
577 "LI",
578 "UL",
579 "IMG",
580 "A",
581 "SPAN",
582 "HR",
583 ].includes(elementNode.tagName) ||
584 elementNode.getAttribute("data-entityid") ||
585 elementNode.getAttribute("data-tex")
586 ) {
587 htmlBlocks.push(elementNode);
588 } else {
589 // Recursively collect HTML from child nodes
590 for (let child of node.childNodes) {
591 collectHTML(child, htmlBlocks);
592 }
593 }
594 }
595 }
596
597 const htmlBlocks: HTMLElement[] = [];
598 collectHTML(element, htmlBlocks);
599 return htmlBlocks;
600}