Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions components/Blocks/TextBlock/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ function BaseTextBlock(props: BlockProps & { className?: string }) {
handleMentionOpenChange,
} = useMentionState(props.entityID, props);

let { mountRef, actionTimeout, overlay } = useMountProsemirror({
let { mountRef, overlay } = useMountProsemirror({
props,
openMentionAutocomplete,
});
Expand Down Expand Up @@ -284,11 +284,6 @@ function BaseTextBlock(props: BlockProps & { className?: string }) {
data: { type: "block-type-union", value: "horizontal-rule" },
});
}
if (actionTimeout.current) {
rep.undoManager.endGroup();
window.clearTimeout(actionTimeout.current);
actionTimeout.current = null;
}
}}
onFocus={() => {
handleMentionOpenChange(false);
Expand Down
100 changes: 48 additions & 52 deletions components/Blocks/TextBlock/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,10 @@ const enter =
let index = siblings.findIndex(
(sib) => sib.data.value === propsRef.current.entityID,
);
// Use the freshly-read sibling position, not the render-lagged
// propsRef snapshot, which can collide with the just-created item.
position = generateKeyBetween(
propsRef.current.position,
siblings[index]?.data.position || propsRef.current.position,
siblings[index + 1]?.data.position || null,
);
} else {
Expand All @@ -568,6 +570,14 @@ const enter =
children[0]?.data.position || null,
);
}
let [listStyle] =
(await repRef.current?.query((tx) =>
scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"),
)) || [];
let [checked] =
(await repRef.current?.query((tx) =>
scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"),
)) || [];
await repRef.current?.mutate.addBlock({
newEntityID,
factID: v7(),
Expand All @@ -577,6 +587,14 @@ const enter =
: propsRef.current.listData.parent,
type: blockType,
position,
list: {
listStyle: listStyle?.data.value,
checklist: checked
? state.selection.anchor === 1
? checked.data.value
: false
: undefined,
},
});
if (
!createChild &&
Expand All @@ -591,38 +609,6 @@ const enter =
after: null,
});
}
await repRef.current?.mutate.assertFact({
entity: newEntityID,
attribute: "block/is-list",
data: { type: "boolean", value: true },
});
// Copy list style (ordered/unordered) to new list item
let listStyle = await repRef.current?.query((tx) =>
scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"),
);
if (listStyle?.[0]) {
await repRef.current?.mutate.assertFact({
entity: newEntityID,
attribute: "block/list-style",
data: {
type: "list-style-union",
value: listStyle[0].data.value,
},
});
}
let checked = await repRef.current?.query((tx) =>
scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"),
);
if (checked?.[0])
await repRef.current?.mutate.assertFact({
entity: newEntityID,
attribute: "block/check-list",
data: {
type: "boolean",
value:
state.selection.anchor === 1 ? checked?.[0].data.value : false,
},
});
}
// if the block is not a list, add a new text block after it
if (!propsRef.current.listData) {
Expand Down Expand Up @@ -681,26 +667,36 @@ const enter =
});
}
};
// focusBlock silently no-ops until the new block's editor mounts, so a
// fixed timeout could drop focus and send the next keystrokes to the old
// block. Retry until it registers.
let focusNewBlock = () => {
let block = useEditorStates.getState().editorStates[newEntityID];
if (!block) return;
let tr = block.editor.tr;
if (newContent.content.size > 2) {
tr.replaceWith(0, tr.doc.content.size, newContent.content);
tr.setSelection(TextSelection.create(tr.doc, 0));
let newState = block.editor.apply(tr);
setEditorState(newEntityID, {
editor: newState,
});
}
focusBlock(
{
value: newEntityID,
parent: propsRef.current.parent,
type: "text",
},
{ type: "start" },
);
let attempts = 0;
let run = () => {
let block = useEditorStates.getState().editorStates[newEntityID];
if (!block) {
if (attempts++ < 50) setTimeout(run, 10);
return;
}
let tr = block.editor.tr;
if (newContent.content.size > 2) {
tr.replaceWith(0, tr.doc.content.size, newContent.content);
tr.setSelection(TextSelection.create(tr.doc, 0));
let newState = block.editor.apply(tr);
setEditorState(newEntityID, {
editor: newState,
});
}
focusBlock(
{
value: newEntityID,
parent: propsRef.current.parent,
type: "text",
},
{ type: "start" },
);
};
run();
};

asyncRun()
Expand Down
40 changes: 17 additions & 23 deletions components/Blocks/TextBlock/mountProsemirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function useMountProsemirror({
props: BlockProps;
openMentionAutocomplete: () => void;
}) {
let { entityID, parent } = props;
let { entityID } = props;
let rep = useReplicache();
let mountRef = useRef<HTMLPreElement | null>(null);
const repRef = useRef<Replicache<ReplicacheMutators> | null>(null);
Expand All @@ -66,8 +66,6 @@ export function useMountProsemirror({
let propsRef = useRef({ ...props, entity_set, alignment });
let handlePaste = useHandlePaste(entityID, propsRef);

const actionTimeout = useRef<number | null>(null);

propsRef.current = { ...props, entity_set, alignment };
repRef.current = rep.rep;

Expand Down Expand Up @@ -348,7 +346,7 @@ export function useMountProsemirror({
trackUndoRedo(
tr,
rep.undoManager,
actionTimeout,
entityID,
setState(oldEditorState),
setState(newState),
);
Expand All @@ -366,38 +364,34 @@ export function useMountProsemirror({
};
});
}
}, [entityID, parent, value, cursorPlugin, handlePaste, rep]);
return { mountRef, actionTimeout, overlay };
// `parent` is intentionally NOT a dependency: indent/outdent change a
// block's parent but keep the same entityID, and rebuilding the EditorView
// on reparent destroyed the caret (dropping focus to <body> on every
// structural edit and its undo/redo). The view reads parent via propsRef.
}, [entityID, value, cursorPlugin, handlePaste, rep]);
return { mountRef, overlay };
}

export function trackUndoRedo(
tr: Transaction,
undoManager: UndoManager,
actionTimeout: { current: number | null },
coalesceKey: string,
undo: () => void,
redo: () => void,
) {
let addToHistory = tr.getMeta("addToHistory");
let isBulkOp = tr.getMeta("bulkOp");
// externalUndoGroup: the caller already has an undo group open and is keeping
// it open across async mutations (e.g. the Enter handler grouping a block
// split with the new block's creation), so skip the timeout-based group
// management here and just add the entry. Unlike bulkOp this does NOT suppress
// focus on undo (see setState above), so the cursor returns to the block.
// externalUndoGroup: the caller already holds a command group open across its
// async mutations (e.g. the Enter handler grouping a block split with the new
// block's creation), so just add to it. bulkOp likewise manages its own
// grouping. Otherwise coalesce consecutive text edits into one undo step
// (the undo manager owns the coalescing window, so a command can flush it and
// never merge with adjacent typing).
let skipGroupManagement = isBulkOp || tr.getMeta("externalUndoGroup");
let docHasChanges = tr.steps.length !== 0 || tr.docChanged;

if (addToHistory !== false && docHasChanges) {
if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
else if (!skipGroupManagement) undoManager.startGroup();

if (!skipGroupManagement) {
actionTimeout.current = window.setTimeout(() => {
undoManager.endGroup();
actionTimeout.current = null;
}, 200);
}

undoManager.add({ undo, redo });
if (skipGroupManagement) undoManager.add({ undo, redo });
else undoManager.addGrouped({ undo, redo }, coalesceKey);
}
}
3 changes: 1 addition & 2 deletions components/Footnotes/FootnoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ function EditableFootnote(props: {
cursorPlugin,
overlay,
} = useCollabText(props.footnoteEntityID);
let actionTimeout = useRef<number | null>(null);
let { pageID } = useFootnoteContext();

useLayoutEffect(() => {
Expand Down Expand Up @@ -165,7 +164,7 @@ function EditableFootnote(props: {
trackUndoRedo(
tr,
rep.undoManager,
actionTimeout,
props.footnoteEntityID,
() => {
this.focus();
this.updateState(oldState);
Expand Down
26 changes: 26 additions & 0 deletions docs/editor-behaviors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Text Editor Behaviors

A living reference for how Leaflet's text editor is expected to behave. Add a
new section for each behavior as it is defined. Each entry specifies the
behavior from the user's point of view, independent of how it is implemented.

## Enter on a blank list item

When the cursor is in a **blank** list item (the item contains no text) and the
user presses **Enter**:

1. **If the item is nested** — there are outer list layers above it — it
**outdents by one level**: the item moves up to its parent's level. No new
list item is created.
2. **If the item is already at the top level** — there are no more layers to
outdent — it is **converted into a normal, non-list text block**. The block
keeps its text type: a heading stays a heading (at the same heading level), a
blockquote stays a blockquote, and plain text stays plain text. Only the list
treatment (bullet / number / checkbox) is removed.

This applies to every kind of list item — unordered, ordered, and checkbox
(to-do).

**Not affected:** pressing Enter in a list item that has content behaves
normally — it splits the item / creates a new sibling list item at the same
depth. Only blank items outdent.
2 changes: 1 addition & 1 deletion src/replicache/clientMutationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function clientMutationContext(
}
},
redo: () => {
rep.mutate.retractFact({ factID: id });
rep.mutate.retractFact({ ignoreUndo: true, factID: id });
},
});
await tx.del(id);
Expand Down
12 changes: 10 additions & 2 deletions src/replicache/getBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ export const getBlocksWithType = async (
): Promise<Block[]> => {
let children = (
await scan.eav(root.data.value, "card/block")
).sort((a, b) => (a.data.position > b.data.position ? 1 : -1));
).sort((a, b) => {
if (a.data.position === b.data.position)
return a.id > b.id ? 1 : -1;
return a.data.position > b.data.position ? 1 : -1;
});
let type = (await scan.eav(root.data.value, "block/type"))[0];
let checklist = await scan.eav(
root.data.value,
Expand Down Expand Up @@ -131,7 +135,11 @@ export const getBlocksWithTypeLocal = (
): Block[] => {
let children = scan
.eav(root.data.value, "card/block")
.sort((a, b) => (a.data.position > b.data.position ? 1 : -1));
.sort((a, b) => {
if (a.data.position === b.data.position)
return a.id > b.id ? 1 : -1;
return a.data.position > b.data.position ? 1 : -1;
});
let type = scan.eav(root.data.value, "block/type")[0];
let listStyle = scan.eav(root.data.value, "block/list-style")[0];
let listNumber = scan.eav(root.data.value, "block/list-number")[0];
Expand Down
35 changes: 33 additions & 2 deletions src/replicache/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ const addBlock: Mutation<{
type: Fact<"block/type">["data"]["value"];
newEntityID: string;
position: string;
// Set in the same mutation as the type so the block is never briefly a
// non-list block — otherwise a racing Enter sees no list data and inserts a
// plain paragraph, breaking the list.
list?: {
listStyle?: Fact<"block/list-style">["data"]["value"];
checklist?: boolean;
};
}> = async (args, ctx) => {
await ctx.createEntity({
entityID: args.newEntityID,
Expand All @@ -96,6 +103,27 @@ const addBlock: Mutation<{
data: { type: "block-type-union", value: args.type },
attribute: "block/type",
});
if (args.list) {
await ctx.assertFact({
entity: args.newEntityID,
attribute: "block/is-list",
data: { type: "boolean", value: true },
});
if (args.list.listStyle) {
await ctx.assertFact({
entity: args.newEntityID,
attribute: "block/list-style",
data: { type: "list-style-union", value: args.list.listStyle },
});
}
if (args.list.checklist !== undefined) {
await ctx.assertFact({
entity: args.newEntityID,
attribute: "block/check-list",
data: { type: "boolean", value: args.list.checklist },
});
}
}
};

const addLastBlock: Mutation<{
Expand Down Expand Up @@ -227,6 +255,11 @@ const outdentBlock: Mutation<{
(f) => f.data.value === args.block,
);
if (currentFactIndex === -1) return;
// Bail before the retraction below: a missing anchor here is a clean no-op,
// but checking after retracting would orphan the block and the siblings
// re-parented under it, dropping them from the document.
let index = newSiblings.findIndex((f) => f.data.value === args.after);
if (index === -1) return;
// Filter out blocks that are being processed separately (e.g., in multi-select outdent)
let excludeSet = new Set(args.excludeFromSiblings || []);
let currentSiblingsAfter = currentSiblings
Expand All @@ -253,8 +286,6 @@ const outdentBlock: Mutation<{
});
}

let index = newSiblings.findIndex((f) => f.data.value === args.after);
if (index === -1) return;
let newPosition = generateKeyBetween(
newSiblings[index]?.data.position,
newSiblings[index + 1]?.data.position || null,
Expand Down
Loading