diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index d4016a95..4e51580c 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -32,10 +32,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { scanIndexLocal } from "src/replicache/utils"; import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; -import { - Delta, - YJSFragmentToString, -} from "components/Blocks/TextBlock/RenderYJSFragment"; +import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { ids } from "lexicons/api/lexicons"; import { BlobRef } from "@atproto/lexicon"; import { AtUri } from "@atproto/syntax"; diff --git a/actions/subscriptions/subscribeToMailboxWithEmail.ts b/actions/subscriptions/subscribeToMailboxWithEmail.ts index 08a65cec..7f145132 100644 --- a/actions/subscriptions/subscribeToMailboxWithEmail.ts +++ b/actions/subscriptions/subscribeToMailboxWithEmail.ts @@ -11,7 +11,7 @@ import type { Fact, PermissionToken } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { Database } from "supabase/database.types"; import * as Y from "yjs"; -import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; +import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { pool } from "supabase/pool"; let supabase = createServerClient( diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index fc8be6e9..2a0265ef 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -35,7 +35,7 @@ import { } from "src/hooks/queries/useBlocks"; import * as Y from "yjs"; import * as base64 from "base64-js"; -import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; +import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { BlueskyLogin } from "app/login/LoginForm"; import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; import { AddTiny } from "components/Icons/AddTiny"; diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index 44265a4e..be3c7b31 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -4,7 +4,7 @@ import * as base64 from "base64-js"; import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; -import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; +import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { Leaflet } from "./Leaflet"; import { scanIndexLocal } from "src/replicache/utils"; import { getRSVPData } from "actions/getRSVPData"; diff --git a/app/api/rpc/[command]/getFactsFromHomeLeaflets.ts b/app/api/rpc/[command]/getFactsFromHomeLeaflets.ts index 279b780b..e5795534 100644 --- a/app/api/rpc/[command]/getFactsFromHomeLeaflets.ts +++ b/app/api/rpc/[command]/getFactsFromHomeLeaflets.ts @@ -5,7 +5,7 @@ import { makeRoute } from "../lib"; import type { Env } from "./route"; import { scanIndexLocal } from "src/replicache/utils"; import * as base64 from "base64-js"; -import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; +import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { applyUpdate, Doc } from "yjs"; export const getFactsFromHomeLeaflets = makeRoute({ diff --git a/components/ActionBar/ActionButton.tsx b/components/ActionBar/ActionButton.tsx index ed7bded2..1155117d 100644 --- a/components/ActionBar/ActionButton.tsx +++ b/components/ActionBar/ActionButton.tsx @@ -3,7 +3,7 @@ import { useContext, useEffect } from "react"; import { SidebarContext } from "./Sidebar"; import React, { forwardRef, type JSX } from "react"; -import { PopoverOpenContext } from "components/Popover"; +import { PopoverOpenContext } from "components/Popover/PopoverContext"; type ButtonProps = Omit; diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index 2062527b..5e021a47 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -2,15 +2,15 @@ import type { Fact, ReplicacheMutators } from "src/replicache"; import { useUIState } from "src/useUIState"; import { generateKeyBetween } from "fractional-indexing"; -import { focusPage } from "components/Pages"; +import { focusPage } from "src/utils/focusPage"; import { v7 } from "uuid"; import { Replicache } from "replicache"; import { useEditorStates } from "src/state/useEditorState"; import { elementId } from "src/utils/elementId"; import { UndoManager } from "src/undoManager"; import { focusBlock } from "src/utils/focusBlock"; -import { usePollBlockUIState } from "./PollBlock"; -import { focusElement } from "components/Input"; +import { usePollBlockUIState } from "./PollBlock/pollBlockState"; +import { focusElement } from "src/utils/focusElement"; import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; diff --git a/components/Blocks/DeleteBlock.tsx b/components/Blocks/DeleteBlock.tsx index 89abc9dd..31c3d403 100644 --- a/components/Blocks/DeleteBlock.tsx +++ b/components/Blocks/DeleteBlock.tsx @@ -1,16 +1,7 @@ -import { - Fact, - ReplicacheMutators, - useEntity, - useReplicache, -} from "src/replicache"; -import { Replicache } from "replicache"; -import { useUIState } from "src/useUIState"; -import { scanIndex } from "src/replicache/utils"; -import { getBlocksWithType } from "src/hooks/queries/useBlocks"; -import { focusBlock } from "src/utils/focusBlock"; +import { Fact, useReplicache } from "src/replicache"; import { ButtonPrimary } from "components/Buttons"; import { CloseTiny } from "components/Icons/CloseTiny"; +import { deleteBlock } from "src/utils/deleteBlock"; export const AreYouSure = (props: { entityID: string[] | string; @@ -82,112 +73,3 @@ export const AreYouSure = (props: { ); }; -export async function deleteBlock( - entities: string[], - rep: Replicache, -) { - // get what pagess we need to close as a result of deleting this block - let pagesToClose = [] as string[]; - for (let entity of entities) { - let [type] = await rep.query((tx) => - scanIndex(tx).eav(entity, "block/type"), - ); - if (type.data.value === "card") { - let [childPages] = await rep?.query( - (tx) => scanIndex(tx).eav(entity, "block/card") || [], - ); - pagesToClose = [childPages?.data.value]; - } - if (type.data.value === "mailbox") { - let [archive] = await rep?.query( - (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], - ); - let [draft] = await rep?.query( - (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], - ); - pagesToClose = [archive?.data.value, draft?.data.value]; - } - } - - // the next and previous blocks in the block list - // if the focused thing is a page and not a block, return - let focusedBlock = useUIState.getState().focusedEntity; - let parent = - focusedBlock?.entityType === "page" - ? focusedBlock.entityID - : focusedBlock?.parent; - - if (parent) { - let parentType = await rep?.query((tx) => - scanIndex(tx).eav(parent, "page/type"), - ); - if (parentType[0]?.data.value === "canvas") { - useUIState - .getState() - .setFocusedBlock({ entityType: "page", entityID: parent }); - useUIState.getState().setSelectedBlocks([]); - } else { - let siblings = - (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; - - let selectedBlocks = useUIState.getState().selectedBlocks; - let firstSelected = selectedBlocks[0]; - let lastSelected = selectedBlocks[entities.length - 1]; - - let prevBlock = - siblings?.[ - siblings.findIndex((s) => s.value === firstSelected?.value) - 1 - ]; - let prevBlockType = await rep?.query((tx) => - scanIndex(tx).eav(prevBlock?.value, "block/type"), - ); - - let nextBlock = - siblings?.[ - siblings.findIndex((s) => s.value === lastSelected.value) + 1 - ]; - let nextBlockType = await rep?.query((tx) => - scanIndex(tx).eav(nextBlock?.value, "block/type"), - ); - - if (prevBlock) { - useUIState.getState().setSelectedBlock({ - value: prevBlock.value, - parent: prevBlock.parent, - }); - - focusBlock( - { - value: prevBlock.value, - type: prevBlockType?.[0].data.value, - parent: prevBlock.parent, - }, - { type: "end" }, - ); - } else { - useUIState.getState().setSelectedBlock({ - value: nextBlock.value, - parent: nextBlock.parent, - }); - - focusBlock( - { - value: nextBlock.value, - type: nextBlockType?.[0]?.data.value, - parent: nextBlock.parent, - }, - { type: "start" }, - ); - } - } - } - - pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); - await Promise.all( - entities.map((entity) => - rep?.mutate.removeBlock({ - blockEntity: entity, - }), - ), - ); -} diff --git a/components/Blocks/ExternalLinkBlock.tsx b/components/Blocks/ExternalLinkBlock.tsx index e1a2140b..0c923a38 100644 --- a/components/Blocks/ExternalLinkBlock.tsx +++ b/components/Blocks/ExternalLinkBlock.tsx @@ -8,7 +8,8 @@ import { BlockProps } from "./Block"; import { v7 } from "uuid"; import { useSmoker } from "components/Toast"; import { Separator } from "components/Layout"; -import { focusElement, Input } from "components/Input"; +import { Input } from "components/Input"; +import { focusElement } from "src/utils/focusElement"; import { isUrl } from "src/utils/isURL"; import { elementId } from "src/utils/elementId"; import { focusBlock } from "src/utils/focusBlock"; diff --git a/components/Blocks/MailboxBlock.tsx b/components/Blocks/MailboxBlock.tsx index 393bc3f5..17236337 100644 --- a/components/Blocks/MailboxBlock.tsx +++ b/components/Blocks/MailboxBlock.tsx @@ -9,7 +9,7 @@ import { useEntity, useReplicache } from "src/replicache"; import { useEntitySetContext } from "components/EntitySetProvider"; import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; -import { focusPage } from "components/Pages"; +import { focusPage } from "src/utils/focusPage"; import { v7 } from "uuid"; import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; import { getBlocksWithType } from "src/hooks/queries/useBlocks"; diff --git a/components/Blocks/PageLinkBlock.tsx b/components/Blocks/PageLinkBlock.tsx index d0f63c3f..b3b8d64b 100644 --- a/components/Blocks/PageLinkBlock.tsx +++ b/components/Blocks/PageLinkBlock.tsx @@ -2,7 +2,7 @@ import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; import { focusBlock } from "src/utils/focusBlock"; -import { focusPage } from "components/Pages"; +import { focusPage } from "src/utils/focusPage"; import { useEntity, useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; import { RenderedTextBlock } from "components/Blocks/TextBlock"; diff --git a/components/Blocks/PollBlock.tsx b/components/Blocks/PollBlock/index.tsx similarity index 97% rename from components/Blocks/PollBlock.tsx rename to components/Blocks/PollBlock/index.tsx index 1a8ba4a8..35a780ad 100644 --- a/components/Blocks/PollBlock.tsx +++ b/components/Blocks/PollBlock/index.tsx @@ -1,8 +1,9 @@ import { useUIState } from "src/useUIState"; -import { BlockProps } from "./Block"; +import { BlockProps } from "../Block"; import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; import { useCallback, useEffect, useState } from "react"; -import { focusElement, Input } from "components/Input"; +import { Input } from "components/Input"; +import { focusElement } from "src/utils/focusElement"; import { Separator } from "components/Layout"; import { useEntitySetContext } from "components/EntitySetProvider"; import { theme } from "tailwind.config"; @@ -13,18 +14,11 @@ import { usePollData, } from "components/PageSWRDataProvider"; import { voteOnPoll } from "actions/pollActions"; -import { create } from "zustand"; import { elementId } from "src/utils/elementId"; import { CheckTiny } from "components/Icons/CheckTiny"; import { CloseTiny } from "components/Icons/CloseTiny"; -import { PublicationPollBlock } from "./PublicationPollBlock"; - -export let usePollBlockUIState = create( - () => - ({}) as { - [entity: string]: { state: "editing" | "voting" | "results" } | undefined; - }, -); +import { PublicationPollBlock } from "../PublicationPollBlock"; +import { usePollBlockUIState } from "./pollBlockState"; export const PollBlock = (props: BlockProps) => { let { data: pub } = useLeafletPublicationData(); diff --git a/components/Blocks/PollBlock/pollBlockState.ts b/components/Blocks/PollBlock/pollBlockState.ts new file mode 100644 index 00000000..8bd6038b --- /dev/null +++ b/components/Blocks/PollBlock/pollBlockState.ts @@ -0,0 +1,8 @@ +import { create } from "zustand"; + +export let usePollBlockUIState = create( + () => + ({}) as { + [entity: string]: { state: "editing" | "voting" | "results" } | undefined; + }, +); diff --git a/components/Blocks/PublicationPollBlock.tsx b/components/Blocks/PublicationPollBlock.tsx index e69a3116..a7ed7274 100644 --- a/components/Blocks/PublicationPollBlock.tsx +++ b/components/Blocks/PublicationPollBlock.tsx @@ -1,7 +1,8 @@ import { useUIState } from "src/useUIState"; import { BlockProps } from "./Block"; import { useMemo } from "react"; -import { focusElement, AsyncValueInput } from "components/Input"; +import { AsyncValueInput } from "components/Input"; +import { focusElement } from "src/utils/focusElement"; import { useEntitySetContext } from "components/EntitySetProvider"; import { useEntity, useReplicache } from "src/replicache"; import { v7 } from "uuid"; diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index c68cff6c..35c1cb7f 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -5,6 +5,7 @@ import { theme } from "tailwind.config"; import * as base64 from "base64-js"; import { didToBlueskyUrl } from "src/utils/mentionUtils"; import { AtMentionLink } from "components/AtMentionLink"; +import { Delta } from "src/utils/yjsFragmentToString"; type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; export function RenderYJSFragment({ @@ -131,19 +132,6 @@ const BlockWrapper = (props: { } }; -export type Delta = { - insert: string; - attributes?: { - strong?: {}; - code?: {}; - em?: {}; - underline?: {}; - strikethrough?: {}; - highlight?: { color: string }; - link?: { href: string }; - }; -}; - function attributesToStyle(d: Delta) { let props = { style: {}, @@ -174,29 +162,3 @@ function attributesToStyle(d: Delta) { return props; } -export function YJSFragmentToString( - node: XmlElement | XmlText | XmlHook, -): string { - if (node.constructor === XmlElement) { - // Handle hard_break nodes specially - if (node.nodeName === "hard_break") { - return "\n"; - } - // Handle inline mention nodes - if (node.nodeName === "didMention" || node.nodeName === "atMention") { - return node.getAttribute("text") || ""; - } - return node - .toArray() - .map((f) => YJSFragmentToString(f)) - .join(""); - } - if (node.constructor === XmlText) { - return (node.toDelta() as Delta[]) - .map((d) => { - return d.insert; - }) - .join(""); - } - return ""; -} diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts index f1c2c32d..861adf2f 100644 --- a/components/Blocks/TextBlock/keymap.ts +++ b/components/Blocks/TextBlock/keymap.ts @@ -17,7 +17,7 @@ import { elementId } from "src/utils/elementId"; import { schema } from "./schema"; import { useUIState } from "src/useUIState"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; -import { focusPage } from "components/Pages"; +import { focusPage } from "src/utils/focusPage"; import { v7 } from "uuid"; import { scanIndex } from "src/replicache/utils"; import { indent, outdent } from "src/utils/list-operations"; diff --git a/components/Blocks/useBlockKeyboardHandlers.ts b/components/Blocks/useBlockKeyboardHandlers.ts index ba0238ba..18b1f43b 100644 --- a/components/Blocks/useBlockKeyboardHandlers.ts +++ b/components/Blocks/useBlockKeyboardHandlers.ts @@ -12,7 +12,7 @@ import { BlockProps } from "./Block"; import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; import { useEntitySetContext } from "components/EntitySetProvider"; import { Replicache } from "replicache"; -import { deleteBlock } from "./DeleteBlock"; +import { deleteBlock } from "src/utils/deleteBlock"; import { entities } from "drizzle/schema"; import { scanIndex } from "src/replicache/utils"; diff --git a/components/Blocks/useBlockMouseHandlers.ts b/components/Blocks/useBlockMouseHandlers.ts index 392024de..fd1e0188 100644 --- a/components/Blocks/useBlockMouseHandlers.ts +++ b/components/Blocks/useBlockMouseHandlers.ts @@ -1,4 +1,4 @@ -import { useSelectingMouse } from "components/SelectionManager"; +import { useSelectingMouse } from "components/SelectionManager/selectionState"; import { MouseEvent, useCallback, useRef } from "react"; import { useUIState } from "src/useUIState"; import { Block } from "./Block"; diff --git a/components/Input.tsx b/components/Input.tsx index 3d689695..796dd7b9 100644 --- a/components/Input.tsx +++ b/components/Input.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type JSX } from "react"; import { onMouseDown } from "src/utils/iosInputMouseDown"; import { isIOS } from "src/utils/isDevice"; +import { focusElement } from "src/utils/focusElement"; export const Input = ( props: { @@ -58,42 +59,6 @@ export const AsyncValueInput = ( ); }; -export const focusElement = ( - el?: HTMLInputElement | HTMLTextAreaElement | null, -) => { - if (!isIOS()) { - el?.focus(); - return; - } - - let fakeInput = document.createElement("input"); - fakeInput.setAttribute("type", "text"); - fakeInput.style.position = "fixed"; - fakeInput.style.height = "0px"; - fakeInput.style.width = "0px"; - fakeInput.style.fontSize = "16px"; // disable auto zoom - document.body.appendChild(fakeInput); - fakeInput.focus(); - setTimeout(() => { - if (!el) return; - el.style.transform = "translateY(-2000px)"; - el?.focus(); - fakeInput.remove(); - el.value = " "; - el.setSelectionRange(1, 1); - requestAnimationFrame(() => { - if (el) { - el.style.transform = ""; - } - }); - setTimeout(() => { - if (!el) return; - el.value = ""; - el.setSelectionRange(0, 0); - }, 50); - }, 20); -}; - export const InputWithLabel = ( props: { label: string; diff --git a/components/Layout.tsx b/components/Layout.tsx index ef2208a5..cd754906 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -3,7 +3,7 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { theme } from "tailwind.config"; import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; import { PopoverArrow } from "./Icons/PopoverArrow"; -import { PopoverOpenContext } from "./Popover"; +import { PopoverOpenContext } from "./Popover/PopoverContext"; import { useState } from "react"; export const Separator = (props: { classname?: string }) => { diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index 23b38ab9..a6b28246 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -12,7 +12,7 @@ import { Canvas } from "../Canvas"; import { Blocks } from "components/Blocks"; import { PublicationMetadata } from "./PublicationMetadata"; import { useCardBorderHidden } from "./useCardBorderHidden"; -import { focusPage } from "."; +import { focusPage } from "src/utils/focusPage"; import { PageOptions } from "./PageOptions"; import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; diff --git a/components/Pages/index.tsx b/components/Pages/index.tsx index aad8c8d1..c25dacf3 100644 --- a/components/Pages/index.tsx +++ b/components/Pages/index.tsx @@ -4,15 +4,8 @@ import React from "react"; import { useUIState } from "src/useUIState"; import { useSearchParams } from "next/navigation"; -import { focusBlock } from "src/utils/focusBlock"; -import { elementId } from "src/utils/elementId"; +import { useEntity } from "src/replicache"; -import { Replicache } from "replicache"; -import { Fact, ReplicacheMutators, useEntity } from "src/replicache"; - -import { scanIndex } from "src/replicache/utils"; -import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; -import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; import { useCardBorderHidden } from "./useCardBorderHidden"; import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; @@ -62,73 +55,7 @@ export function Pages(props: { rootPage: string }) { ); } -export async function focusPage( - pageID: string, - rep: Replicache, - focusFirstBlock?: "focusFirstBlock", -) { - // if this page is already focused, - let focusedBlock = useUIState.getState().focusedEntity; - // else set this page as focused - useUIState.setState(() => ({ - focusedEntity: { - entityType: "page", - entityID: pageID, - }, - })); - - setTimeout(async () => { - //scroll to page - - scrollIntoViewIfNeeded( - document.getElementById(elementId.page(pageID).container), - false, - "smooth", - ); - - // if we asked that the function focus the first block, focus the first block - if (focusFirstBlock === "focusFirstBlock") { - let firstBlock = await rep.query(async (tx) => { - let type = await scanIndex(tx).eav(pageID, "page/type"); - let blocks = await scanIndex(tx).eav( - pageID, - type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", - ); - - let firstBlock = blocks[0]; - - if (!firstBlock) { - return null; - } - - let blockType = ( - await tx - .scan< - Fact<"block/type"> - >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) - .toArray() - )[0]; - - if (!blockType) return null; - - return { - value: firstBlock.data.value, - type: blockType.data.value, - parent: firstBlock.entity, - position: firstBlock.data.position, - }; - }); - - if (firstBlock) { - setTimeout(() => { - focusBlock(firstBlock, { type: "start" }); - }, 500); - } - } - }, 50); -} - -export const blurPage = () => { +const blurPage = () => { useUIState.setState(() => ({ focusedEntity: null, selectedBlocks: [], diff --git a/components/Popover/PopoverContext.ts b/components/Popover/PopoverContext.ts new file mode 100644 index 00000000..bac21d72 --- /dev/null +++ b/components/Popover/PopoverContext.ts @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const PopoverOpenContext = createContext(false); diff --git a/components/Popover.tsx b/components/Popover/index.tsx similarity index 91% rename from components/Popover.tsx rename to components/Popover/index.tsx index 6a607624..6ad13603 100644 --- a/components/Popover.tsx +++ b/components/Popover/index.tsx @@ -1,11 +1,10 @@ "use client"; import * as RadixPopover from "@radix-ui/react-popover"; import { theme } from "tailwind.config"; -import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; -import { createContext, useEffect, useState } from "react"; -import { PopoverArrow } from "./Icons/PopoverArrow"; - -export const PopoverOpenContext = createContext(false); +import { NestedCardThemeProvider } from "../ThemeManager/ThemeProvider"; +import { useEffect, useState } from "react"; +import { PopoverArrow } from "../Icons/PopoverArrow"; +import { PopoverOpenContext } from "./PopoverContext"; export const Popover = (props: { trigger: React.ReactNode; disabled?: boolean; diff --git a/components/SelectionManager.tsx b/components/SelectionManager/index.tsx similarity index 93% rename from components/SelectionManager.tsx rename to components/SelectionManager/index.tsx index f62537ac..5fd8253e 100644 --- a/components/SelectionManager.tsx +++ b/components/SelectionManager/index.tsx @@ -1,30 +1,22 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { create } from "zustand"; -import { ReplicacheMutators, useReplicache } from "src/replicache"; +import { useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; import { scanIndex } from "src/replicache/utils"; import { focusBlock } from "src/utils/focusBlock"; import { useEditorStates } from "src/state/useEditorState"; -import { useEntitySetContext } from "./EntitySetProvider"; +import { useEntitySetContext } from "../EntitySetProvider"; import { getBlocksWithType } from "src/hooks/queries/useBlocks"; -import { v7 } from "uuid"; import { indent, outdent, outdentFull } from "src/utils/list-operations"; import { addShortcut, Shortcut } from "src/shortcuts"; -import { htmlToMarkdown } from "src/htmlMarkdownParsers"; import { elementId } from "src/utils/elementId"; import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; import { copySelection } from "src/utils/copySelection"; -import { isTextBlock } from "src/utils/isTextBlock"; import { useIsMobile } from "src/hooks/isMobile"; -import { deleteBlock } from "./Blocks/DeleteBlock"; -import { Replicache } from "replicache"; -import { schema } from "./Blocks/TextBlock/schema"; -import { TextSelection } from "prosemirror-state"; +import { deleteBlock } from "src/utils/deleteBlock"; +import { schema } from "../Blocks/TextBlock/schema"; import { MarkType } from "prosemirror-model"; -export const useSelectingMouse = create(() => ({ - start: null as null | string, -})); +import { useSelectingMouse, getSortedSelection } from "./selectionState"; //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? // How does this relate to *when dragging* ? @@ -632,7 +624,7 @@ type SavedRange = { endOffset: number; direction: "forward" | "backward"; }; -export function saveSelection() { +function saveSelection() { let selection = window.getSelection(); if (selection && selection.rangeCount > 0) { let ranges: SavedRange[] = []; @@ -655,7 +647,7 @@ export function saveSelection() { return []; } -export function restoreSelection(savedRanges: SavedRange[]) { +function restoreSelection(savedRanges: SavedRange[]) { if (savedRanges && savedRanges.length > 0) { let selection = window.getSelection(); if (!selection) return; @@ -693,44 +685,6 @@ function getContentEditableParent(e: Node | null): Node | null { return null; } -export const getSortedSelection = async ( - rep: Replicache, -) => { - let selectedBlocks = useUIState.getState().selectedBlocks; - let foldedBlocks = useUIState.getState().foldedBlocks; - if (!selectedBlocks[0]) return [[], []]; - let siblings = - (await rep?.query((tx) => - getBlocksWithType(tx, selectedBlocks[0].parent), - )) || []; - let sortedBlocks = siblings.filter((s) => { - let selected = selectedBlocks.find((sb) => sb.value === s.value); - return selected; - }); - let sortedBlocksWithChildren = siblings.filter((s) => { - let selected = selectedBlocks.find((sb) => sb.value === s.value); - if (s.listData && !selected) { - //Select the children of folded list blocks (in order to copy them) - return s.listData.path.find( - (p) => - selectedBlocks.find((sb) => sb.value === p.entity) && - foldedBlocks.includes(p.entity), - ); - } - return selected; - }); - return [ - sortedBlocks, - siblings.filter( - (f) => - !f.listData || - !f.listData.path.find( - (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, - ), - ), - sortedBlocksWithChildren, - ]; -}; function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { let everyBlockHasMark = blocks.reduce((acc, block) => { diff --git a/components/SelectionManager/selectionState.ts b/components/SelectionManager/selectionState.ts new file mode 100644 index 00000000..7f45ca43 --- /dev/null +++ b/components/SelectionManager/selectionState.ts @@ -0,0 +1,48 @@ +import { create } from "zustand"; +import { Replicache } from "replicache"; +import { ReplicacheMutators } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { getBlocksWithType } from "src/hooks/queries/useBlocks"; + +export const useSelectingMouse = create(() => ({ + start: null as null | string, +})); + +export const getSortedSelection = async ( + rep: Replicache, +) => { + let selectedBlocks = useUIState.getState().selectedBlocks; + let foldedBlocks = useUIState.getState().foldedBlocks; + if (!selectedBlocks[0]) return [[], []]; + let siblings = + (await rep?.query((tx) => + getBlocksWithType(tx, selectedBlocks[0].parent), + )) || []; + let sortedBlocks = siblings.filter((s) => { + let selected = selectedBlocks.find((sb) => sb.value === s.value); + return selected; + }); + let sortedBlocksWithChildren = siblings.filter((s) => { + let selected = selectedBlocks.find((sb) => sb.value === s.value); + if (s.listData && !selected) { + //Select the children of folded list blocks (in order to copy them) + return s.listData.path.find( + (p) => + selectedBlocks.find((sb) => sb.value === p.entity) && + foldedBlocks.includes(p.entity), + ); + } + return selected; + }); + return [ + sortedBlocks, + siblings.filter( + (f) => + !f.listData || + !f.listData.path.find( + (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, + ), + ), + sortedBlocksWithChildren, + ]; +}; diff --git a/components/ThemeManager/PublicationThemeProvider.tsx b/components/ThemeManager/PublicationThemeProvider.tsx index cf29abbb..16a0b250 100644 --- a/components/ThemeManager/PublicationThemeProvider.tsx +++ b/components/ThemeManager/PublicationThemeProvider.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { parseColor } from "react-aria-components"; import { useEntity } from "src/replicache"; -import { getColorContrast } from "./ThemeProvider"; +import { getColorContrast } from "./themeUtils"; import { useColorAttribute, colorToString } from "./useColorAttribute"; import { BaseThemeProvider } from "./ThemeProvider"; import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; diff --git a/components/ThemeManager/ThemeProvider.tsx b/components/ThemeManager/ThemeProvider.tsx index 0a30de2a..dbd1c539 100644 --- a/components/ThemeManager/ThemeProvider.tsx +++ b/components/ThemeManager/ThemeProvider.tsx @@ -5,8 +5,6 @@ import { CSSProperties, useContext, useEffect, - useMemo, - useState, } from "react"; import { colorToString, @@ -14,7 +12,6 @@ import { useColorAttributeNullable, } from "./useColorAttribute"; import { Color as AriaColor, parseColor } from "react-aria-components"; -import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; import { useEntity } from "src/replicache"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; @@ -23,34 +20,7 @@ import { PublicationThemeProvider, } from "./PublicationThemeProvider"; import { PubLeafletPublication } from "lexicons/api"; - -type CSSVariables = { - "--bg-leaflet": string; - "--bg-page": string; - "--primary": string; - "--accent-1": string; - "--accent-2": string; - "--accent-contrast": string; - "--highlight-1": string; - "--highlight-2": string; - "--highlight-3": string; -}; - -// define the color defaults for everything -export const ThemeDefaults = { - "theme/page-background": "#FDFCFA", - "theme/card-background": "#FFFFFF", - "theme/primary": "#272727", - "theme/highlight-1": "#FFFFFF", - "theme/highlight-2": "#EDD280", - "theme/highlight-3": "#FFCDC3", - - //everywhere else, accent-background = accent-1 and accent-text = accent-2. - // we just need to create a migration pipeline before we can change this - "theme/accent-text": "#FFFFFF", - "theme/accent-background": "#0000FF", - "theme/accent-contrast": "#0000FF", -}; +import { getColorContrast } from "./themeUtils"; // define a function to set an Aria Color to a CSS Variable in RGB function setCSSVariableToColor( @@ -368,12 +338,3 @@ export const ThemeBackgroundProvider = (props: { ); }; -// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast -export function getColorContrast(color1: string, color2: string) { - ColorSpace.register(sRGB); - - let parsedColor1 = parse(`rgb(${color1})`); - let parsedColor2 = parse(`rgb(${color2})`); - - return contrastLstar(parsedColor1, parsedColor2); -} diff --git a/components/ThemeManager/themeUtils.ts b/components/ThemeManager/themeUtils.ts new file mode 100644 index 00000000..0f9fd175 --- /dev/null +++ b/components/ThemeManager/themeUtils.ts @@ -0,0 +1,27 @@ +import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; + +// define the color defaults for everything +export const ThemeDefaults = { + "theme/page-background": "#FDFCFA", + "theme/card-background": "#FFFFFF", + "theme/primary": "#272727", + "theme/highlight-1": "#FFFFFF", + "theme/highlight-2": "#EDD280", + "theme/highlight-3": "#FFCDC3", + + //everywhere else, accent-background = accent-1 and accent-text = accent-2. + // we just need to create a migration pipeline before we can change this + "theme/accent-text": "#FFFFFF", + "theme/accent-background": "#0000FF", + "theme/accent-contrast": "#0000FF", +}; + +// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast +export function getColorContrast(color1: string, color2: string) { + ColorSpace.register(sRGB); + + let parsedColor1 = parse(`rgb(${color1})`); + let parsedColor2 = parse(`rgb(${color2})`); + + return contrastLstar(parsedColor1, parsedColor2); +} diff --git a/components/ThemeManager/useColorAttribute.ts b/components/ThemeManager/useColorAttribute.ts index 161b1882..8df7b5a9 100644 --- a/components/ThemeManager/useColorAttribute.ts +++ b/components/ThemeManager/useColorAttribute.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { Color, parseColor } from "react-aria-components"; import { useEntity, useReplicache } from "src/replicache"; import { FilterAttributes } from "src/replicache/attributes"; -import { ThemeDefaults } from "./ThemeProvider"; +import { ThemeDefaults } from "./themeUtils"; export function useColorAttribute( entity: string | null, diff --git a/components/Toolbar/BlockToolbar.tsx b/components/Toolbar/BlockToolbar.tsx index 65be182f..0a2f270d 100644 --- a/components/Toolbar/BlockToolbar.tsx +++ b/components/Toolbar/BlockToolbar.tsx @@ -2,12 +2,12 @@ import { useEntity, useReplicache } from "src/replicache"; import { ToolbarButton } from "."; import { Separator, ShortcutKey } from "components/Layout"; import { metaKey } from "src/utils/metaKey"; -import { getBlocksWithType } from "src/hooks/queries/useBlocks"; import { useUIState } from "src/useUIState"; import { LockBlockButton } from "./LockBlockButton"; import { TextAlignmentButton } from "./TextAlignmentToolbar"; import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; import { DeleteSmall } from "components/Icons/DeleteSmall"; +import { getSortedSelection } from "components/SelectionManager/selectionState"; export const BlockToolbar = (props: { setToolbarState: ( @@ -66,23 +66,13 @@ export const BlockToolbar = (props: { const MoveBlockButtons = () => { let { rep } = useReplicache(); - const getSortedSelection = async () => { - let selectedBlocks = useUIState.getState().selectedBlocks; - let siblings = - (await rep?.query((tx) => - getBlocksWithType(tx, selectedBlocks[0].parent), - )) || []; - let sortedBlocks = siblings.filter((s) => - selectedBlocks.find((sb) => sb.value === s.value), - ); - return [sortedBlocks, siblings]; - }; return ( <> { - let [sortedBlocks, siblings] = await getSortedSelection(); + if (!rep) return; + let [sortedBlocks, siblings] = await getSortedSelection(rep); if (sortedBlocks.length > 1) return; let block = sortedBlocks[0]; let previousBlock = @@ -139,7 +129,8 @@ const MoveBlockButtons = () => { { - let [sortedBlocks, siblings] = await getSortedSelection(); + if (!rep) return; + let [sortedBlocks, siblings] = await getSortedSelection(rep); if (sortedBlocks.length > 1) return; let block = sortedBlocks[0]; let nextBlock = siblings diff --git a/components/Toolbar/MultiSelectToolbar.tsx b/components/Toolbar/MultiSelectToolbar.tsx index e61c4161..c2cbb167 100644 --- a/components/Toolbar/MultiSelectToolbar.tsx +++ b/components/Toolbar/MultiSelectToolbar.tsx @@ -8,7 +8,7 @@ import { Replicache } from "replicache"; import { LockBlockButton } from "./LockBlockButton"; import { Props } from "components/Icons/Props"; import { TextAlignmentButton } from "./TextAlignmentToolbar"; -import { getSortedSelection } from "components/SelectionManager"; +import { getSortedSelection } from "components/SelectionManager/selectionState"; export const MultiselectToolbar = (props: { setToolbarState: ( diff --git a/components/Toolbar/index.tsx b/components/Toolbar/index.tsx index d9047ebc..e26d1e1d 100644 --- a/components/Toolbar/index.tsx +++ b/components/Toolbar/index.tsx @@ -13,7 +13,8 @@ import { HighlightToolbar } from "./HighlightToolbar"; import { TextToolbar } from "./TextToolbar"; import { BlockToolbar } from "./BlockToolbar"; import { MultiselectToolbar } from "./MultiSelectToolbar"; -import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; +import { AreYouSure } from "components/Blocks/DeleteBlock"; +import { deleteBlock } from "src/utils/deleteBlock"; import { TooltipButton } from "components/Buttons"; import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; import { useIsMobile } from "src/hooks/isMobile"; diff --git a/components/utils/UpdateLeafletTitle.tsx b/components/utils/UpdateLeafletTitle.tsx index 15f99810..a722e65b 100644 --- a/components/utils/UpdateLeafletTitle.tsx +++ b/components/utils/UpdateLeafletTitle.tsx @@ -8,7 +8,7 @@ import { import { useEntity, useReplicache } from "src/replicache"; import * as Y from "yjs"; import * as base64 from "base64-js"; -import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; +import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { focusBlock } from "src/utils/focusBlock"; import { useIsMobile } from "src/hooks/isMobile"; diff --git a/src/utils/deleteBlock.ts b/src/utils/deleteBlock.ts new file mode 100644 index 00000000..50eb3b32 --- /dev/null +++ b/src/utils/deleteBlock.ts @@ -0,0 +1,116 @@ +import { Replicache } from "replicache"; +import { ReplicacheMutators } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { scanIndex } from "src/replicache/utils"; +import { getBlocksWithType } from "src/hooks/queries/useBlocks"; +import { focusBlock } from "src/utils/focusBlock"; + +export async function deleteBlock( + entities: string[], + rep: Replicache, +) { + // get what pagess we need to close as a result of deleting this block + let pagesToClose = [] as string[]; + for (let entity of entities) { + let [type] = await rep.query((tx) => + scanIndex(tx).eav(entity, "block/type"), + ); + if (type.data.value === "card") { + let [childPages] = await rep?.query( + (tx) => scanIndex(tx).eav(entity, "block/card") || [], + ); + pagesToClose = [childPages?.data.value]; + } + if (type.data.value === "mailbox") { + let [archive] = await rep?.query( + (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], + ); + let [draft] = await rep?.query( + (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], + ); + pagesToClose = [archive?.data.value, draft?.data.value]; + } + } + + // the next and previous blocks in the block list + // if the focused thing is a page and not a block, return + let focusedBlock = useUIState.getState().focusedEntity; + let parent = + focusedBlock?.entityType === "page" + ? focusedBlock.entityID + : focusedBlock?.parent; + + if (parent) { + let parentType = await rep?.query((tx) => + scanIndex(tx).eav(parent, "page/type"), + ); + if (parentType[0]?.data.value === "canvas") { + useUIState + .getState() + .setFocusedBlock({ entityType: "page", entityID: parent }); + useUIState.getState().setSelectedBlocks([]); + } else { + let siblings = + (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; + + let selectedBlocks = useUIState.getState().selectedBlocks; + let firstSelected = selectedBlocks[0]; + let lastSelected = selectedBlocks[entities.length - 1]; + + let prevBlock = + siblings?.[ + siblings.findIndex((s) => s.value === firstSelected?.value) - 1 + ]; + let prevBlockType = await rep?.query((tx) => + scanIndex(tx).eav(prevBlock?.value, "block/type"), + ); + + let nextBlock = + siblings?.[ + siblings.findIndex((s) => s.value === lastSelected.value) + 1 + ]; + let nextBlockType = await rep?.query((tx) => + scanIndex(tx).eav(nextBlock?.value, "block/type"), + ); + + if (prevBlock) { + useUIState.getState().setSelectedBlock({ + value: prevBlock.value, + parent: prevBlock.parent, + }); + + focusBlock( + { + value: prevBlock.value, + type: prevBlockType?.[0].data.value, + parent: prevBlock.parent, + }, + { type: "end" }, + ); + } else { + useUIState.getState().setSelectedBlock({ + value: nextBlock.value, + parent: nextBlock.parent, + }); + + focusBlock( + { + value: nextBlock.value, + type: nextBlockType?.[0]?.data.value, + parent: nextBlock.parent, + }, + { type: "start" }, + ); + } + } + } + + pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); + await Promise.all( + entities.map((entity) => + rep?.mutate.removeBlock({ + blockEntity: entity, + }), + ), + ); +} diff --git a/src/utils/focusElement.ts b/src/utils/focusElement.ts new file mode 100644 index 00000000..b48327ed --- /dev/null +++ b/src/utils/focusElement.ts @@ -0,0 +1,37 @@ +import { isIOS } from "src/utils/isDevice"; + +export const focusElement = ( + el?: HTMLInputElement | HTMLTextAreaElement | null, +) => { + if (!isIOS()) { + el?.focus(); + return; + } + + let fakeInput = document.createElement("input"); + fakeInput.setAttribute("type", "text"); + fakeInput.style.position = "fixed"; + fakeInput.style.height = "0px"; + fakeInput.style.width = "0px"; + fakeInput.style.fontSize = "16px"; // disable auto zoom + document.body.appendChild(fakeInput); + fakeInput.focus(); + setTimeout(() => { + if (!el) return; + el.style.transform = "translateY(-2000px)"; + el?.focus(); + fakeInput.remove(); + el.value = " "; + el.setSelectionRange(1, 1); + requestAnimationFrame(() => { + if (el) { + el.style.transform = ""; + } + }); + setTimeout(() => { + if (!el) return; + el.value = ""; + el.setSelectionRange(0, 0); + }, 50); + }, 20); +}; diff --git a/src/utils/focusPage.ts b/src/utils/focusPage.ts new file mode 100644 index 00000000..cc68de6f --- /dev/null +++ b/src/utils/focusPage.ts @@ -0,0 +1,73 @@ +import { Replicache } from "replicache"; +import { Fact, ReplicacheMutators } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { scanIndex } from "src/replicache/utils"; +import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; +import { elementId } from "src/utils/elementId"; +import { focusBlock } from "src/utils/focusBlock"; + +export async function focusPage( + pageID: string, + rep: Replicache, + focusFirstBlock?: "focusFirstBlock", +) { + // if this page is already focused, + let focusedBlock = useUIState.getState().focusedEntity; + // else set this page as focused + useUIState.setState(() => ({ + focusedEntity: { + entityType: "page", + entityID: pageID, + }, + })); + + setTimeout(async () => { + //scroll to page + + scrollIntoViewIfNeeded( + document.getElementById(elementId.page(pageID).container), + false, + "smooth", + ); + + // if we asked that the function focus the first block, focus the first block + if (focusFirstBlock === "focusFirstBlock") { + let firstBlock = await rep.query(async (tx) => { + let type = await scanIndex(tx).eav(pageID, "page/type"); + let blocks = await scanIndex(tx).eav( + pageID, + type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", + ); + + let firstBlock = blocks[0]; + + if (!firstBlock) { + return null; + } + + let blockType = ( + await tx + .scan< + Fact<"block/type"> + >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) + .toArray() + )[0]; + + if (!blockType) return null; + + return { + value: firstBlock.data.value, + type: blockType.data.value, + parent: firstBlock.entity, + position: firstBlock.data.position, + }; + }); + + if (firstBlock) { + setTimeout(() => { + focusBlock(firstBlock, { type: "start" }); + }, 500); + } + } + }, 50); +} diff --git a/src/utils/yjsFragmentToString.ts b/src/utils/yjsFragmentToString.ts new file mode 100644 index 00000000..a4738061 --- /dev/null +++ b/src/utils/yjsFragmentToString.ts @@ -0,0 +1,41 @@ +import { XmlElement, XmlText, XmlHook } from "yjs"; + +export type Delta = { + insert: string; + attributes?: { + strong?: {}; + code?: {}; + em?: {}; + underline?: {}; + strikethrough?: {}; + highlight?: { color: string }; + link?: { href: string }; + }; +}; + +export function YJSFragmentToString( + node: XmlElement | XmlText | XmlHook, +): string { + if (node.constructor === XmlElement) { + // Handle hard_break nodes specially + if (node.nodeName === "hard_break") { + return "\n"; + } + // Handle inline mention nodes + if (node.nodeName === "didMention" || node.nodeName === "atMention") { + return node.getAttribute("text") || ""; + } + return node + .toArray() + .map((f) => YJSFragmentToString(f)) + .join(""); + } + if (node.constructor === XmlText) { + return (node.toDelta() as Delta[]) + .map((d) => { + return d.insert; + }) + .join(""); + } + return ""; +}