From 332218805be47b857fee8647dde4b68aa69c7dec Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 18 Feb 2025 21:21:14 -0500 Subject: [PATCH 01/12] add basic undo handling --- components/Blocks/TextBlock/index.tsx | 62 ++++++++++++++++- components/Blocks/TextBlock/keymap.ts | 37 +++++++--- package-lock.json | 6 ++ package.json | 1 + src/replicache/clientMutationContext.ts | 92 ++++++++++++++++++++++--- src/replicache/index.tsx | 43 +++++++++++- src/replicache/mutations.ts | 16 +++-- 7 files changed, 233 insertions(+), 24 deletions(-) diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index 6c41cd41..3db27789 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -224,7 +224,7 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { )?.editor; useEffect(() => { if (!editorState) { - let km = TextBlockKeymap(propsRef, repRef); + let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); setEditorState(props.entityID, { keymap: km, editor: EditorState.create({ @@ -261,11 +261,62 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { window.open(mark.attrs.href, "_blank"); } }, []); + let actionTimeout = useRef(null); let dispatchTransaction = useCallback( (tr: Transaction) => { useEditorStates.setState((s) => { let existingState = s.editorStates[props.entityID]; if (!existingState) return s; + let newState = existingState.editor.apply(tr); + let addToHistory = tr.getMeta("addToHistory"); + console.log(addToHistory); + if (addToHistory !== false) { + if (actionTimeout.current) { + window.clearTimeout(actionTimeout.current); + } else { + console.log("starting group"); + rep.undoManager.startGroup(); + } + + actionTimeout.current = window.setTimeout(() => { + rep.undoManager.endGroup(); + console.log("ending group"); + actionTimeout.current = null; + }, 200); + rep.undoManager.add({ + redo: () => { + useEditorStates.setState((oldState) => { + let view = oldState.editorStates[props.entityID]?.view; + if (!view?.hasFocus()) view?.focus(); + return { + editorStates: { + ...oldState.editorStates, + [props.entityID]: { + ...oldState.editorStates[props.entityID]!, + editor: newState, + }, + }, + }; + }); + }, + undo: () => { + useEditorStates.setState((oldState) => { + let view = oldState.editorStates[props.entityID]?.view; + if (!view?.hasFocus()) view?.focus(); + return { + editorStates: { + ...oldState.editorStates, + [props.entityID]: { + ...oldState.editorStates[props.entityID]!, + editor: existingState.editor, + }, + }, + }; + }); + }, + }); + } + return { editorStates: { ...s.editorStates, @@ -277,7 +328,7 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { }; }); }, - [props.entityID], + [props.entityID, rep.undoManager], ); if (!editorState) return null; @@ -295,6 +346,11 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
 {
+            if (actionTimeout.current) {
+              rep.undoManager.endGroup();
+              window.clearTimeout(actionTimeout.current);
+              actionTimeout.current = null;
+            }
             if (editorState.doc.textContent.startsWith("http")) {
               await addLinkBlock(
                 editorState.doc.textContent,
@@ -593,6 +649,8 @@ function useYJSValue(entityID: string) {
       const updateReplicache = async () => {
         const update = Y.encodeStateAsUpdate(ydoc);
         await rep.rep?.mutate.assertFact({
+          //These undos are handled above in the Prosemirror context
+          ignoreUndo: true,
           entity: entityID,
           attribute: "block/text",
           data: {
diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts
index 4d0631bc..1269d18a 100644
--- a/components/Blocks/TextBlock/keymap.ts
+++ b/components/Blocks/TextBlock/keymap.ts
@@ -23,11 +23,13 @@ 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";
+import { UndoManager } from "src/undoManager";
 
 type PropsRef = MutableRefObject;
 export const TextBlockKeymap = (
   propsRef: PropsRef,
   repRef: MutableRefObject | null>,
+  um: UndoManager,
 ) =>
   ({
     "Meta-b": toggleMark(schema.marks.strong),
@@ -45,12 +47,20 @@ export const TextBlockKeymap = (
     "Ctrl-a": metaA(propsRef, repRef),
     "Meta-a": metaA(propsRef, repRef),
     Tab: () => {
-      if (useUIState.getState().selectedBlocks.length > 1) return false;
-      if (!repRef.current || !propsRef.current.previousBlock) return false;
-      indent(propsRef.current, propsRef.current.previousBlock, repRef.current);
-      return true;
+      return um.withUndoGroup(() => {
+        if (useUIState.getState().selectedBlocks.length > 1) return false;
+        if (!repRef.current || !propsRef.current.previousBlock) return false;
+        indent(
+          propsRef.current,
+          propsRef.current.previousBlock,
+          repRef.current,
+        );
+        return true;
+      });
+    },
+    "Shift-Tab": () => {
+      return um.withUndoGroup(shifttab(propsRef, repRef));
     },
-    "Shift-Tab": shifttab(propsRef, repRef),
     Escape: (_state, _dispatch, view) => {
       view?.dom.blur();
       useUIState.setState(() => ({
@@ -164,10 +174,21 @@ export const TextBlockKeymap = (
       }
       return true;
     },
-    Backspace: backspace(propsRef, repRef),
+    Backspace: (state, dispatch, view) =>
+      um.withUndoGroup(() =>
+        backspace(propsRef, repRef)(state, dispatch, view),
+      ),
     "Shift-Backspace": backspace(propsRef, repRef),
-    Enter: enter(propsRef, repRef),
-    "Shift-Enter": enter(propsRef, repRef),
+    Enter: (state, dispatch, view) => {
+      return um.withUndoGroup(() =>
+        enter(propsRef, repRef)(state, dispatch, view),
+      );
+    },
+    "Shift-Enter": (state, dispatch, view) => {
+      return um.withUndoGroup(() =>
+        enter(propsRef, repRef)(state, dispatch, view),
+      );
+    },
     "Ctrl-Enter": CtrlEnter(propsRef, repRef),
     "Meta-Enter": CtrlEnter(propsRef, repRef),
   }) as { [key: string]: Command };
diff --git a/package-lock.json b/package-lock.json
index 4c4853b2..7dc9fc1e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
         "@radix-ui/react-tooltip": "^1.1.2",
         "@react-aria/utils": "^3.24.1",
         "@react-spring/web": "^9.7.3",
+        "@rocicorp/undo": "^0.2.1",
         "@supabase/ssr": "^0.3.0",
         "@supabase/supabase-js": "^2.43.2",
         "@types/mdx": "^2.0.13",
@@ -5229,6 +5230,11 @@
         "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
       }
     },
+    "node_modules/@rocicorp/undo": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@rocicorp/undo/-/undo-0.2.1.tgz",
+      "integrity": "sha512-m4hPbRPFI/3XEzCJdyZbT1JY+3+SV+WZQXiujyDr7gynsNFuNE3tr95f1arCHLuCJVonwL8OE8mQTVEwjD8lDg=="
+    },
     "node_modules/@rushstack/eslint-patch": {
       "version": "1.10.3",
       "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz",
diff --git a/package.json b/package.json
index c78ef477..fa5e5fda 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
     "@radix-ui/react-tooltip": "^1.1.2",
     "@react-aria/utils": "^3.24.1",
     "@react-spring/web": "^9.7.3",
+    "@rocicorp/undo": "^0.2.1",
     "@supabase/ssr": "^0.3.0",
     "@supabase/supabase-js": "^2.43.2",
     "@types/mdx": "^2.0.13",
diff --git a/src/replicache/clientMutationContext.ts b/src/replicache/clientMutationContext.ts
index 7f66fad1..fdcc6e8e 100644
--- a/src/replicache/clientMutationContext.ts
+++ b/src/replicache/clientMutationContext.ts
@@ -1,14 +1,28 @@
-import { WriteTransaction } from "replicache";
+import { Replicache, WriteTransaction } from "replicache";
 import * as Y from "yjs";
 import * as base64 from "base64-js";
 import { FactWithIndexes, scanIndex } from "./utils";
 import { Attributes, FilterAttributes } from "./attributes";
-import { Fact } from ".";
-import { MutationContext } from "./mutations";
+import { Fact, ReplicacheMutators } from ".";
+import { FactInput, MutationContext } from "./mutations";
 import { supabaseBrowserClient } from "supabase/browserClient";
 import { v7 } from "uuid";
+import { UndoManager } from "src/undoManager";
 
-export function clientMutationContext(tx: WriteTransaction) {
+export function clientMutationContext(
+  tx: WriteTransaction,
+  {
+    rep,
+    undoManager,
+    ignoreUndo,
+    defaultEntitySet,
+  }: {
+    undoManager: UndoManager;
+    rep: Replicache;
+    ignoreUndo: boolean;
+    defaultEntitySet: string;
+  },
+) {
   let ctx: MutationContext = {
     async runOnServer(cb) {},
     async runOnClient(cb) {
@@ -29,8 +43,9 @@ export function clientMutationContext(tx: WriteTransaction) {
       if (!attribute) return;
       let id = f.id || v7();
       let data = { ...f.data };
+      let existingFact = [] as Fact[];
       if (attribute.cardinality === "one") {
-        let existingFact = await scanIndex(tx).eav(f.entity, f.attribute);
+        existingFact = await scanIndex(tx).eav(f.entity, f.attribute);
         if (existingFact[0]) {
           id = existingFact[0].id;
           if (attribute.type === "text") {
@@ -50,12 +65,50 @@ export function clientMutationContext(tx: WriteTransaction) {
           }
         }
       }
+      if (!ignoreUndo)
+        undoManager.add({
+          undo: () => {
+            if (existingFact[0]) {
+              rep.mutate.assertFact({ ignoreUndo: true, ...existingFact[0] });
+            } else {
+              if (attribute.cardinality === "one" && !f.id)
+                rep.mutate.retractAttribute({
+                  attribute: f.attribute as keyof FilterAttributes<{
+                    cardinality: "one";
+                  }>,
+                  entity: f.entity,
+                });
+              rep.mutate.retractFact({ ignoreUndo: true, factID: id });
+            }
+          },
+          redo: () => {
+            rep.mutate.assertFact({ ignoreUndo: true, ...(f as Fact) });
+          },
+        });
       await tx.set(id, FactWithIndexes({ id, ...f, data }));
     },
     async retractFact(id) {
+      let fact = await tx.get(id);
+      if (!ignoreUndo)
+        undoManager.add({
+          undo: () => {
+            if (fact) {
+              rep.mutate.assertFact({
+                ignoreUndo: true,
+                ...(fact as Fact),
+              });
+            } else {
+              rep.mutate.retractFact({ ignoreUndo: true, factID: id });
+            }
+          },
+          redo: () => {
+            rep.mutate.assertFact({ ignoreUndo: true, ...(fact as Fact) });
+          },
+        });
       await tx.del(id);
     },
     async deleteEntity(entity) {
+      console.trace("deleting entity ");
       let existingFacts = await tx
         .scan>({
           indexName: "eav",
@@ -68,9 +121,32 @@ export function clientMutationContext(tx: WriteTransaction) {
           prefix: entity,
         })
         .toArray();
-      await Promise.all(
-        [...existingFacts, ...references].map((f) => tx.del(f.id)),
-      );
+      let facts = [...existingFacts, ...references];
+      await Promise.all(facts.map((f) => tx.del(f.id)));
+      if (!ignoreUndo && facts.length > 0) {
+        undoManager.add({
+          undo: async () => {
+            console.log("undoin!");
+            let input: FactInput[] & { ignoreUndo?: true } = facts.map(
+              (f) =>
+                ({
+                  id: f.id,
+                  attribute: f.attribute,
+                  entity: f.entity,
+                  data: f.data,
+                }) as FactInput,
+            );
+            input.ignoreUndo = true;
+            await rep.mutate.createEntity([
+              { entityID: entity, permission_set: defaultEntitySet },
+            ]);
+            await rep.mutate.assertFact(input);
+          },
+          redo: () => {
+            rep.mutate.deleteEntity({ entity, ignoreUndo: true });
+          },
+        });
+      }
     },
   };
   return ctx;
diff --git a/src/replicache/index.tsx b/src/replicache/index.tsx
index f27e3809..d0713798 100644
--- a/src/replicache/index.tsx
+++ b/src/replicache/index.tsx
@@ -1,5 +1,13 @@
 "use client";
-import { createContext, useContext, useEffect, useMemo, useState } from "react";
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
 import { useSubscribe } from "replicache-react";
 import {
   DeepReadonlyObject,
@@ -13,6 +21,9 @@ import { Attributes, Data, FilterAttributes } from "./attributes";
 import { clientMutationContext } from "./clientMutationContext";
 import { supabaseBrowserClient } from "supabase/browserClient";
 import { callRPC } from "app/api/rpc/client";
+import { UndoManager } from "@rocicorp/undo";
+import { addShortcut } from "src/shortcuts";
+import { createUndoManager } from "src/undoManager";
 
 export type Fact = {
   id: string;
@@ -22,6 +33,7 @@ export type Fact = {
 };
 
 let ReplicacheContext = createContext({
+  undoManager: createUndoManager(),
   rootEntity: "" as string,
   rep: null as null | Replicache,
   initialFacts: [] as Fact[],
@@ -59,6 +71,26 @@ export function ReplicacheProvider(props: {
   initialFactsOnly?: boolean;
 }) {
   let [rep, setRep] = useState>(null);
+  let [undoManager] = useState(createUndoManager());
+  useEffect(() => {
+    return addShortcut([
+      {
+        metaKey: true,
+        key: "z",
+        handler: () => {
+          undoManager.undo();
+        },
+      },
+      {
+        metaKey: true,
+        shift: true,
+        key: "Z",
+        handler: () => {
+          undoManager.redo();
+        },
+      },
+    ]);
+  }, [undoManager]);
   useEffect(() => {
     if (props.initialFactsOnly) return;
     let supabase = supabaseBrowserClient();
@@ -71,7 +103,13 @@ export function ReplicacheProvider(props: {
             async (tx: WriteTransaction, args: any) => {
               await mutations[m as keyof typeof mutations](
                 args,
-                clientMutationContext(tx),
+                clientMutationContext(tx, {
+                  undoManager,
+                  rep: newRep,
+                  ignoreUndo: args.ignoreUndo,
+                  defaultEntitySet:
+                    props.token.permission_token_rights[0]?.entity_set,
+                }),
               );
             },
           ];
@@ -127,6 +165,7 @@ export function ReplicacheProvider(props: {
   return (
     ;
 };
 
-type Mutation = (args: T, ctx: MutationContext) => Promise;
+type Mutation = (
+  args: T & { ignoreUndo?: true },
+  ctx: MutationContext,
+) => Promise;
 
 const addCanvasBlock: Mutation<{
   parent: string;
@@ -324,7 +327,11 @@ const removeBlock: Mutation<
   }
 };
 
-type FactInput = {
+const deleteEntity: Mutation<{ entity: string }> = async (args, ctx) => {
+  await ctx.deleteEntity(args.entity);
+};
+
+export type FactInput = {
   [k in keyof typeof Attributes]: Omit, "id"> & { id?: string };
 }[keyof typeof Attributes];
 const assertFact: Mutation> = async (
@@ -569,6 +576,7 @@ export const mutations = {
   assertFact,
   retractFact,
   removeBlock,
+  deleteEntity,
   moveChildren,
   increaseHeadingLevel,
   archiveDraft,
-- 
2.51.2


From 22df4d9b39390bc57634202fd4f0b05ddd324688 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Tue, 18 Feb 2025 21:21:22 -0500
Subject: [PATCH 02/12] await mutation in keyboard handler

---
 components/Blocks/useBlockKeyboardHandlers.ts |  2 +-
 src/undoManager.ts                            | 37 +++++++++++++++++++
 2 files changed, 38 insertions(+), 1 deletion(-)
 create mode 100644 src/undoManager.ts

diff --git a/components/Blocks/useBlockKeyboardHandlers.ts b/components/Blocks/useBlockKeyboardHandlers.ts
index b48b814e..a7d5f313 100644
--- a/components/Blocks/useBlockKeyboardHandlers.ts
+++ b/components/Blocks/useBlockKeyboardHandlers.ts
@@ -168,7 +168,7 @@ async function Backspace({
   }
 
   e.preventDefault();
-  rep.mutate.removeBlock({ blockEntity: props.entityID });
+  await rep.mutate.removeBlock({ blockEntity: props.entityID });
   useUIState.getState().closePage(props.entityID);
   let prevBlock = props.previousBlock;
   if (prevBlock) focusBlock(prevBlock, { type: "end" });
diff --git a/src/undoManager.ts b/src/undoManager.ts
new file mode 100644
index 00000000..9ca79419
--- /dev/null
+++ b/src/undoManager.ts
@@ -0,0 +1,37 @@
+import { UndoManager as RociUndoManager } from "@rocicorp/undo";
+
+export type UndoManager = ReturnType;
+export const createUndoManager = () => {
+  let isGrouping = false;
+  let undoManager = new RociUndoManager({
+    onChange: (state) => {},
+  });
+  let um = {
+    add: (args: {
+      undo: () => Promise | void;
+      redo: () => Promise | void;
+    }) => {
+      undoManager.add(args);
+    },
+    startGroup: (groupName?: string) => {
+      if (isGrouping) {
+        undoManager.endGroup();
+      }
+      isGrouping = true;
+      undoManager.startGroup();
+    },
+    endGroup: () => {
+      isGrouping = false;
+      undoManager.endGroup();
+    },
+    undo: () => undoManager.undo(),
+    redo: () => undoManager.redo(),
+    withUndoGroup: (cb: () => T) => {
+      if (!isGrouping) um.startGroup();
+      const r = cb();
+      if (!isGrouping) um.endGroup();
+      return r;
+    },
+  };
+  return um;
+};
-- 
2.51.2


From 47c5877c37e96500748fcad827ea50aacfdaace9 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Tue, 18 Feb 2025 22:36:27 -0500
Subject: [PATCH 03/12] remove logs, prevent rebase mutations adding to undo

---
 components/Blocks/TextBlock/index.tsx   | 6 ++----
 src/replicache/clientMutationContext.ts | 1 +
 src/replicache/index.tsx                | 4 ++--
 3 files changed, 5 insertions(+), 6 deletions(-)

diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index 3db27789..c9894f54 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -226,7 +226,6 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
     if (!editorState) {
       let km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
       setEditorState(props.entityID, {
-        keymap: km,
         editor: EditorState.create({
           schema,
           plugins: [
@@ -269,18 +268,17 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
         if (!existingState) return s;
         let newState = existingState.editor.apply(tr);
         let addToHistory = tr.getMeta("addToHistory");
-        console.log(addToHistory);
         if (addToHistory !== false) {
+        let docHasChanges = !tr.steps.length || tr.docChanged;
+        if (addToHistory !== false && docHasChanges) {
           if (actionTimeout.current) {
             window.clearTimeout(actionTimeout.current);
           } else {
-            console.log("starting group");
             rep.undoManager.startGroup();
           }
 
           actionTimeout.current = window.setTimeout(() => {
             rep.undoManager.endGroup();
-            console.log("ending group");
             actionTimeout.current = null;
           }, 200);
           rep.undoManager.add({
diff --git a/src/replicache/clientMutationContext.ts b/src/replicache/clientMutationContext.ts
index fdcc6e8e..3d3b0002 100644
--- a/src/replicache/clientMutationContext.ts
+++ b/src/replicache/clientMutationContext.ts
@@ -73,6 +73,7 @@ export function clientMutationContext(
             } else {
               if (attribute.cardinality === "one" && !f.id)
                 rep.mutate.retractAttribute({
+                  ignoreUndo: true,
                   attribute: f.attribute as keyof FilterAttributes<{
                     cardinality: "one";
                   }>,
diff --git a/src/replicache/index.tsx b/src/replicache/index.tsx
index d0713798..40c133da 100644
--- a/src/replicache/index.tsx
+++ b/src/replicache/index.tsx
@@ -100,13 +100,13 @@ export function ReplicacheProvider(props: {
         Object.keys(mutations).map((m) => {
           return [
             m,
-            async (tx: WriteTransaction, args: any) => {
+            async (tx: WriteTransaction, args: any, ...rest) => {
               await mutations[m as keyof typeof mutations](
                 args,
                 clientMutationContext(tx, {
                   undoManager,
                   rep: newRep,
-                  ignoreUndo: args.ignoreUndo,
+                  ignoreUndo: args.ignoreUndo || tx.reason !== "initial",
                   defaultEntitySet:
                     props.token.permission_token_rights[0]?.entity_set,
                 }),
-- 
2.51.2


From 2d7974b3e6a922241b84cb1327c9342bd09bf9a1 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Wed, 19 Feb 2025 12:00:44 -0500
Subject: [PATCH 04/12] fix type errors

---
 components/Blocks/TextBlock/index.tsx | 1 -
 src/replicache/index.tsx              | 2 +-
 2 files changed, 1 insertion(+), 2 deletions(-)

diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index c9894f54..8ad843b1 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -268,7 +268,6 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
         if (!existingState) return s;
         let newState = existingState.editor.apply(tr);
         let addToHistory = tr.getMeta("addToHistory");
-        if (addToHistory !== false) {
         let docHasChanges = !tr.steps.length || tr.docChanged;
         if (addToHistory !== false && docHasChanges) {
           if (actionTimeout.current) {
diff --git a/src/replicache/index.tsx b/src/replicache/index.tsx
index 40c133da..dca3c2a4 100644
--- a/src/replicache/index.tsx
+++ b/src/replicache/index.tsx
@@ -100,7 +100,7 @@ export function ReplicacheProvider(props: {
         Object.keys(mutations).map((m) => {
           return [
             m,
-            async (tx: WriteTransaction, args: any, ...rest) => {
+            async (tx: WriteTransaction, args: any) => {
               await mutations[m as keyof typeof mutations](
                 args,
                 clientMutationContext(tx, {
-- 
2.51.2


From acd6906df6ec249915cb4b8856893bbfa2a694c0 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Wed, 19 Feb 2025 22:31:47 -0500
Subject: [PATCH 05/12] add more undo grouping

---
 components/Blocks/BlockCommandBar.tsx         |  35 ++-
 components/Blocks/BlockCommands.tsx           |  44 ++-
 components/Blocks/useBlockKeyboardHandlers.ts |   4 +-
 components/SelectionManager.tsx               | 287 +++++++++---------
 src/replicache/index.tsx                      |   8 +
 5 files changed, 219 insertions(+), 159 deletions(-)

diff --git a/components/Blocks/BlockCommandBar.tsx b/components/Blocks/BlockCommandBar.tsx
index bc0e78a3..b9e14526 100644
--- a/components/Blocks/BlockCommandBar.tsx
+++ b/components/Blocks/BlockCommandBar.tsx
@@ -4,6 +4,7 @@ import { blockCommands } from "./BlockCommands";
 import { useReplicache } from "src/replicache";
 import { useEntitySetContext } from "components/EntitySetProvider";
 import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider";
+import { UndoManager } from "src/undoManager";
 
 type Props = {
   parent: string;
@@ -26,7 +27,7 @@ export const BlockCommandBar = ({
 
   let [highlighted, setHighlighted] = useState(undefined);
 
-  let { rep } = useReplicache();
+  let { rep, undoManager } = useReplicache();
   let entity_set = useEntitySetContext();
 
   let commandResults = blockCommands.filter((command) =>
@@ -44,7 +45,6 @@ export const BlockCommandBar = ({
   }, [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 }) =>
@@ -77,22 +77,23 @@ export const BlockCommandBar = ({
 
       // on enter, select the highlighted item
       if (e.key === "Enter") {
+        undoManager.startGroup();
         e.preventDefault();
         rep &&
-          commandResults[currentHighlightIndex]?.onSelect(rep, {
-            ...props,
-            entity_set: entity_set.set,
-          });
+          (await commandResults[currentHighlightIndex]?.onSelect(
+            rep,
+            {
+              ...props,
+              entity_set: entity_set.set,
+            },
+            undoManager,
+          ));
+        undoManager.endGroup();
         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);
 
@@ -130,10 +131,14 @@ export const BlockCommandBar = ({
                       icon={result.icon}
                       onSelect={() => {
                         rep &&
-                          result.onSelect(rep, {
-                            ...props,
-                            entity_set: entity_set.set,
-                          });
+                          result.onSelect(
+                            rep,
+                            {
+                              ...props,
+                              entity_set: entity_set.set,
+                            },
+                            undoManager,
+                          );
                       }}
                       highlighted={highlighted}
                       setHighlighted={(highlighted) =>
diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx
index 605f6de4..1bb6f5df 100644
--- a/components/Blocks/BlockCommands.tsx
+++ b/components/Blocks/BlockCommands.tsx
@@ -22,6 +22,8 @@ import { Replicache } from "replicache";
 import { keepFocus } from "components/Toolbar/TextBlockTypeToolbar";
 import { useEditorStates } from "src/state/useEditorState";
 import { elementId } from "src/utils/elementId";
+import { UndoManager } from "src/undoManager";
+import { focusBlock } from "src/utils/focusBlock";
 
 type Props = {
   parent: string;
@@ -95,7 +97,8 @@ type Command = {
   onSelect: (
     rep: Replicache,
     props: Props & { entity_set: string },
-  ) => void;
+    undoManager: UndoManager,
+  ) => Promise;
 };
 export const blockCommands: Command[] = [
   // please keep these in the order that they appear in the menu, grouped by type
@@ -226,7 +229,7 @@ export const blockCommands: Command[] = [
     name: "New Page",
     icon: ,
     type: "page",
-    onSelect: async (rep, props) => {
+    onSelect: async (rep, props, um) => {
       let entity = await createBlockWithType(rep, props, "card");
 
       let newPage = v7();
@@ -238,7 +241,25 @@ export const blockCommands: Command[] = [
         type: "doc",
         permission_set: props.entity_set,
       });
+
       useUIState.getState().openPage(props.parent, newPage);
+      um.add({
+        undo: () => {
+          useUIState.getState().closePage(newPage);
+          setTimeout(
+            () =>
+              focusBlock(
+                { parent: props.parent, value: entity, type: "text" },
+                { type: "end" },
+              ),
+            100,
+          );
+        },
+        redo: () => {
+          useUIState.getState().openPage(props.parent, newPage);
+          focusPage(newPage, rep, "focusFirstBlock");
+        },
+      });
       focusPage(newPage, rep, "focusFirstBlock");
     },
   },
@@ -246,7 +267,7 @@ export const blockCommands: Command[] = [
     name: "New Canvas",
     icon: ,
     type: "page",
-    onSelect: async (rep, props) => {
+    onSelect: async (rep, props, um) => {
       let entity = await createBlockWithType(rep, props, "card");
 
       let newPage = v7();
@@ -260,6 +281,23 @@ export const blockCommands: Command[] = [
       });
       useUIState.getState().openPage(props.parent, newPage);
       focusPage(newPage, rep, "focusFirstBlock");
+      um.add({
+        undo: () => {
+          useUIState.getState().closePage(newPage);
+          setTimeout(
+            () =>
+              focusBlock(
+                { parent: props.parent, value: entity, type: "text" },
+                { type: "end" },
+              ),
+            100,
+          );
+        },
+        redo: () => {
+          useUIState.getState().openPage(props.parent, newPage);
+          focusPage(newPage, rep, "focusFirstBlock");
+        },
+      });
     },
   },
 ];
diff --git a/components/Blocks/useBlockKeyboardHandlers.ts b/components/Blocks/useBlockKeyboardHandlers.ts
index a7d5f313..bf82c8d8 100644
--- a/components/Blocks/useBlockKeyboardHandlers.ts
+++ b/components/Blocks/useBlockKeyboardHandlers.ts
@@ -21,7 +21,7 @@ export function useBlockKeyboardHandlers(
   areYouSure: boolean,
   setAreYouSure: (value: boolean) => void,
 ) {
-  let { rep } = useReplicache();
+  let { rep, undoManager } = useReplicache();
   let entity_set = useEntitySetContext();
   let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value;
 
@@ -54,6 +54,7 @@ export function useBlockKeyboardHandlers(
         if ((el as HTMLInputElement).value !== "" || e.key === "Tab") return;
       }
 
+      undoManager.startGroup();
       command?.({
         e,
         props,
@@ -63,6 +64,7 @@ export function useBlockKeyboardHandlers(
         setAreYouSure,
         isLocked,
       });
+      undoManager.endGroup();
     };
     window.addEventListener("keydown", listener);
     return () => window.removeEventListener("keydown", listener);
diff --git a/components/SelectionManager.tsx b/components/SelectionManager.tsx
index 408fe6ff..93ff80a0 100644
--- a/components/SelectionManager.tsx
+++ b/components/SelectionManager.tsx
@@ -27,7 +27,7 @@ export const useSelectingMouse = create(() => ({
 export function SelectionManager() {
   let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1);
   let entity_set = useEntitySetContext();
-  let { rep } = useReplicache();
+  let { rep, undoManager } = useReplicache();
   let isMobile = useIsMobile();
   useEffect(() => {
     if (!entity_set.permissions.write) return;
@@ -68,156 +68,163 @@ export function SelectionManager() {
         sortedBlocksWithChildren,
       ];
     };
-    let removeListener = addShortcut([
-      {
-        metaKey: true,
-        key: "ArrowUp",
-        handler: async () => {
-          let [firstBlock] =
-            (await rep?.query((tx) =>
-              getBlocksWithType(
-                tx,
-                useUIState.getState().selectedBlocks[0].parent,
-              ),
-            )) || [];
-          if (firstBlock) focusBlock(firstBlock, { type: "start" });
+    let removeListener = addShortcut(
+      [
+        {
+          metaKey: true,
+          key: "ArrowUp",
+          handler: async () => {
+            let [firstBlock] =
+              (await rep?.query((tx) =>
+                getBlocksWithType(
+                  tx,
+                  useUIState.getState().selectedBlocks[0].parent,
+                ),
+              )) || [];
+            if (firstBlock) focusBlock(firstBlock, { type: "start" });
+          },
         },
-      },
-      {
-        metaKey: true,
-        key: "ArrowDown",
-        handler: async () => {
-          let blocks =
-            (await rep?.query((tx) =>
-              getBlocksWithType(
-                tx,
-                useUIState.getState().selectedBlocks[0].parent,
-              ),
-            )) || [];
-          let folded = useUIState.getState().foldedBlocks;
-          blocks = blocks.filter(
-            (f) =>
-              !f.listData ||
-              !f.listData.path.find(
-                (path) =>
-                  folded.includes(path.entity) && f.value !== path.entity,
-              ),
-          );
-          let lastBlock = blocks[blocks.length - 1];
-          if (lastBlock) focusBlock(lastBlock, { type: "end" });
+        {
+          metaKey: true,
+          key: "ArrowDown",
+          handler: async () => {
+            let blocks =
+              (await rep?.query((tx) =>
+                getBlocksWithType(
+                  tx,
+                  useUIState.getState().selectedBlocks[0].parent,
+                ),
+              )) || [];
+            let folded = useUIState.getState().foldedBlocks;
+            blocks = blocks.filter(
+              (f) =>
+                !f.listData ||
+                !f.listData.path.find(
+                  (path) =>
+                    folded.includes(path.entity) && f.value !== path.entity,
+                ),
+            );
+            let lastBlock = blocks[blocks.length - 1];
+            if (lastBlock) focusBlock(lastBlock, { type: "end" });
+          },
         },
-      },
-      {
-        metaKey: true,
-        altKey: true,
-        key: ["l", "¬"],
-        handler: async () => {
-          let [sortedBlocks, siblings] = await getSortedSelection();
-          for (let block of sortedBlocks) {
-            if (!block.listData) {
-              await rep?.mutate.assertFact({
-                entity: block.value,
-                attribute: "block/is-list",
-                data: { type: "boolean", value: true },
+        {
+          metaKey: true,
+          altKey: true,
+          key: ["l", "¬"],
+          handler: async () => {
+            let [sortedBlocks, siblings] = await getSortedSelection();
+            for (let block of sortedBlocks) {
+              if (!block.listData) {
+                await rep?.mutate.assertFact({
+                  entity: block.value,
+                  attribute: "block/is-list",
+                  data: { type: "boolean", value: true },
+                });
+              } else {
+                outdentFull(block, rep);
+              }
+            }
+          },
+        },
+        {
+          metaKey: true,
+          shift: true,
+          key: ["ArrowDown"],
+          handler: async () => {
+            let [sortedBlocks, siblings] = await getSortedSelection();
+            let block = sortedBlocks[0];
+            let nextBlock = siblings
+              .slice(siblings.findIndex((s) => s.value === block.value) + 1)
+              .find(
+                (f) =>
+                  f.listData &&
+                  block.listData &&
+                  !f.listData.path.find((f) => f.entity === block.value),
+              );
+            if (
+              nextBlock?.listData &&
+              block.listData &&
+              nextBlock.listData.depth === block.listData.depth - 1
+            ) {
+              if (useUIState.getState().foldedBlocks.includes(nextBlock.value))
+                useUIState.getState().toggleFold(nextBlock.value);
+              rep?.mutate.moveBlock({
+                block: block.value,
+                oldParent: block.listData?.parent,
+                newParent: nextBlock.value,
+                position: { type: "first" },
               });
             } else {
-              outdentFull(block, rep);
+              rep?.mutate.moveBlockDown({
+                entityID: block.value,
+                parent: block.listData?.parent || block.parent,
+              });
             }
-          }
+          },
         },
-      },
-      {
-        metaKey: true,
-        shift: true,
-        key: ["ArrowDown"],
-        handler: async () => {
-          let [sortedBlocks, siblings] = await getSortedSelection();
-          let block = sortedBlocks[0];
-          let nextBlock = siblings
-            .slice(siblings.findIndex((s) => s.value === block.value) + 1)
-            .find(
-              (f) =>
-                f.listData &&
-                block.listData &&
-                !f.listData.path.find((f) => f.entity === block.value),
-            );
-          if (
-            nextBlock?.listData &&
-            block.listData &&
-            nextBlock.listData.depth === block.listData.depth - 1
-          ) {
-            if (useUIState.getState().foldedBlocks.includes(nextBlock.value))
-              useUIState.getState().toggleFold(nextBlock.value);
-            rep?.mutate.moveBlock({
-              block: block.value,
-              oldParent: block.listData?.parent,
-              newParent: nextBlock.value,
-              position: { type: "first" },
-            });
-          } else {
-            rep?.mutate.moveBlockDown({
-              entityID: block.value,
-              parent: block.listData?.parent || block.parent,
-            });
-          }
-        },
-      },
-      {
-        metaKey: true,
-        shift: true,
-        key: ["ArrowUp"],
-        handler: async () => {
-          let [sortedBlocks, siblings] = await getSortedSelection();
-          let block = sortedBlocks[0];
-          let previousBlock =
-            siblings?.[siblings.findIndex((s) => s.value === block.value) - 1];
-          if (previousBlock.value === block.listData?.parent) {
-            previousBlock =
+        {
+          metaKey: true,
+          shift: true,
+          key: ["ArrowUp"],
+          handler: async () => {
+            let [sortedBlocks, siblings] = await getSortedSelection();
+            let block = sortedBlocks[0];
+            let previousBlock =
               siblings?.[
-                siblings.findIndex((s) => s.value === block.value) - 2
+                siblings.findIndex((s) => s.value === block.value) - 1
               ];
-          }
+            if (previousBlock.value === block.listData?.parent) {
+              previousBlock =
+                siblings?.[
+                  siblings.findIndex((s) => s.value === block.value) - 2
+                ];
+            }
 
-          if (
-            previousBlock?.listData &&
-            block.listData &&
-            block.listData.depth > 1 &&
-            !previousBlock.listData.path.find(
-              (f) => f.entity === block.listData?.parent,
-            )
-          ) {
-            let depth = block.listData.depth;
-            let newParent = previousBlock.listData.path.find(
-              (f) => f.depth === depth - 1,
-            );
-            if (!newParent) return;
-            if (useUIState.getState().foldedBlocks.includes(newParent.entity))
-              useUIState.getState().toggleFold(newParent.entity);
-            rep?.mutate.moveBlock({
-              block: block.value,
-              oldParent: block.listData?.parent,
-              newParent: newParent.entity,
-              position: { type: "end" },
-            });
-          } else {
-            rep?.mutate.moveBlockUp({
-              entityID: block.value,
-              parent: block.listData?.parent || block.parent,
-            });
-          }
+            if (
+              previousBlock?.listData &&
+              block.listData &&
+              block.listData.depth > 1 &&
+              !previousBlock.listData.path.find(
+                (f) => f.entity === block.listData?.parent,
+              )
+            ) {
+              let depth = block.listData.depth;
+              let newParent = previousBlock.listData.path.find(
+                (f) => f.depth === depth - 1,
+              );
+              if (!newParent) return;
+              if (useUIState.getState().foldedBlocks.includes(newParent.entity))
+                useUIState.getState().toggleFold(newParent.entity);
+              rep?.mutate.moveBlock({
+                block: block.value,
+                oldParent: block.listData?.parent,
+                newParent: newParent.entity,
+                position: { type: "end" },
+              });
+            } else {
+              rep?.mutate.moveBlockUp({
+                entityID: block.value,
+                parent: block.listData?.parent || block.parent,
+              });
+            }
+          },
         },
-      },
-      {
-        metaKey: true,
-        shift: true,
-        key: "Enter",
-        handler: async () => {
-          let [sortedBlocks, siblings] = await getSortedSelection();
-          if (!sortedBlocks[0].listData) return;
-          useUIState.getState().toggleFold(sortedBlocks[0].value);
+        {
+          metaKey: true,
+          shift: true,
+          key: "Enter",
+          handler: async () => {
+            let [sortedBlocks, siblings] = await getSortedSelection();
+            if (!sortedBlocks[0].listData) return;
+            useUIState.getState().toggleFold(sortedBlocks[0].value);
+          },
         },
-      },
-    ]);
+      ].map((shortcut) => ({
+        ...shortcut,
+        handler: () => undoManager.withUndoGroup(() => shortcut.handler()),
+      })),
+    );
     let listener = async (e: KeyboardEvent) => {
       if (e.key === "Backspace" || e.key === "Delete") {
         if (!entity_set.permissions.write) return;
diff --git a/src/replicache/index.tsx b/src/replicache/index.tsx
index dca3c2a4..28cadb69 100644
--- a/src/replicache/index.tsx
+++ b/src/replicache/index.tsx
@@ -81,6 +81,14 @@ export function ReplicacheProvider(props: {
           undoManager.undo();
         },
       },
+      {
+        metaKey: true,
+        shift: true,
+        key: "z",
+        handler: () => {
+          undoManager.redo();
+        },
+      },
       {
         metaKey: true,
         shift: true,
-- 
2.51.2


From 57e22550d48697a3ca254eedb04a870f5702e0e4 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Wed, 19 Feb 2025 22:56:24 -0500
Subject: [PATCH 06/12] better focus persistence on undo/redo

---
 components/Blocks/BlockCommands.tsx         | 110 ++++++++++++++++----
 components/Buttons.tsx                      |   4 +
 components/Toolbar/TextBlockTypeToolbar.tsx |  23 +++-
 3 files changed, 116 insertions(+), 21 deletions(-)

diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx
index 1bb6f5df..5d2c68c2 100644
--- a/components/Blocks/BlockCommands.tsx
+++ b/components/Blocks/BlockCommands.tsx
@@ -106,10 +106,19 @@ export const blockCommands: Command[] = [
     name: "Text",
     icon: ,
     type: "text",
-    onSelect: async (rep, props) => {
+    onSelect: async (rep, props, um) => {
       props.entityID && clearCommandSearchText(props.entityID);
       let entity = await createBlockWithType(rep, props, "text");
       clearCommandSearchText(entity);
+      um.add({
+        undo: () => {
+          keepFocus(entity);
+        },
+        redo: () => {
+          keepFocus(entity);
+        },
+      });
+
       keepFocus(entity);
     },
   },
@@ -117,14 +126,22 @@ export const blockCommands: Command[] = [
     name: "Title",
     icon: ,
     type: "text",
-    onSelect: async (rep, props) => {
-      props.entityID && clearCommandSearchText(props.entityID);
+    onSelect: async (rep, props, um) => {
       let entity = await createBlockWithType(rep, props, "heading");
       await rep.mutate.assertFact({
         entity,
         attribute: "block/heading-level",
         data: { type: "number", value: 1 },
       });
+      clearCommandSearchText(entity);
+      um.add({
+        undo: () => {
+          keepFocus(entity);
+        },
+        redo: () => {
+          keepFocus(entity);
+        },
+      });
 
       keepFocus(entity);
     },
@@ -133,14 +150,21 @@ export const blockCommands: Command[] = [
     name: "Header",
     icon: ,
     type: "text",
-    onSelect: async (rep, props) => {
-      props.entityID && clearCommandSearchText(props.entityID);
+    onSelect: async (rep, props, um) => {
       let entity = await createBlockWithType(rep, props, "heading");
-      rep.mutate.assertFact({
+      await rep.mutate.assertFact({
         entity,
         attribute: "block/heading-level",
         data: { type: "number", value: 2 },
       });
+      um.add({
+        undo: () => {
+          keepFocus(entity);
+        },
+        redo: () => {
+          keepFocus(entity);
+        },
+      });
       clearCommandSearchText(entity);
       keepFocus(entity);
     },
@@ -149,14 +173,21 @@ export const blockCommands: Command[] = [
     name: "Subheader",
     icon: ,
     type: "text",
-    onSelect: async (rep, props) => {
-      props.entityID && clearCommandSearchText(props.entityID);
+    onSelect: async (rep, props, um) => {
       let entity = await createBlockWithType(rep, props, "heading");
-      rep.mutate.assertFact({
+      await rep.mutate.assertFact({
         entity,
         attribute: "block/heading-level",
         data: { type: "number", value: 3 },
       });
+      um.add({
+        undo: () => {
+          keepFocus(entity);
+        },
+        redo: () => {
+          keepFocus(entity);
+        },
+      });
       clearCommandSearchText(entity);
       keepFocus(entity);
     },
@@ -166,36 +197,67 @@ export const blockCommands: Command[] = [
     name: "External Link",
     icon: ,
     type: "block",
-    onSelect: async (rep, props) => {
-      createBlockWithType(rep, props, "link");
+    onSelect: async (rep, props, um) => {
+      props.entityID && clearCommandSearchText(props.entityID);
+      await createBlockWithType(rep, props, "link");
+      um.add({
+        undo: () => {
+          props.entityID && keepFocus(props.entityID);
+        },
+        redo: () => {},
+      });
     },
   },
   {
     name: "Embed Website",
     icon: ,
     type: "block",
-    onSelect: async (rep, props) => {
-      createBlockWithType(rep, props, "embed");
+    onSelect: async (rep, props, um) => {
+      props.entityID && clearCommandSearchText(props.entityID);
+      await createBlockWithType(rep, props, "embed");
+      um.add({
+        undo: () => {
+          props.entityID && keepFocus(props.entityID);
+        },
+        redo: () => {},
+      });
     },
   },
   {
     name: "Image",
     icon: ,
     type: "block",
-    onSelect: async (rep, props) => {
+    onSelect: async (rep, props, um) => {
+      props.entityID && clearCommandSearchText(props.entityID);
       let entity = await createBlockWithType(rep, props, "image");
       setTimeout(() => {
         let el = document.getElementById(elementId.block(entity).input);
         el?.focus();
       }, 100);
+      um.add({
+        undo: () => {
+          keepFocus(entity);
+        },
+        redo: () => {
+          let el = document.getElementById(elementId.block(entity).input);
+          el?.focus();
+        },
+      });
     },
   },
   {
     name: "Button",
     icon: ,
     type: "block",
-    onSelect: async (rep, props) => {
-      createBlockWithType(rep, props, "button");
+    onSelect: async (rep, props, um) => {
+      props.entityID && clearCommandSearchText(props.entityID);
+      await createBlockWithType(rep, props, "button");
+      um.add({
+        undo: () => {
+          props.entityID && keepFocus(props.entityID);
+        },
+        redo: () => {},
+      });
     },
   },
   {
@@ -203,8 +265,8 @@ export const blockCommands: Command[] = [
     icon: ,
     type: "block",
     onSelect: async (rep, props) => {
-      let entity;
-      createBlockWithType(rep, props, "mailbox");
+      props.entityID && clearCommandSearchText(props.entityID);
+      await createBlockWithType(rep, props, "mailbox");
     },
   },
 
@@ -214,13 +276,19 @@ export const blockCommands: Command[] = [
     name: "RSVP",
     icon: ,
     type: "event",
-    onSelect: (rep, props) => createBlockWithType(rep, props, "rsvp"),
+    onSelect: (rep, props) => {
+      props.entityID && clearCommandSearchText(props.entityID);
+      return createBlockWithType(rep, props, "rsvp");
+    },
   },
   {
     name: "Date and Time",
     icon: ,
     type: "event",
-    onSelect: (rep, props) => createBlockWithType(rep, props, "datetime"),
+    onSelect: (rep, props) => {
+      props.entityID && clearCommandSearchText(props.entityID);
+      return createBlockWithType(rep, props, "datetime");
+    },
   },
 
   // PAGE TYPES
@@ -230,6 +298,7 @@ export const blockCommands: Command[] = [
     icon: ,
     type: "page",
     onSelect: async (rep, props, um) => {
+      props.entityID && clearCommandSearchText(props.entityID);
       let entity = await createBlockWithType(rep, props, "card");
 
       let newPage = v7();
@@ -268,6 +337,7 @@ export const blockCommands: Command[] = [
     icon: ,
     type: "page",
     onSelect: async (rep, props, um) => {
+      props.entityID && clearCommandSearchText(props.entityID);
       let entity = await createBlockWithType(rep, props, "card");
 
       let newPage = v7();
diff --git a/components/Buttons.tsx b/components/Buttons.tsx
index b4a3f6cd..3c33647c 100644
--- a/components/Buttons.tsx
+++ b/components/Buttons.tsx
@@ -6,6 +6,7 @@ import {
   CardThemeProvider,
   NestedCardThemeProvider,
 } from "./ThemeManager/ThemeProvider";
+import { useReplicache } from "src/replicache";
 
 type ButtonProps = Omit;
 export const ButtonPrimary = forwardRef<
@@ -153,6 +154,7 @@ export const TooltipButton = (props: {
   open?: boolean;
   delayDuration?: number;
 }) => {
+  let { undoManager } = useReplicache();
   return (
     // toolbar button does not control the highlight theme setter
     // if toolbar button is updated, be sure to update there as well
@@ -165,7 +167,9 @@ export const TooltipButton = (props: {
           className={props.className}
           onMouseDown={(e) => {
             e.preventDefault();
+            undoManager.startGroup();
             props.onMouseDown && props.onMouseDown(e);
+            undoManager.endGroup();
           }}
         >
           {props.children}
diff --git a/components/Toolbar/TextBlockTypeToolbar.tsx b/components/Toolbar/TextBlockTypeToolbar.tsx
index 5ba93af8..2fda2e2d 100644
--- a/components/Toolbar/TextBlockTypeToolbar.tsx
+++ b/components/Toolbar/TextBlockTypeToolbar.tsx
@@ -170,7 +170,28 @@ export const TextBlockTypeToolbar = (props: {
 };
 
 export function keepFocus(entityID: string) {
-  setTimeout(() => {}, 1000);
+  let existingEditor = useEditorStates.getState().editorStates[entityID];
+
+  let selection = existingEditor?.editor.selection;
+
+  setTimeout(() => {
+    let existingEditor = useEditorStates.getState().editorStates[entityID];
+
+    if (!existingEditor) return;
+
+    existingEditor.view?.focus();
+
+    setEditorState(entityID, {
+      editor: existingEditor.editor.apply(
+        existingEditor.editor.tr.setSelection(
+          TextSelection.create(
+            existingEditor.editor.doc,
+            selection?.anchor || 1,
+          ),
+        ),
+      ),
+    });
+  }, 50);
 }
 
 export function TextBlockTypeButton(props: {
-- 
2.51.2


From 68c6d575d40b7cd75f4b371b82cc20348382f20e Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Thu, 20 Feb 2025 18:43:58 -0500
Subject: [PATCH 07/12] properly ignore selection changes

---
 components/Blocks/TextBlock/index.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index 8ad843b1..0fe0ee63 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -268,7 +268,7 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
         if (!existingState) return s;
         let newState = existingState.editor.apply(tr);
         let addToHistory = tr.getMeta("addToHistory");
-        let docHasChanges = !tr.steps.length || tr.docChanged;
+        let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
         if (addToHistory !== false && docHasChanges) {
           if (actionTimeout.current) {
             window.clearTimeout(actionTimeout.current);
-- 
2.51.2


From 504131708266aacbd9cee4987b30b09c720ee474 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Thu, 20 Feb 2025 18:44:09 -0500
Subject: [PATCH 08/12] group selection manager shortcuts

---
 components/SelectionManager.tsx | 454 ++++++++++++++++----------------
 1 file changed, 232 insertions(+), 222 deletions(-)

diff --git a/components/SelectionManager.tsx b/components/SelectionManager.tsx
index 93ff80a0..4c231145 100644
--- a/components/SelectionManager.tsx
+++ b/components/SelectionManager.tsx
@@ -225,55 +225,125 @@ export function SelectionManager() {
         handler: () => undoManager.withUndoGroup(() => shortcut.handler()),
       })),
     );
-    let listener = async (e: KeyboardEvent) => {
-      if (e.key === "Backspace" || e.key === "Delete") {
-        if (!entity_set.permissions.write) return;
-        if (moreThanOneSelected) {
-          e.preventDefault();
-          let [sortedBlocks, siblings] = await getSortedSelection();
-          let selectedBlocks = useUIState.getState().selectedBlocks;
-          let firstBlock = sortedBlocks[0];
+    let listener = async (e: KeyboardEvent) =>
+      undoManager.withUndoGroup(async () => {
+        if (e.key === "Backspace" || e.key === "Delete") {
+          if (!entity_set.permissions.write) return;
+          if (moreThanOneSelected) {
+            e.preventDefault();
+            let [sortedBlocks, siblings] = await getSortedSelection();
+            let selectedBlocks = useUIState.getState().selectedBlocks;
+            let firstBlock = sortedBlocks[0];
 
-          await rep?.mutate.removeBlock(
-            selectedBlocks.map((block) => ({ blockEntity: block.value })),
-          );
-          useUIState.getState().closePage(selectedBlocks.map((b) => b.value));
+            await rep?.mutate.removeBlock(
+              selectedBlocks.map((block) => ({ blockEntity: block.value })),
+            );
+            useUIState.getState().closePage(selectedBlocks.map((b) => b.value));
 
-          let nextBlock =
-            siblings?.[
-              siblings.findIndex((s) => s.value === firstBlock.value) - 1
-            ];
-          if (nextBlock) {
-            useUIState.getState().setSelectedBlock({
-              value: nextBlock.value,
-              parent: nextBlock.parent,
-            });
+            let nextBlock =
+              siblings?.[
+                siblings.findIndex((s) => s.value === firstBlock.value) - 1
+              ];
+            if (nextBlock) {
+              useUIState.getState().setSelectedBlock({
+                value: nextBlock.value,
+                parent: nextBlock.parent,
+              });
+              let type = await rep?.query((tx) =>
+                scanIndex(tx).eav(nextBlock.value, "block/type"),
+              );
+              if (!type?.[0]) return;
+              if (
+                type[0]?.data.value === "text" ||
+                type[0]?.data.value === "heading"
+              )
+                focusBlock(
+                  {
+                    value: nextBlock.value,
+                    type: "text",
+                    parent: nextBlock.parent,
+                  },
+                  { type: "end" },
+                );
+            }
+          }
+        }
+        if (e.key === "ArrowUp") {
+          let [sortedBlocks, siblings] = await getSortedSelection();
+          let focusedBlock = useUIState.getState().focusedEntity;
+          if (!e.shiftKey && !e.ctrlKey) {
+            if (e.defaultPrevented) return;
+            if (sortedBlocks.length === 1) return;
+            let firstBlock = sortedBlocks[0];
+            if (!firstBlock) return;
             let type = await rep?.query((tx) =>
-              scanIndex(tx).eav(nextBlock.value, "block/type"),
+              scanIndex(tx).eav(firstBlock.value, "block/type"),
             );
             if (!type?.[0]) return;
+            useUIState.getState().setSelectedBlock(firstBlock);
+            focusBlock(
+              { ...firstBlock, type: type[0].data.value },
+              { type: "start" },
+            );
+          } else {
+            if (e.defaultPrevented) return;
             if (
-              type[0]?.data.value === "text" ||
-              type[0]?.data.value === "heading"
+              sortedBlocks.length <= 1 ||
+              !focusedBlock ||
+              focusedBlock.entityType === "page"
             )
-              focusBlock(
-                {
-                  value: nextBlock.value,
-                  type: "text",
-                  parent: nextBlock.parent,
-                },
-                { type: "end" },
+              return;
+            let b = focusedBlock;
+            let focusedBlockIndex = sortedBlocks.findIndex(
+              (s) => s.value == b.entityID,
+            );
+            if (focusedBlockIndex === 0) {
+              let index = siblings.findIndex((s) => s.value === b.entityID);
+              let nextSelectedBlock = siblings[index - 1];
+              if (!nextSelectedBlock) return;
+
+              scrollIntoViewIfNeeded(
+                document.getElementById(
+                  elementId.block(nextSelectedBlock.value).container,
+                ),
+                false,
               );
+              useUIState.getState().addBlockToSelection({
+                ...nextSelectedBlock,
+              });
+              useUIState.getState().setFocusedBlock({
+                entityType: "block",
+                parent: nextSelectedBlock.parent,
+                entityID: nextSelectedBlock.value,
+              });
+            } else {
+              let nextBlock = sortedBlocks[sortedBlocks.length - 2];
+              useUIState.getState().setFocusedBlock({
+                entityType: "block",
+                parent: b.parent,
+                entityID: nextBlock.value,
+              });
+              scrollIntoViewIfNeeded(
+                document.getElementById(
+                  elementId.block(nextBlock.value).container,
+                ),
+                false,
+              );
+              if (sortedBlocks.length === 2) {
+                useEditorStates
+                  .getState()
+                  .editorStates[nextBlock.value]?.view?.focus();
+              }
+              useUIState
+                .getState()
+                .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]);
+            }
           }
         }
-      }
-      if (e.key === "ArrowUp") {
-        let [sortedBlocks, siblings] = await getSortedSelection();
-        let focusedBlock = useUIState.getState().focusedEntity;
-        if (!e.shiftKey && !e.ctrlKey) {
-          if (e.defaultPrevented) return;
-          if (sortedBlocks.length === 1) return;
-          let firstBlock = sortedBlocks[0];
+        if (e.key === "ArrowLeft") {
+          let [sortedSelection, siblings] = await getSortedSelection();
+          if (sortedSelection.length === 1) return;
+          let firstBlock = sortedSelection[0];
           if (!firstBlock) return;
           let type = await rep?.query((tx) =>
             scanIndex(tx).eav(firstBlock.value, "block/type"),
@@ -284,134 +354,9 @@ export function SelectionManager() {
             { ...firstBlock, type: type[0].data.value },
             { type: "start" },
           );
-        } else {
-          if (e.defaultPrevented) return;
-          if (
-            sortedBlocks.length <= 1 ||
-            !focusedBlock ||
-            focusedBlock.entityType === "page"
-          )
-            return;
-          let b = focusedBlock;
-          let focusedBlockIndex = sortedBlocks.findIndex(
-            (s) => s.value == b.entityID,
-          );
-          if (focusedBlockIndex === 0) {
-            let index = siblings.findIndex((s) => s.value === b.entityID);
-            let nextSelectedBlock = siblings[index - 1];
-            if (!nextSelectedBlock) return;
-
-            scrollIntoViewIfNeeded(
-              document.getElementById(
-                elementId.block(nextSelectedBlock.value).container,
-              ),
-              false,
-            );
-            useUIState.getState().addBlockToSelection({
-              ...nextSelectedBlock,
-            });
-            useUIState.getState().setFocusedBlock({
-              entityType: "block",
-              parent: nextSelectedBlock.parent,
-              entityID: nextSelectedBlock.value,
-            });
-          } else {
-            let nextBlock = sortedBlocks[sortedBlocks.length - 2];
-            useUIState.getState().setFocusedBlock({
-              entityType: "block",
-              parent: b.parent,
-              entityID: nextBlock.value,
-            });
-            scrollIntoViewIfNeeded(
-              document.getElementById(
-                elementId.block(nextBlock.value).container,
-              ),
-              false,
-            );
-            if (sortedBlocks.length === 2) {
-              useEditorStates
-                .getState()
-                .editorStates[nextBlock.value]?.view?.focus();
-            }
-            useUIState
-              .getState()
-              .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]);
-          }
-        }
-      }
-      if (e.key === "ArrowLeft") {
-        let [sortedSelection, siblings] = await getSortedSelection();
-        if (sortedSelection.length === 1) return;
-        let firstBlock = sortedSelection[0];
-        if (!firstBlock) return;
-        let type = await rep?.query((tx) =>
-          scanIndex(tx).eav(firstBlock.value, "block/type"),
-        );
-        if (!type?.[0]) return;
-        useUIState.getState().setSelectedBlock(firstBlock);
-        focusBlock(
-          { ...firstBlock, type: type[0].data.value },
-          { type: "start" },
-        );
-      }
-      if (e.key === "ArrowRight") {
-        let [sortedSelection, siblings] = await getSortedSelection();
-        if (sortedSelection.length === 1) return;
-        let lastBlock = sortedSelection[sortedSelection.length - 1];
-        if (!lastBlock) return;
-        let type = await rep?.query((tx) =>
-          scanIndex(tx).eav(lastBlock.value, "block/type"),
-        );
-        if (!type?.[0]) return;
-        useUIState.getState().setSelectedBlock(lastBlock);
-        focusBlock({ ...lastBlock, type: type[0].data.value }, { type: "end" });
-      }
-      if (e.key === "Tab") {
-        let [sortedSelection, siblings] = await getSortedSelection();
-        if (sortedSelection.length <= 1) return;
-        e.preventDefault();
-        if (e.shiftKey) {
-          for (let i = siblings.length - 1; i >= 0; i--) {
-            let block = siblings[i];
-            if (!sortedSelection.find((s) => s.value === block.value)) continue;
-            if (sortedSelection.find((s) => s.value === block.listData?.parent))
-              continue;
-            let parentoffset = 1;
-            let previousBlock = siblings[i - parentoffset];
-            while (
-              previousBlock &&
-              sortedSelection.find((s) => previousBlock.value === s.value)
-            ) {
-              parentoffset += 1;
-              previousBlock = siblings[i - parentoffset];
-            }
-            if (!block.listData || !previousBlock.listData) continue;
-            outdent(block, previousBlock, rep);
-          }
-        } else {
-          for (let i = 0; i < siblings.length; i++) {
-            let block = siblings[i];
-            if (!sortedSelection.find((s) => s.value === block.value)) continue;
-            if (sortedSelection.find((s) => s.value === block.listData?.parent))
-              continue;
-            let parentoffset = 1;
-            let previousBlock = siblings[i - parentoffset];
-            while (
-              previousBlock &&
-              sortedSelection.find((s) => previousBlock.value === s.value)
-            ) {
-              parentoffset += 1;
-              previousBlock = siblings[i - parentoffset];
-            }
-            if (!block.listData || !previousBlock.listData) continue;
-            indent(block, previousBlock, rep);
-          }
         }
-      }
-      if (e.key === "ArrowDown") {
-        let [sortedSelection, siblings] = await getSortedSelection();
-        let focusedBlock = useUIState.getState().focusedEntity;
-        if (!e.shiftKey) {
+        if (e.key === "ArrowRight") {
+          let [sortedSelection, siblings] = await getSortedSelection();
           if (sortedSelection.length === 1) return;
           let lastBlock = sortedSelection[sortedSelection.length - 1];
           if (!lastBlock) return;
@@ -425,68 +370,133 @@ export function SelectionManager() {
             { type: "end" },
           );
         }
-        if (e.shiftKey) {
-          if (e.defaultPrevented) return;
-          if (
-            sortedSelection.length <= 1 ||
-            !focusedBlock ||
-            focusedBlock.entityType === "page"
-          )
-            return;
-          let b = focusedBlock;
-          let focusedBlockIndex = sortedSelection.findIndex(
-            (s) => s.value == b.entityID,
-          );
-          if (focusedBlockIndex === sortedSelection.length - 1) {
-            let index = siblings.findIndex((s) => s.value === b.entityID);
-            let nextSelectedBlock = siblings[index + 1];
-            if (!nextSelectedBlock) return;
-            useUIState.getState().addBlockToSelection({
-              ...nextSelectedBlock,
-            });
-
-            scrollIntoViewIfNeeded(
-              document.getElementById(
-                elementId.block(nextSelectedBlock.value).container,
-              ),
-              false,
-            );
-            useUIState.getState().setFocusedBlock({
-              entityType: "block",
-              parent: nextSelectedBlock.parent,
-              entityID: nextSelectedBlock.value,
-            });
+        if (e.key === "Tab") {
+          let [sortedSelection, siblings] = await getSortedSelection();
+          if (sortedSelection.length <= 1) return;
+          e.preventDefault();
+          if (e.shiftKey) {
+            for (let i = siblings.length - 1; i >= 0; i--) {
+              let block = siblings[i];
+              if (!sortedSelection.find((s) => s.value === block.value))
+                continue;
+              if (
+                sortedSelection.find((s) => s.value === block.listData?.parent)
+              )
+                continue;
+              let parentoffset = 1;
+              let previousBlock = siblings[i - parentoffset];
+              while (
+                previousBlock &&
+                sortedSelection.find((s) => previousBlock.value === s.value)
+              ) {
+                parentoffset += 1;
+                previousBlock = siblings[i - parentoffset];
+              }
+              if (!block.listData || !previousBlock.listData) continue;
+              outdent(block, previousBlock, rep);
+            }
           } else {
-            let nextBlock = sortedSelection[1];
-            useUIState
-              .getState()
-              .removeBlockFromSelection({ value: b.entityID });
-            scrollIntoViewIfNeeded(
-              document.getElementById(
-                elementId.block(nextBlock.value).container,
-              ),
-              false,
+            for (let i = 0; i < siblings.length; i++) {
+              let block = siblings[i];
+              if (!sortedSelection.find((s) => s.value === block.value))
+                continue;
+              if (
+                sortedSelection.find((s) => s.value === block.listData?.parent)
+              )
+                continue;
+              let parentoffset = 1;
+              let previousBlock = siblings[i - parentoffset];
+              while (
+                previousBlock &&
+                sortedSelection.find((s) => previousBlock.value === s.value)
+              ) {
+                parentoffset += 1;
+                previousBlock = siblings[i - parentoffset];
+              }
+              if (!block.listData || !previousBlock.listData) continue;
+              indent(block, previousBlock, rep);
+            }
+          }
+        }
+        if (e.key === "ArrowDown") {
+          let [sortedSelection, siblings] = await getSortedSelection();
+          let focusedBlock = useUIState.getState().focusedEntity;
+          if (!e.shiftKey) {
+            if (sortedSelection.length === 1) return;
+            let lastBlock = sortedSelection[sortedSelection.length - 1];
+            if (!lastBlock) return;
+            let type = await rep?.query((tx) =>
+              scanIndex(tx).eav(lastBlock.value, "block/type"),
             );
-            useUIState.getState().setFocusedBlock({
-              entityType: "block",
-              parent: b.parent,
-              entityID: nextBlock.value,
-            });
-            if (sortedSelection.length === 2) {
-              useEditorStates
+            if (!type?.[0]) return;
+            useUIState.getState().setSelectedBlock(lastBlock);
+            focusBlock(
+              { ...lastBlock, type: type[0].data.value },
+              { type: "end" },
+            );
+          }
+          if (e.shiftKey) {
+            if (e.defaultPrevented) return;
+            if (
+              sortedSelection.length <= 1 ||
+              !focusedBlock ||
+              focusedBlock.entityType === "page"
+            )
+              return;
+            let b = focusedBlock;
+            let focusedBlockIndex = sortedSelection.findIndex(
+              (s) => s.value == b.entityID,
+            );
+            if (focusedBlockIndex === sortedSelection.length - 1) {
+              let index = siblings.findIndex((s) => s.value === b.entityID);
+              let nextSelectedBlock = siblings[index + 1];
+              if (!nextSelectedBlock) return;
+              useUIState.getState().addBlockToSelection({
+                ...nextSelectedBlock,
+              });
+
+              scrollIntoViewIfNeeded(
+                document.getElementById(
+                  elementId.block(nextSelectedBlock.value).container,
+                ),
+                false,
+              );
+              useUIState.getState().setFocusedBlock({
+                entityType: "block",
+                parent: nextSelectedBlock.parent,
+                entityID: nextSelectedBlock.value,
+              });
+            } else {
+              let nextBlock = sortedSelection[1];
+              useUIState
                 .getState()
-                .editorStates[nextBlock.value]?.view?.focus();
+                .removeBlockFromSelection({ value: b.entityID });
+              scrollIntoViewIfNeeded(
+                document.getElementById(
+                  elementId.block(nextBlock.value).container,
+                ),
+                false,
+              );
+              useUIState.getState().setFocusedBlock({
+                entityType: "block",
+                parent: b.parent,
+                entityID: nextBlock.value,
+              });
+              if (sortedSelection.length === 2) {
+                useEditorStates
+                  .getState()
+                  .editorStates[nextBlock.value]?.view?.focus();
+              }
             }
           }
         }
-      }
-      if (e.key === "c" && (e.metaKey || e.ctrlKey)) {
-        if (!rep) return;
-        let [, , selectionWithFoldedChildren] = await getSortedSelection();
-        if (!selectionWithFoldedChildren) return;
-        await copySelection(rep, selectionWithFoldedChildren);
-      }
-    };
+        if (e.key === "c" && (e.metaKey || e.ctrlKey)) {
+          if (!rep) return;
+          let [, , selectionWithFoldedChildren] = await getSortedSelection();
+          if (!selectionWithFoldedChildren) return;
+          await copySelection(rep, selectionWithFoldedChildren);
+        }
+      });
     window.addEventListener("keydown", listener);
     return () => {
       removeListener();
-- 
2.51.2


From 87c59652b383249e7a2b36637353230b539424d0 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Thu, 20 Feb 2025 18:44:20 -0500
Subject: [PATCH 09/12] remove logs

---
 src/replicache/clientMutationContext.ts | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/replicache/clientMutationContext.ts b/src/replicache/clientMutationContext.ts
index 3d3b0002..7972547d 100644
--- a/src/replicache/clientMutationContext.ts
+++ b/src/replicache/clientMutationContext.ts
@@ -109,7 +109,6 @@ export function clientMutationContext(
       await tx.del(id);
     },
     async deleteEntity(entity) {
-      console.trace("deleting entity ");
       let existingFacts = await tx
         .scan>({
           indexName: "eav",
@@ -127,7 +126,6 @@ export function clientMutationContext(
       if (!ignoreUndo && facts.length > 0) {
         undoManager.add({
           undo: async () => {
-            console.log("undoin!");
             let input: FactInput[] & { ignoreUndo?: true } = facts.map(
               (f) =>
                 ({
-- 
2.51.2


From 661f993f7cff4a03a1fb118a0598d807b0a0e7b1 Mon Sep 17 00:00:00 2001
From: Jared Pereira 
Date: Thu, 20 Feb 2025 22:53:19 -0500
Subject: [PATCH 10/12] fix block command button grouping

---
 components/Blocks/TextBlock/index.tsx | 72 ++++++---------------------
 components/Buttons.tsx                |  6 +--
 2 files changed, 18 insertions(+), 60 deletions(-)

diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index 0fe0ee63..226e4064 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -52,6 +52,7 @@ import { ToolbarButton } from "components/Toolbar";
 import { TooltipButton } from "components/Buttons";
 import { v7 } from "uuid";
 import { focusPage } from "components/Pages";
+import { blockCommands } from "../BlockCommands";
 
 export function TextBlock(
   props: BlockProps & { className?: string; preview?: boolean },
@@ -406,29 +407,13 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
              {
-                let entity;
-                if (!props.entityID) {
-                  entity = v7();
-                  await rep.rep?.mutate.addBlock({
-                    parent: props.parent,
-                    factID: v7(),
-                    permission_set: entity_set.set,
-                    type: "image",
-                    position: generateKeyBetween(
-                      props.position,
-                      props.nextPosition,
-                    ),
-                    newEntityID: entity,
-                  });
-                } else {
-                  entity = props.entityID;
-                  await rep.rep?.mutate.assertFact({
-                    entity,
-                    attribute: "block/type",
-                    data: { type: "block-type-union", value: "image" },
-                  });
-                }
-                return entity;
+                let command = blockCommands.find((f) => f.name === "Image");
+                if (!rep.rep) return;
+                await command?.onSelect(
+                  rep.rep,
+                  { ...props, entity_set: entity_set.set },
+                  rep.undoManager,
+                );
               }}
               side="bottom"
               tooltipContent={
@@ -441,40 +426,13 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
              {
-                let entity;
-                if (!props.entityID) {
-                  entity = v7();
-                  await rep.rep?.mutate.addBlock({
-                    parent: props.parent,
-                    factID: v7(),
-                    permission_set: entity_set.set,
-                    type: "card",
-                    position: generateKeyBetween(
-                      props.position,
-                      props.nextPosition,
-                    ),
-                    newEntityID: entity,
-                  });
-                } else {
-                  entity = props.entityID;
-                  await rep.rep?.mutate.assertFact({
-                    entity,
-                    attribute: "block/type",
-                    data: { type: "block-type-union", value: "card" },
-                  });
-                }
-
-                let newPage = v7();
-                await rep.rep?.mutate.addPageLinkBlock({
-                  blockEntity: entity,
-                  firstBlockFactID: v7(),
-                  firstBlockEntity: v7(),
-                  pageEntity: newPage,
-                  type: "doc",
-                  permission_set: entity_set.set,
-                });
-                useUIState.getState().openPage(props.parent, newPage);
-                rep.rep && focusPage(newPage, rep.rep, "focusFirstBlock");
+                let command = blockCommands.find((f) => f.name === "New Page");
+                if (!rep.rep) return;
+                await command?.onSelect(
+                  rep.rep,
+                  { ...props, entity_set: entity_set.set },
+                  rep.undoManager,
+                );
               }}
               side="bottom"
               tooltipContent={
diff --git a/components/Buttons.tsx b/components/Buttons.tsx
index 3c33647c..13b6aaf6 100644
--- a/components/Buttons.tsx
+++ b/components/Buttons.tsx
@@ -145,7 +145,7 @@ export const HoverButton = (props: {
 };
 
 export const TooltipButton = (props: {
-  onMouseDown?: (e: React.MouseEvent) => void;
+  onMouseDown?: (e: React.MouseEvent) => void | Promise;
   disabled?: boolean;
   className?: string;
   children: React.ReactNode;
@@ -165,10 +165,10 @@ export const TooltipButton = (props: {
          {
+          onMouseDown={async (e) => {
             e.preventDefault();
             undoManager.startGroup();
-            props.onMouseDown && props.onMouseDown(e);
+            props.onMouseDown && (await props.onMouseDown(e));
             undoManager.endGroup();
           }}
         >
-- 
2.51.2


From 0f0cc686eea924f0d9eda54ced66405a13552d55 Mon Sep 17 00:00:00 2001
From: celine 
Date: Fri, 21 Feb 2025 15:46:53 -0500
Subject: [PATCH 11/12] added undo buttons to the page

---
 components/Icons.tsx       | 28 +++++++++----------
 components/Pages/index.tsx | 55 +++++++++++++++++++++++++++++---------
 2 files changed, 57 insertions(+), 26 deletions(-)

diff --git a/components/Icons.tsx b/components/Icons.tsx
index 1fcdfe82..855363c7 100644
--- a/components/Icons.tsx
+++ b/components/Icons.tsx
@@ -936,7 +936,7 @@ export const MoreOptionsTiny = (props: Props) => {
   );
 };
 
-export const SearchTiny = (props: Props) => {
+export const UndoTiny = (props: Props) => {
   return (
      {
       
     
   );
 };
 
-// Text Toolbar Icons h:24px, w: variable
-
-export const UndoSmall = (props: Props) => {
+export const RedoTiny = (props: Props) => {
   return (
      {
       
     
   );
 };
 
-export const RedoSmall = (props: Props) => {
+export const SearchTiny = (props: Props) => {
   return (
      {
       
     
   );
 };
 
+// Text Toolbar Icons h:24px, w: variable
+
 export const BoldSmall = (props: Props) => {
   return (
     
           
-            
+            
           
           
           {isDraft.length > 0 && (
@@ -177,7 +184,7 @@ function Page(props: { entityID: string; first?: boolean }) {
         
         
           {isFocused && (
-            
+            
           )}
         
       
@@ -242,15 +249,25 @@ const DocContent = (props: { entityID: string }) => {
   );
 };
 
-const PageOptionsMenu = (props: {
+const PageOptions = (props: {
   entityID: string;
   first: boolean | undefined;
 }) => {
+  let greyButtonStyle =
+    "pt-[2px] h-5 w-5 p-0.5 mx-auto bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2";
+  let whiteButtonStyle = `
+    pageOptionsTrigger
+    shrink-0
+    bg-bg-page text-border
+    outline-none border sm:border-l-0 border-t-1 border-border sm:rounded-r-md sm:rounded-l-none rounded-b-md
+    hover:shadow-[0_1px_0_theme(colors.border)_inset,_0_-1px_0_theme(colors.border)_inset,_-1px_0_0_theme(colors.border)_inset]
+    flex items-center justify-center`;
+
   return (
     
{!props.first && ( )} - {} + +
+ + +
); }; -const OptionsMenu = (props: { entityID: string; first: boolean }) => { +const OptionsMenu = (props: { + entityID: string; + first: boolean; + buttonStyle: string; +}) => { let [state, setState] = useState<"normal" | "theme" | "share">("normal"); let { permissions } = useEntitySetContext(); if (!permissions.write) return null; @@ -276,11 +309,9 @@ const OptionsMenu = (props: { entityID: string; first: boolean }) => { trigger={
-- 2.51.2 From 2f6020dcb11f7225d6e461b09021c32fcff5aed2 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 21 Feb 2025 16:18:04 -0500 Subject: [PATCH 12/12] wire up undo/redo buttons --- components/Pages/index.tsx | 53 +++++++++++++++++++++++++++----------- src/undoManager.ts | 6 ++++- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/components/Pages/index.tsx b/components/Pages/index.tsx index e19727f8..c81fdbcc 100644 --- a/components/Pages/index.tsx +++ b/components/Pages/index.tsx @@ -42,6 +42,8 @@ import { PageShareMenu } from "./PageShareMenu"; import { Watermark } from "components/Watermark"; import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; import { LoginButton } from "components/LoginButton"; +import { useUndoState } from "src/undoManager"; +import { useIsMobile } from "src/hooks/isMobile"; export function Pages(props: { rootPage: string }) { let rootPage = useEntity(props.rootPage, "root/page")[0]; @@ -249,20 +251,19 @@ const DocContent = (props: { entityID: string }) => { ); }; -const PageOptions = (props: { - entityID: string; - first: boolean | undefined; -}) => { - let greyButtonStyle = - "pt-[2px] h-5 w-5 p-0.5 mx-auto bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2"; - let whiteButtonStyle = ` +let greyButtonStyle = + "pt-[2px] h-5 w-5 p-0.5 mx-auto bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2"; +let whiteButtonStyle = ` pageOptionsTrigger shrink-0 bg-bg-page text-border outline-none border sm:border-l-0 border-t-1 border-border sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:shadow-[0_1px_0_theme(colors.border)_inset,_0_-1px_0_theme(colors.border)_inset,_-1px_0_0_theme(colors.border)_inset] flex items-center justify-center`; - +const PageOptions = (props: { + entityID: string; + first: boolean | undefined; +}) => { return (
{!props.first && ( @@ -280,15 +281,37 @@ const PageOptions = (props: { first={!!props.first} buttonStyle={whiteButtonStyle} /> + +
+ ); +}; + +const UndoButtons = () => { + let undoState = useUndoState(); + let { undoManager } = useReplicache(); + return ( +
- - + {undoState.canUndo && ( + + )} + {undoState.canRedo ? ( + + ) : ( +
+ )}
-
+
); }; diff --git a/src/undoManager.ts b/src/undoManager.ts index 9ca79419..20e1171c 100644 --- a/src/undoManager.ts +++ b/src/undoManager.ts @@ -1,10 +1,14 @@ import { UndoManager as RociUndoManager } from "@rocicorp/undo"; +import { create } from "zustand"; export type UndoManager = ReturnType; +export const useUndoState = create(() => ({ canUndo: false, canRedo: false })); export const createUndoManager = () => { let isGrouping = false; let undoManager = new RociUndoManager({ - onChange: (state) => {}, + onChange: (state) => { + useUndoState.setState(state); + }, }); let um = { add: (args: { -- 2.51.2