a tool for shared writing and social publishing
1import { useEntity, useReplicache } from "src/replicache";
2import { useEntitySetContext } from "./EntitySetProvider";
3import { v7 } from "uuid";
4import { BaseBlock } from "./Blocks/Block";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6import { useDrag } from "src/hooks/useDrag";
7import { useLongPress } from "src/hooks/useLongPress";
8import { focusBlock } from "src/utils/focusBlock";
9import { elementId } from "src/utils/elementId";
10import { useUIState } from "src/useUIState";
11import useMeasure from "react-use-measure";
12import { useIsMobile } from "src/hooks/isMobile";
13import { Media } from "./Media";
14import { TooltipButton } from "./Buttons";
15import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers";
16import { AddSmall } from "./Icons/AddSmall";
17import { InfoSmall } from "./Icons/InfoSmall";
18import { Popover } from "./Popover";
19import { Separator } from "./Layout";
20import { CommentTiny } from "./Icons/CommentTiny";
21import { QuoteTiny } from "./Icons/QuoteTiny";
22import { PublicationMetadata } from "./Pages/PublicationMetadata";
23import { useLeafletPublicationData } from "./PageSWRDataProvider";
24import {
25 PubLeafletPublication,
26 PubLeafletPublicationRecord,
27} from "lexicons/api";
28import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop";
29
30export function Canvas(props: {
31 entityID: string;
32 preview?: boolean;
33 first?: boolean;
34}) {
35 let entity_set = useEntitySetContext();
36 let ref = useRef<HTMLDivElement>(null);
37 useEffect(() => {
38 let abort = new AbortController();
39 let isTouch = false;
40 let startX: number, startY: number, scrollLeft: number, scrollTop: number;
41 let el = ref.current;
42 ref.current?.addEventListener(
43 "wheel",
44 (e) => {
45 if (!el) return;
46 if (
47 (e.deltaX > 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth) ||
48 (e.deltaX < 0 && el.scrollLeft <= 0) ||
49 (e.deltaY > 0 && el.scrollTop >= el.scrollHeight - el.clientHeight) ||
50 (e.deltaY < 0 && el.scrollTop <= 0)
51 ) {
52 return;
53 }
54 e.preventDefault();
55 el.scrollLeft += e.deltaX;
56 el.scrollTop += e.deltaY;
57 },
58 { passive: false, signal: abort.signal },
59 );
60 return () => abort.abort();
61 });
62
63 return (
64 <div
65 ref={ref}
66 id={elementId.page(props.entityID).canvasScrollArea}
67 className={`
68 canvasWrapper
69 h-full w-fit
70 overflow-y-scroll
71 `}
72 >
73 <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} />
74
75 <CanvasMetadata isSubpage={!props.first} />
76
77 <CanvasContent {...props} />
78 </div>
79 );
80}
81
82export function CanvasContent(props: { entityID: string; preview?: boolean }) {
83 let blocks = useEntity(props.entityID, "canvas/block");
84 let { rep } = useReplicache();
85 let entity_set = useEntitySetContext();
86 let height = Math.max(...blocks.map((f) => f.data.position.y), 0);
87 let handleDrop = useHandleCanvasDrop(props.entityID);
88
89 return (
90 <div
91 onClick={async (e) => {
92 if (e.currentTarget !== e.target) return;
93 useUIState.setState(() => ({
94 selectedBlocks: [],
95 focusedEntity: { entityType: "page", entityID: props.entityID },
96 }));
97 useUIState.setState({
98 focusedEntity: { entityType: "page", entityID: props.entityID },
99 });
100 document
101 .getElementById(elementId.page(props.entityID).container)
102 ?.scrollIntoView({
103 behavior: "smooth",
104 inline: "nearest",
105 });
106 if (e.detail === 2 || e.ctrlKey || e.metaKey) {
107 let parentRect = e.currentTarget.getBoundingClientRect();
108 let newEntityID = v7();
109 await rep?.mutate.addCanvasBlock({
110 newEntityID,
111 parent: props.entityID,
112 position: {
113 x: Math.max(e.clientX - parentRect.left, 0),
114 y: Math.max(e.clientY - parentRect.top - 12, 0),
115 },
116 factID: v7(),
117 type: "text",
118 permission_set: entity_set.set,
119 });
120 focusBlock(
121 { type: "text", parent: props.entityID, value: newEntityID },
122 { type: "start" },
123 );
124 }
125 }}
126 onDragOver={
127 !props.preview && entity_set.permissions.write
128 ? (e) => {
129 e.preventDefault();
130 e.stopPropagation();
131 }
132 : undefined
133 }
134 onDrop={
135 !props.preview && entity_set.permissions.write ? handleDrop : undefined
136 }
137 style={{
138 minHeight: height + 512,
139 contain: "size layout paint",
140 }}
141 className="relative h-full w-[1272px]"
142 >
143 <CanvasBackground entityID={props.entityID} />
144 {blocks
145 .sort((a, b) => {
146 if (a.data.position.y === b.data.position.y) {
147 return a.data.position.x - b.data.position.x;
148 }
149 return a.data.position.y - b.data.position.y;
150 })
151 .map((b) => {
152 return (
153 <CanvasBlock
154 preview={props.preview}
155 parent={props.entityID}
156 entityID={b.data.value}
157 position={b.data.position}
158 factID={b.id}
159 key={b.id}
160 />
161 );
162 })}
163 </div>
164 );
165}
166
167const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => {
168 let { data: pub } = useLeafletPublicationData();
169 if (!pub || !pub.publications) return null;
170
171 let pubRecord = pub.publications.record as PubLeafletPublication.Record;
172 let showComments = pubRecord.preferences?.showComments !== false;
173 let showMentions = pubRecord.preferences?.showMentions !== false;
174
175 return (
176 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
177 {showComments && (
178 <div className="flex gap-1 text-tertiary items-center">
179 <CommentTiny className="text-border" /> —
180 </div>
181 )}
182 {showComments && (
183 <div className="flex gap-1 text-tertiary items-center">
184 <QuoteTiny className="text-border" /> —
185 </div>
186 )}
187
188 {!props.isSubpage && (
189 <>
190 <Separator classname="h-5" />
191 <Popover
192 side="left"
193 align="start"
194 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]"
195 trigger={<InfoSmall />}
196 >
197 <PublicationMetadata />
198 </Popover>
199 </>
200 )}
201 </div>
202 );
203};
204
205const AddCanvasBlockButton = (props: {
206 entityID: string;
207 entity_set: { set: string };
208}) => {
209 let { rep } = useReplicache();
210 let { permissions } = useEntitySetContext();
211 let blocks = useEntity(props.entityID, "canvas/block");
212
213 if (!permissions.write) return null;
214 return (
215 <div className="absolute right-2 sm:bottom-4 sm:right-4 bottom-2 sm:top-auto z-10 flex flex-col gap-1 justify-center">
216 <TooltipButton
217 side="left"
218 open={blocks.length === 0 ? true : undefined}
219 tooltipContent={
220 <div className="flex flex-col justify-end text-center px-1 leading-snug ">
221 <div>Add a Block!</div>
222 <div className="font-normal">or double click anywhere</div>
223 </div>
224 }
225 className="w-fit p-2 rounded-full bg-accent-1 border-2 outline-solid outline-transparent hover:outline-1 hover:outline-accent-1 border-accent-1 text-accent-2"
226 onMouseDown={() => {
227 let page = document.getElementById(
228 elementId.page(props.entityID).canvasScrollArea,
229 );
230 if (!page) return;
231 let newEntityID = v7();
232 rep?.mutate.addCanvasBlock({
233 newEntityID,
234 parent: props.entityID,
235 position: {
236 x: page?.clientWidth + page?.scrollLeft - 468,
237 y: 32 + page.scrollTop,
238 },
239 factID: v7(),
240 type: "text",
241 permission_set: props.entity_set.set,
242 });
243 setTimeout(() => {
244 focusBlock(
245 { type: "text", value: newEntityID, parent: props.entityID },
246 { type: "start" },
247 );
248 }, 20);
249 }}
250 >
251 <AddSmall />
252 </TooltipButton>
253 </div>
254 );
255};
256
257function CanvasBlock(props: {
258 preview?: boolean;
259 entityID: string;
260 parent: string;
261 position: { x: number; y: number };
262 factID: string;
263}) {
264 let width =
265 useEntity(props.entityID, "canvas/block/width")?.data.value || 360;
266 let rotation =
267 useEntity(props.entityID, "canvas/block/rotation")?.data.value || 0;
268 let [ref, rect] = useMeasure();
269 let type = useEntity(props.entityID, "block/type");
270 let { rep } = useReplicache();
271 let isMobile = useIsMobile();
272
273 let { permissions } = useEntitySetContext();
274 let onDragEnd = useCallback(
275 (dragPosition: { x: number; y: number }) => {
276 if (!permissions.write) return;
277 rep?.mutate.assertFact({
278 id: props.factID,
279 entity: props.parent,
280 attribute: "canvas/block",
281 data: {
282 type: "spatial-reference",
283 value: props.entityID,
284 position: {
285 x: props.position.x + dragPosition.x,
286 y: props.position.y + dragPosition.y,
287 },
288 },
289 });
290 },
291 [props, rep, permissions],
292 );
293 let { dragDelta, handlers } = useDrag({
294 onDragEnd,
295 delay: isMobile,
296 });
297
298 let widthOnDragEnd = useCallback(
299 (dragPosition: { x: number; y: number }) => {
300 rep?.mutate.assertFact({
301 entity: props.entityID,
302 attribute: "canvas/block/width",
303 data: {
304 type: "number",
305 value: width + dragPosition.x,
306 },
307 });
308 },
309 [props, rep, width],
310 );
311 let widthHandle = useDrag({ onDragEnd: widthOnDragEnd });
312
313 let RotateOnDragEnd = useCallback(
314 (dragDelta: { x: number; y: number }) => {
315 let originX = rect.x + rect.width / 2;
316 let originY = rect.y + rect.height / 2;
317
318 let angle =
319 find_angle(
320 { x: rect.x + rect.width, y: rect.y + rect.height },
321 { x: originX, y: originY },
322 {
323 x: rect.x + rect.width + dragDelta.x,
324 y: rect.y + rect.height + dragDelta.y,
325 },
326 ) *
327 (180 / Math.PI);
328
329 rep?.mutate.assertFact({
330 entity: props.entityID,
331 attribute: "canvas/block/rotation",
332 data: {
333 type: "number",
334 value: (rotation + angle) % 360,
335 },
336 });
337 },
338 [props, rep, rect, rotation],
339 );
340 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd });
341
342 let { isLongPress, handlers: longPressHandlers } = useLongPress(() => {
343 if (isLongPress.current && permissions.write) {
344 focusBlock(
345 {
346 type: type?.data.value || "text",
347 value: props.entityID,
348 parent: props.parent,
349 },
350 { type: "start" },
351 );
352 }
353 });
354 let angle = 0;
355 if (rotateHandle.dragDelta) {
356 let originX = rect.x + rect.width / 2;
357 let originY = rect.y + rect.height / 2;
358
359 angle =
360 find_angle(
361 { x: rect.x + rect.width, y: rect.y + rect.height },
362 { x: originX, y: originY },
363 {
364 x: rect.x + rect.width + rotateHandle.dragDelta.x,
365 y: rect.y + rect.height + rotateHandle.dragDelta.y,
366 },
367 ) *
368 (180 / Math.PI);
369 }
370 let x = props.position.x + (dragDelta?.x || 0);
371 let y = props.position.y + (dragDelta?.y || 0);
372 let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`;
373 let [areYouSure, setAreYouSure] = useState(false);
374 let blockProps = useMemo(() => {
375 return {
376 pageType: "canvas" as const,
377 preview: props.preview,
378 type: type?.data.value || "text",
379 value: props.entityID,
380 factID: props.factID,
381 position: "",
382 nextPosition: "",
383 entityID: props.entityID,
384 parent: props.parent,
385 nextBlock: null,
386 previousBlock: null,
387 };
388 }, [props, type?.data.value]);
389 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure);
390 let isList = useEntity(props.entityID, "block/is-list");
391 let isFocused = useUIState(
392 (s) => s.focusedEntity?.entityID === props.entityID,
393 );
394
395 return (
396 <div
397 ref={ref}
398 {...(!props.preview ? { ...longPressHandlers } : {})}
399 {...(isMobile && permissions.write ? { ...handlers } : {})}
400 id={props.preview ? undefined : elementId.block(props.entityID).container}
401 className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `}
402 style={{
403 top: 0,
404 left: 0,
405 zIndex: dragDelta || isFocused ? 10 : undefined,
406 width: width + (widthHandle.dragDelta?.x || 0),
407 transform,
408 }}
409 >
410 {/* the gripper show on hover, but longpress logic needs to be added for mobile*/}
411 {!props.preview && permissions.write && <Gripper {...handlers} />}
412 <div
413 className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `}
414 >
415 <BaseBlock
416 {...blockProps}
417 listData={
418 isList?.data.value
419 ? { path: [], parent: props.parent, depth: 1 }
420 : undefined
421 }
422 areYouSure={areYouSure}
423 setAreYouSure={setAreYouSure}
424 />
425 </div>
426
427 {!props.preview && permissions.write && (
428 <div
429 className={`resizeHandle
430 cursor-e-resize shrink-0 z-10
431 hidden group-hover/canvas-block:block
432 w-[5px] h-6 -ml-[3px]
433 absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px]
434 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`}
435 {...widthHandle.handlers}
436 />
437 )}
438
439 {!props.preview && permissions.write && (
440 <div
441 className={`rotateHandle
442 cursor-grab shrink-0 z-10
443 hidden group-hover/canvas-block:block
444 w-[8px] h-[8px]
445 absolute bottom-0 -right-0
446 -translate-y-1/2 -translate-x-1/2
447 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`}
448 {...rotateHandle.handlers}
449 />
450 )}
451 </div>
452 );
453}
454
455export const CanvasBackground = (props: { entityID: string }) => {
456 let cardBackgroundImage = useEntity(
457 props.entityID,
458 "theme/card-background-image",
459 );
460 let cardBackgroundImageRepeat = useEntity(
461 props.entityID,
462 "theme/card-background-image-repeat",
463 );
464 let cardBackgroundImageOpacity =
465 useEntity(props.entityID, "theme/card-background-image-opacity")?.data
466 .value || 1;
467
468 let canvasPattern =
469 useEntity(props.entityID, "canvas/background-pattern")?.data.value ||
470 "grid";
471 return (
472 <div
473 className="w-full h-full pointer-events-none"
474 style={{
475 backgroundImage: cardBackgroundImage
476 ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})`
477 : undefined,
478 backgroundRepeat: "repeat",
479 backgroundPosition: "center",
480 backgroundSize: cardBackgroundImageRepeat?.data.value || 500,
481 opacity: cardBackgroundImage?.data.src ? cardBackgroundImageOpacity : 1,
482 }}
483 >
484 <CanvasBackgroundPattern pattern={canvasPattern} />
485 </div>
486 );
487};
488
489export const CanvasBackgroundPattern = (props: {
490 pattern: "grid" | "dot" | "plain";
491 scale?: number;
492}) => {
493 if (props.pattern === "plain") return null;
494 let patternID = `canvasPattern-${props.pattern}-${props.scale}`;
495 if (props.pattern === "grid")
496 return (
497 <svg
498 width="100%"
499 height="100%"
500 xmlns="http://www.w3.org/2000/svg"
501 className="pointer-events-none text-border-light"
502 >
503 <defs>
504 <pattern
505 id={patternID}
506 x="0"
507 y="0"
508 width={props.scale ? 32 * props.scale : 32}
509 height={props.scale ? 32 * props.scale : 32}
510 viewBox={`${props.scale ? 16 * props.scale : 0} ${props.scale ? 16 * props.scale : 0} ${props.scale ? 32 * props.scale : 32} ${props.scale ? 32 * props.scale : 32}`}
511 patternUnits="userSpaceOnUse"
512 >
513 <path
514 fillRule="evenodd"
515 clipRule="evenodd"
516 d="M16.5 0H15.5L15.5 2.06061C15.5 2.33675 15.7239 2.56061 16 2.56061C16.2761 2.56061 16.5 2.33675 16.5 2.06061V0ZM0 16.5V15.5L2.06061 15.5C2.33675 15.5 2.56061 15.7239 2.56061 16C2.56061 16.2761 2.33675 16.5 2.06061 16.5L0 16.5ZM16.5 32H15.5V29.9394C15.5 29.6633 15.7239 29.4394 16 29.4394C16.2761 29.4394 16.5 29.6633 16.5 29.9394V32ZM32 15.5V16.5L29.9394 16.5C29.6633 16.5 29.4394 16.2761 29.4394 16C29.4394 15.7239 29.6633 15.5 29.9394 15.5H32ZM5.4394 16C5.4394 15.7239 5.66325 15.5 5.93939 15.5H10.0606C10.3367 15.5 10.5606 15.7239 10.5606 16C10.5606 16.2761 10.3368 16.5 10.0606 16.5H5.9394C5.66325 16.5 5.4394 16.2761 5.4394 16ZM13.4394 16C13.4394 15.7239 13.6633 15.5 13.9394 15.5H15.5V13.9394C15.5 13.6633 15.7239 13.4394 16 13.4394C16.2761 13.4394 16.5 13.6633 16.5 13.9394V15.5H18.0606C18.3367 15.5 18.5606 15.7239 18.5606 16C18.5606 16.2761 18.3367 16.5 18.0606 16.5H16.5V18.0606C16.5 18.3367 16.2761 18.5606 16 18.5606C15.7239 18.5606 15.5 18.3367 15.5 18.0606V16.5H13.9394C13.6633 16.5 13.4394 16.2761 13.4394 16ZM21.4394 16C21.4394 15.7239 21.6633 15.5 21.9394 15.5H26.0606C26.3367 15.5 26.5606 15.7239 26.5606 16C26.5606 16.2761 26.3367 16.5 26.0606 16.5H21.9394C21.6633 16.5 21.4394 16.2761 21.4394 16ZM16 5.4394C16.2761 5.4394 16.5 5.66325 16.5 5.93939V10.0606C16.5 10.3367 16.2761 10.5606 16 10.5606C15.7239 10.5606 15.5 10.3368 15.5 10.0606V5.9394C15.5 5.66325 15.7239 5.4394 16 5.4394ZM16 21.4394C16.2761 21.4394 16.5 21.6633 16.5 21.9394V26.0606C16.5 26.3367 16.2761 26.5606 16 26.5606C15.7239 26.5606 15.5 26.3367 15.5 26.0606V21.9394C15.5 21.6633 15.7239 21.4394 16 21.4394Z"
517 fill="currentColor"
518 />
519 </pattern>
520 </defs>
521 <rect
522 width="100%"
523 height="100%"
524 x="0"
525 y="0"
526 fill={`url(#${patternID})`}
527 />
528 </svg>
529 );
530
531 if (props.pattern === "dot") {
532 return (
533 <svg
534 width="100%"
535 height="100%"
536 xmlns="http://www.w3.org/2000/svg"
537 className={`pointer-events-none text-border`}
538 >
539 <defs>
540 <pattern
541 id={patternID}
542 x="0"
543 y="0"
544 width={props.scale ? 24 * props.scale : 24}
545 height={props.scale ? 24 * props.scale : 24}
546 patternUnits="userSpaceOnUse"
547 >
548 <circle
549 cx={props.scale ? 12 * props.scale : 12}
550 cy={props.scale ? 12 * props.scale : 12}
551 r="1"
552 fill="currentColor"
553 />
554 </pattern>
555 </defs>
556 <rect
557 width="100%"
558 height="100%"
559 x="0"
560 y="0"
561 fill={`url(#${patternID})`}
562 />
563 </svg>
564 );
565 }
566};
567
568const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => {
569 return (
570 <div
571 onMouseDown={props.onMouseDown}
572 onPointerDown={props.onMouseDown}
573 className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none"
574 >
575 <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 ">
576 {/* the gripper is two svg's stacked on top of each other.
577 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */}
578 <div
579 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block"
580 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }}
581 />
582 <div
583 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block"
584 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }}
585 />
586 </Media>
587 </div>
588 );
589};
590
591type P = { x: number; y: number };
592function find_angle(P2: P, P1: P, P3: P) {
593 if (P1.x === P3.x && P1.y === P3.y) return 0;
594 let a = Math.atan2(P3.y - P1.y, P3.x - P1.x);
595 let b = Math.atan2(P2.y - P1.y, P2.x - P1.x);
596 return a - b;
597}