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;
173
174 return (
175 <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">
176 {showComments && (
177 <div className="flex gap-1 text-tertiary items-center">
178 <CommentTiny className="text-border" /> —
179 </div>
180 )}
181 <div className="flex gap-1 text-tertiary items-center">
182 <QuoteTiny className="text-border" /> —
183 </div>
184
185 {!props.isSubpage && (
186 <>
187 <Separator classname="h-5" />
188 <Popover
189 side="left"
190 align="start"
191 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]"
192 trigger={<InfoSmall />}
193 >
194 <PublicationMetadata />
195 </Popover>
196 </>
197 )}
198 </div>
199 );
200};
201
202const AddCanvasBlockButton = (props: {
203 entityID: string;
204 entity_set: { set: string };
205}) => {
206 let { rep } = useReplicache();
207 let { permissions } = useEntitySetContext();
208 let blocks = useEntity(props.entityID, "canvas/block");
209
210 if (!permissions.write) return null;
211 return (
212 <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">
213 <TooltipButton
214 side="left"
215 open={blocks.length === 0 ? true : undefined}
216 tooltipContent={
217 <div className="flex flex-col justify-end text-center px-1 leading-snug ">
218 <div>Add a Block!</div>
219 <div className="font-normal">or double click anywhere</div>
220 </div>
221 }
222 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"
223 onMouseDown={() => {
224 let page = document.getElementById(
225 elementId.page(props.entityID).canvasScrollArea,
226 );
227 if (!page) return;
228 let newEntityID = v7();
229 rep?.mutate.addCanvasBlock({
230 newEntityID,
231 parent: props.entityID,
232 position: {
233 x: page?.clientWidth + page?.scrollLeft - 468,
234 y: 32 + page.scrollTop,
235 },
236 factID: v7(),
237 type: "text",
238 permission_set: props.entity_set.set,
239 });
240 setTimeout(() => {
241 focusBlock(
242 { type: "text", value: newEntityID, parent: props.entityID },
243 { type: "start" },
244 );
245 }, 20);
246 }}
247 >
248 <AddSmall />
249 </TooltipButton>
250 </div>
251 );
252};
253
254function CanvasBlock(props: {
255 preview?: boolean;
256 entityID: string;
257 parent: string;
258 position: { x: number; y: number };
259 factID: string;
260}) {
261 let width =
262 useEntity(props.entityID, "canvas/block/width")?.data.value || 360;
263 let rotation =
264 useEntity(props.entityID, "canvas/block/rotation")?.data.value || 0;
265 let [ref, rect] = useMeasure();
266 let type = useEntity(props.entityID, "block/type");
267 let { rep } = useReplicache();
268 let isMobile = useIsMobile();
269
270 let { permissions } = useEntitySetContext();
271 let onDragEnd = useCallback(
272 (dragPosition: { x: number; y: number }) => {
273 if (!permissions.write) return;
274 rep?.mutate.assertFact({
275 id: props.factID,
276 entity: props.parent,
277 attribute: "canvas/block",
278 data: {
279 type: "spatial-reference",
280 value: props.entityID,
281 position: {
282 x: props.position.x + dragPosition.x,
283 y: props.position.y + dragPosition.y,
284 },
285 },
286 });
287 },
288 [props, rep, permissions],
289 );
290 let { dragDelta, handlers } = useDrag({
291 onDragEnd,
292 delay: isMobile,
293 });
294
295 let widthOnDragEnd = useCallback(
296 (dragPosition: { x: number; y: number }) => {
297 rep?.mutate.assertFact({
298 entity: props.entityID,
299 attribute: "canvas/block/width",
300 data: {
301 type: "number",
302 value: width + dragPosition.x,
303 },
304 });
305 },
306 [props, rep, width],
307 );
308 let widthHandle = useDrag({ onDragEnd: widthOnDragEnd });
309
310 let RotateOnDragEnd = useCallback(
311 (dragDelta: { x: number; y: number }) => {
312 let originX = rect.x + rect.width / 2;
313 let originY = rect.y + rect.height / 2;
314
315 let angle =
316 find_angle(
317 { x: rect.x + rect.width, y: rect.y + rect.height },
318 { x: originX, y: originY },
319 {
320 x: rect.x + rect.width + dragDelta.x,
321 y: rect.y + rect.height + dragDelta.y,
322 },
323 ) *
324 (180 / Math.PI);
325
326 rep?.mutate.assertFact({
327 entity: props.entityID,
328 attribute: "canvas/block/rotation",
329 data: {
330 type: "number",
331 value: (rotation + angle) % 360,
332 },
333 });
334 },
335 [props, rep, rect, rotation],
336 );
337 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd });
338
339 let { isLongPress, handlers: longPressHandlers } = useLongPress(() => {
340 if (isLongPress.current && permissions.write) {
341 focusBlock(
342 {
343 type: type?.data.value || "text",
344 value: props.entityID,
345 parent: props.parent,
346 },
347 { type: "start" },
348 );
349 }
350 });
351 let angle = 0;
352 if (rotateHandle.dragDelta) {
353 let originX = rect.x + rect.width / 2;
354 let originY = rect.y + rect.height / 2;
355
356 angle =
357 find_angle(
358 { x: rect.x + rect.width, y: rect.y + rect.height },
359 { x: originX, y: originY },
360 {
361 x: rect.x + rect.width + rotateHandle.dragDelta.x,
362 y: rect.y + rect.height + rotateHandle.dragDelta.y,
363 },
364 ) *
365 (180 / Math.PI);
366 }
367 let x = props.position.x + (dragDelta?.x || 0);
368 let y = props.position.y + (dragDelta?.y || 0);
369 let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`;
370 let [areYouSure, setAreYouSure] = useState(false);
371 let blockProps = useMemo(() => {
372 return {
373 pageType: "canvas" as const,
374 preview: props.preview,
375 type: type?.data.value || "text",
376 value: props.entityID,
377 factID: props.factID,
378 position: "",
379 nextPosition: "",
380 entityID: props.entityID,
381 parent: props.parent,
382 nextBlock: null,
383 previousBlock: null,
384 };
385 }, [props, type?.data.value]);
386 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure);
387 let isList = useEntity(props.entityID, "block/is-list");
388 let isFocused = useUIState(
389 (s) => s.focusedEntity?.entityID === props.entityID,
390 );
391
392 return (
393 <div
394 ref={ref}
395 {...(!props.preview ? { ...longPressHandlers } : {})}
396 {...(isMobile && permissions.write ? { ...handlers } : {})}
397 id={props.preview ? undefined : elementId.block(props.entityID).container}
398 className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `}
399 style={{
400 top: 0,
401 left: 0,
402 zIndex: dragDelta || isFocused ? 10 : undefined,
403 width: width + (widthHandle.dragDelta?.x || 0),
404 transform,
405 }}
406 >
407 {/* the gripper show on hover, but longpress logic needs to be added for mobile*/}
408 {!props.preview && permissions.write && <Gripper {...handlers} />}
409 <div
410 className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `}
411 >
412 <BaseBlock
413 {...blockProps}
414 listData={
415 isList?.data.value
416 ? { path: [], parent: props.parent, depth: 1 }
417 : undefined
418 }
419 areYouSure={areYouSure}
420 setAreYouSure={setAreYouSure}
421 />
422 </div>
423
424 {!props.preview && permissions.write && (
425 <div
426 className={`resizeHandle
427 cursor-e-resize shrink-0 z-10
428 hidden group-hover/canvas-block:block
429 w-[5px] h-6 -ml-[3px]
430 absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px]
431 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`}
432 {...widthHandle.handlers}
433 />
434 )}
435
436 {!props.preview && permissions.write && (
437 <div
438 className={`rotateHandle
439 cursor-grab shrink-0 z-10
440 hidden group-hover/canvas-block:block
441 w-[8px] h-[8px]
442 absolute bottom-0 -right-0
443 -translate-y-1/2 -translate-x-1/2
444 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`}
445 {...rotateHandle.handlers}
446 />
447 )}
448 </div>
449 );
450}
451
452export const CanvasBackground = (props: { entityID: string }) => {
453 let cardBackgroundImage = useEntity(
454 props.entityID,
455 "theme/card-background-image",
456 );
457 let cardBackgroundImageRepeat = useEntity(
458 props.entityID,
459 "theme/card-background-image-repeat",
460 );
461 let cardBackgroundImageOpacity =
462 useEntity(props.entityID, "theme/card-background-image-opacity")?.data
463 .value || 1;
464
465 let canvasPattern =
466 useEntity(props.entityID, "canvas/background-pattern")?.data.value ||
467 "grid";
468 return (
469 <div
470 className="w-full h-full pointer-events-none"
471 style={{
472 backgroundImage: cardBackgroundImage
473 ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})`
474 : undefined,
475 backgroundRepeat: "repeat",
476 backgroundPosition: "center",
477 backgroundSize: cardBackgroundImageRepeat?.data.value || 500,
478 opacity: cardBackgroundImage?.data.src ? cardBackgroundImageOpacity : 1,
479 }}
480 >
481 <CanvasBackgroundPattern pattern={canvasPattern} />
482 </div>
483 );
484};
485
486export const CanvasBackgroundPattern = (props: {
487 pattern: "grid" | "dot" | "plain";
488 scale?: number;
489}) => {
490 if (props.pattern === "plain") return null;
491 let patternID = `canvasPattern-${props.pattern}-${props.scale}`;
492 if (props.pattern === "grid")
493 return (
494 <svg
495 width="100%"
496 height="100%"
497 xmlns="http://www.w3.org/2000/svg"
498 className="pointer-events-none text-border-light"
499 >
500 <defs>
501 <pattern
502 id={patternID}
503 x="0"
504 y="0"
505 width={props.scale ? 32 * props.scale : 32}
506 height={props.scale ? 32 * props.scale : 32}
507 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}`}
508 patternUnits="userSpaceOnUse"
509 >
510 <path
511 fillRule="evenodd"
512 clipRule="evenodd"
513 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"
514 fill="currentColor"
515 />
516 </pattern>
517 </defs>
518 <rect
519 width="100%"
520 height="100%"
521 x="0"
522 y="0"
523 fill={`url(#${patternID})`}
524 />
525 </svg>
526 );
527
528 if (props.pattern === "dot") {
529 return (
530 <svg
531 width="100%"
532 height="100%"
533 xmlns="http://www.w3.org/2000/svg"
534 className={`pointer-events-none text-border`}
535 >
536 <defs>
537 <pattern
538 id={patternID}
539 x="0"
540 y="0"
541 width={props.scale ? 24 * props.scale : 24}
542 height={props.scale ? 24 * props.scale : 24}
543 patternUnits="userSpaceOnUse"
544 >
545 <circle
546 cx={props.scale ? 12 * props.scale : 12}
547 cy={props.scale ? 12 * props.scale : 12}
548 r="1"
549 fill="currentColor"
550 />
551 </pattern>
552 </defs>
553 <rect
554 width="100%"
555 height="100%"
556 x="0"
557 y="0"
558 fill={`url(#${patternID})`}
559 />
560 </svg>
561 );
562 }
563};
564
565const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => {
566 return (
567 <div
568 onMouseDown={props.onMouseDown}
569 onPointerDown={props.onMouseDown}
570 className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none"
571 >
572 <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 ">
573 {/* the gripper is two svg's stacked on top of each other.
574 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */}
575 <div
576 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block"
577 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }}
578 />
579 <div
580 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block"
581 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }}
582 />
583 </Media>
584 </div>
585 );
586};
587
588type P = { x: number; y: number };
589function find_angle(P2: P, P1: P, P3: P) {
590 if (P1.x === P3.x && P1.y === P3.y) return 0;
591 let a = Math.atan2(P3.y - P1.y, P3.x - P1.x);
592 let b = Math.atan2(P2.y - P1.y, P2.x - P1.x);
593 return a - b;
594}