a tool for shared writing and social publishing

refactor exports to support fast refresh

+1 -4
actions/publishToPublication.ts
··· 32 32 import { scanIndexLocal } from "src/replicache/utils"; 33 33 import type { Fact } from "src/replicache"; 34 34 import type { Attribute } from "src/replicache/attributes"; 35 - import { 36 - Delta, 37 - YJSFragmentToString, 38 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 36 import { ids } from "lexicons/api/lexicons"; 40 37 import { BlobRef } from "@atproto/lexicon"; 41 38 import { AtUri } from "@atproto/syntax";
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 11 11 import type { Attribute } from "src/replicache/attributes"; 12 12 import { Database } from "supabase/database.types"; 13 13 import * as Y from "yjs"; 14 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 14 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 15 15 import { pool } from "supabase/pool"; 16 16 17 17 let supabase = createServerClient<Database>(
+1 -1
app/[leaflet_id]/actions/PublishButton.tsx
··· 35 35 } from "src/hooks/queries/useBlocks"; 36 36 import * as Y from "yjs"; 37 37 import * as base64 from "base64-js"; 38 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 38 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 39 import { BlueskyLogin } from "app/login/LoginForm"; 40 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 41 import { AddTiny } from "components/Icons/AddTiny";
+1 -1
app/[leaflet_id]/page.tsx
··· 4 4 5 5 import type { Fact } from "src/replicache"; 6 6 import type { Attribute } from "src/replicache/attributes"; 7 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 7 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 8 import { Leaflet } from "./Leaflet"; 9 9 import { scanIndexLocal } from "src/replicache/utils"; 10 10 import { getRSVPData } from "actions/getRSVPData";
+1 -1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
··· 5 5 import type { Env } from "./route"; 6 6 import { scanIndexLocal } from "src/replicache/utils"; 7 7 import * as base64 from "base64-js"; 8 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 9 9 import { applyUpdate, Doc } from "yjs"; 10 10 11 11 export const getFactsFromHomeLeaflets = makeRoute({
+1 -1
components/ActionBar/ActionButton.tsx
··· 3 3 import { useContext, useEffect } from "react"; 4 4 import { SidebarContext } from "./Sidebar"; 5 5 import React, { forwardRef, type JSX } from "react"; 6 - import { PopoverOpenContext } from "components/Popover"; 6 + import { PopoverOpenContext } from "components/Popover/PopoverContext"; 7 7 8 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 9
+3 -3
components/Blocks/BlockCommands.tsx
··· 2 2 import { useUIState } from "src/useUIState"; 3 3 4 4 import { generateKeyBetween } from "fractional-indexing"; 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { v7 } from "uuid"; 7 7 import { Replicache } from "replicache"; 8 8 import { useEditorStates } from "src/state/useEditorState"; 9 9 import { elementId } from "src/utils/elementId"; 10 10 import { UndoManager } from "src/undoManager"; 11 11 import { focusBlock } from "src/utils/focusBlock"; 12 - import { usePollBlockUIState } from "./PollBlock"; 13 - import { focusElement } from "components/Input"; 12 + import { usePollBlockUIState } from "./PollBlock/pollBlockState"; 13 + import { focusElement } from "src/utils/focusElement"; 14 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
+2 -120
components/Blocks/DeleteBlock.tsx
··· 1 - import { 2 - Fact, 3 - ReplicacheMutators, 4 - useEntity, 5 - useReplicache, 6 - } from "src/replicache"; 7 - import { Replicache } from "replicache"; 8 - import { useUIState } from "src/useUIState"; 9 - import { scanIndex } from "src/replicache/utils"; 10 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { focusBlock } from "src/utils/focusBlock"; 1 + import { Fact, useReplicache } from "src/replicache"; 12 2 import { ButtonPrimary } from "components/Buttons"; 13 3 import { CloseTiny } from "components/Icons/CloseTiny"; 4 + import { deleteBlock } from "src/utils/deleteBlock"; 14 5 15 6 export const AreYouSure = (props: { 16 7 entityID: string[] | string; ··· 82 73 ); 83 74 }; 84 75 85 - export async function deleteBlock( 86 - entities: string[], 87 - rep: Replicache<ReplicacheMutators>, 88 - ) { 89 - // get what pagess we need to close as a result of deleting this block 90 - let pagesToClose = [] as string[]; 91 - for (let entity of entities) { 92 - let [type] = await rep.query((tx) => 93 - scanIndex(tx).eav(entity, "block/type"), 94 - ); 95 - if (type.data.value === "card") { 96 - let [childPages] = await rep?.query( 97 - (tx) => scanIndex(tx).eav(entity, "block/card") || [], 98 - ); 99 - pagesToClose = [childPages?.data.value]; 100 - } 101 - if (type.data.value === "mailbox") { 102 - let [archive] = await rep?.query( 103 - (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 104 - ); 105 - let [draft] = await rep?.query( 106 - (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 107 - ); 108 - pagesToClose = [archive?.data.value, draft?.data.value]; 109 - } 110 - } 111 - 112 - // the next and previous blocks in the block list 113 - // if the focused thing is a page and not a block, return 114 - let focusedBlock = useUIState.getState().focusedEntity; 115 - let parent = 116 - focusedBlock?.entityType === "page" 117 - ? focusedBlock.entityID 118 - : focusedBlock?.parent; 119 - 120 - if (parent) { 121 - let parentType = await rep?.query((tx) => 122 - scanIndex(tx).eav(parent, "page/type"), 123 - ); 124 - if (parentType[0]?.data.value === "canvas") { 125 - useUIState 126 - .getState() 127 - .setFocusedBlock({ entityType: "page", entityID: parent }); 128 - useUIState.getState().setSelectedBlocks([]); 129 - } else { 130 - let siblings = 131 - (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 132 - 133 - let selectedBlocks = useUIState.getState().selectedBlocks; 134 - let firstSelected = selectedBlocks[0]; 135 - let lastSelected = selectedBlocks[entities.length - 1]; 136 - 137 - let prevBlock = 138 - siblings?.[ 139 - siblings.findIndex((s) => s.value === firstSelected?.value) - 1 140 - ]; 141 - let prevBlockType = await rep?.query((tx) => 142 - scanIndex(tx).eav(prevBlock?.value, "block/type"), 143 - ); 144 - 145 - let nextBlock = 146 - siblings?.[ 147 - siblings.findIndex((s) => s.value === lastSelected.value) + 1 148 - ]; 149 - let nextBlockType = await rep?.query((tx) => 150 - scanIndex(tx).eav(nextBlock?.value, "block/type"), 151 - ); 152 - 153 - if (prevBlock) { 154 - useUIState.getState().setSelectedBlock({ 155 - value: prevBlock.value, 156 - parent: prevBlock.parent, 157 - }); 158 - 159 - focusBlock( 160 - { 161 - value: prevBlock.value, 162 - type: prevBlockType?.[0].data.value, 163 - parent: prevBlock.parent, 164 - }, 165 - { type: "end" }, 166 - ); 167 - } else { 168 - useUIState.getState().setSelectedBlock({ 169 - value: nextBlock.value, 170 - parent: nextBlock.parent, 171 - }); 172 - 173 - focusBlock( 174 - { 175 - value: nextBlock.value, 176 - type: nextBlockType?.[0]?.data.value, 177 - parent: nextBlock.parent, 178 - }, 179 - { type: "start" }, 180 - ); 181 - } 182 - } 183 - } 184 - 185 - pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 186 - await Promise.all( 187 - entities.map((entity) => 188 - rep?.mutate.removeBlock({ 189 - blockEntity: entity, 190 - }), 191 - ), 192 - ); 193 - }
+2 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 8 import { v7 } from "uuid"; 9 9 import { useSmoker } from "components/Toast"; 10 10 import { Separator } from "components/Layout"; 11 - import { focusElement, Input } from "components/Input"; 11 + import { Input } from "components/Input"; 12 + import { focusElement } from "src/utils/focusElement"; 12 13 import { isUrl } from "src/utils/isURL"; 13 14 import { elementId } from "src/utils/elementId"; 14 15 import { focusBlock } from "src/utils/focusBlock";
+1 -1
components/Blocks/MailboxBlock.tsx
··· 9 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 12 + import { focusPage } from "src/utils/focusPage"; 13 13 import { v7 } from "uuid"; 14 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 2 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 3 import { focusBlock } from "src/utils/focusBlock"; 4 4 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { useEntity, useReplicache } from "src/replicache"; 7 7 import { useUIState } from "src/useUIState"; 8 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
+5 -11
components/Blocks/PollBlock.tsx components/Blocks/PollBlock/index.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockProps } from "../Block"; 3 3 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 4 import { useCallback, useEffect, useState } from "react"; 5 - import { focusElement, Input } from "components/Input"; 5 + import { Input } from "components/Input"; 6 + import { focusElement } from "src/utils/focusElement"; 6 7 import { Separator } from "components/Layout"; 7 8 import { useEntitySetContext } from "components/EntitySetProvider"; 8 9 import { theme } from "tailwind.config"; ··· 13 14 usePollData, 14 15 } from "components/PageSWRDataProvider"; 15 16 import { voteOnPoll } from "actions/pollActions"; 16 - import { create } from "zustand"; 17 17 import { elementId } from "src/utils/elementId"; 18 18 import { CheckTiny } from "components/Icons/CheckTiny"; 19 19 import { CloseTiny } from "components/Icons/CloseTiny"; 20 - import { PublicationPollBlock } from "./PublicationPollBlock"; 21 - 22 - export let usePollBlockUIState = create( 23 - () => 24 - ({}) as { 25 - [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 26 - }, 27 - ); 20 + import { PublicationPollBlock } from "../PublicationPollBlock"; 21 + import { usePollBlockUIState } from "./pollBlockState"; 28 22 29 23 export const PollBlock = (props: BlockProps) => { 30 24 let { data: pub } = useLeafletPublicationData();
+8
components/Blocks/PollBlock/pollBlockState.ts
··· 1 + import { create } from "zustand"; 2 + 3 + export let usePollBlockUIState = create( 4 + () => 5 + ({}) as { 6 + [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 7 + }, 8 + );
+2 -1
components/Blocks/PublicationPollBlock.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 2 import { BlockProps } from "./Block"; 3 3 import { useMemo } from "react"; 4 - import { focusElement, AsyncValueInput } from "components/Input"; 4 + import { AsyncValueInput } from "components/Input"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 import { useEntitySetContext } from "components/EntitySetProvider"; 6 7 import { useEntity, useReplicache } from "src/replicache"; 7 8 import { v7 } from "uuid";
+1 -39
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 5 5 import * as base64 from "base64-js"; 6 6 import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 7 import { AtMentionLink } from "components/AtMentionLink"; 8 + import { Delta } from "src/utils/yjsFragmentToString"; 8 9 9 10 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 10 11 export function RenderYJSFragment({ ··· 131 132 } 132 133 }; 133 134 134 - export type Delta = { 135 - insert: string; 136 - attributes?: { 137 - strong?: {}; 138 - code?: {}; 139 - em?: {}; 140 - underline?: {}; 141 - strikethrough?: {}; 142 - highlight?: { color: string }; 143 - link?: { href: string }; 144 - }; 145 - }; 146 - 147 135 function attributesToStyle(d: Delta) { 148 136 let props = { 149 137 style: {}, ··· 174 162 return props; 175 163 } 176 164 177 - export function YJSFragmentToString( 178 - node: XmlElement | XmlText | XmlHook, 179 - ): string { 180 - if (node.constructor === XmlElement) { 181 - // Handle hard_break nodes specially 182 - if (node.nodeName === "hard_break") { 183 - return "\n"; 184 - } 185 - // Handle inline mention nodes 186 - if (node.nodeName === "didMention" || node.nodeName === "atMention") { 187 - return node.getAttribute("text") || ""; 188 - } 189 - return node 190 - .toArray() 191 - .map((f) => YJSFragmentToString(f)) 192 - .join(""); 193 - } 194 - if (node.constructor === XmlText) { 195 - return (node.toDelta() as Delta[]) 196 - .map((d) => { 197 - return d.insert; 198 - }) 199 - .join(""); 200 - } 201 - return ""; 202 - }
+1 -1
components/Blocks/TextBlock/keymap.ts
··· 17 17 import { schema } from "./schema"; 18 18 import { useUIState } from "src/useUIState"; 19 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 - import { focusPage } from "components/Pages"; 20 + import { focusPage } from "src/utils/focusPage"; 21 21 import { v7 } from "uuid"; 22 22 import { scanIndex } from "src/replicache/utils"; 23 23 import { indent, outdent } from "src/utils/list-operations";
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 12 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 14 import { Replicache } from "replicache"; 15 - import { deleteBlock } from "./DeleteBlock"; 15 + import { deleteBlock } from "src/utils/deleteBlock"; 16 16 import { entities } from "drizzle/schema"; 17 17 import { scanIndex } from "src/replicache/utils"; 18 18
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 1 - import { useSelectingMouse } from "components/SelectionManager"; 1 + import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 2 import { MouseEvent, useCallback, useRef } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { Block } from "./Block";
+1 -36
components/Input.tsx
··· 2 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 4 import { isIOS } from "src/utils/isDevice"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 6 7 export const Input = ( 7 8 props: { ··· 56 57 }} 57 58 /> 58 59 ); 59 - }; 60 - 61 - export const focusElement = ( 62 - el?: HTMLInputElement | HTMLTextAreaElement | null, 63 - ) => { 64 - if (!isIOS()) { 65 - el?.focus(); 66 - return; 67 - } 68 - 69 - let fakeInput = document.createElement("input"); 70 - fakeInput.setAttribute("type", "text"); 71 - fakeInput.style.position = "fixed"; 72 - fakeInput.style.height = "0px"; 73 - fakeInput.style.width = "0px"; 74 - fakeInput.style.fontSize = "16px"; // disable auto zoom 75 - document.body.appendChild(fakeInput); 76 - fakeInput.focus(); 77 - setTimeout(() => { 78 - if (!el) return; 79 - el.style.transform = "translateY(-2000px)"; 80 - el?.focus(); 81 - fakeInput.remove(); 82 - el.value = " "; 83 - el.setSelectionRange(1, 1); 84 - requestAnimationFrame(() => { 85 - if (el) { 86 - el.style.transform = ""; 87 - } 88 - }); 89 - setTimeout(() => { 90 - if (!el) return; 91 - el.value = ""; 92 - el.setSelectionRange(0, 0); 93 - }, 50); 94 - }, 20); 95 60 }; 96 61 97 62 export const InputWithLabel = (
+1 -1
components/Layout.tsx
··· 3 3 import { theme } from "tailwind.config"; 4 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 6 - import { PopoverOpenContext } from "./Popover"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 7 import { useState } from "react"; 8 8 9 9 export const Separator = (props: { classname?: string }) => {
+1 -1
components/Pages/Page.tsx
··· 12 12 import { Blocks } from "components/Blocks"; 13 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 - import { focusPage } from "."; 15 + import { focusPage } from "src/utils/focusPage"; 16 16 import { PageOptions } from "./PageOptions"; 17 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
+2 -75
components/Pages/index.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { useSearchParams } from "next/navigation"; 6 6 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { elementId } from "src/utils/elementId"; 7 + import { useEntity } from "src/replicache"; 9 8 10 - import { Replicache } from "replicache"; 11 - import { Fact, ReplicacheMutators, useEntity } from "src/replicache"; 12 - 13 - import { scanIndex } from "src/replicache/utils"; 14 - import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 15 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 16 9 import { useCardBorderHidden } from "./useCardBorderHidden"; 17 10 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 11 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 62 55 ); 63 56 } 64 57 65 - export async function focusPage( 66 - pageID: string, 67 - rep: Replicache<ReplicacheMutators>, 68 - focusFirstBlock?: "focusFirstBlock", 69 - ) { 70 - // if this page is already focused, 71 - let focusedBlock = useUIState.getState().focusedEntity; 72 - // else set this page as focused 73 - useUIState.setState(() => ({ 74 - focusedEntity: { 75 - entityType: "page", 76 - entityID: pageID, 77 - }, 78 - })); 79 - 80 - setTimeout(async () => { 81 - //scroll to page 82 - 83 - scrollIntoViewIfNeeded( 84 - document.getElementById(elementId.page(pageID).container), 85 - false, 86 - "smooth", 87 - ); 88 - 89 - // if we asked that the function focus the first block, focus the first block 90 - if (focusFirstBlock === "focusFirstBlock") { 91 - let firstBlock = await rep.query(async (tx) => { 92 - let type = await scanIndex(tx).eav(pageID, "page/type"); 93 - let blocks = await scanIndex(tx).eav( 94 - pageID, 95 - type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 96 - ); 97 - 98 - let firstBlock = blocks[0]; 99 - 100 - if (!firstBlock) { 101 - return null; 102 - } 103 - 104 - let blockType = ( 105 - await tx 106 - .scan< 107 - Fact<"block/type"> 108 - >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 109 - .toArray() 110 - )[0]; 111 - 112 - if (!blockType) return null; 113 - 114 - return { 115 - value: firstBlock.data.value, 116 - type: blockType.data.value, 117 - parent: firstBlock.entity, 118 - position: firstBlock.data.position, 119 - }; 120 - }); 121 - 122 - if (firstBlock) { 123 - setTimeout(() => { 124 - focusBlock(firstBlock, { type: "start" }); 125 - }, 500); 126 - } 127 - } 128 - }, 50); 129 - } 130 - 131 - export const blurPage = () => { 58 + const blurPage = () => { 132 59 useUIState.setState(() => ({ 133 60 focusedEntity: null, 134 61 selectedBlocks: [],
+4 -5
components/Popover.tsx components/Popover/index.tsx
··· 1 1 "use client"; 2 2 import * as RadixPopover from "@radix-ui/react-popover"; 3 3 import { theme } from "tailwind.config"; 4 - import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { createContext, useEffect, useState } from "react"; 6 - import { PopoverArrow } from "./Icons/PopoverArrow"; 7 - 8 - export const PopoverOpenContext = createContext(false); 4 + import { NestedCardThemeProvider } from "../ThemeManager/ThemeProvider"; 5 + import { useEffect, useState } from "react"; 6 + import { PopoverArrow } from "../Icons/PopoverArrow"; 7 + import { PopoverOpenContext } from "./PopoverContext"; 9 8 export const Popover = (props: { 10 9 trigger: React.ReactNode; 11 10 disabled?: boolean;
+3
components/Popover/PopoverContext.ts
··· 1 + import { createContext } from "react"; 2 + 3 + export const PopoverOpenContext = createContext(false);
+7 -53
components/SelectionManager.tsx components/SelectionManager/index.tsx
··· 1 1 "use client"; 2 2 import { useEffect, useRef, useState } from "react"; 3 - import { create } from "zustand"; 4 - import { ReplicacheMutators, useReplicache } from "src/replicache"; 3 + import { useReplicache } from "src/replicache"; 5 4 import { useUIState } from "src/useUIState"; 6 5 import { scanIndex } from "src/replicache/utils"; 7 6 import { focusBlock } from "src/utils/focusBlock"; 8 7 import { useEditorStates } from "src/state/useEditorState"; 9 - import { useEntitySetContext } from "./EntitySetProvider"; 8 + import { useEntitySetContext } from "../EntitySetProvider"; 10 9 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { v7 } from "uuid"; 12 10 import { indent, outdent, outdentFull } from "src/utils/list-operations"; 13 11 import { addShortcut, Shortcut } from "src/shortcuts"; 14 - import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 15 12 import { elementId } from "src/utils/elementId"; 16 13 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 17 14 import { copySelection } from "src/utils/copySelection"; 18 - import { isTextBlock } from "src/utils/isTextBlock"; 19 15 import { useIsMobile } from "src/hooks/isMobile"; 20 - import { deleteBlock } from "./Blocks/DeleteBlock"; 21 - import { Replicache } from "replicache"; 22 - import { schema } from "./Blocks/TextBlock/schema"; 23 - import { TextSelection } from "prosemirror-state"; 16 + import { deleteBlock } from "src/utils/deleteBlock"; 17 + import { schema } from "../Blocks/TextBlock/schema"; 24 18 import { MarkType } from "prosemirror-model"; 25 - export const useSelectingMouse = create(() => ({ 26 - start: null as null | string, 27 - })); 19 + import { useSelectingMouse, getSortedSelection } from "./selectionState"; 28 20 29 21 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 30 22 // How does this relate to *when dragging* ? ··· 632 624 endOffset: number; 633 625 direction: "forward" | "backward"; 634 626 }; 635 - export function saveSelection() { 627 + function saveSelection() { 636 628 let selection = window.getSelection(); 637 629 if (selection && selection.rangeCount > 0) { 638 630 let ranges: SavedRange[] = []; ··· 655 647 return []; 656 648 } 657 649 658 - export function restoreSelection(savedRanges: SavedRange[]) { 650 + function restoreSelection(savedRanges: SavedRange[]) { 659 651 if (savedRanges && savedRanges.length > 0) { 660 652 let selection = window.getSelection(); 661 653 if (!selection) return; ··· 693 685 return null; 694 686 } 695 687 696 - export const getSortedSelection = async ( 697 - rep: Replicache<ReplicacheMutators>, 698 - ) => { 699 - let selectedBlocks = useUIState.getState().selectedBlocks; 700 - let foldedBlocks = useUIState.getState().foldedBlocks; 701 - if (!selectedBlocks[0]) return [[], []]; 702 - let siblings = 703 - (await rep?.query((tx) => 704 - getBlocksWithType(tx, selectedBlocks[0].parent), 705 - )) || []; 706 - let sortedBlocks = siblings.filter((s) => { 707 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 708 - return selected; 709 - }); 710 - let sortedBlocksWithChildren = siblings.filter((s) => { 711 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 712 - if (s.listData && !selected) { 713 - //Select the children of folded list blocks (in order to copy them) 714 - return s.listData.path.find( 715 - (p) => 716 - selectedBlocks.find((sb) => sb.value === p.entity) && 717 - foldedBlocks.includes(p.entity), 718 - ); 719 - } 720 - return selected; 721 - }); 722 - return [ 723 - sortedBlocks, 724 - siblings.filter( 725 - (f) => 726 - !f.listData || 727 - !f.listData.path.find( 728 - (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 729 - ), 730 - ), 731 - sortedBlocksWithChildren, 732 - ]; 733 - }; 734 688 735 689 function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 736 690 let everyBlockHasMark = blocks.reduce((acc, block) => {
+48
components/SelectionManager/selectionState.ts
··· 1 + import { create } from "zustand"; 2 + import { Replicache } from "replicache"; 3 + import { ReplicacheMutators } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + 7 + export const useSelectingMouse = create(() => ({ 8 + start: null as null | string, 9 + })); 10 + 11 + export const getSortedSelection = async ( 12 + rep: Replicache<ReplicacheMutators>, 13 + ) => { 14 + let selectedBlocks = useUIState.getState().selectedBlocks; 15 + let foldedBlocks = useUIState.getState().foldedBlocks; 16 + if (!selectedBlocks[0]) return [[], []]; 17 + let siblings = 18 + (await rep?.query((tx) => 19 + getBlocksWithType(tx, selectedBlocks[0].parent), 20 + )) || []; 21 + let sortedBlocks = siblings.filter((s) => { 22 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 23 + return selected; 24 + }); 25 + let sortedBlocksWithChildren = siblings.filter((s) => { 26 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 27 + if (s.listData && !selected) { 28 + //Select the children of folded list blocks (in order to copy them) 29 + return s.listData.path.find( 30 + (p) => 31 + selectedBlocks.find((sb) => sb.value === p.entity) && 32 + foldedBlocks.includes(p.entity), 33 + ); 34 + } 35 + return selected; 36 + }); 37 + return [ 38 + sortedBlocks, 39 + siblings.filter( 40 + (f) => 41 + !f.listData || 42 + !f.listData.path.find( 43 + (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 44 + ), 45 + ), 46 + sortedBlocksWithChildren, 47 + ]; 48 + };
+1 -1
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 2 import { useMemo, useState } from "react"; 3 3 import { parseColor } from "react-aria-components"; 4 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./ThemeProvider"; 5 + import { getColorContrast } from "./themeUtils"; 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
+1 -40
components/ThemeManager/ThemeProvider.tsx
··· 5 5 CSSProperties, 6 6 useContext, 7 7 useEffect, 8 - useMemo, 9 - useState, 10 8 } from "react"; 11 9 import { 12 10 colorToString, ··· 14 12 useColorAttributeNullable, 15 13 } from "./useColorAttribute"; 16 14 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 15 19 16 import { useEntity } from "src/replicache"; 20 17 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 20 PublicationThemeProvider, 24 21 } from "./PublicationThemeProvider"; 25 22 import { PubLeafletPublication } from "lexicons/api"; 26 - 27 - type CSSVariables = { 28 - "--bg-leaflet": string; 29 - "--bg-page": string; 30 - "--primary": string; 31 - "--accent-1": string; 32 - "--accent-2": string; 33 - "--accent-contrast": string; 34 - "--highlight-1": string; 35 - "--highlight-2": string; 36 - "--highlight-3": string; 37 - }; 38 - 39 - // define the color defaults for everything 40 - export const ThemeDefaults = { 41 - "theme/page-background": "#FDFCFA", 42 - "theme/card-background": "#FFFFFF", 43 - "theme/primary": "#272727", 44 - "theme/highlight-1": "#FFFFFF", 45 - "theme/highlight-2": "#EDD280", 46 - "theme/highlight-3": "#FFCDC3", 47 - 48 - //everywhere else, accent-background = accent-1 and accent-text = accent-2. 49 - // we just need to create a migration pipeline before we can change this 50 - "theme/accent-text": "#FFFFFF", 51 - "theme/accent-background": "#0000FF", 52 - "theme/accent-contrast": "#0000FF", 53 - }; 23 + import { getColorContrast } from "./themeUtils"; 54 24 55 25 // define a function to set an Aria Color to a CSS Variable in RGB 56 26 function setCSSVariableToColor( ··· 368 338 ); 369 339 }; 370 340 371 - // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 372 - export function getColorContrast(color1: string, color2: string) { 373 - ColorSpace.register(sRGB); 374 - 375 - let parsedColor1 = parse(`rgb(${color1})`); 376 - let parsedColor2 = parse(`rgb(${color2})`); 377 - 378 - return contrastLstar(parsedColor1, parsedColor2); 379 - }
+27
components/ThemeManager/themeUtils.ts
··· 1 + import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 2 + 3 + // define the color defaults for everything 4 + export const ThemeDefaults = { 5 + "theme/page-background": "#FDFCFA", 6 + "theme/card-background": "#FFFFFF", 7 + "theme/primary": "#272727", 8 + "theme/highlight-1": "#FFFFFF", 9 + "theme/highlight-2": "#EDD280", 10 + "theme/highlight-3": "#FFCDC3", 11 + 12 + //everywhere else, accent-background = accent-1 and accent-text = accent-2. 13 + // we just need to create a migration pipeline before we can change this 14 + "theme/accent-text": "#FFFFFF", 15 + "theme/accent-background": "#0000FF", 16 + "theme/accent-contrast": "#0000FF", 17 + }; 18 + 19 + // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 + export function getColorContrast(color1: string, color2: string) { 21 + ColorSpace.register(sRGB); 22 + 23 + let parsedColor1 = parse(`rgb(${color1})`); 24 + let parsedColor2 = parse(`rgb(${color2})`); 25 + 26 + return contrastLstar(parsedColor1, parsedColor2); 27 + }
+1 -1
components/ThemeManager/useColorAttribute.ts
··· 2 2 import { Color, parseColor } from "react-aria-components"; 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { FilterAttributes } from "src/replicache/attributes"; 5 - import { ThemeDefaults } from "./ThemeProvider"; 5 + import { ThemeDefaults } from "./themeUtils"; 6 6 7 7 export function useColorAttribute( 8 8 entity: string | null,
+5 -14
components/Toolbar/BlockToolbar.tsx
··· 2 2 import { ToolbarButton } from "."; 3 3 import { Separator, ShortcutKey } from "components/Layout"; 4 4 import { metaKey } from "src/utils/metaKey"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 5 import { useUIState } from "src/useUIState"; 7 6 import { LockBlockButton } from "./LockBlockButton"; 8 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 8 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 11 12 12 export const BlockToolbar = (props: { 13 13 setToolbarState: ( ··· 66 66 67 67 const MoveBlockButtons = () => { 68 68 let { rep } = useReplicache(); 69 - const getSortedSelection = async () => { 70 - let selectedBlocks = useUIState.getState().selectedBlocks; 71 - let siblings = 72 - (await rep?.query((tx) => 73 - getBlocksWithType(tx, selectedBlocks[0].parent), 74 - )) || []; 75 - let sortedBlocks = siblings.filter((s) => 76 - selectedBlocks.find((sb) => sb.value === s.value), 77 - ); 78 - return [sortedBlocks, siblings]; 79 - }; 80 69 return ( 81 70 <> 82 71 <ToolbarButton 83 72 hiddenOnCanvas 84 73 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 74 + if (!rep) return; 75 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 86 76 if (sortedBlocks.length > 1) return; 87 77 let block = sortedBlocks[0]; 88 78 let previousBlock = ··· 139 129 <ToolbarButton 140 130 hiddenOnCanvas 141 131 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 132 + if (!rep) return; 133 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 143 134 if (sortedBlocks.length > 1) return; 144 135 let block = sortedBlocks[0]; 145 136 let nextBlock = siblings
+1 -1
components/Toolbar/MultiSelectToolbar.tsx
··· 8 8 import { LockBlockButton } from "./LockBlockButton"; 9 9 import { Props } from "components/Icons/Props"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { getSortedSelection } from "components/SelectionManager"; 11 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 12 13 13 export const MultiselectToolbar = (props: { 14 14 setToolbarState: (
+2 -1
components/Toolbar/index.tsx
··· 13 13 import { TextToolbar } from "./TextToolbar"; 14 14 import { BlockToolbar } from "./BlockToolbar"; 15 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 17 18 import { TooltipButton } from "components/Buttons"; 18 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 19 20 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
components/utils/UpdateLeafletTitle.tsx
··· 8 8 import { useEntity, useReplicache } from "src/replicache"; 9 9 import * as Y from "yjs"; 10 10 import * as base64 from "base64-js"; 11 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 11 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 12 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 13 import { focusBlock } from "src/utils/focusBlock"; 14 14 import { useIsMobile } from "src/hooks/isMobile";
+116
src/utils/deleteBlock.ts
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + 8 + export async function deleteBlock( 9 + entities: string[], 10 + rep: Replicache<ReplicacheMutators>, 11 + ) { 12 + // get what pagess we need to close as a result of deleting this block 13 + let pagesToClose = [] as string[]; 14 + for (let entity of entities) { 15 + let [type] = await rep.query((tx) => 16 + scanIndex(tx).eav(entity, "block/type"), 17 + ); 18 + if (type.data.value === "card") { 19 + let [childPages] = await rep?.query( 20 + (tx) => scanIndex(tx).eav(entity, "block/card") || [], 21 + ); 22 + pagesToClose = [childPages?.data.value]; 23 + } 24 + if (type.data.value === "mailbox") { 25 + let [archive] = await rep?.query( 26 + (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 27 + ); 28 + let [draft] = await rep?.query( 29 + (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 30 + ); 31 + pagesToClose = [archive?.data.value, draft?.data.value]; 32 + } 33 + } 34 + 35 + // the next and previous blocks in the block list 36 + // if the focused thing is a page and not a block, return 37 + let focusedBlock = useUIState.getState().focusedEntity; 38 + let parent = 39 + focusedBlock?.entityType === "page" 40 + ? focusedBlock.entityID 41 + : focusedBlock?.parent; 42 + 43 + if (parent) { 44 + let parentType = await rep?.query((tx) => 45 + scanIndex(tx).eav(parent, "page/type"), 46 + ); 47 + if (parentType[0]?.data.value === "canvas") { 48 + useUIState 49 + .getState() 50 + .setFocusedBlock({ entityType: "page", entityID: parent }); 51 + useUIState.getState().setSelectedBlocks([]); 52 + } else { 53 + let siblings = 54 + (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 + 56 + let selectedBlocks = useUIState.getState().selectedBlocks; 57 + let firstSelected = selectedBlocks[0]; 58 + let lastSelected = selectedBlocks[entities.length - 1]; 59 + 60 + let prevBlock = 61 + siblings?.[ 62 + siblings.findIndex((s) => s.value === firstSelected?.value) - 1 63 + ]; 64 + let prevBlockType = await rep?.query((tx) => 65 + scanIndex(tx).eav(prevBlock?.value, "block/type"), 66 + ); 67 + 68 + let nextBlock = 69 + siblings?.[ 70 + siblings.findIndex((s) => s.value === lastSelected.value) + 1 71 + ]; 72 + let nextBlockType = await rep?.query((tx) => 73 + scanIndex(tx).eav(nextBlock?.value, "block/type"), 74 + ); 75 + 76 + if (prevBlock) { 77 + useUIState.getState().setSelectedBlock({ 78 + value: prevBlock.value, 79 + parent: prevBlock.parent, 80 + }); 81 + 82 + focusBlock( 83 + { 84 + value: prevBlock.value, 85 + type: prevBlockType?.[0].data.value, 86 + parent: prevBlock.parent, 87 + }, 88 + { type: "end" }, 89 + ); 90 + } else { 91 + useUIState.getState().setSelectedBlock({ 92 + value: nextBlock.value, 93 + parent: nextBlock.parent, 94 + }); 95 + 96 + focusBlock( 97 + { 98 + value: nextBlock.value, 99 + type: nextBlockType?.[0]?.data.value, 100 + parent: nextBlock.parent, 101 + }, 102 + { type: "start" }, 103 + ); 104 + } 105 + } 106 + } 107 + 108 + pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 109 + await Promise.all( 110 + entities.map((entity) => 111 + rep?.mutate.removeBlock({ 112 + blockEntity: entity, 113 + }), 114 + ), 115 + ); 116 + }
+37
src/utils/focusElement.ts
··· 1 + import { isIOS } from "src/utils/isDevice"; 2 + 3 + export const focusElement = ( 4 + el?: HTMLInputElement | HTMLTextAreaElement | null, 5 + ) => { 6 + if (!isIOS()) { 7 + el?.focus(); 8 + return; 9 + } 10 + 11 + let fakeInput = document.createElement("input"); 12 + fakeInput.setAttribute("type", "text"); 13 + fakeInput.style.position = "fixed"; 14 + fakeInput.style.height = "0px"; 15 + fakeInput.style.width = "0px"; 16 + fakeInput.style.fontSize = "16px"; // disable auto zoom 17 + document.body.appendChild(fakeInput); 18 + fakeInput.focus(); 19 + setTimeout(() => { 20 + if (!el) return; 21 + el.style.transform = "translateY(-2000px)"; 22 + el?.focus(); 23 + fakeInput.remove(); 24 + el.value = " "; 25 + el.setSelectionRange(1, 1); 26 + requestAnimationFrame(() => { 27 + if (el) { 28 + el.style.transform = ""; 29 + } 30 + }); 31 + setTimeout(() => { 32 + if (!el) return; 33 + el.value = ""; 34 + el.setSelectionRange(0, 0); 35 + }, 50); 36 + }, 20); 37 + };
+73
src/utils/focusPage.ts
··· 1 + import { Replicache } from "replicache"; 2 + import { Fact, ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 6 + import { elementId } from "src/utils/elementId"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 + 9 + export async function focusPage( 10 + pageID: string, 11 + rep: Replicache<ReplicacheMutators>, 12 + focusFirstBlock?: "focusFirstBlock", 13 + ) { 14 + // if this page is already focused, 15 + let focusedBlock = useUIState.getState().focusedEntity; 16 + // else set this page as focused 17 + useUIState.setState(() => ({ 18 + focusedEntity: { 19 + entityType: "page", 20 + entityID: pageID, 21 + }, 22 + })); 23 + 24 + setTimeout(async () => { 25 + //scroll to page 26 + 27 + scrollIntoViewIfNeeded( 28 + document.getElementById(elementId.page(pageID).container), 29 + false, 30 + "smooth", 31 + ); 32 + 33 + // if we asked that the function focus the first block, focus the first block 34 + if (focusFirstBlock === "focusFirstBlock") { 35 + let firstBlock = await rep.query(async (tx) => { 36 + let type = await scanIndex(tx).eav(pageID, "page/type"); 37 + let blocks = await scanIndex(tx).eav( 38 + pageID, 39 + type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 40 + ); 41 + 42 + let firstBlock = blocks[0]; 43 + 44 + if (!firstBlock) { 45 + return null; 46 + } 47 + 48 + let blockType = ( 49 + await tx 50 + .scan< 51 + Fact<"block/type"> 52 + >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 53 + .toArray() 54 + )[0]; 55 + 56 + if (!blockType) return null; 57 + 58 + return { 59 + value: firstBlock.data.value, 60 + type: blockType.data.value, 61 + parent: firstBlock.entity, 62 + position: firstBlock.data.position, 63 + }; 64 + }); 65 + 66 + if (firstBlock) { 67 + setTimeout(() => { 68 + focusBlock(firstBlock, { type: "start" }); 69 + }, 500); 70 + } 71 + } 72 + }, 50); 73 + }
+41
src/utils/yjsFragmentToString.ts
··· 1 + import { XmlElement, XmlText, XmlHook } from "yjs"; 2 + 3 + export type Delta = { 4 + insert: string; 5 + attributes?: { 6 + strong?: {}; 7 + code?: {}; 8 + em?: {}; 9 + underline?: {}; 10 + strikethrough?: {}; 11 + highlight?: { color: string }; 12 + link?: { href: string }; 13 + }; 14 + }; 15 + 16 + export function YJSFragmentToString( 17 + node: XmlElement | XmlText | XmlHook, 18 + ): string { 19 + if (node.constructor === XmlElement) { 20 + // Handle hard_break nodes specially 21 + if (node.nodeName === "hard_break") { 22 + return "\n"; 23 + } 24 + // Handle inline mention nodes 25 + if (node.nodeName === "didMention" || node.nodeName === "atMention") { 26 + return node.getAttribute("text") || ""; 27 + } 28 + return node 29 + .toArray() 30 + .map((f) => YJSFragmentToString(f)) 31 + .join(""); 32 + } 33 + if (node.constructor === XmlText) { 34 + return (node.toDelta() as Delta[]) 35 + .map((d) => { 36 + return d.insert; 37 + }) 38 + .join(""); 39 + } 40 + return ""; 41 + }