From bc43f2532d3434c31a8083ab273a9aa045b997b3 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 13 Sep 2024 16:49:58 -0400 Subject: [PATCH] Feature/canvas (#61) * test canvas * add super basic canvas page type * full width canvas page previews * oops add back add block button * make add block button cute * add gripper to the canvas block * add basic resizing * styled resize handle! * make backspace to delete a block work on canvases and if you're the first block on a doc * handle long press to select on canvas blocks * handle spatial referencees in more places * whoops didnt actually push anything last time? * make enter in textblocks sorta work! * make canvas height minheight * don't show move block buttons on canvas * deselect blocks if you click canvas bg * fix canvas previews on home and bg in blocks * share styles between canvas and doc page preview blocks * hide grippers on preview * add rotation to canvas elements * add gripper bg * sort canvas items to always visible * make canvas bg theme based * create new canvas item next to new item button * add pagetype doc to blocks component * replaced block options with block cmd bar * add z-index to item when dragging * tweaked gripper styles * basic slash command stuff * highlight first item of commands * added canvas block icon, renamed doc page block icon, little style tweaks to the cmd menu * translate then rotate on cnavas * styled empty states for link and image blocks, added some keyboard stuff and selected styling to them * fixed overflowing issue with externalLinkBlocks if the title is very long with no word breaks * removed a lil weirdness in the svg for canvas icon * tweak scroll behavior * added a switcher toggle to pages. TODO, only show switcher if page is empty * lil positioning fixes cause i borked up the stickiness of the toolbar * idk man some notion of long pressing on mobile does stuff * fix some hydration issues! * don't show page toggle if page isn't empty * double/ctrl/cmd click to add a card to canvas * added canvas resizing * fixed issue with block toolbar not closing * add lil command button to text blocks * fix positioning on enter for textblocks * add are u sure state to canvas blocks * added some padding to the drag handle, added padding to whole block (for better hovering * added some styling to the / button * tweaked empty state langauge * persist canvas width toggle * add migration to update all facts to support spatial ref --------- Co-authored-by: celine --- app/canvastest/page.tsx | 106 +++++ app/globals.css | 3 + app/home/LeafletPreview.tsx | 23 +- components/Blocks/Block.tsx | 4 +- components/Blocks/BlockCommandBar.tsx | 176 +++++++ components/Blocks/BlockCommands.tsx | 231 +++++++++ components/Blocks/BlockOptions.tsx | 7 +- components/Blocks/ExternalLinkBlock.tsx | 138 +++++- components/Blocks/ImageBlock.tsx | 77 ++- components/Blocks/PageLinkBlock.tsx | 72 ++- components/Blocks/TextBlock/index.tsx | 54 ++- components/Blocks/TextBlock/keymap.ts | 51 +- components/Blocks/index.tsx | 28 +- components/Blocks/useBlockKeyboardHandlers.ts | 28 +- components/Buttons.tsx | 51 ++ components/Canvas.tsx | 450 ++++++++++++++++++ components/DesktopFooter.tsx | 16 +- components/Icons.tsx | 84 +++- components/MobileFooter.tsx | 2 +- components/Pages.tsx | 147 ++++-- components/Toolbar/BlockToolbar.tsx | 244 +++++----- components/Toolbar/index.tsx | 15 +- public/gripperPattern.svg | 4 + public/gripperPattern2.svg | 4 + src/hooks/useDrag.ts | 101 ++++ src/hooks/useLongPress.ts | 54 ++- src/replicache/attributes.ts | 26 + src/replicache/mutations.ts | 36 ++ src/replicache/serverMutationContext.ts | 2 +- src/replicache/utils.ts | 19 +- src/utils/elementId.ts | 4 +- .../20240913204647_update_all_facts.sql | 24 + 32 files changed, 2033 insertions(+), 248 deletions(-) create mode 100644 app/canvastest/page.tsx create mode 100644 components/Blocks/BlockCommandBar.tsx create mode 100644 components/Blocks/BlockCommands.tsx create mode 100644 components/Canvas.tsx create mode 100644 public/gripperPattern.svg create mode 100644 public/gripperPattern2.svg create mode 100644 src/hooks/useDrag.ts create mode 100644 supabase/migrations/20240913204647_update_all_facts.sql diff --git a/app/canvastest/page.tsx b/app/canvastest/page.tsx new file mode 100644 index 00000000..3489c7b6 --- /dev/null +++ b/app/canvastest/page.tsx @@ -0,0 +1,106 @@ +"use client"; +import { useEffect, useState } from "react"; + +// NOTES +// the canvas is min-w size of the page and max-w right most item until 1152px +// the page is responsive up to max-w of 1152px + +// canvas is wider than the page, pan + +export default function CanvasTest() { + let [height, setHeight] = useState(undefined); + + useEffect(() => { + setHeight(document.getElementById("canvasContent")?.scrollHeight); + }, []); + + return ( + + ); +} + +const CanvasBlock = (props: { + w: number; + h: number; + x: number; + y: number; + r: number; +}) => { + return ( +
+ hi +
+ ); +}; + +const CanvasBackground = (props: { color: string }) => { + return ( + + + + + + + + + ); +}; diff --git a/app/globals.css b/app/globals.css index 35415df8..69c4e4dc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -21,6 +21,9 @@ --list-marker-width: 36px; --page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12)); --page-width-units: min(624px, calc(100vw - 12px)); + + --gripperSVG: url("/gripperPattern.svg"); + --gripperSVG2: url("/gripperPattern2.svg"); } @media (max-width: 640px) { :root { diff --git a/app/home/LeafletPreview.tsx b/app/home/LeafletPreview.tsx index 3b889074..30e95995 100644 --- a/app/home/LeafletPreview.tsx +++ b/app/home/LeafletPreview.tsx @@ -7,13 +7,14 @@ import { import { useRef, useState } from "react"; import { Link } from "react-aria-components"; import { useBlocks } from "src/hooks/queries/useBlocks"; -import { PermissionToken } from "src/replicache"; +import { PermissionToken, useEntity } from "src/replicache"; import { deleteLeaflet } from "actions/deleteLeaflet"; import { removeDocFromHome } from "./storage"; import { mutate } from "swr"; import useMeasure from "react-use-measure"; import { ButtonPrimary } from "components/Buttons"; import { LeafletOptions } from "./LeafletOptions"; +import { CanvasContent } from "components/Canvas"; export const LeafletPreview = (props: { token: PermissionToken; @@ -56,10 +57,29 @@ export const LeafletPreview = (props: { }; const LeafletContent = (props: { entityID: string }) => { + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; let blocks = useBlocks(props.entityID); let previewRef = useRef(null); let [ref, dimensions] = useMeasure(); + if (type === "canvas") + return ( +
+
+ +
+
+ ); + return (
{ {blocks.slice(0, 10).map((b, index, arr) => { return ( ["data"]["value"]; entityID: string; parent: string; position: string; @@ -48,9 +49,10 @@ export function Block(props: BlockProps & { preview?: boolean }) { // focus block on longpress, shouldnt the type be based on the block type (?) let { isLongPress, handlers } = useLongPress(() => { + console.log("wat"); if (isLongPress.current) { focusBlock( - { type: "card", value: props.entityID, parent: props.parent }, + { type: props.type, value: props.entityID, parent: props.parent }, { type: "start" }, ); } diff --git a/components/Blocks/BlockCommandBar.tsx b/components/Blocks/BlockCommandBar.tsx new file mode 100644 index 00000000..5e320ee5 --- /dev/null +++ b/components/Blocks/BlockCommandBar.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { blockCommands } from "./BlockCommands"; +import { useReplicache } from "src/replicache"; +import { useEntitySetContext } from "components/EntitySetProvider"; + +type Props = { + parent: string; + entityID: string | null; + position: string | null; + nextPosition: string | null; + factID?: string | undefined; + first?: boolean; + className?: string; +}; + +export const BlockCommandBar = ({ + props, + searchValue, +}: { + props: Props; + searchValue: string; +}) => { + let ref = useRef(null); + + let [highlighted, setHighlighted] = useState(undefined); + + let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); + + let commandResults = blockCommands.filter((command) => + command.name.toLocaleLowerCase().includes(searchValue.toLocaleLowerCase()), + ); + useEffect(() => { + if ( + !highlighted || + !commandResults.find((result) => result.name === highlighted) + ) + setHighlighted(commandResults[0]?.name); + if (commandResults.length === 1) { + setHighlighted(commandResults[0].name); + } + }, [commandResults, setHighlighted, highlighted]); + useEffect(() => { + let listener = async (e: KeyboardEvent) => { + let input = document.getElementById("block-search"); + let reverseDir = ref.current?.dataset.side === "top"; + let currentHighlightIndex = commandResults.findIndex( + (command: { name: string }) => + highlighted && command.name === highlighted, + ); + + if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") { + setHighlighted( + commandResults[ + currentHighlightIndex === commandResults.length - 1 || + currentHighlightIndex === undefined + ? 0 + : currentHighlightIndex + 1 + ].name, + ); + return; + } + if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") { + setHighlighted( + commandResults[ + currentHighlightIndex === 0 || + currentHighlightIndex === undefined || + currentHighlightIndex === -1 + ? commandResults.length - 1 + : currentHighlightIndex - 1 + ].name, + ); + return; + } + + // on enter, select the highlighted item + if (e.key === "Enter") { + e.preventDefault(); + rep && + commandResults[currentHighlightIndex]?.onSelect(rep, { + ...props, + entity_set: entity_set.set, + }); + return; + } + + // radix menu component handles esc + if (e.key === "Escape") return; + + // any keypress that is not up down, left right, enter, esc, space focuses the search + if (input) { + input.focus(); + } + }; + window.addEventListener("keydown", listener); + + return () => window.removeEventListener("keydown", listener); + }, [highlighted, setHighlighted, commandResults, rep, entity_set.set, props]); + + return ( + + + + e.preventDefault()} + className={`commandMenuContent group/cmd-menu z-20 h-[292px] w-[264px] flex data-[side=top]:items-end items-start`} + > +
+ {commandResults.length === 0 ? ( +
+ No blocks found +
+ ) : ( + commandResults.map((result, index) => ( + <> + { + rep && + result.onSelect(rep, { + ...props, + entity_set: entity_set.set, + }); + }} + highlighted={highlighted} + setHighlighted={(highlighted) => + setHighlighted(highlighted) + } + /> + {commandResults[index + 1] && + result.type !== commandResults[index + 1].type && ( +
+ )} + + )) + )} +
+
+
+
+ ); +}; + +const CommandResult = (props: { + name: string; + icon: React.ReactNode; + onSelect: () => void; + highlighted: string | undefined; + setHighlighted: (state: string | undefined) => void; +}) => { + let isHighlighted = props.highlighted === props.name; + + return ( + + ); +}; diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx new file mode 100644 index 00000000..676f27b4 --- /dev/null +++ b/components/Blocks/BlockCommands.tsx @@ -0,0 +1,231 @@ +import { Fact, ReplicacheMutators } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { + BlockDocPageSmall, + BlockCanvasPageSmall, + BlockImageSmall, + BlockLinkSmall, + Header1Small, + Header2Small, + Header3Small, + MailboxSmall, + ParagraphSmall, +} from "components/Icons"; +import { generateKeyBetween } from "fractional-indexing"; +import { focusPage } from "components/Pages"; +import { v7 } from "uuid"; +import { Replicache } from "replicache"; +import { keepFocus } from "components/Toolbar/TextBlockTypeToolbar"; +import { useEditorStates } from "src/state/useEditorState"; +import { elementId } from "src/utils/elementId"; + +type Props = { + parent: string; + entityID: string | null; + position: string | null; + nextPosition: string | null; + factID?: string | undefined; + first?: boolean; + className?: string; +}; + +async function createBlockWithType( + rep: Replicache, + args: { + entity_set: string; + parent: string; + position: string | null; + nextPosition: string | null; + entityID: string | null; + }, + type: Fact<"block/type">["data"]["value"], +) { + let entity; + + if (!args.entityID) { + entity = v7(); + await rep?.mutate.addBlock({ + parent: args.parent, + factID: v7(), + permission_set: args.entity_set, + type: type, + position: generateKeyBetween(args.position, args.nextPosition), + newEntityID: entity, + }); + } else { + entity = args.entityID; + await rep?.mutate.assertFact({ + entity, + attribute: "block/type", + data: { type: "block-type-union", value: type }, + }); + } + return entity; +} + +function clearCommandSearchText(entityID: string) { + useEditorStates.setState((s) => { + let existingState = s.editorStates[entityID]; + if (!existingState) { + console.log("no existing state???"); + return s; + } + + let tr = existingState.editor.tr; + console.log("deleting!"); + tr.deleteRange(1, tr.doc.content.size - 1); + return { + editorStates: { + ...s.editorStates, + [entityID]: { + ...existingState, + editor: existingState.editor.apply(tr), + }, + }, + }; + }); +} + +type Command = { + name: string; + icon: React.ReactNode; + type: string; + onSelect: ( + rep: Replicache, + props: Props & { entity_set: string }, + ) => void; +}; +export const blockCommands: Command[] = [ + // please keep these in the order that they appear in the menu, grouped by type + { + name: "Text", + icon: , + type: "text", + onSelect: async (rep, props) => { + props.entityID && clearCommandSearchText(props.entityID); + let entity = await createBlockWithType(rep, props, "text"); + clearCommandSearchText(entity); + keepFocus(entity); + }, + }, + { + name: "Title", + icon: , + type: "text", + onSelect: async (rep, props) => { + props.entityID && clearCommandSearchText(props.entityID); + let entity = await createBlockWithType(rep, props, "heading"); + await rep.mutate.assertFact({ + entity, + attribute: "block/heading-level", + data: { type: "number", value: 1 }, + }); + + keepFocus(entity); + }, + }, + { + name: "Header", + icon: , + type: "text", + onSelect: async (rep, props) => { + props.entityID && clearCommandSearchText(props.entityID); + let entity = await createBlockWithType(rep, props, "heading"); + rep.mutate.assertFact({ + entity, + attribute: "block/heading-level", + data: { type: "number", value: 2 }, + }); + clearCommandSearchText(entity); + keepFocus(entity); + }, + }, + { + name: "Subheader", + icon: , + type: "text", + onSelect: async (rep, props) => { + props.entityID && clearCommandSearchText(props.entityID); + let entity = await createBlockWithType(rep, props, "heading"); + rep.mutate.assertFact({ + entity, + attribute: "block/heading-level", + data: { type: "number", value: 3 }, + }); + clearCommandSearchText(entity); + keepFocus(entity); + }, + }, + + { + name: "External Link", + icon: , + type: "block", + onSelect: async (rep, props) => { + createBlockWithType(rep, props, "link"); + }, + }, + { + name: "Image", + icon: , + type: "block", + onSelect: async (rep, props) => { + let entity = await createBlockWithType(rep, props, "image"); + setTimeout(() => { + let el = document.getElementById(elementId.block(entity).input); + console.log(el); + el?.focus(); + }, 100); + }, + }, + { + name: "Mailbox", + icon: , + type: "block", + onSelect: async (rep, props) => { + let entity; + createBlockWithType(rep, props, "mailbox"); + }, + }, + + { + name: "New Page", + icon: , + type: "page", + onSelect: async (rep, props) => { + let entity = await createBlockWithType(rep, props, "card"); + + let newPage = v7(); + await rep?.mutate.addPageLinkBlock({ + blockEntity: entity, + firstBlockFactID: v7(), + firstBlockEntity: v7(), + pageEntity: newPage, + type: "doc", + permission_set: props.entity_set, + }); + useUIState.getState().openPage(props.parent, newPage); + focusPage(newPage, rep, "focusFirstBlock"); + }, + }, + { + name: "New Canvas", + icon: , + type: "page", + onSelect: async (rep, props) => { + let entity = await createBlockWithType(rep, props, "card"); + + let newPage = v7(); + await rep?.mutate.addPageLinkBlock({ + type: "canvas", + blockEntity: entity, + firstBlockFactID: v7(), + firstBlockEntity: v7(), + pageEntity: newPage, + permission_set: props.entity_set, + }); + useUIState.getState().openPage(props.parent, newPage); + focusPage(newPage, rep, "focusFirstBlock"); + }, + }, +]; diff --git a/components/Blocks/BlockOptions.tsx b/components/Blocks/BlockOptions.tsx index a39e5bf7..12998972 100644 --- a/components/Blocks/BlockOptions.tsx +++ b/components/Blocks/BlockOptions.tsx @@ -1,7 +1,7 @@ import { useEntity, useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; import { - BlockPageLinkSmall, + BlockDocPageSmall, BlockImageSmall, BlockLinkSmall, CheckTiny, @@ -34,7 +34,7 @@ import { useSmoker, useToaster } from "components/Toast"; type Props = { parent: string; - entityID: string | null; + entityID: string; position: string | null; nextPosition: string | null; factID?: string | undefined; @@ -139,6 +139,7 @@ export function BlockOptions(props: Props) { } let newPage = v7(); await rep?.mutate.addPageLinkBlock({ + type: "doc", blockEntity: entity, firstBlockFactID: v7(), firstBlockEntity: v7(), @@ -149,7 +150,7 @@ export function BlockOptions(props: Props) { if (rep) focusPage(newPage, rep, "focusFirstBlock"); }} > - + { +export const ExternalLinkBlock = (props: BlockProps) => { let previewImage = useEntity(props.entityID, "link/preview"); let title = useEntity(props.entityID, "link/title"); let description = useEntity(props.entityID, "link/description"); @@ -10,6 +24,29 @@ export const ExternalLinkBlock = (props: { entityID: string }) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); + useEffect(() => { + let input = document.getElementById(elementId.block(props.entityID).input); + if (isSelected) { + input?.focus(); + } else input?.blur(); + }, [isSelected]); + + if (!url) { + return ( + + ); + } return ( {
{title?.data.value}
@@ -54,3 +96,95 @@ export const ExternalLinkBlock = (props: { entityID: string }) => {
); }; + +const BlockLinkInput = (props: BlockProps) => { + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + let entity_set = useEntitySetContext(); + let [linkValue, setLinkValue] = useState(""); + let { rep } = useReplicache(); + let submit = async () => { + let entity = props.entityID; + if (!entity) { + entity = v7(); + + await rep?.mutate.addBlock({ + permission_set: entity_set.set, + factID: v7(), + parent: props.parent, + type: "card", + position: generateKeyBetween(props.position, props.nextPosition), + newEntityID: entity, + }); + } + let link = linkValue; + if (!linkValue.startsWith("http")) link = `https://${linkValue}`; + addLinkBlock(link, entity, rep); + }; + let smoke = useSmoker(); + + return ( +
+ <> + + + setLinkValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Backspace" && linkValue === "") { + rep && deleteBlock([props.entityID].flat(), rep); + return; + } + if (e.key === "Enter") { + if (!linkValue) return; + if (!isUrl(linkValue)) { + let rect = e.currentTarget.getBoundingClientRect(); + smoke({ + error: true, + text: "invalid url!", + position: { x: rect.left, y: rect.top - 8 }, + }); + return; + } + submit(); + } + }} + /> +
+ +
+ +
+ ); +}; diff --git a/components/Blocks/ImageBlock.tsx b/components/Blocks/ImageBlock.tsx index a633f668..9193387e 100644 --- a/components/Blocks/ImageBlock.tsx +++ b/components/Blocks/ImageBlock.tsx @@ -1,15 +1,88 @@ "use client"; import { useEntity, useReplicache } from "src/replicache"; -import { Block } from "./Block"; +import { Block, BlockProps } from "./Block"; import { useUIState } from "src/useUIState"; +import { BlockImageSmall } from "components/Icons"; +import { v7 } from "uuid"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { generateKeyBetween } from "fractional-indexing"; +import { addImage } from "src/utils/addImage"; +import { elementId } from "src/utils/elementId"; +import { useEffect } from "react"; +import { deleteBlock } from "./DeleteBlock"; -export function ImageBlock(props: Block) { +export function ImageBlock(props: BlockProps) { let { rep } = useReplicache(); let image = useEntity(props.value, "block/image"); + let entity_set = useEntitySetContext(); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.value), ); + useEffect(() => { + let input = document.getElementById(elementId.block(props.entityID).input); + if (isSelected) { + input?.focus(); + } else { + input?.blur(); + } + }, [isSelected]); + + if (!image) { + return ( +
+ +
+ ); + } return (
diff --git a/components/Blocks/PageLinkBlock.tsx b/components/Blocks/PageLinkBlock.tsx index 6ae73d55..d7e7b743 100644 --- a/components/Blocks/PageLinkBlock.tsx +++ b/components/Blocks/PageLinkBlock.tsx @@ -9,25 +9,24 @@ import { RenderedTextBlock } from "components/Blocks/TextBlock"; import { usePageMetadata } from "src/hooks/queries/usePageMetadata"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useBlocks } from "src/hooks/queries/useBlocks"; +import { Canvas, CanvasBackground, CanvasContent } from "components/Canvas"; export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { - let { rep } = useReplicache(); let page = useEntity(props.entityID, "block/card"); - let pageEntity = page ? page.data.value : props.entityID; - let leafletMetadata = usePageMetadata(pageEntity); + let type = + useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; + let { rep } = useReplicache(); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); - let isOpen = useUIState((s) => s.openPages).includes(pageEntity); + let isOpen = useUIState((s) => s.openPages).includes(page?.data.value || ""); return (
{ + if (!page) return; + if (e.isDefaultPrevented()) return; + if (e.shiftKey) return; + e.preventDefault(); + e.stopPropagation(); + useUIState.getState().openPage(props.parent, page.data.value); + if (rep) focusPage(page.data.value, rep); + }} + > + {type === "canvas" && page ? ( + + ) : ( + + )} +
+ ); +} +export function DocLinkBlock(props: BlockProps & { preview?: boolean }) { + let { rep } = useReplicache(); + let page = useEntity(props.entityID, "block/card"); + let pageEntity = page ? page.data.value : props.entityID; + let leafletMetadata = usePageMetadata(pageEntity); + + return ( +
<>
{ if (e.isDefaultPrevented()) return; if (e.shiftKey) return; @@ -117,6 +146,7 @@ export function PagePreview(props: { entityID: string }) { {blocks.slice(0, 20).map((b, index, arr) => { return ( { + let pageWidth = `var(--page-width-unitless)`; + return ( +
+
+ {props.preview ? ( + + ) : ( + + )} +
+
+ ); +}; + export function BlockPreview( b: BlockProps & { previewRef: React.RefObject; - size?: "small" | "large"; }, ) { let ref = useRef(null); diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index 74754808..551d7cfb 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -31,7 +31,7 @@ import { useUIState } from "src/useUIState"; import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; import { useAppEventListener } from "src/eventBus"; import { addLinkBlock } from "src/utils/addLinkBlock"; -import { BlockOptions } from "components/Blocks/BlockOptions"; +import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { isIOS } from "@react-aria/utils"; import { useIsMobile } from "src/hooks/isMobile"; @@ -41,6 +41,7 @@ import { useEntitySetContext } from "components/EntitySetProvider"; import { useHandlePaste } from "./useHandlePaste"; import { highlightSelectionPlugin } from "./plugins"; import { inputrules } from "./inputRules"; +import { MoreOptionsTiny } from "components/Icons"; export function TextBlock( props: BlockProps & { className: string; preview?: boolean }, @@ -148,7 +149,6 @@ export function RenderedTextBlock(props: { export function BaseTextBlock(props: BlockProps & { className: string }) { const [mount, setMount] = useState(null); - let repRef = useRef>(null); let entity_set = useEntitySetContext(); let propsRef = useRef({ ...props, entity_set }); @@ -271,7 +271,7 @@ export function BaseTextBlock(props: BlockProps & { className: string }) { className={`${props.className} pointer-events-none absolute top-0 left-0 italic text-tertiary `} > {props.type === "text" - ? "write something..." + ? 'write something... or type "/"' : headingLevel?.data.value === 3 ? "Subheader" : headingLevel?.data.value === 2 @@ -280,14 +280,46 @@ export function BaseTextBlock(props: BlockProps & { className: string }) {
)} {/* if this is the block is empty and selected */} - {editorState.doc.textContent.length === 0 && selected && ( - { + e.preventDefault(); + console.log("yo!"); + let editor = + useEditorStates.getState().editorStates[props.entityID]; + + let editorState = editor?.editor; + if (editorState) { + editor?.view?.focus(); + let tr = editorState.tr.insertText("/", 1); + tr.setSelection(TextSelection.create(tr.doc, 2)); + console.log(tr); + useEditorStates.setState((s) => ({ + editorStates: { + ...s.editorStates, + [props.entityID]: { + ...s.editorStates[props.entityID]!, + editor: editorState!.apply(tr), + }, + }, + })); + } + }} + > +
/
+ + ) : null} + {editorState.doc.textContent.startsWith("/") && selected && ( + + )} + {editorState.doc.textContent.startsWith("/") && selected && ( + )}
diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts index 07656b88..6ee03e8b 100644 --- a/components/Blocks/TextBlock/keymap.ts +++ b/components/Blocks/TextBlock/keymap.ts @@ -17,6 +17,7 @@ import { v7 } from "uuid"; import { scanIndex } from "src/replicache/utils"; import { indent, outdent } from "src/utils/list-operations"; import { getBlocksWithType } from "src/hooks/queries/useBlocks"; +import { isTextBlock } from "src/utils/isTextBlock"; type PropsRef = MutableRefObject; export const TextBlockKeymap = ( @@ -101,6 +102,7 @@ export const TextBlockKeymap = ( }, ArrowUp: (state, _tr, view) => { if (!view) return false; + if (state.doc.textContent.startsWith("/")) return true; if (useUIState.getState().selectedBlocks.length > 1) return true; if (view.state.selection.from !== view.state.selection.to) return false; const viewClientRect = view.dom.getBoundingClientRect(); @@ -118,6 +120,7 @@ export const TextBlockKeymap = ( }, ArrowDown: (state, tr, view) => { if (!view) return true; + if (state.doc.textContent.startsWith("/")) return true; if (useUIState.getState().selectedBlocks.length > 1) return true; if (view.state.selection.from !== view.state.selection.to) return false; const viewClientRect = view.dom.getBoundingClientRect(); @@ -232,18 +235,21 @@ const backspace = ), 10, ); + + return false; } - return false; } - let block = - useEditorStates.getState().editorStates[ - propsRef.current.previousBlock.value - ]; + let block = !!propsRef.current.previousBlock + ? useEditorStates.getState().editorStates[ + propsRef.current.previousBlock.value + ] + : null; if ( block && + propsRef.current.previousBlock && block.editor.doc.textContent.length === 0 && - !propsRef.current.previousBlock.listData + !propsRef.current.previousBlock?.listData ) { repRef.current?.mutate.removeBlock({ blockEntity: propsRef.current.previousBlock.value, @@ -261,13 +267,16 @@ const backspace = return true; } - if (propsRef.current.previousBlock.type === "card") { + if ( + propsRef.current.previousBlock && + !isTextBlock[propsRef.current.previousBlock?.type] + ) { focusBlock(propsRef.current.previousBlock, { type: "end" }); view?.dom.blur(); return true; } - if (!block) return false; + if (!block || !propsRef.current.previousBlock) return false; repRef.current?.mutate.removeBlock({ blockEntity: propsRef.current.entityID, @@ -318,6 +327,7 @@ const enter = dispatch?: (tr: Transaction) => void, view?: EditorView, ) => { + if (state.doc.textContent.startsWith("/")) return true; let tr = state.tr; let newContent = tr.doc.slice(state.selection.anchor); tr.delete(state.selection.anchor, state.doc.content.size); @@ -330,6 +340,31 @@ const enter = propsRef.current.type === "heading" && state.selection.anchor <= 2 ? ("heading" as const) : ("text" as const); + if (propsRef.current.pageType === "canvas") { + let el = document.getElementById( + elementId.block(propsRef.current.entityID).container, + ); + let [position] = + (await repRef.current?.query((tx) => + scanIndex(tx).vae(propsRef.current.entityID, "canvas/block"), + )) || []; + if (!position || !el) return; + + let box = el.getBoundingClientRect(); + + await repRef.current?.mutate.addCanvasBlock({ + newEntityID, + factID: v7(), + permission_set: propsRef.current.entity_set.set, + parent: propsRef.current.parent, + type: blockType, + position: { + x: position.data.position.x, + y: position.data.position.y + box.height + 12, + }, + }); + return; + } if (propsRef.current.listData) { if (state.doc.content.size <= 2) { return shifttab(propsRef, repRef)(); diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index 94a2843c..525eb2ed 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -72,15 +72,15 @@ export function Blocks(props: { entityID: string }) { }, [blocks]); let lastRootBlock = blocks.findLast( - (f) => !f.listData || f.listData.depth === 1 + (f) => !f.listData || f.listData.depth === 1, ); let lastVisibleBlock = blocks.findLast( (f) => !f.listData || !f.listData.path.find( - (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity - ) + (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity, + ), ); return ( @@ -102,7 +102,7 @@ export function Blocks(props: { entityID: string }) { type: "text", position: generateKeyBetween( lastRootBlock?.position || null, - null + null, ), newEntityID, }); @@ -124,8 +124,8 @@ export function Blocks(props: { entityID: string }) { !f.listData || !f.listData.path.find( (path) => - foldedBlocks.includes(path.entity) && f.value !== path.entity - ) + foldedBlocks.includes(path.entity) && f.value !== path.entity, + ), ) .map((f, index, arr) => { let nextBlock = arr[index + 1]; @@ -140,6 +140,7 @@ export function Blocks(props: { entityID: string }) { } return ( props.lastBlock?.type === "text" ? s.editorStates[props.lastBlock.value] - : null + : null, ); if (!entity_set.permissions.write) return null; @@ -192,7 +193,7 @@ function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { permission_set: entity_set.set, position: generateKeyBetween( props.lastBlock?.position || null, - null + null, ), newEntityID, }); @@ -210,13 +211,6 @@ function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { " " )}
-
); } @@ -243,7 +237,7 @@ const BlockListBottom = (props: { ) { focusBlock( { ...props.lastVisibleBlock, type: "text" }, - { type: "end" } + { type: "end" }, ); } else { // else add a new text block at the end and focus it @@ -254,7 +248,7 @@ const BlockListBottom = (props: { type: "text", position: generateKeyBetween( props.lastRootBlock?.position || null, - null + null, ), newEntityID, }); diff --git a/components/Blocks/useBlockKeyboardHandlers.ts b/components/Blocks/useBlockKeyboardHandlers.ts index 76164950..de5a2dd2 100644 --- a/components/Blocks/useBlockKeyboardHandlers.ts +++ b/components/Blocks/useBlockKeyboardHandlers.ts @@ -95,6 +95,14 @@ function ArrowUp({ e, props }: Args) { async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { // if this is a textBlock, let the textBlock/keymap handle the backspace if (isTextBlock[props.type]) return; + let el = e.target as HTMLElement; + if ( + el.tagName === "LABEL" || + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.contentEditable === "true" + ) + return; // if the block is a card or mailbox... if (props.type === "card" || props.type === "mailbox") { @@ -107,15 +115,6 @@ async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { // and the user is not in an input or textarea, // if there is a page to close, close it and remove the block if (areYouSure) { - let el = e.target as HTMLElement; - - if ( - el.tagName === "INPUT" || - el.tagName === "textarea" || - el.contentEditable === "true" - ) - return; - return deleteBlock([props.entityID].flat(), rep); } } @@ -127,9 +126,18 @@ async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { if (prevBlock) focusBlock(prevBlock, { type: "end" }); } -async function Enter({ props, rep, entity_set }: Args) { +async function Enter({ e, props, rep, entity_set }: Args) { let newEntityID = v7(); let position; + let el = e.target as HTMLElement; + if ( + el.tagName === "LABEL" || + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.contentEditable === "true" + ) + return; + // if it's a list, create a new list item at the same depth if (props.listData) { let hasChild = diff --git a/components/Buttons.tsx b/components/Buttons.tsx index 00b8278b..b9e65691 100644 --- a/components/Buttons.tsx +++ b/components/Buttons.tsx @@ -1,4 +1,7 @@ import React from "react"; +import * as RadixTooltip from "@radix-ui/react-tooltip"; +import { theme } from "tailwind.config"; +import { PopoverArrow } from "./Icons"; type ButtonProps = Omit; export function ButtonPrimary( @@ -55,3 +58,51 @@ export const HoverButton = (props: {
); }; + +export const TooltipButton = (props: { + onMouseDown?: (e: React.MouseEvent) => void; + className?: string; + children: React.ReactNode; + content: React.ReactNode; + side?: "top" | "right" | "bottom" | "left" | undefined; +}) => { + return ( + // toolbar button does not control the highlight theme setter + // if toolbar button is updated, be sure to update there as well + + + { + e.preventDefault(); + props.onMouseDown && props.onMouseDown(e); + }} + > + {props.children} + + + + + {props.content} + + + + + + + + ); +}; diff --git a/components/Canvas.tsx b/components/Canvas.tsx new file mode 100644 index 00000000..b804d00b --- /dev/null +++ b/components/Canvas.tsx @@ -0,0 +1,450 @@ +import { useEntity, useReplicache } from "src/replicache"; +import { useEntitySetContext } from "./EntitySetProvider"; +import { v7 } from "uuid"; +import { BaseBlock, Block } from "./Blocks/Block"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AddBlockLarge, + AddSmall, + CanvasShrinkSmall, + CanvasWidenSmall, +} from "./Icons"; +import { useDrag } from "src/hooks/useDrag"; +import { useLongPress } from "src/hooks/useLongPress"; +import { focusBlock } from "src/utils/focusBlock"; +import { elementId } from "src/utils/elementId"; +import { useUIState } from "src/useUIState"; +import useMeasure from "react-use-measure"; +import { useIsMobile } from "src/hooks/isMobile"; +import { Media } from "./Media"; +import { TooltipButton } from "./Buttons"; +import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; + +export function Canvas(props: { entityID: string; preview?: boolean }) { + let entity_set = useEntitySetContext(); + let ref = useRef(null); + useEffect(() => { + let abort = new AbortController(); + let isTouch = false; + let startX: number, startY: number, scrollLeft: number, scrollTop: number; + let el = ref.current; + ref.current?.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + if (!el) return; + el.scrollLeft += e.deltaX; + el.scrollTop += e.deltaY; + }, + { passive: false, signal: abort.signal }, + ); + return () => abort.abort(); + }); + + let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data + .value; + + return ( +
+ + +
+ ); +} + +export function CanvasContent(props: { entityID: string; preview?: boolean }) { + let blocks = useEntity(props.entityID, "canvas/block"); + let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); + let [height, setHeight] = useState(undefined); + useEffect(() => { + setHeight( + document.getElementById(elementId.page(props.entityID).canvasScrollArea) + ?.scrollHeight, + ); + }, [blocks, props.entityID]); + return ( +
{ + if (e.currentTarget !== e.target) return; + useUIState.setState(() => ({ + selectedBlocks: [], + })); + if (e.detail === 2 || e.ctrlKey || e.metaKey) { + let parentRect = e.currentTarget.getBoundingClientRect(); + let newEntityID = v7(); + await rep?.mutate.addCanvasBlock({ + newEntityID, + parent: props.entityID, + position: { + x: Math.max(e.clientX - parentRect.left, 0), + y: Math.max(e.clientY - parentRect.top - 12, 0), + }, + factID: v7(), + type: "text", + permission_set: entity_set.set, + }); + focusBlock( + { type: "text", parent: props.entityID, value: newEntityID }, + { type: "start" }, + ); + } + }} + style={{ + minHeight: `calc(${height}px + 32px)`, + contain: "size layout paint", + }} + className="relative h-full w-[1272px]" + > + + {blocks + .sort((a, b) => { + if (a.data.position.y === b.data.position.y) { + return a.data.position.x - b.data.position.x; + } + return a.data.position.y - b.data.position.y; + }) + .map((b) => { + return ( + + ); + })} +
+ ); +} + +const AddCanvasBlockButton = (props: { + entityID: string; + entity_set: { set: string }; +}) => { + let { rep } = useReplicache(); + let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data + .value; + return ( +
+ +
Add a Block!
+
or double click anywhere
+
+ } + className="w-fit p-2 rounded-full bg-accent-1 border-2 outline outline-transparent hover:outline-1 hover:outline-accent-1 border-accent-1 text-accent-2" + onMouseDown={() => { + let page = document.getElementById( + elementId.page(props.entityID).canvasScrollArea, + ); + if (!page) return; + let newEntityID = v7(); + rep?.mutate.addCanvasBlock({ + newEntityID, + parent: props.entityID, + position: { + x: page?.clientWidth + page?.scrollLeft - 468, + y: 32 + page.scrollTop, + }, + factID: v7(), + type: "text", + permission_set: props.entity_set.set, + }); + setTimeout(() => { + focusBlock( + { type: "text", value: newEntityID, parent: props.entityID }, + { type: "start" }, + ); + }, 20); + }} + > + + + + { + rep?.mutate.assertFact({ + entity: props.entityID, + attribute: "canvas/narrow-width", + data: { type: "boolean", value: !narrowWidth }, + }); + }} + content={narrowWidth ? "Widen Canvas" : "Narrow Canvas"} + className="hidden sm:block bg-accent-2 border-2 outline outline-transparent hover:outline-1 hover:outline-accent-1 border-accent-1 text-accent-1 p-1 rounded-full w-fit mx-auto" + > + {narrowWidth ? : } + + + ); +}; + +function CanvasBlock(props: { + preview?: boolean; + entityID: string; + parent: string; + position: { x: number; y: number }; + factID: string; +}) { + let width = + useEntity(props.entityID, "canvas/block/width")?.data.value || 360; + let rotation = + useEntity(props.entityID, "canvas/block/rotation")?.data.value || 0; + let [ref, rect] = useMeasure(); + let type = useEntity(props.entityID, "block/type"); + let { rep } = useReplicache(); + let isMobile = useIsMobile(); + + let onDragEnd = useCallback( + (dragPosition: { x: number; y: number }) => { + rep?.mutate.assertFact({ + id: props.factID, + entity: props.parent, + attribute: "canvas/block", + data: { + type: "spatial-reference", + value: props.entityID, + position: { + x: props.position.x + dragPosition.x, + y: props.position.y + dragPosition.y, + }, + }, + }); + }, + [props, rep], + ); + let { dragDelta, handlers } = useDrag({ + onDragEnd, + delay: isMobile, + }); + + let widthOnDragEnd = useCallback( + (dragPosition: { x: number; y: number }) => { + console.log(dragPosition, rep); + rep?.mutate.assertFact({ + entity: props.entityID, + attribute: "canvas/block/width", + data: { + type: "number", + value: width + dragPosition.x, + }, + }); + }, + [props, rep, width], + ); + let widthHandle = useDrag({ onDragEnd: widthOnDragEnd }); + + let RotateOnDragEnd = useCallback( + (dragDelta: { x: number; y: number }) => { + let originX = rect.x + rect.width / 2; + let originY = rect.y + rect.height / 2; + + let angle = + find_angle( + { x: rect.x + rect.width, y: rect.y + rect.height }, + { x: originX, y: originY }, + { + x: rect.x + rect.width + dragDelta.x, + y: rect.y + rect.height + dragDelta.y, + }, + ) * + (180 / Math.PI); + + rep?.mutate.assertFact({ + entity: props.entityID, + attribute: "canvas/block/rotation", + data: { + type: "number", + value: (rotation + angle) % 360, + }, + }); + }, + [props, rep, rect, rotation], + ); + let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); + + let { isLongPress, handlers: longPressHandlers } = useLongPress( + () => { + if (isLongPress.current) { + focusBlock( + { + type: type?.data.value || "text", + value: props.entityID, + parent: props.parent, + }, + { type: "start" }, + ); + } + }, + () => {}, + ); + let angle = 0; + if (rotateHandle.dragDelta) { + let originX = rect.x + rect.width / 2; + let originY = rect.y + rect.height / 2; + + angle = + find_angle( + { x: rect.x + rect.width, y: rect.y + rect.height }, + { x: originX, y: originY }, + { + x: rect.x + rect.width + rotateHandle.dragDelta.x, + y: rect.y + rect.height + rotateHandle.dragDelta.y, + }, + ) * + (180 / Math.PI); + } + let x = props.position.x + (dragDelta?.x || 0); + let y = props.position.y + (dragDelta?.y || 0); + let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`; + let [areYouSure, setAreYouSure] = useState(false); + let blockProps = useMemo(() => { + return { + pageType: "canvas" as const, + preview: props.preview, + type: type?.data.value || "text", + value: props.entityID, + factID: props.factID, + position: "", + nextPosition: "", + entityID: props.entityID, + parent: props.parent, + nextBlock: null, + previousBlock: null, + }; + }, [props, type?.data.value]); + useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); + let isList = useEntity(props.entityID, "block/is-list"); + + return ( +
+ {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} + {!props.preview && } + + + {!props.preview && ( +