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