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