diff --git a/docs/commands.md b/docs/commands.md
index 8bf1e206ce8..5a0507bf320 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -125,7 +125,7 @@ Both filter `globalCommands` by name and respect `hideFromDesktopCommandUniverse
When `state.multicursors` is non-empty, the user has one or more thoughts selected. A selection of exactly one thought is common — on mobile, opening the Command Center selects the cursor thought. Every command must declare how it behaves in this case via the required `multicursor` field — there is no implicit default.
-- **`multicursor: false`** — execute on `state.cursor` as if no multicursor existed; selection stays. For commands that don't interact with the thoughtspace (e.g. opening modals). The cursor-navigation commands also declare `multicursor: false` yet still respond to a selection, since navigating a multiselect means moving the selection itself rather than executing once per selected thought: [`cursorUp`](../src/commands/cursorUp.ts) and [`cursorDown`](../src/commands/cursorDown.ts) read `state.multicursors` in their own `exec` to extend or collapse the selection, and the [`cursorForward`](../src/actions/cursorForward.ts) reducer replaces the selection with the thoughts one level forward.
+- **`multicursor: false`** — execute on `state.cursor` as if no multicursor existed; selection stays. For commands that don't interact with the thoughtspace (e.g. opening modals). The cursor-navigation commands also declare `multicursor: false` yet still respond to a selection, since navigating a multiselect means moving the selection itself rather than executing once per selected thought: [`cursorUp`](../src/commands/cursorUp.ts) and [`cursorDown`](../src/commands/cursorDown.ts) read `state.multicursors` in their own `exec` to extend or collapse the selection, and the [`cursorForward`](../src/actions/cursorForward.ts) and [`cursorBack`](../src/actions/cursorBack.ts) reducers replace the selection with the thoughts one level forward or back.
- **`multicursor: true`** — execute once per selected thought.
- **`multicursor: { ... }`** — fine-grained control with these options:
@@ -144,6 +144,8 @@ When `state.multicursors` is non-empty, the user has one or more thoughts select
`setIsMulticursorExecuting` is the general mechanism for that collapsing, not a private detail of the command loop: [`undoRedoEnhancer`](../src/redux-enhancers/undoRedoEnhancer.ts) merges every action dispatched while `state.isMulticursorExecuting` is true into the preceding undo patch, and shows `undoLabel` in the undo/redo alert. Any code path that edits every selected thought without going through a `multicursor: true` command must bracket its dispatch with the same pair, or the user has to undo once per thought. The [`ColorPicker`](../src/components/ColorPicker.tsx) and [`LetterCasePicker`](../src/components/LetterCasePicker.tsx) reach the thoughtspace through [`formatSelection`](../src/actions/formatSelection.ts) and [`formatLetterCase`](../src/actions/formatLetterCase.ts) rather than through their `multicursor: false` toolbar commands, so those two action-creators do the bracketing themselves; drag-and-drop of a multiselect does the same.
+The whole of `executeCommandWithMulticursor` is synchronous, including that bracket, so an **asynchronous** command gets no help from it: the bracket is opened and closed around the call, and anything dispatched after the first `await` lands outside it. [`generateThought`](../src/commands/generateThought.ts) is the case in point — its `exec` only reaches the thoughtspace once a network request has returned. It defines an `execMulticursor` that yields once (so that the loop's own synchronous bracket has closed), opens a second bracket of its own, generates every selected thought, and closes it only after all of them have settled. A `multicursor: true` declaration would instead leave one undo step per generated thought, and each `exec`'s own `setCursor` would drop the caret on whichever request happened to finish last.
+
### Gating and defaults
Three fields shape what happens when the command might not be runnable:
@@ -159,7 +161,7 @@ Three fields shape what happens when the command might not be runnable:
`keyboardIndex` is recorded alongside the command and restored when it is repeated, since it cannot be derived from the Command/Ctrl + . keypress — that keypress matches none of the repeated command's own shortcuts. Without it, repeating `applyColor` would have no swatch to apply and would silently do nothing. `executeCommandWithMulticursor` resolves `repeat` itself and then delegates an already-resolved command, so it forwards the recorded index through executeCommand's `keyboardIndex` option.
-Only commands that make an *undoable, non-navigational* change are recorded, so that Repeat repeats the last edit no matter how many navigation or non-undoable commands intervened. `executeCommand` detects this by comparing the last non-navigation undo patch (the patch that Undo would revert, as classified by [`actionMetadata.registry`](../src/util/actionMetadata.registry.ts)) before and after `exec`. Consequently:
+Only commands that make an *undoable, non-navigational* change are recorded, so that Repeat repeats the last edit no matter how many navigation or non-undoable commands intervened. This is detected by comparing the last non-navigation undo patch (the patch that Undo would revert, as classified by [`actionMetadata.registry`](../src/util/actionMetadata.registry.ts)) before and after execution. A command with a custom `execMulticursor` never reaches `executeCommand`, so `executeCommandWithMulticursor` records it around that call instead, comparing the patch from the same point the per-cursor loop does — after `setIsMulticursorExecuting`. Consequently:
- Navigation commands (Cursor Down, Jump Back) are skipped — their actions are registered `isNavigation`.
- Commands that dispatch no undoable action (Export, Settings, Command Universe) are skipped, since they add no patch.
@@ -185,7 +187,7 @@ The full list of user-facing commands. For the canonical, always-up-to-date set,
### Back
-Move the cursor up a level. If Clear Thought is active, cancel it instead and leave the cursor where it is.
+Move the cursor up a level. If Clear Thought is active, cancel it instead and leave the cursor where it is. When thoughts are selected, deselect them and select the parent of each selected thought instead — except on desktop, where Escape clears the selection rather than moving it.
Escape
@@ -421,7 +423,7 @@ https://github.com/user-attachments/assets/95f037cc-cf88-4392-98fb-4d79cdae4fba
### Bump Thought Down
-Bump the current thought down one level and replace it with a new, empty thought.
+Bump the current thought down one level and replace it with a new, empty thought. When multiple thoughts are selected, their parent is bumped down and the selected thoughts are moved into the new thought.
Command + Option + d
diff --git a/docs/drag-and-drop.md b/docs/drag-and-drop.md
index 4b668c37564..cb8e31f61a8 100644
--- a/docs/drag-and-drop.md
+++ b/docs/drag-and-drop.md
@@ -135,6 +135,8 @@ When the press ends, `useLongPress` defers `onLongPressEnd` by 10 ms so that the
[`useDragLeave`](../src/hooks/useDragLeave.ts) tracks how many drop targets are currently being deep-hovered (a module-level `hoverCount`). When the count drops to zero, it debounces a 50 ms clear of `state.hoveringPath`. This prevents flicker when the cursor briefly leaves one drop zone before entering an adjacent one.
+Because `hoverCount` is shared across every drop target, only a change in `isDeepHovering` may adjust it. The hook's effect also re-runs on mount and when `canDropThought` or `hoverZone` change, and treating those as hover transitions would let a thought mounting mid-drag decrement the count to zero and blank the drop indicator while a target is still hovered. A separate unmount-only effect releases a target's contribution to the count, so a thought the layout unmounts mid-drag doesn't leak one.
+
### `useDropHoverColor`
[`useDropHoverColor`](../src/hooks/useDropHoverColor.ts) — small UI hook that maps the drop zone's depth to its hover color. Used by the various Drop* components.
diff --git a/package.json b/package.json
index d6eb7f7087d..6e9802f7cda 100644
--- a/package.json
+++ b/package.json
@@ -131,7 +131,7 @@
"immer": "^11.1.16",
"ipfs-http-client": "^43.0.1",
"lodash": "^4.18.1",
- "lottie-react": "^2.4.1",
+ "lottie-react": "^3.1.0",
"marked": "^18.0.9",
"moize": "^6.1.7",
"motion": "^13.1.0",
@@ -238,7 +238,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
- "expect-webdriverio": "^5.7.0",
+ "expect-webdriverio": "^6.0.2",
"fake-indexeddb": "^6.2.5",
"happy-dom": "^20.11.2",
"it-all": "^3.0.11",
diff --git a/src/actions/__tests__/cursorBack.ts b/src/actions/__tests__/cursorBack.ts
index 2c7abb27eb5..62d46c36944 100644
--- a/src/actions/__tests__/cursorBack.ts
+++ b/src/actions/__tests__/cursorBack.ts
@@ -1,10 +1,23 @@
+import State from '../../@types/State'
+import importText from '../../actions/importText'
+import toggleContextView from '../../actions/toggleContextView'
+import childIdsToThoughts from '../../selectors/childIdsToThoughts'
+import contextToPath from '../../selectors/contextToPath'
+import addMulticursor from '../../test-helpers/addMulticursorAtFirstMatch'
import expectPathToEqual from '../../test-helpers/expectPathToEqual'
+import setCursor from '../../test-helpers/setCursorFirstMatch'
+import hashPath from '../../util/hashPath'
import initialState from '../../util/initialState'
import reducerFlow from '../../util/reducerFlow'
import cursorBack from '../cursorBack'
+import cursorForward from '../cursorForward'
import newSubthought from '../newSubthought'
import newThought from '../newThought'
+/** Converts the multicursor set to a list of contexts in a readable way. */
+const multicursorContexts = (state: State): string[][] =>
+ Object.values(state.multicursors).map(path => childIdsToThoughts(state, path).map(thought => thought.value))
+
it('move cursor to parent', () => {
const steps = [newThought('a'), newSubthought('b'), cursorBack]
@@ -20,3 +33,116 @@ it('remove cursor from root thought', () => {
expect(stateNew.cursor).toEqual(null)
})
+
+// https://github.com/cybersemics/em/issues/3526
+describe('multicursor', () => {
+ it('select the parents of the selected thoughts', () => {
+ const text = `
+ - x
+ - =children
+ - =pin
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f
+ - g
+ `
+ const steps = [
+ importText({ text }),
+ setCursor(['x', 'a', 'b']),
+ addMulticursor(['x', 'a', 'b']),
+ addMulticursor(['x', 'a', 'c']),
+ addMulticursor(['x', 'd', 'e']),
+ cursorBack,
+ ]
+
+ const stateNew = reducerFlow(steps)(initialState())
+
+ // b and c share the parent a, which is selected only once
+ expect(multicursorContexts(stateNew)).toEqual([
+ ['x', 'a'],
+ ['x', 'd'],
+ ])
+ })
+
+ it('select the context view thought when its contexts are selected', () => {
+ const text = `
+ - a
+ - m
+ - x
+ - b
+ - m
+ - y
+ `
+ const steps = [
+ importText({ text }),
+ setCursor(['a', 'm']),
+ toggleContextView,
+ addMulticursor(['a', 'm']),
+ // select the contexts a and b of the context view thought m
+ cursorForward,
+ cursorBack,
+ ]
+
+ const stateNew = reducerFlow(steps)(initialState())
+
+ expect(multicursorContexts(stateNew)).toEqual([['a', 'm']])
+ })
+
+ it('keep a selected thought selected when it is the parent of another selected thought', () => {
+ const text = `
+ - x
+ - a
+ - b
+ `
+ const steps = [
+ importText({ text }),
+ setCursor(['x']),
+ addMulticursor(['x', 'a']),
+ addMulticursor(['x', 'a', 'b']),
+ cursorBack,
+ ]
+
+ const stateNew = reducerFlow(steps)(initialState())
+
+ expect(multicursorContexts(stateNew)).toEqual([['x'], ['x', 'a']])
+ })
+
+ it('do nothing if all of the selected thoughts are in the root context', () => {
+ const text = `
+ - a
+ - b
+ `
+ const steps = [importText({ text }), setCursor(['a']), addMulticursor(['a']), addMulticursor(['b']), cursorBack]
+
+ const stateNew = reducerFlow(steps)(initialState())
+
+ expect(multicursorContexts(stateNew)).toEqual([['a'], ['b']])
+ })
+
+ it('collapse the newly selected parents so that the previously selected children are hidden', () => {
+ // x needs a second child, otherwise a is already expanded by the only-child rule
+ const text = `
+ - x
+ - a
+ - b
+ - c
+ - d
+ `
+ const steps = [
+ importText({ text }),
+ setCursor(['x']),
+ addMulticursor(['x', 'a']),
+ // select b and c, which expands their deselected parent a
+ cursorForward,
+ cursorBack,
+ ]
+
+ const stateNew = reducerFlow(steps)(initialState())
+
+ expect(multicursorContexts(stateNew)).toEqual([['x', 'a']])
+ expect(stateNew.expanded[hashPath(contextToPath(stateNew, ['x', 'a'])!)]).toBeFalsy()
+ })
+})
diff --git a/src/actions/__tests__/swapParent.ts b/src/actions/__tests__/swapParent.ts
index 8a7f5b53b8c..57310a5e24e 100644
--- a/src/actions/__tests__/swapParent.ts
+++ b/src/actions/__tests__/swapParent.ts
@@ -283,27 +283,34 @@ describe('sort', () => {
it('root children are re-sorted after swapParent with active sort', () => {
// Reproduce the issue: cursor on A, set Created sort, create subthought B, swap B with A.
- // B must be created as a separate step so its creation order is after A, C, D.
+ // B must be created in a later millisecond than A, C, and D: Created sort falls back to alphabetical order on
+ // thoughts created in the same millisecond, which would put b first regardless of the swap. The reducers run
+ // synchronously, so the clock has to be advanced explicitly between the two creation steps.
vi.useFakeTimers()
- vi.setSystemTime(0)
-
- let stateNew = initialState()
- stateNew = reducerFlow([
- importText({
- text: `
+ let stateNew
+ try {
+ const stateBefore = reducerFlow([
+ importText({
+ text: `
- a
- c
- d
`,
- }),
- setCursor(['a']),
- setSortPreference({ simplePath: HOME_PATH, sortPreference: { type: 'Created', direction: 'Asc' } }),
- ])(stateNew)
-
- vi.setSystemTime(1000)
- stateNew = reducerFlow([newThought({ value: 'b', insertNewSubthought: true }), setCursor(['a', 'b']), swapParent])(
- stateNew,
- )
+ }),
+ setCursor(['a']),
+ setSortPreference({ simplePath: HOME_PATH, sortPreference: { type: 'Created', direction: 'Asc' } }),
+ ])(initialState())
+
+ vi.advanceTimersByTime(1000)
+
+ stateNew = reducerFlow([
+ newThought({ value: 'b', insertNewSubthought: true }),
+ setCursor(['a', 'b']),
+ swapParent,
+ ])(stateBefore)
+ } finally {
+ vi.useRealTimers()
+ }
// Use excludeMeta to focus on regular thoughts only.
// b was created last (separate newThought step), so it always sorts after c and d in Created Asc order.
diff --git a/src/actions/bumpThoughtDown.ts b/src/actions/bumpThoughtDown.ts
index fb6d99fb392..b28099eef5e 100644
--- a/src/actions/bumpThoughtDown.ts
+++ b/src/actions/bumpThoughtDown.ts
@@ -8,6 +8,8 @@ import editThought from '../actions/editThought'
import editableRender from '../actions/editableRender'
import moveThought from '../actions/moveThought'
import setCursor from '../actions/setCursor'
+import { AlertType } from '../constants'
+import documentSort from '../selectors/documentSort'
import findDescendant from '../selectors/findDescendant'
import { getAllChildren } from '../selectors/getChildren'
import getPrevRank from '../selectors/getPrevRank'
@@ -17,16 +19,35 @@ import getThoughtById from '../selectors/getThoughtById'
import simplifyPath from '../selectors/simplifyPath'
import { registerActionMetadata } from '../util/actionMetadata.registry'
import appendToPath from '../util/appendToPath'
+import createId from '../util/createId'
+import equalPath from '../util/equalPath'
import head from '../util/head'
import parentOf from '../util/parentOf'
import reducerFlow from '../util/reducerFlow'
+import alert from './alert'
import categorize from './categorize'
-/** Clears a thought's text, moving it to its first child. */
-const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath }): State => {
- if (!simplePath && !state.cursor) return state
+/** Clears a thought's text, moving it to its first child. If multiple thoughts are selected, bumps their parent down and moves the selected thoughts into the new thought. */
+const bumpThoughtDown = (state: State, { paths, simplePath }: { paths?: Path[]; simplePath?: SimplePath }): State => {
+ // the selected thoughts that are moved into the new thought, in document order
+ const selection = paths && paths.length > 1 ? documentSort(state, paths) : null
- simplePath = simplePath || simplifyPath(state, state.cursor!)
+ if (selection && !selection.every(path => equalPath(parentOf(path), parentOf(selection[0])))) {
+ return alert(state, {
+ alertType: AlertType.MulticursorError,
+ value: 'Cannot bump down thoughts from different parents.',
+ })
+ }
+
+ // The home context has no text to bump down, so simply move the selected thoughts into a new empty thought.
+ if (selection && parentOf(selection[0]).length === 0) return categorize(state)
+
+ // Bump the parent of the selected thoughts down, otherwise bump the selected thought or the cursor down.
+ const path = simplePath || (selection ? parentOf(selection[0]) : paths?.[0]) || state.cursor
+
+ if (!path) return state
+
+ simplePath = simplePath || simplifyPath(state, path)
const headThought = getThoughtById(state, head(simplePath))
if (!headThought) {
@@ -55,6 +76,9 @@ const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath
const simplePathWithNewRank: SimplePath = appendToPath(parentPath, head(simplePath))
const simplePathWithNewRankAndValue: Path = appendToPath(parentPath, head(simplePathWithNewRank))
+ // the id of the new thought that the bumped value is moved to, and that the selected thoughts are moved into
+ const newThoughtId = createId()
+
return reducerFlow([
// modify the rank to get the thought to re-render (via the Subthoughts child key)
moveThought({
@@ -67,6 +91,7 @@ const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath
state => {
// the context of the new empty thought
return createThought(state, {
+ id: newThoughtId,
path: simplePath as Path,
// If there is a sort preference, use it. Otherwise, insert at the top.
rank: sortId ? getSortedRank(state, head(simplePath), value) : getPrevRank(state, head(simplePath)),
@@ -81,6 +106,18 @@ const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath
path: simplePathWithNewRank,
}),
+ // move the selected thoughts into the new thought, preserving their order
+ // we ignore selected thoughts that are somehow missing, see getThoughtById
+ ...(selection || [])
+ .filter(path => getThoughtById(state, head(path)))
+ .map(path =>
+ moveThought({
+ oldPath: simplifyPath(state, path),
+ newPath: appendToPath(simplePathWithNewRank, newThoughtId, head(path)),
+ newRank: getThoughtById(state, head(path))!.rank,
+ }),
+ ),
+
// set cursor
setCursor({
path: simplePathWithNewRankAndValue,
diff --git a/src/actions/cursorBack.ts b/src/actions/cursorBack.ts
index b349c57a4be..539e717c57e 100644
--- a/src/actions/cursorBack.ts
+++ b/src/actions/cursorBack.ts
@@ -1,16 +1,49 @@
import State from '../@types/State'
import Thunk from '../@types/Thunk'
+import addMulticursor from '../actions/addMulticursor'
import cursorHistory from '../actions/cursorHistory'
+import removeMulticursor from '../actions/removeMulticursor'
import searchReducer from '../actions/search'
import setCursor from '../actions/setCursor'
+import expandThoughts from '../selectors/expandThoughts'
+import hasMulticursor from '../selectors/hasMulticursor'
import { registerActionMetadata } from '../util/actionMetadata.registry'
import isAbsolute from '../util/isAbsolute'
import parentOf from '../util/parentOf'
import reducerFlow from '../util/reducerFlow'
import toggleAbsoluteContext from './toggleAbsoluteContext'
-/** Moves the cursor up one level. */
+/** Replaces the multiselect with the parents of each selected thought. Parents shared by multiple selected thoughts are selected once, since the multicursor set is keyed by path. */
+const multicursorBack = (state: State): State => {
+ const paths = Object.values(state.multicursors)
+
+ // Root-level thoughts contribute no parent, since the root cannot be selected.
+ const backPaths = paths.filter(path => path.length > 1).map(parentOf)
+
+ // do nothing if all selected thoughts are at the root level, so that an extra Back gesture does not destroy the selection
+ if (backPaths.length === 0) return state
+
+ const stateNew = reducerFlow([
+ // Deselecting before selecting is safe within a single action, since multicursorAlertMiddleware only sees the
+ // final state and thus never a momentarily empty multiselect (which would close the Command Center on mobile).
+ // A selected thought that is also the parent of another selected thought is deselected and reselected.
+ ...paths.map(path => removeMulticursor({ path })),
+ ...backPaths.map(path => addMulticursor({ path })),
+ ])(state)
+
+ return {
+ ...stateNew,
+ // Selected thoughts are kept collapsed by expandThoughts, so expansion must be recalculated for the newly
+ // selected parents to collapse the previously selected children.
+ // https://github.com/cybersemics/em/issues/4738
+ expanded: expandThoughts(stateNew, stateNew.cursor),
+ }
+}
+
+/** Moves the cursor up one level. When thoughts are selected, replaces the selection with their parents instead of moving the cursor. */
const cursorBack = (state: State): State => {
+ if (hasMulticursor(state)) return multicursorBack(state)
+
const { cursor: cursorOld, isKeyboardOpen, search, rootContext } = state
const isAbsoluteRoot = isAbsolute(rootContext)
diff --git a/src/actions/importText.ts b/src/actions/importText.ts
index b55960a43f9..b8dae1a9f51 100644
--- a/src/actions/importText.ts
+++ b/src/actions/importText.ts
@@ -15,6 +15,7 @@ import getThoughtById from '../selectors/getThoughtById'
import rootedParentOf from '../selectors/rootedParentOf'
import simplifyPath from '../selectors/simplifyPath'
import { registerActionMetadata } from '../util/actionMetadata.registry'
+import addEmojiSpace from '../util/addEmojiSpace'
import appendToPath from '../util/appendToPath'
import createId from '../util/createId'
import head from '../util/head'
@@ -136,8 +137,14 @@ const importText = (
: destValue.slice(0, htmlReplaceStart || 0) + destValue.slice(htmlReplaceEnd || 0)
const insertPosition = htmlReplaceStart || htmlCaretPosition
- const newValue = `${replacedDestValue.slice(0, insertPosition)}${text}${replacedDestValue.slice(insertPosition)}`
- const offset = caretPosition + getTextContentFromHTML(text).length
+ const combinedValue = `${replacedDestValue.slice(0, insertPosition)}${text}${replacedDestValue.slice(insertPosition)}`
+ const newValue = addEmojiSpace(combinedValue)
+ const offsetBeforeEmojiSpace = caretPosition + getTextContentFromHTML(text).length
+ const emojiSpaceInsertionOffset = newValue === combinedValue ? -1 : getTextContentFromHTML(newValue).indexOf(' ')
+ const offset =
+ emojiSpaceInsertionOffset >= 0 && offsetBeforeEmojiSpace >= emojiSpaceInsertionOffset
+ ? offsetBeforeEmojiSpace + 1
+ : offsetBeforeEmojiSpace
return reducerFlow([
// Force the editable to re-render in order to trigger setSelectionToCursorOffset in useEditMode and restore the caret.
diff --git a/src/commands.ts b/src/commands.ts
index b6dc8ef1850..96c8dfd7433 100644
--- a/src/commands.ts
+++ b/src/commands.ts
@@ -420,6 +420,22 @@ const lastUndoablePatch = (state: State): Patch | undefined => {
return undefined
}
+/**
+ * Records the last command so that it can be executed again by the repeat command, but only if it made an undoable, non-navigational change to the thoughtspace. Otherwise repeat would repeat cursor movements and commands that dispatch no undoable actions (e.g. Cursor Down, Export) rather than the last edit, no matter how many of them occurred since.
+ *
+ * Patches are compared by identity rather than by action type, since the same command may be executed repeatedly (e.g. Bold twice in a row). A command that only dispatches asynchronously (e.g. Generate Thought) is not recorded, as its patch does not exist yet.
+ */
+const recordLastCommand = (
+ command: Command,
+ keyboardIndex: number | undefined,
+ stateAfter: State,
+ undoablePatchPrev: Patch | undefined,
+) => {
+ if (command.repeatable !== false && lastUndoablePatch(stateAfter) !== undoablePatchPrev) {
+ lastCommand = { command, keyboardIndex }
+ }
+}
+
/** Execute a single command. Defaults to global store and keyboard shortcuts. Use `executeCommandWithMulticursor` to execute a command with multicursor mode. */
export const executeCommand = (
commandArg: Command,
@@ -460,11 +476,7 @@ export const executeCommand = (
// execute single command
command.exec(commandStore.dispatch, commandStore.getState, event, { type, keyboardIndex })
- // Record the last command so that it can be executed again by the repeat command, but only if it made an undoable, non-navigational change to the thoughtspace. Otherwise repeat would repeat cursor movements and commands that dispatch no undoable actions (e.g. Cursor Down, Export) rather than the last edit, no matter how many of them occurred since.
- // Patches are compared by identity rather than by action type, since the same command may be executed repeatedly (e.g. Bold twice in a row). A command that only dispatches asynchronously (e.g. Generate Thought) is not recorded, as its patch does not exist yet.
- if (command.repeatable !== false && lastUndoablePatch(commandStore.getState()) !== undoablePatchPrev) {
- lastCommand = { command, keyboardIndex }
- }
+ recordLastCommand(command, keyboardIndex, commandStore.getState(), undoablePatchPrev)
}
/** Execute command. Defaults to global store and keyboard shortcuts. */
@@ -553,7 +565,10 @@ export const executeCommandWithMulticursor = (
// If there is a custom execMulticursor function, call it with the filtered multicursors.
// Otherwise, execute the command once for each of the filtered multicursors.
if (multicursor.execMulticursor) {
+ // execMulticursor bypasses executeCommand, which is what records the last command for the repeat command, so record it here. The patch is captured after setIsMulticursorExecuting, the same point the per-cursor loop below captures it from, so that both branches judge a change by the same measure.
+ const undoablePatchPrev = lastUndoablePatch(commandStore.getState())
multicursor.execMulticursor(filteredPaths, commandStore.dispatch, commandStore.getState)
+ recordLastCommand(command, keyboardIndex, commandStore.getState(), undoablePatchPrev)
} else {
for (const path of filteredPaths) {
// Make sure we have the correct path to the thought in case it was moved during execution.
diff --git a/src/commands/__tests__/bindContext.ts b/src/commands/__tests__/bindContext.ts
new file mode 100644
index 00000000000..e972a8e9063
--- /dev/null
+++ b/src/commands/__tests__/bindContext.ts
@@ -0,0 +1,331 @@
+import { importTextActionCreator as importText } from '../../actions/importText'
+import { toggleContextViewActionCreator as toggleContextView } from '../../actions/toggleContextView'
+import { undoActionCreator as undo } from '../../actions/undo'
+import { executeCommand, executeCommandWithMulticursor } from '../../commands'
+import { HOME_TOKEN } from '../../constants'
+import childIdsToThoughts from '../../selectors/childIdsToThoughts'
+import exportContext from '../../selectors/exportContext'
+import store from '../../stores/app'
+import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
+import expectPathToEqual from '../../test-helpers/expectPathToEqual'
+import initStore from '../../test-helpers/initStore'
+import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
+import bindContextCommand from '../bindContext'
+
+beforeEach(initStore)
+
+/** Imports two contexts of m and activates the context view on a/m, so that a and b are listed as contexts under a/m~. */
+const importOneContextView = () =>
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - m
+ - x
+ - b
+ - m
+ - y
+ `,
+ }),
+ setCursor(['a', 'm']),
+ toggleContextView(),
+ ])
+
+/** Imports two independent pairs of contexts and activates the context view on both a/m and c/n. */
+const importTwoContextViews = () =>
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - m
+ - x
+ - b
+ - m
+ - y
+ - c
+ - n
+ - x
+ - d
+ - n
+ - y
+ `,
+ }),
+ setCursor(['a', 'm']),
+ toggleContextView(),
+ setCursor(['c', 'n']),
+ toggleContextView(),
+ ])
+
+describe('multicursor', () => {
+ it('binds each selected context under its own context view', () => {
+ importTwoContextViews()
+ store.dispatch([setCursor(['a', 'm', 'b']), addMulticursor(['a', 'm', 'b']), addMulticursor(['c', 'n', 'd'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y
+ - c
+ - n
+ - =bindContextCommand
+ - ["d","n"]
+ - x
+ - d
+ - n
+ - y`)
+ })
+
+ it('binds only the last selected context when several contexts of the same context view are selected', () => {
+ importOneContextView()
+ store.dispatch([setCursor(['a', 'm', 'a']), addMulticursor(['a', 'm', 'a']), addMulticursor(['a', 'm', 'b'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ // A binding is a relation between the context view thought and a single context, stored as one
+ // =bindContextCommand value. Each iteration overwrites the previous one, so the last context in
+ // document order wins. See the sequential equivalence test below.
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y`)
+ })
+
+ it('matches invoking the command on each selected context in turn', () => {
+ importOneContextView()
+
+ store.dispatch(setCursor(['a', 'm', 'a']))
+ executeCommand(bindContextCommand, { store })
+ store.dispatch(setCursor(['a', 'm', 'b']))
+ executeCommand(bindContextCommand, { store })
+
+ // Identical to the multiselect result above: the multicursor loop is exactly a sequence of
+ // single-cursor invocations in document order.
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y`)
+ })
+
+ it('rebinds to the last selected context when an already bound context is also selected', () => {
+ importOneContextView()
+ store.dispatch(setCursor(['a', 'm', 'a']))
+ executeCommand(bindContextCommand, { store })
+
+ store.dispatch([addMulticursor(['a', 'm', 'a']), addMulticursor(['a', 'm', 'b'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ // a's binding is toggled off by the first iteration, then b is bound by the second.
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y`)
+ })
+
+ it('preserves the binding when the last selected context is already bound', () => {
+ importOneContextView()
+ store.dispatch(setCursor(['a', 'm', 'b']))
+ executeCommand(bindContextCommand, { store })
+
+ store.dispatch([setCursor(['a', 'm', 'a']), addMulticursor(['a', 'm', 'a']), addMulticursor(['a', 'm', 'b'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ // The first iteration overwrites the binding with a, the second overwrites it back with b, so the
+ // run is a net no-op. It is a silent no-op, not a blocked command, so no error alert is shown.
+ expect(store.getState().alert?.value).toBeFalsy()
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y`)
+
+ // Contrast with the single-cursor case, which toggles the existing binding off.
+ store.dispatch(setCursor(['a', 'm', 'b']))
+ executeCommand(bindContextCommand, { store })
+
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - x
+ - b
+ - m
+ - y`)
+ })
+
+ it('binds the cursor context when it is the only selected thought', () => {
+ importOneContextView()
+ store.dispatch([setCursor(['a', 'm', 'b']), addMulticursor(['a', 'm', 'b'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y`)
+ })
+
+ it('does nothing when the context view is not active', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - m
+ - x
+ - b
+ - m
+ - y
+ `,
+ }),
+ setCursor(['a', 'm']),
+ addMulticursor(['a', 'm']),
+ addMulticursor(['b', 'm']),
+ ])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ // exec returns early for every selected thought, silently. No error alert is shown.
+ expect(store.getState().alert?.value).toBeFalsy()
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - x
+ - b
+ - m
+ - y`)
+ })
+
+ it('skips selected thoughts that are not contexts of the context view', () => {
+ importOneContextView()
+ store.dispatch([setCursor(['a', 'm', 'b']), addMulticursor(['a', 'm', 'b']), addMulticursor(['a', 'm', 'b', 'y'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ // a/m~/b/y is inside the context view but its parent is not the context view thought, so it is a
+ // no-op rather than blocking the run.
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y`)
+ })
+
+ it('restores the cursor and multicursors to their context view paths', () => {
+ importOneContextView()
+ store.dispatch([setCursor(['a', 'm', 'a']), addMulticursor(['a', 'm', 'a']), addMulticursor(['a', 'm', 'b'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ const state = store.getState()
+
+ // Precondition: the run bound a context, otherwise the restore below would be asserted on a no-op.
+ expect(exportContext(state, [HOME_TOKEN], 'text/plain')).toContain('=bindContextCommand')
+
+ // Nothing moved, so the cursor returns to the context it started on rather than the last context
+ // executed on, and the selection remains meaningful.
+ expectPathToEqual(state, state.cursor, ['a', 'm', 'a'])
+ expect(
+ Object.values(state.multicursors).map(path => childIdsToThoughts(state, path).map(thought => thought.value)),
+ ).toEqual([
+ ['a', 'm', 'a'],
+ ['a', 'm', 'b'],
+ ])
+ })
+
+ it('reverts every binding on a single undo', () => {
+ importTwoContextViews()
+ store.dispatch([setCursor(['a', 'm', 'b']), addMulticursor(['a', 'm', 'b']), addMulticursor(['c', 'n', 'd'])])
+
+ executeCommandWithMulticursor(bindContextCommand, { store })
+
+ // Precondition: both bindings were created, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - =bindContextCommand
+ - ["b","m"]
+ - x
+ - b
+ - m
+ - y
+ - c
+ - n
+ - =bindContextCommand
+ - ["d","n"]
+ - x
+ - d
+ - n
+ - y`)
+
+ store.dispatch(undo())
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - m
+ - x
+ - b
+ - m
+ - y
+ - c
+ - n
+ - x
+ - d
+ - n
+ - y`)
+ })
+})
diff --git a/src/commands/__tests__/bumpThoughtDown.ts b/src/commands/__tests__/bumpThoughtDown.ts
index c6da731015d..accc9940543 100644
--- a/src/commands/__tests__/bumpThoughtDown.ts
+++ b/src/commands/__tests__/bumpThoughtDown.ts
@@ -3,7 +3,7 @@ import { importTextActionCreator as importText } from '../../actions/importText'
import { undoActionCreator as undo } from '../../actions/undo'
import { executeCommand, executeCommandWithMulticursor } from '../../commands'
import bumpThoughtDown from '../../commands/bumpThoughtDown'
-import { HOME_TOKEN } from '../../constants'
+import { AlertType, HOME_TOKEN } from '../../constants'
import exportContext from '../../selectors/exportContext'
import hasMulticursor from '../../selectors/hasMulticursor'
import store from '../../stores/app'
@@ -45,214 +45,172 @@ describe('DOM', () => {
})
describe('multicursor', () => {
- it('bumps each selected thought down into a new empty thought', () => {
+ // https://github.com/cybersemics/em/issues/3134
+ it('bumps the parent of the selected thoughts down and moves the selected thoughts into it', () => {
store.dispatch([
importText({
text: `
- a
- b
- - c
+ - c
- d
- `,
+ - e
+ - f`,
}),
- setCursor(['a']),
- addMulticursor(['a']),
- addMulticursor(['c']),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['a', 'c']),
+ addMulticursor(['a', 'd']),
])
executeCommandWithMulticursor(bumpThoughtDown, { store })
const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
- expect(exported).toEqual(`- ${HOME_TOKEN}
- -
+ expect(exported).toBe(`- ${HOME_TOKEN}
+ - ${''}
- a
- - b
- -
- - c
- - d`)
+ - b
+ - c
+ - d
+ - e
+ - f`)
})
- it('bumps selected thoughts at different depths', () => {
+ it('bumps a single selected thought down', () => {
store.dispatch([
importText({
text: `
- a
- b
- - c
- - d
- - e
- `,
+ - x
+ - c`,
}),
setCursor(['a', 'b']),
addMulticursor(['a', 'b']),
- addMulticursor(['c', 'd']),
])
executeCommandWithMulticursor(bumpThoughtDown, { store })
const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
- expect(exported).toEqual(`- ${HOME_TOKEN}
+ expect(exported).toBe(`- ${HOME_TOKEN}
- a
- -
+ - ${''}
- b
- - c
- -
- - d
- - e`)
- })
-
- it('bumps each selected sibling down into its own empty thought', () => {
- store.dispatch([
- importText({
- text: `
- - p
- - a
- - b
- `,
- }),
- setCursor(['p', 'a']),
- addMulticursor(['p', 'a']),
- addMulticursor(['p', 'b']),
- ])
-
- executeCommandWithMulticursor(bumpThoughtDown, { store })
-
- // A childless thought is bumped via the categorize path, which creates a new empty parent for it.
- // Each sibling must get its own empty parent rather than being grouped under a single one.
- const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
-
- expect(exported).toEqual(`- ${HOME_TOKEN}
- - p
- -
- - a
- -
- - b`)
+ - x
+ - c`)
})
- it('bumps a selected parent and its selected child', () => {
+ it('moves the selected thoughts into a new empty thought when they are in the home context', () => {
store.dispatch([
importText({
text: `
- a
- - b
- - c
- `,
+ - b
+ - c`,
}),
setCursor(['a']),
addMulticursor(['a']),
- addMulticursor(['a', 'b']),
+ addMulticursor(['b']),
])
executeCommandWithMulticursor(bumpThoughtDown, { store })
- // a is bumped first, moving its value into a new first child. b's path is recomputed through the
- // now-empty parent before b is bumped in turn.
const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
- expect(exported).toEqual(`- ${HOME_TOKEN}
- -
+ expect(exported).toBe(`- ${HOME_TOKEN}
+ - ${''}
- a
- -
- - b
- - c`)
+ - b
+ - c`)
})
- it('sets the cursor on the new empty thought when a single selected thought is bumped', () => {
+ it('sets the cursor on the new empty thought and clears the multicursor', () => {
store.dispatch([
importText({
text: `
- - p
- - a
- `,
+ - a
+ - b
+ - c`,
}),
- setCursor(['p', 'a']),
- addMulticursor(['p', 'a']),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['a', 'c']),
])
executeCommandWithMulticursor(bumpThoughtDown, { store })
const state = store.getState()
- expect(exportContext(state, [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
- - p
- -
- - a`)
-
- // The caret belongs on the new empty thought, ready to type the bumped thought's replacement,
+ // The caret belongs on the bumped parent's empty replacement, ready to type its new value,
// just as when the command is executed without a multiselect.
- expectPathToEqual(state, state.cursor, ['p', ''])
+ expectPathToEqual(state, state.cursor, [''])
expect(hasMulticursor(state)).toBeFalse()
})
- it('sets the cursor on the last bumped empty thought and clears the multicursor', () => {
+ it('reverts the bump on a single undo', () => {
store.dispatch([
importText({
text: `
- a
- b
- - c
- - d
- - e
- `,
+ - c
+ - d`,
}),
setCursor(['a', 'b']),
addMulticursor(['a', 'b']),
- addMulticursor(['c', 'd']),
+ addMulticursor(['a', 'c']),
])
executeCommandWithMulticursor(bumpThoughtDown, { store })
- const state = store.getState()
+ // Precondition: the bump occurred, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - ${''}
+ - a
+ - b
+ - c
+ - d`)
- // d is the last selected thought in document order, so the caret ends on its empty replacement.
- expectPathToEqual(state, state.cursor, ['c', ''])
- expect(hasMulticursor(state)).toBeFalse()
+ store.dispatch(undo())
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toBe(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c
+ - d`)
})
- it('reverts every bump on a single undo', () => {
+ it('disallows bumping down thoughts from different parents', () => {
store.dispatch([
importText({
text: `
- a
- b
- c
- - d
- - e
- - f
- `,
+ - d`,
}),
- setCursor(['a']),
- addMulticursor(['a']),
- addMulticursor(['c']),
- addMulticursor(['e']),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['c', 'd']),
])
executeCommandWithMulticursor(bumpThoughtDown, { store })
- // Precondition: all three bumps occurred, otherwise the undo below would have nothing to revert.
- expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
- -
- - a
- - b
- -
- - c
- - d
- -
- - e
- - f`)
-
- store.dispatch(undo())
+ expect(store.getState().alert).toMatchObject({
+ alertType: AlertType.MulticursorError,
+ value: 'Cannot bump down thoughts from different parents.',
+ })
const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
- expect(exported).toEqual(`- ${HOME_TOKEN}
+ expect(exported).toBe(`- ${HOME_TOKEN}
- a
- b
- c
- - d
- - e
- - f`)
+ - d`)
})
})
diff --git a/src/commands/__tests__/extractThought.ts b/src/commands/__tests__/extractThought.ts
index 04fd4e8323e..02fb210e334 100644
--- a/src/commands/__tests__/extractThought.ts
+++ b/src/commands/__tests__/extractThought.ts
@@ -1,12 +1,21 @@
import { findAllByLabelText, screen } from '@testing-library/react'
import { act } from 'react'
import { extractThoughtActionCreator as extractThought } from '../../actions/extractThought'
+import { importTextActionCreator as importText } from '../../actions/importText'
import { newThoughtActionCreator as newThought } from '../../actions/newThought'
+import { undoActionCreator as undo } from '../../actions/undo'
+import { executeCommandWithMulticursor } from '../../commands'
+import { HOME_TOKEN } from '../../constants'
import childIdsToThoughts from '../../selectors/childIdsToThoughts'
+import exportContext from '../../selectors/exportContext'
import store from '../../stores/app'
+import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
import createTestApp, { cleanupTestApp } from '../../test-helpers/createTestApp'
+import expectPathToEqual from '../../test-helpers/expectPathToEqual'
+import initStore from '../../test-helpers/initStore'
import findThoughtByText from '../../test-helpers/queries/findThoughtByText'
import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
+import extractThoughtCommand from '../extractThought'
/**
* Set range selection.
@@ -24,6 +33,8 @@ const setSelection = (element: HTMLElement, selectionStart: number, selectionEnd
return range.toString()
}
+beforeEach(initStore)
+
describe('Extract thought', () => {
beforeEach(createTestApp)
afterEach(cleanupTestApp)
@@ -95,4 +106,166 @@ describe('Extract thought', () => {
expect(cursorThoughts).toMatchObject([{ value: thoughtValue.slice(0, 9) }])
})
+
+ describe('multicursor', () => {
+ it('extracts from the thought being edited when several thoughts are selected', async () => {
+ store.dispatch([
+ importText({
+ text: `
+ - alpha bravo
+ - charlie delta
+ - echo
+ `,
+ }),
+ setCursor(['alpha bravo']),
+ ])
+
+ await act(vi.runOnlyPendingTimersAsync)
+
+ const thought = await findThoughtByText('alpha bravo')
+ expect(thought).toBeTruthy()
+ setSelection(thought!, 6, 11)
+
+ store.dispatch([addMulticursor(['alpha bravo']), addMulticursor(['charlie delta']), addMulticursor(['echo'])])
+
+ executeCommandWithMulticursor(extractThoughtCommand, { store })
+
+ // The other selected thoughts keep their values. Slicing them at the selection's offsets would have left
+ // "charlita" with the child "e del", and "echo" with an empty child.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - alpha
+ - bravo
+ - charlie delta
+ - echo`)
+ })
+
+ it('extracts from the thought being edited when a different thought is selected', async () => {
+ store.dispatch([
+ importText({
+ text: `
+ - alpha bravo
+ - charlie delta
+ `,
+ }),
+ setCursor(['alpha bravo']),
+ ])
+
+ await act(vi.runOnlyPendingTimersAsync)
+
+ const thought = await findThoughtByText('alpha bravo')
+ expect(thought).toBeTruthy()
+ setSelection(thought!, 6, 11)
+
+ // select a thought other than the one being edited, as alt-clicking its bullet does
+ store.dispatch([addMulticursor(['charlie delta'])])
+
+ executeCommandWithMulticursor(extractThoughtCommand, { store })
+
+ // The extraction applies to the thought that owns the selection, not to the selected thought.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - alpha
+ - bravo
+ - charlie delta`)
+ })
+
+ it('leaves the cursor and the selected thoughts selected', async () => {
+ store.dispatch([
+ importText({
+ text: `
+ - alpha bravo
+ - charlie delta
+ - echo
+ `,
+ }),
+ setCursor(['alpha bravo']),
+ ])
+
+ await act(vi.runOnlyPendingTimersAsync)
+
+ const thought = await findThoughtByText('alpha bravo')
+ expect(thought).toBeTruthy()
+ setSelection(thought!, 6, 11)
+
+ store.dispatch([addMulticursor(['alpha bravo']), addMulticursor(['charlie delta']), addMulticursor(['echo'])])
+
+ executeCommandWithMulticursor(extractThoughtCommand, { store })
+
+ // Precondition: the extraction occurred, otherwise the assertions below would hold vacuously.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - alpha
+ - bravo
+ - charlie delta
+ - echo`)
+
+ const state = store.getState()
+
+ expectPathToEqual(state, state.cursor, ['alpha'])
+ expect(
+ Object.values(state.multicursors).map(path => childIdsToThoughts(state, path).map(thought => thought.value)),
+ ).toEqual([['alpha'], ['charlie delta'], ['echo']])
+ })
+
+ it('an alert should be shown if there is no selection and several thoughts are selected', async () => {
+ store.dispatch([
+ importText({
+ text: `
+ - alpha bravo
+ - charlie delta
+ `,
+ }),
+ setCursor(['alpha bravo']),
+ addMulticursor(['alpha bravo']),
+ addMulticursor(['charlie delta']),
+ ])
+
+ // Flush the throttled "2 thoughts selected" alert from the multiselect so that it cannot overwrite the alert
+ // raised by the command below.
+ await act(vi.runOnlyPendingTimersAsync)
+
+ executeCommandWithMulticursor(extractThoughtCommand, { store })
+
+ expect(await screen.findByText('No text selected to extract')).toBeTruthy()
+
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - alpha bravo
+ - charlie delta`)
+ })
+
+ it('reverts the extraction on a single undo', async () => {
+ store.dispatch([
+ importText({
+ text: `
+ - alpha bravo
+ - charlie delta
+ - echo
+ `,
+ }),
+ setCursor(['alpha bravo']),
+ ])
+
+ await act(vi.runOnlyPendingTimersAsync)
+
+ const thought = await findThoughtByText('alpha bravo')
+ expect(thought).toBeTruthy()
+ setSelection(thought!, 6, 11)
+
+ store.dispatch([addMulticursor(['alpha bravo']), addMulticursor(['charlie delta']), addMulticursor(['echo'])])
+
+ executeCommandWithMulticursor(extractThoughtCommand, { store })
+
+ // Precondition: the extraction occurred, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - alpha
+ - bravo
+ - charlie delta
+ - echo`)
+
+ store.dispatch(undo())
+
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - alpha bravo
+ - charlie delta
+ - echo`)
+ })
+ })
})
diff --git a/src/commands/__tests__/generateThought.ts b/src/commands/__tests__/generateThought.ts
index 093e3146215..cf6b8e9f4c4 100644
--- a/src/commands/__tests__/generateThought.ts
+++ b/src/commands/__tests__/generateThought.ts
@@ -1,10 +1,14 @@
import { act } from 'react'
import { importTextActionCreator as importText } from '../../actions/importText'
-import { executeCommand } from '../../commands'
+import { undoActionCreator as undo } from '../../actions/undo'
+import { executeCommand, executeCommandWithMulticursor } from '../../commands'
import { HOME_TOKEN } from '../../constants'
+import childIdsToThoughts from '../../selectors/childIdsToThoughts'
import exportContext from '../../selectors/exportContext'
import store from '../../stores/app'
+import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
import dispatch from '../../test-helpers/dispatch'
+import expectPathToEqual from '../../test-helpers/expectPathToEqual'
import initStore from '../../test-helpers/initStore'
import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
import generateThought from '../generateThought'
@@ -16,6 +20,8 @@ global.fetch = mockFetch
beforeEach(async () => {
await initStore()
vi.clearAllMocks()
+ // clearAllMocks does not drain queued mockResolvedValueOnce responses, which would otherwise leak into the next test
+ mockFetch.mockReset()
})
test('fetch and set webpage title when cursor is on empty thought with URL child', async () => {
@@ -229,3 +235,303 @@ test('not fetch title when first child is not a URL', async () => {
vi.unstubAllEnvs()
})
+
+test('restore the original value rather than the pending value on undo', async () => {
+ // Mock AI URL environment variable
+ vi.stubEnv('VITE_AI_URL', 'http://test-ai-url')
+
+ // Mock AI response
+ mockFetch.mockResolvedValueOnce({
+ json: () => Promise.resolve({ content: 'generated', err: null }),
+ })
+
+ await dispatch([importText({ text: `- a` }), setCursor(['a'])])
+
+ // use act, otherwise pending value (...) will still be rendered
+ await act(async () => {
+ executeCommand(generateThought)
+ })
+
+ // Precondition: the thought was generated, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a generated`)
+
+ await dispatch(undo())
+
+ // The pending value "a..." is set with updateThoughts, which is not undoable, so it must be restored to the
+ // original value before the generated value is applied. Otherwise undo reverts to "a...".
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a`)
+
+ vi.unstubAllEnvs()
+})
+
+describe('multicursor', () => {
+ it('generates a thought for each selected thought', async () => {
+ // Mock AI URL environment variable
+ vi.stubEnv('VITE_AI_URL', 'http://test-ai-url')
+
+ // The selected thoughts are generated concurrently in document order, so the mocked responses are consumed in the
+ // order a, b, c. Distinct content proves each response is applied to its own thought.
+ mockFetch
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'one', err: null }) })
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'two', err: null }) })
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'three', err: null }) })
+
+ await dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ await act(async () => {
+ executeCommandWithMulticursor(generateThought, { store })
+ })
+
+ // Wait for every generation to settle. execMulticursor holds the undo bracket open for the whole run, so the flag
+ // going false is exactly the condition that all of the requests have been applied.
+ await act(async () => {
+ await vi.waitFor(() => expect(store.getState().isMulticursorExecuting).toBe(false))
+ })
+
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a one
+ - b two
+ - c three`)
+
+ vi.unstubAllEnvs()
+ })
+
+ it('fetches the webpage title for each selected empty thought with a URL child', async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve('
First Title'),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve('Second Title'),
+ })
+
+ await dispatch([
+ importText({
+ text: `
+ - a
+ -${' '}
+ - https://first.example.com
+ - b
+ -${' '}
+ - https://second.example.com
+ `,
+ }),
+ setCursor(['a', '']),
+ addMulticursor(['a', '']),
+ addMulticursor(['b', '']),
+ ])
+
+ await act(async () => {
+ executeCommandWithMulticursor(generateThought, { store })
+ })
+
+ // Wait for every generation to settle. execMulticursor holds the undo bracket open for the whole run, so the flag
+ // going false is exactly the condition that all of the requests have been applied.
+ await act(async () => {
+ await vi.waitFor(() => expect(store.getState().isMulticursorExecuting).toBe(false))
+ })
+
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a
+ - First Title
+ - https://first.example.com
+ - b
+ - Second Title
+ - https://second.example.com`)
+ })
+
+ it('reverts every generated thought on a single undo', async () => {
+ // Mock AI URL environment variable
+ vi.stubEnv('VITE_AI_URL', 'http://test-ai-url')
+
+ mockFetch
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'one', err: null }) })
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'two', err: null }) })
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'three', err: null }) })
+
+ await dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ await act(async () => {
+ executeCommandWithMulticursor(generateThought, { store })
+ })
+
+ // Wait for every generation to settle. execMulticursor holds the undo bracket open for the whole run, so the flag
+ // going false is exactly the condition that all of the requests have been applied.
+ await act(async () => {
+ await vi.waitFor(() => expect(store.getState().isMulticursorExecuting).toBe(false))
+ })
+
+ // Precondition: all three thoughts were generated, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a one
+ - b two
+ - c three`)
+
+ await dispatch(undo())
+
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c`)
+
+ // The whole run is a single undo step labelled with the command, rather than one Edit Thought step per generation.
+ expect(store.getState().alert?.value).toBe('Undo: Generate Thought')
+
+ vi.unstubAllEnvs()
+ })
+
+ it('keeps the cursor and the multicursor selection after generating', async () => {
+ // Mock AI URL environment variable
+ vi.stubEnv('VITE_AI_URL', 'http://test-ai-url')
+
+ mockFetch
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'one', err: null }) })
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'two', err: null }) })
+
+ await dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ await act(async () => {
+ executeCommandWithMulticursor(generateThought, { store })
+ })
+
+ // Wait for every generation to settle. execMulticursor holds the undo bracket open for the whole run, so the flag
+ // going false is exactly the condition that all of the requests have been applied.
+ await act(async () => {
+ await vi.waitFor(() => expect(store.getState().isMulticursorExecuting).toBe(false))
+ })
+
+ // Precondition: both thoughts were generated, otherwise there would be no completion that could have moved the
+ // cursor.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a one
+ - b two`)
+
+ const state = store.getState()
+ expectPathToEqual(state, state.cursor, ['a one'])
+ expect(
+ Object.values(state.multicursors).map(path => childIdsToThoughts(state, path).map(thought => thought.value)),
+ ).toEqual([['a one'], ['b two']])
+ })
+
+ it('generates the other selected thoughts when one request returns an error', async () => {
+ // Mock AI URL environment variable
+ vi.stubEnv('VITE_AI_URL', 'http://test-ai-url')
+
+ mockFetch
+ .mockResolvedValueOnce({
+ json: () => Promise.resolve({ content: '', err: { status: 500, message: 'Model unavailable' } }),
+ })
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'two', err: null }) })
+
+ await dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ await act(async () => {
+ executeCommandWithMulticursor(generateThought, { store })
+ })
+
+ // Wait for every generation to settle. execMulticursor holds the undo bracket open for the whole run, so the flag
+ // going false is exactly the condition that all of the requests have been applied.
+ await act(async () => {
+ await vi.waitFor(() => expect(store.getState().isMulticursorExecuting).toBe(false))
+ })
+
+ // a is left at its original value, without the pending ellipsis, and b is generated as usual.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a
+ - b two`)
+
+ expect(store.getState().error).toBe('Model unavailable')
+
+ vi.unstubAllEnvs()
+ })
+
+ it('generates a thought for each selected thought when there is no cursor', async () => {
+ // Mock AI URL environment variable
+ vi.stubEnv('VITE_AI_URL', 'http://test-ai-url')
+
+ mockFetch
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'one', err: null }) })
+ .mockResolvedValueOnce({ json: () => Promise.resolve({ content: 'two', err: null }) })
+
+ await dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(null),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ // The keydown handler gates execution on canExecute against the real state, so a cursorless multiselect would not
+ // otherwise reach executeCommandWithMulticursor.
+ expect(store.getState().cursor).toBeNull()
+ expect(generateThought.canExecute!(store.getState())).toBe(true)
+
+ await act(async () => {
+ executeCommandWithMulticursor(generateThought, { store })
+ })
+
+ // Wait for every generation to settle. execMulticursor holds the undo bracket open for the whole run, so the flag
+ // going false is exactly the condition that all of the requests have been applied.
+ await act(async () => {
+ await vi.waitFor(() => expect(store.getState().isMulticursorExecuting).toBe(false))
+ })
+
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a one
+ - b two`)
+
+ vi.unstubAllEnvs()
+ })
+})
diff --git a/src/commands/__tests__/newGrandChild.ts b/src/commands/__tests__/newGrandChild.ts
new file mode 100644
index 00000000000..37f6fe1890f
--- /dev/null
+++ b/src/commands/__tests__/newGrandChild.ts
@@ -0,0 +1,236 @@
+import { importTextActionCreator as importText } from '../../actions/importText'
+import { undoActionCreator as undo } from '../../actions/undo'
+import { executeCommandWithMulticursor } from '../../commands'
+import { HOME_TOKEN } from '../../constants'
+import exportContext from '../../selectors/exportContext'
+import store from '../../stores/app'
+import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
+import expectPathToEqual from '../../test-helpers/expectPathToEqual'
+import initStore from '../../test-helpers/initStore'
+import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
+import newGrandChildCommand from '../newGrandChild'
+
+beforeEach(initStore)
+
+describe('multicursor', () => {
+ it('creates a new empty grandchild in each selected thought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['c']),
+ addMulticursor(['e']),
+ ])
+
+ executeCommandWithMulticursor(newGrandChildCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - ${''}
+ - c
+ - d
+ - ${''}
+ - e
+ - f
+ - ${''}`)
+ })
+
+ it('appends the new grandchild to the existing children of the first subthought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - x
+ - c
+ - d
+ - e
+ - y
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['d']),
+ ])
+
+ executeCommandWithMulticursor(newGrandChildCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - x
+ - ${''}
+ - c
+ - d
+ - e
+ - y
+ - ${''}`)
+ })
+
+ it('creates new grandchildren in selected thoughts at different depths', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ - e
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['c', 'd']),
+ ])
+
+ executeCommandWithMulticursor(newGrandChildCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - ${''}
+ - c
+ - d
+ - e
+ - ${''}`)
+ })
+
+ it('skips a selected thought with no children while the rest of the selection proceeds', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newGrandChildCommand, { store })
+
+ // a has no subthought to create a grandchild in, so the action is a no-op for it. b still gets its new grandchild.
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c
+ - ${''}`)
+ })
+
+ it('places the caret in the last created empty grandchild and clears the multicursor', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newGrandChildCommand, { store })
+
+ const state = store.getState()
+
+ // the cursor must end in the new empty grandchild of the last selected thought, ready to type
+ expectPathToEqual(state, state.cursor, ['c', 'd', ''])
+
+ // the selection of parent thoughts is stale once the caret is in a new empty thought
+ expect(state.multicursors).toEqual({})
+ })
+
+ it('places the caret in the new empty grandchild when a single thought is selected', () => {
+ // on mobile, opening the Command Center selects the cursor thought, so a single selected thought is the common case
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ ])
+
+ executeCommandWithMulticursor(newGrandChildCommand, { store })
+
+ const state = store.getState()
+
+ expect(exportContext(state, [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - ${''}`)
+
+ expectPathToEqual(state, state.cursor, ['a', 'b', ''])
+ expect(state.multicursors).toEqual({})
+ })
+
+ it('reverts every created grandchild on a single undo', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['c']),
+ addMulticursor(['e']),
+ ])
+
+ executeCommandWithMulticursor(newGrandChildCommand, { store })
+
+ // Precondition: all three grandchildren were created, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - ${''}
+ - c
+ - d
+ - ${''}
+ - e
+ - f
+ - ${''}`)
+
+ store.dispatch(undo())
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f`)
+ })
+})
diff --git a/src/commands/__tests__/newSubthought.ts b/src/commands/__tests__/newSubthought.ts
index 6842687d384..5a904cdf496 100644
--- a/src/commands/__tests__/newSubthought.ts
+++ b/src/commands/__tests__/newSubthought.ts
@@ -1,6 +1,8 @@
import { importTextActionCreator as importText } from '../../actions/importText'
+import { undoActionCreator as undo } from '../../actions/undo'
import { executeCommandWithMulticursor } from '../../commands'
import { HOME_TOKEN } from '../../constants'
+import childIdsToThoughts from '../../selectors/childIdsToThoughts'
import exportContext from '../../selectors/exportContext'
import hasMulticursor from '../../selectors/hasMulticursor'
import store from '../../stores/app'
@@ -13,38 +15,191 @@ import newSubthoughtCommand from '../newSubthought'
beforeEach(initStore)
describe('multicursor', () => {
- it('create a new subthought on the last multicursor', () => {
+ it('creates a new empty subthought in each selected thought', () => {
store.dispatch([
importText({
text: `
- - a
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - b
+ - ${''}
+ - c
+ - ${''}`)
+ })
+
+ it('inserts the new subthought below existing children', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - a1
+ - a2
+ - b
+ - b1
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - a1
+ - a2
+ - ${''}
+ - b
+ - b1
+ - ${''}`)
+ })
+
+ it('creates a subthought in each of a selected parent and its selected child', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
- b
- - c
- - d
- - e
- `,
+ - c
+ `,
}),
- setCursor(['c']),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['a', 'b']),
addMulticursor(['c']),
- addMulticursor(['d']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - ${''}
+ - ${''}
+ - c
+ - ${''}`)
+ })
+
+ it('places the cursor in the new subthought of the last selected thought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtCommand, { store })
+
+ const state = store.getState()
+ expectPathToEqual(state, state.cursor, ['b', ''])
+ })
+
+ it('selects the new subthoughts so that each of them is expanded into view', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
])
executeCommandWithMulticursor(newSubthoughtCommand, { store })
const state = store.getState()
- const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
- expect(exported).toBe(`- ${HOME_TOKEN}
+
+ expect(
+ Object.values(state.multicursors).map(path => childIdsToThoughts(state, path).map(thought => thought.value)),
+ ).toEqual([
+ ['a', ''],
+ ['b', ''],
+ ])
+
+ // A thought is only expanded when it is the cursor or a multicursor parent, so the selection above is what makes both new subthoughts visible.
+ expect(
+ Object.values(state.expanded).map(path => childIdsToThoughts(state, path).map(thought => thought.value)),
+ ).toIncludeAllMembers([['a'], ['b']])
+ })
+
+ it('clears the multicursor when a single thought is selected, so that the new subthought can be typed into', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtCommand, { store })
+
+ expect(hasMulticursor(store.getState())).toBeFalse()
+ })
+
+ it('reverts every created subthought on a single undo', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtCommand, { store })
+
+ // Precondition: all three subthoughts were created, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
- a
+ - ${''}
- b
- - c
- - d
- ${''}
- - e`)
+ - c
+ - ${''}`)
+
+ store.dispatch(undo())
- // expect multicursor to be cleared
- expect(hasMulticursor(state)).toBeFalse()
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
- // expect cursor to be on the new thought
- expectPathToEqual(state, state.cursor, ['d', ''])
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c`)
})
})
diff --git a/src/commands/__tests__/newSubthoughtTop.ts b/src/commands/__tests__/newSubthoughtTop.ts
new file mode 100644
index 00000000000..e90a899947b
--- /dev/null
+++ b/src/commands/__tests__/newSubthoughtTop.ts
@@ -0,0 +1,202 @@
+import { importTextActionCreator as importText } from '../../actions/importText'
+import { undoActionCreator as undo } from '../../actions/undo'
+import { executeCommandWithMulticursor } from '../../commands'
+import { HOME_TOKEN } from '../../constants'
+import contextToPath from '../../selectors/contextToPath'
+import exportContext from '../../selectors/exportContext'
+import store from '../../stores/app'
+import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
+import expectPathToEqual from '../../test-helpers/expectPathToEqual'
+import initStore from '../../test-helpers/initStore'
+import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
+import hashPath from '../../util/hashPath'
+import newSubthoughtTopCommand from '../newSubthoughtTop'
+
+beforeEach(initStore)
+
+describe('multicursor', () => {
+ it('creates a new empty subthought in each selected thought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtTopCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - b
+ - ${''}
+ - c
+ - ${''}`)
+ })
+
+ it('inserts the new subthought above existing children', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - a1
+ - a2
+ - b
+ - b1
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtTopCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - a1
+ - a2
+ - b
+ - ${''}
+ - b1`)
+ })
+
+ it('creates a subthought in each of a selected parent and its selected child', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtTopCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - b
+ - ${''}
+ - c
+ - ${''}`)
+ })
+
+ it('places the cursor in the new subthought of the last selected thought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtTopCommand, { store })
+
+ const state = store.getState()
+ expectPathToEqual(state, state.cursor, ['b', ''])
+ })
+
+ it('selects the new subthoughts after execution', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtTopCommand, { store })
+
+ const state = store.getState()
+ const multicursors = Object.values(state.multicursors)
+
+ expect(multicursors).toHaveLength(2)
+ expectPathToEqual(state, multicursors[0], ['a', ''])
+ expectPathToEqual(state, multicursors[1], ['b', ''])
+ })
+
+ it('expands each selected thought so that its new subthought is visible', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtTopCommand, { store })
+
+ const state = store.getState()
+
+ expect(state.expanded[hashPath(contextToPath(state, ['a'])!)]).toBeTruthy()
+ expect(state.expanded[hashPath(contextToPath(state, ['b'])!)]).toBeTruthy()
+ })
+
+ it('reverts every created subthought on a single undo', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newSubthoughtTopCommand, { store })
+
+ // Precondition: all three subthoughts were created, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - b
+ - ${''}
+ - c
+ - ${''}`)
+
+ store.dispatch(undo())
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c`)
+ })
+})
diff --git a/src/commands/__tests__/newThought.ts b/src/commands/__tests__/newThought.ts
index 636fdce7b10..7f9e365205e 100644
--- a/src/commands/__tests__/newThought.ts
+++ b/src/commands/__tests__/newThought.ts
@@ -1,19 +1,46 @@
import { importTextActionCreator as importText } from '../../actions/importText'
+import { undoActionCreator as undo } from '../../actions/undo'
import { executeCommandWithMulticursor } from '../../commands'
import { HOME_TOKEN } from '../../constants'
import exportContext from '../../selectors/exportContext'
+import { getChildrenRanked } from '../../selectors/getChildren'
import hasMulticursor from '../../selectors/hasMulticursor'
import store from '../../stores/app'
import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
import expectPathToEqual from '../../test-helpers/expectPathToEqual'
import initStore from '../../test-helpers/initStore'
import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
+import head from '../../util/head'
import newThoughtCommand from '../newThought'
beforeEach(initStore)
+it('create an empty thought after the cursor thought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtCommand, { store })
+
+ const state = store.getState()
+ const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
+ expect(exported).toBe(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - b`)
+
+ // expect cursor to be on the new thought
+ expectPathToEqual(state, state.cursor, [''])
+})
+
describe('multicursor', () => {
- it('create a new thought after the last multicursor', () => {
+ it('create a new empty thought after each selected thought', () => {
store.dispatch([
importText({
text: `
@@ -37,6 +64,7 @@ describe('multicursor', () => {
- a
- b
- c
+ - ${''}
- d
- ${''}
- e`)
@@ -44,7 +72,111 @@ describe('multicursor', () => {
// expect multicursor to be cleared
expect(hasMulticursor(state)).toBeFalse()
+ // expect cursor to be on the last created thought (the empty thought after d), ready for typing
+ const children = getChildrenRanked(state, HOME_TOKEN)
+ expect(head(state.cursor!)).toBe(children[5].id)
+ })
+
+ it('create each new thought as a sibling of its own selected thought across parents and depths', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - a1
+ - a2
+ - b
+ - b1
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['a', 'a1']),
+ addMulticursor(['b', 'b1']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtCommand, { store })
+
+ const state = store.getState()
+ const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
+ expect(exported).toBe(`- ${HOME_TOKEN}
+ - a
+ - a1
+ - ${''}
+ - a2
+ - ${''}
+ - b
+ - b1
+ - ${''}`)
+
+ // expect multicursor to be cleared
+ expect(hasMulticursor(state)).toBeFalse()
+
+ // expect cursor to be on the last created thought (the empty thought after b1)
+ const bChildren = getChildrenRanked(state, head(state.cursor!.slice(0, -1)))
+ expectPathToEqual(state, state.cursor, ['b', ''])
+ expect(head(state.cursor!)).toBe(bChildren[1].id)
+ })
+
+ it('create a single empty thought after a single selected thought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtCommand, { store })
+
+ const state = store.getState()
+ const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
+ expect(exported).toBe(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - b`)
+
+ // expect multicursor to be cleared
+ expect(hasMulticursor(state)).toBeFalse()
+
// expect cursor to be on the new thought
expectPathToEqual(state, state.cursor, [''])
})
+
+ it('revert every created thought on a single undo', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtCommand, { store })
+
+ // Precondition: all three thoughts were created, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toBe(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - b
+ - ${''}
+ - c
+ - ${''}`)
+
+ store.dispatch(undo())
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+ expect(exported).toBe(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c`)
+ })
})
diff --git a/src/commands/__tests__/newThoughtAbove.ts b/src/commands/__tests__/newThoughtAbove.ts
index 454fcd16ca1..276a7097409 100644
--- a/src/commands/__tests__/newThoughtAbove.ts
+++ b/src/commands/__tests__/newThoughtAbove.ts
@@ -1,4 +1,5 @@
import { importTextActionCreator as importText } from '../../actions/importText'
+import { undoActionCreator as undo } from '../../actions/undo'
import { executeCommandWithMulticursor } from '../../commands'
import { HOME_TOKEN } from '../../constants'
import exportContext from '../../selectors/exportContext'
@@ -8,43 +9,154 @@ import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../..
import expectPathToEqual from '../../test-helpers/expectPathToEqual'
import initStore from '../../test-helpers/initStore'
import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
-import newThoughtCommand from '../newThoughtAbove'
+import newThoughtAboveCommand from '../newThoughtAbove'
beforeEach(initStore)
describe('multicursor', () => {
- it('create a new thought before the first multicursor', () => {
+ it('creates a new empty thought above each selected sibling', () => {
store.dispatch([
importText({
text: `
- - a
- - b
- - c
- - d
- - e
- `,
+ - a
+ - b
+ - c
+ - d
+ - e
+ `,
}),
- setCursor(['c']),
+ setCursor(['b']),
+ addMulticursor(['b']),
addMulticursor(['c']),
addMulticursor(['d']),
])
- executeCommandWithMulticursor(newThoughtCommand, { store })
+ executeCommandWithMulticursor(newThoughtAboveCommand, { store })
- const state = store.getState()
- const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
- expect(exported).toBe(`- ${HOME_TOKEN}
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ // Each selected thought gets its own new thought directly above it, rather than the selection collapsing to a single insertion.
+ // The empty lines below intentionally end in a trailing space, which `${''}` preserves.
+ expect(exported).toEqual(`- ${HOME_TOKEN}
- a
+ - ${''}
- b
- ${''}
- c
+ - ${''}
- d
- e`)
+ })
+
+ it('creates a new empty thought above selected thoughts in different parents and at different depths', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - a1
+ - a2
+ - b
+ - b1
+ `,
+ }),
+ setCursor(['a', 'a1']),
+ addMulticursor(['a', 'a1']),
+ addMulticursor(['a', 'a2']),
+ addMulticursor(['b']),
+ addMulticursor(['b', 'b1']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtAboveCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - ${''}
+ - a1
+ - ${''}
+ - a2
+ - ${''}
+ - b
+ - ${''}
+ - b1`)
+ })
+
+ it('places the cursor in the new thought above the last selected thought', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - a1
+ - b
+ - b1
+ `,
+ }),
+ setCursor(['a', 'a1']),
+ addMulticursor(['a', 'a1']),
+ addMulticursor(['b', 'b1']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtAboveCommand, { store })
- // expect multicursor to be cleared
- expect(hasMulticursor(state)).toBeFalse()
+ const state = store.getState()
+
+ // The cursor is left in the last thought created rather than restored to a1, where it started.
+ expectPathToEqual(state, state.cursor, ['b', ''])
+ })
- // expect cursor to be on the new thought
- expectPathToEqual(state, state.cursor, [''])
+ it('clears the multicursor after execution', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtAboveCommand, { store })
+
+ expect(hasMulticursor(store.getState())).toBeFalse()
+ })
+
+ it('reverts every created thought on a single undo', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b']),
+ addMulticursor(['c']),
+ ])
+
+ executeCommandWithMulticursor(newThoughtAboveCommand, { store })
+
+ // Precondition: all three thoughts were created, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - ${''}
+ - a
+ - ${''}
+ - b
+ - ${''}
+ - c`)
+
+ store.dispatch(undo())
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c`)
})
})
diff --git a/src/commands/__tests__/newUncle.ts b/src/commands/__tests__/newUncle.ts
new file mode 100644
index 00000000000..0fe4735a550
--- /dev/null
+++ b/src/commands/__tests__/newUncle.ts
@@ -0,0 +1,202 @@
+import { importTextActionCreator as importText } from '../../actions/importText'
+import { undoActionCreator as undo } from '../../actions/undo'
+import { executeCommandWithMulticursor } from '../../commands'
+import { HOME_TOKEN } from '../../constants'
+import exportContext from '../../selectors/exportContext'
+import hasMulticursor from '../../selectors/hasMulticursor'
+import store from '../../stores/app'
+import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
+import getChildrenRankedByContext from '../../test-helpers/getChildrenRankedByContext'
+import initStore from '../../test-helpers/initStore'
+import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
+import head from '../../util/head'
+import newUncleCommand from '../newUncle'
+
+beforeEach(initStore)
+
+describe('multicursor', () => {
+ it('creates an empty uncle for each selected sibling', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - x
+ `,
+ }),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['a', 'c']),
+ ])
+
+ executeCommandWithMulticursor(newUncleCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c
+ - ${''}
+ - ${''}
+ - x`)
+ })
+
+ it('creates an empty uncle for each selected thought across different parents and depths', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ - e
+ `,
+ }),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['c', 'd', 'e']),
+ ])
+
+ executeCommandWithMulticursor(newUncleCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - ${''}
+ - c
+ - d
+ - e
+ - `)
+ })
+
+ it('skips a selected root thought instead of blocking the rest of the run', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ `,
+ }),
+ setCursor(['a']),
+ addMulticursor(['a']),
+ addMulticursor(['b', 'c']),
+ ])
+
+ executeCommandWithMulticursor(newUncleCommand, { store })
+
+ // a is at the root, so it has no parent to insert at and is skipped; c still gets its uncle
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c
+ - `)
+ })
+
+ it('leaves the caret on the empty uncle of the last selected thought and clears the multicursor', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ `,
+ }),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['c', 'd']),
+ ])
+
+ executeCommandWithMulticursor(newUncleCommand, { store })
+
+ const state = store.getState()
+ const rootChildren = getChildrenRankedByContext(state, [HOME_TOKEN])
+
+ // one empty uncle after each selected thought's parent
+ expect(rootChildren.map(thought => thought.value)).toEqual(['a', '', 'c', ''])
+
+ // the caret ends on the empty uncle created for the last selected thought, ready to type
+ expect(state.cursor && head(state.cursor)).toBe(rootChildren[3].id)
+
+ expect(hasMulticursor(state)).toBe(false)
+ })
+
+ it('creates a single empty uncle with the caret on it when one thought is selected', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ `,
+ }),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ ])
+
+ executeCommandWithMulticursor(newUncleCommand, { store })
+
+ const state = store.getState()
+ const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - `)
+
+ const rootChildren = getChildrenRankedByContext(state, [HOME_TOKEN])
+ expect(state.cursor && head(state.cursor)).toBe(rootChildren[1].id)
+ expect(hasMulticursor(state)).toBe(false)
+ })
+
+ it('reverts every created thought on a single undo', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f
+ `,
+ }),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['c', 'd']),
+ addMulticursor(['e', 'f']),
+ ])
+
+ executeCommandWithMulticursor(newUncleCommand, { store })
+
+ // Precondition: all three uncles were created, otherwise the undo below would have nothing to revert.
+ expect(exportContext(store.getState(), [HOME_TOKEN], 'text/plain')).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - ${''}
+ - c
+ - d
+ - ${''}
+ - e
+ - f
+ - ${''}`)
+
+ store.dispatch(undo())
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f`)
+ })
+})
diff --git a/src/commands/__tests__/repeat.ts b/src/commands/__tests__/repeat.ts
index 923bb6e9c95..c27913174f4 100644
--- a/src/commands/__tests__/repeat.ts
+++ b/src/commands/__tests__/repeat.ts
@@ -3,9 +3,11 @@ import { executeCommandWithMulticursor, resetLastCommand } from '../../commands'
import { HOME_TOKEN } from '../../constants'
import exportContext from '../../selectors/exportContext'
import store from '../../stores/app'
+import { addMulticursorAtFirstMatchActionCreator as addMulticursor } from '../../test-helpers/addMulticursorAtFirstMatch'
import initStore from '../../test-helpers/initStore'
import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch'
import headValue from '../../util/headValue'
+import bumpThoughtDownCommand from '../bumpThoughtDown'
import cursorDownCommand from '../cursorDown'
import exportContextCommand from '../exportContext'
import moveThoughtDownCommand from '../moveThoughtDown'
@@ -141,6 +143,39 @@ it('ignore commands that do not dispatch an undoable action', () => {
- b`)
})
+it('repeat a command that handles the multiselect itself', () => {
+ store.dispatch([
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ - e
+ `,
+ }),
+ setCursor(['a', 'b']),
+ addMulticursor(['a', 'b']),
+ addMulticursor(['a', 'c']),
+ ])
+
+ // Bump Thought Down defines execMulticursor, so it is executed once with the whole selection rather than once per selected thought.
+ executeCommandWithMulticursor(bumpThoughtDownCommand, { store })
+
+ store.dispatch(setCursor(['d']))
+ executeCommandWithMulticursor(repeatCommand, { store })
+
+ const exported = exportContext(store.getState(), [HOME_TOKEN], 'text/plain')
+ expect(exported).toEqual(`- ${HOME_TOKEN}
+ - ${''}
+ - a
+ - b
+ - c
+ - ${''}
+ - d
+ - e`)
+})
+
it('ignore undo', () => {
store.dispatch([
importText({
diff --git a/src/commands/bindContext.ts b/src/commands/bindContext.ts
index 4b9c9366058..582637bde20 100644
--- a/src/commands/bindContext.ts
+++ b/src/commands/bindContext.ts
@@ -14,10 +14,10 @@ const bindContextCommand: Command = {
svg: BindContextIcon,
description: 'Bind two different contexts of a thought so that they always have the same children.',
gesture: 'rud',
- multicursor: {
- disallow: true,
- error: 'Cannot bind multiple thoughts.',
- },
+ // Bind each selected context in turn. Selected contexts of different context views each get their own binding.
+ // Selected contexts of the same context view overwrite each other, since =bindContextCommand holds a single
+ // context, so the last one in document order wins — exactly as if the command were invoked on each in turn.
+ multicursor: true,
keyboard: { key: 'b', shift: true, alt: true },
hideFromHelp: true,
canExecute: state => isDocumentEditable() && !!state.cursor,
diff --git a/src/commands/bumpThoughtDown.ts b/src/commands/bumpThoughtDown.ts
index e2ee709d34c..4c37393e8b2 100644
--- a/src/commands/bumpThoughtDown.ts
+++ b/src/commands/bumpThoughtDown.ts
@@ -13,10 +13,14 @@ const bumpThoughtDownCommand: Command = {
gesture: 'drd',
keyboard: { key: 'd', meta: true, alt: true },
// The command ends with the caret in a new empty thought ready for typing, so keep the cursor where
- // the last execution put it and clear the selection, as newThought does. Restoring the original
- // cursor would move the caret off the empty thought whenever the bumped thought had no children,
- // since the recomputed path then leads to the moved value rather than its empty replacement.
+ // the reducer put it and clear the selection, as newThought does. Restoring the original cursor
+ // would move the caret off the empty thought, since the recomputed path then leads to the moved
+ // value rather than its empty replacement.
multicursor: {
+ // Bump the selected thoughts' parent down and move the selected thoughts into it in a single action.
+ execMulticursor: (cursors, dispatch) => {
+ dispatch(bumpThoughtDown({ paths: cursors }))
+ },
preventSetCursor: true,
clearMulticursor: true,
},
diff --git a/src/commands/cursorBack.ts b/src/commands/cursorBack.ts
index 29019f12ceb..0a2d38980f7 100644
--- a/src/commands/cursorBack.ts
+++ b/src/commands/cursorBack.ts
@@ -36,7 +36,8 @@ const cursorBackCommand: Command = {
const { cursor, search } = state
- if (cursor || search != null) {
+ // a multicursor can exist without a cursor, e.g. a thought selected by long press, so it must be checked in addition to the cursor for the selection to be moved back a level (touch only, since escape clears the multicursor on desktop above)
+ if (cursor || search != null || hasMulticursor(state)) {
dispatch(cursorBack())
// clear browser selection if cursor has been removed
@@ -48,7 +49,8 @@ const cursorBackCommand: Command = {
// As a convenience, allow cursorBack to scroll to the top if the cursor is already null.
// Only do this after the cursor is already null to avoid disrupting the user when they are simply moving up a level to adjust autofocus and immediately back down a level to a sibling.
- if (!cursor) {
+ // Not when thoughts are selected, since then Back moves the selection rather than the cursor.
+ if (!cursor && !hasMulticursor(state)) {
scrollTo('top', 'smooth')
}
}),
diff --git a/src/commands/extractThought.ts b/src/commands/extractThought.ts
index 2abe1fec939..415a816187e 100644
--- a/src/commands/extractThought.ts
+++ b/src/commands/extractThought.ts
@@ -8,10 +8,12 @@ const extractThought: Command = {
label: 'Extract',
description: 'Extract selected part of a thought as its child',
keyboard: { key: 'e', control: true, meta: true },
- multicursor: {
- disallow: true,
- error: 'Cannot extract multiple thoughts.',
- },
+ // Extract takes its input from the browser text selection, of which the document has exactly one. The
+ // extractThought action slices state.cursor's value at that selection's character offsets, so the offsets are only
+ // meaningful for the thought that owns the selection. Executing on state.cursor is therefore the only well-defined
+ // behavior: the per-cursor loop of multicursor: true would slice every other selected thought at offsets that index
+ // into a different string, mangling their values and giving short ones an empty child.
+ multicursor: false,
svg: ExtractThoughtIcon,
canExecute: state => {
return !!state.cursor || hasMulticursor(state)
diff --git a/src/commands/generateThought.ts b/src/commands/generateThought.ts
index 45d50d24cfc..95559a0430a 100644
--- a/src/commands/generateThought.ts
+++ b/src/commands/generateThought.ts
@@ -1,13 +1,17 @@
import Command from '../@types/Command'
+import Path from '../@types/Path'
+import Thunk from '../@types/Thunk'
import { alertActionCreator as alert } from '../actions/alert'
import { cursorClearedActionCreator as cursorCleared } from '../actions/cursorCleared'
import { editThoughtActionCreator as editThought } from '../actions/editThought'
import { errorActionCreator as error } from '../actions/error'
import { setCursorActionCreator as setCursor } from '../actions/setCursor'
+import { setIsMulticursorExecutingActionCreator as setIsMulticursorExecuting } from '../actions/setIsMulticursorExecuting'
import { updateThoughtsActionCreator as updateThoughts } from '../actions/updateThoughts'
import GenerateThoughtIcon from '../components/icons/GenerateThoughtIcon'
import { getChildrenRanked } from '../selectors/getChildren'
import getThoughtById from '../selectors/getThoughtById'
+import hasMulticursor from '../selectors/hasMulticursor'
import simplifyPath from '../selectors/simplifyPath'
import head from '../util/head'
import isDocumentEditable from '../util/isDocumentEditable'
@@ -57,88 +61,68 @@ const fetchWebpageTitle = async (url: string): Promise => {
return null
}
-/** Generate a thought using AI. */
-const generateThought: Command = {
- id: 'generateThought',
- label: 'Generate Thought',
- description: 'Generates a thought using AI.',
- // svg: Icon,
- keyboard: { key: 'g', meta: true, alt: true },
- gesture: 'ur',
- svg: GenerateThoughtIcon,
- multicursor: {
- disallow: true,
- error: 'Cannot generate multiple thoughts.',
- },
- canExecute: state => isDocumentEditable() && !!state.cursor,
- exec: async (dispatch, getState) => {
+/**
+ * Generates a new value for the thought at the given path and applies it to the thought. If the thought is empty and
+ * its first child is a URL, the title of the webpage is fetched; otherwise the value is generated with AI. The thought
+ * is set to a pending value and marked as generating while the request is in flight. Returns the new value, or null if
+ * no generation was performed.
+ *
+ * Takes an explicit path instead of reading state.cursor so that it can be run for every thought of a multiselect.
+ * Cursor-specific side effects (cursorCleared and the caret at the end of the generated value) are the caller's
+ * responsibility, since they apply to a single thought and this may be one of many running concurrently.
+ */
+const generateThoughtAtPathActionCreator =
+ (path: Path): Thunk> =>
+ async (dispatch, getState) => {
const state = getState()
- // do nothing if generation is already in progress
- if (state.cursorCleared) return
-
- const simplePath = simplifyPath(state, state.cursor!)
+ const simplePath = simplifyPath(state, path)
const thought = getThoughtById(state, head(simplePath))
- if (!thought) return
+ if (!thought) return null
+
+ // Do nothing if a generation is already in progress for this thought. Two overlapping runs would each restore their
+ // own snapshot of the thought and race to edit it.
+ if (thought.generating) return null
- // Check if current thought is empty and first child is a URL
- const isCurrentThoughtEmpty = thought.value === ''
const children = getChildrenRanked(state, thought.id)
const firstChild = children[0]
- const isFirstChildURL = firstChild && isURL(firstChild.value)
- const shouldFetchTitle = isCurrentThoughtEmpty && isFirstChildURL
+ // Fetch the webpage title when the thought is empty and its first child is a URL. Otherwise generate with AI.
+ const shouldFetchTitle = thought.value === '' && !!firstChild && isURL(firstChild.value)
- if (shouldFetchTitle) {
- // URL title fetching path
- const valuePending = '...'
-
- // set to pending while title is being fetched
- dispatch([
- updateThoughts({
- thoughtIndexUpdates: {
- [thought.id]: {
- ...thought,
- value: valuePending,
- generating: true,
- },
+ if (!shouldFetchTitle && !import.meta.env.VITE_AI_URL) {
+ throw new Error('import.meta.env.VITE_AI_URL is not configured')
+ }
+
+ const valuePending = `${thought.value}...`
+
+ // set to pending while the value is being generated
+ dispatch(
+ updateThoughts({
+ thoughtIndexUpdates: {
+ [thought.id]: {
+ ...thought,
+ value: valuePending,
+ generating: true,
},
- lexemeIndexUpdates: {},
- local: false,
- remote: false,
- overwritePending: true,
- }),
- cursorCleared({ value: true }),
- ])
-
- let valueNew = ''
+ },
+ lexemeIndexUpdates: {},
+ local: false,
+ remote: false,
+ overwritePending: true,
+ }),
+ )
+
+ let valueNew = thought.value
+
+ if (shouldFetchTitle) {
try {
const title = await fetchWebpageTitle(firstChild.value)
valueNew = title || ''
- } catch (err) {
+ } catch {
dispatch(error({ value: 'Failed to fetch webpage title' }))
valueNew = ''
}
-
- // must reset cursorCleared before thought is updated for some reason, otherwise it is not updated in the DOM
- dispatch([
- // editThought automatically sets Thought.generating to false
- editThought({
- force: true,
- oldValue: valuePending,
- newValue: valueNew,
- path: simplePath,
- }),
- setCursor({ path: state.cursor, offset: valueNew.length }),
- cursorCleared({ value: false }),
- ])
} else {
- // AI generation path
- if (!import.meta.env.VITE_AI_URL) {
- throw new Error('import.meta.env.VITE_AI_URL is not configured')
- }
-
- const valuePending = `${thought.value}...`
-
// prompt with ancestors and siblings
const ancestors = pathToContext(state, parentOf(simplePath))
const siblingsText = children.map(child => (child.id === thought.id ? `${child.value}_` : child.value)).join('\n')
@@ -153,26 +137,7 @@ const generateThought: Command = {
const ancestorsText = ancestors.join('/')
const input = `${ancestorsText}${children.length > 1 ? '/\n' : ''}${siblingsText}`
- // set to pending while thought is being generated
- dispatch([
- updateThoughts({
- thoughtIndexUpdates: {
- [thought.id]: {
- ...thought,
- value: valuePending,
- generating: true,
- },
- },
- lexemeIndexUpdates: {},
- local: false,
- remote: false,
- overwritePending: true,
- }),
- cursorCleared({ value: true }),
- ])
-
// generate thought
- let valueNew = thought.value
const res = await fetch(import.meta.env.VITE_AI_URL!, { method: 'POST', body: input })
const { content, err } = (await res.json()) as { content: string; err: { status: number; message: string } }
if (err) {
@@ -186,20 +151,97 @@ const generateThought: Command = {
const trimmedContent = content.trim()
valueNew = `${thought.value}${thought.value && trimmedContent ? ' ' : ''}${trimmedContent}`
}
-
- // must reset cursorCleared before thought is updated for some reason, otherwise it is not updated in the DOM
- dispatch([
- // editThought automatically sets Thought.generating to false
- editThought({
- force: true,
- oldValue: valuePending,
- newValue: valueNew,
- path: simplePath,
- }),
- setCursor({ path: state.cursor, offset: valueNew.length }),
- cursorCleared({ value: false }),
- ])
}
+
+ const thoughtPending = getThoughtById(getState(), thought.id)
+ // bail if the thought was deleted while its value was being generated
+ if (!thoughtPending) return null
+
+ dispatch([
+ // Restore the original value before applying the generated one. updateThoughts is not undoable, so the pending
+ // value would otherwise become the state that undo reverts to, leaving the thought at "a..." rather than "a". It
+ // is also why editThought was previously given an oldValue whose Lexeme was never created. Both updates are
+ // dispatched in the same batch, so the restored value is never rendered.
+ updateThoughts({
+ thoughtIndexUpdates: {
+ [thought.id]: {
+ ...thoughtPending,
+ value: thought.value,
+ generating: false,
+ },
+ },
+ lexemeIndexUpdates: {},
+ local: false,
+ remote: false,
+ overwritePending: true,
+ }),
+ // editThought automatically sets Thought.generating to false
+ editThought({
+ force: true,
+ oldValue: thought.value,
+ newValue: valueNew,
+ path: simplePath,
+ }),
+ ])
+
+ return valueNew
+ }
+
+/** Generate a thought using AI. */
+const generateThought: Command = {
+ id: 'generateThought',
+ label: 'Generate Thought',
+ description: 'Generates a thought using AI.',
+ // svg: Icon,
+ keyboard: { key: 'g', meta: true, alt: true },
+ gesture: 'ur',
+ svg: GenerateThoughtIcon,
+ multicursor: {
+ // preventSetCursor is not needed: execMulticursor never moves the cursor, so the restore at the end of the loop
+ // sets it to the path it is already on.
+ execMulticursor: (cursors, dispatch) => {
+ /** Generates a thought for every selected thought within a single undo bracket. */
+ const generateAll = async () => {
+ // Yield before opening the undo bracket. executeCommandWithMulticursor is synchronous: it opens its own
+ // bracket, calls execMulticursor, and closes the bracket again as soon as it returns — long before any
+ // generation completes. A bracket opened here synchronously would be closed by that same run, and every
+ // generated thought would land outside it and cost the user another undo.
+ await Promise.resolve()
+
+ dispatch(setIsMulticursorExecuting({ value: true, undoLabel: 'generateThought' }))
+
+ // Generate concurrently, so that the selection takes one round trip rather than one per thought and every
+ // selected thought shows its pending state immediately. allSettled rather than all, so that a rejected request
+ // cannot skip the dispatch below and leave the bracket open over the remaining generations.
+ await Promise.allSettled(cursors.map(path => dispatch(generateThoughtAtPathActionCreator(path))))
+
+ dispatch(setIsMulticursorExecuting({ value: false }))
+ }
+
+ generateAll()
+ },
+ },
+ canExecute: state => isDocumentEditable() && (!!state.cursor || hasMulticursor(state)),
+ exec: async (dispatch, getState) => {
+ const state = getState()
+
+ // do nothing if generation is already in progress
+ if (state.cursorCleared) return
+
+ const cursor = state.cursor!
+
+ // Render the cursor thought as an empty thought while its value is generated. cursorCleared is a single global
+ // flag that only applies to the thought being edited, so it is set here rather than in generateThoughtAtPath.
+ dispatch(cursorCleared({ value: true }))
+
+ const valueNew = await dispatch(generateThoughtAtPathActionCreator(cursor))
+
+ // editThought resets cursorCleared as part of the same reducer pass that updates the thought, which is what allows
+ // the new value to reach the DOM. Resetting it here only has an effect when nothing was generated.
+ dispatch([
+ ...(valueNew !== null ? [setCursor({ path: cursor, offset: valueNew.length })] : []),
+ cursorCleared({ value: false }),
+ ])
},
}
diff --git a/src/commands/newGrandChild.ts b/src/commands/newGrandChild.ts
index 17c9f7efc4a..814f13563e6 100644
--- a/src/commands/newGrandChild.ts
+++ b/src/commands/newGrandChild.ts
@@ -9,8 +9,10 @@ const newGrandChildCommand: Command = {
description: 'Create a thought within the first subthought.',
gesture: 'rdrd',
multicursor: {
- disallow: true,
- error: 'Cannot create a new grandchild with multiple thoughts.',
+ // The action sets the cursor to the new empty grandchild with the keyboard open, ready to type. The default restore would move the caret back to the originally selected thought.
+ preventSetCursor: true,
+ // The selection of parent thoughts is stale once the caret is in a new empty grandchild; keeping it would aim the next multicursor command at the parents while the user is typing elsewhere.
+ clearMulticursor: true,
},
// TODO: Create unique icon
svg: SettingsIcon,
diff --git a/src/commands/newSubthought.ts b/src/commands/newSubthought.ts
index aa8733016fe..6fadfc45dfe 100644
--- a/src/commands/newSubthought.ts
+++ b/src/commands/newSubthought.ts
@@ -1,15 +1,49 @@
+import _ from 'lodash'
import { Key } from 'ts-key-enum'
import Command from '../@types/Command'
+import Path from '../@types/Path'
+import { addMulticursorActionCreator as addMulticursor } from '../actions/addMulticursor'
+import { clearMulticursorsActionCreator as clearMulticursors } from '../actions/clearMulticursors'
import { newThoughtActionCreator as newThought } from '../actions/newThought'
+import { removeMulticursorActionCreator as removeMulticursor } from '../actions/removeMulticursor'
+import { setCursorActionCreator as setCursor } from '../actions/setCursor'
import Icon from '../components/icons/NewSubthoughtIcon'
+import { getChildrenRanked } from '../selectors/getChildren'
+import getThoughtById from '../selectors/getThoughtById'
+import appendToPath from '../util/appendToPath'
+import head from '../util/head'
import isDocumentEditable from '../util/isDocumentEditable'
const exec = newThought({ insertNewSubthought: true })
const multicursor: Command['multicursor'] = {
- filter: 'last-sibling',
- clearMulticursor: true,
+ // Each selected thought is a distinct insertion parent, so execute once per selected thought.
+ // A sibling filter would collapse a selection of siblings to a single insertion, silently discarding most of the selection.
+ // preventSetCursor leaves the cursor in the last created subthought instead of restoring the old cursor.
preventSetCursor: true,
+ onComplete: (filteredCursors, dispatch, getState) => {
+ const state = getState()
+
+ // The new subthought is inserted with the highest rank in each selected thought, including in a sorted context.
+ const newSubthoughtPaths = filteredCursors.reduce((accum, path) => {
+ const lastChild = getThoughtById(state, head(path)) ? _.last(getChildrenRanked(state, head(path))) : null
+ return lastChild ? [...accum, appendToPath(path, lastChild.id)] : accum
+ }, [])
+
+ dispatch(
+ // A single selected thought behaves like the command without a multiselect: clear the selection so that the new subthought, which already has the cursor, can be typed into immediately.
+ newSubthoughtPaths.length < 2
+ ? [clearMulticursors()]
+ : [
+ // Move the selection from the selected thoughts to their new subthoughts. Thoughts are only expanded around the cursor and the multicursors, so otherwise every new subthought except the one with the cursor would be created out of sight.
+ // Add the new subthoughts before removing the old selection so that the number of multicursors never passes through zero, which would close the Command Center on mobile.
+ ...newSubthoughtPaths.map(path => addMulticursor({ path })),
+ ...filteredCursors.map(path => removeMulticursor({ path })),
+ // state.expanded is recalculated on setCursor, so set the cursor to apply the expansion of the new selection. The cursor is already in the last new subthought, so this does not move it.
+ setCursor({ path: _.last(newSubthoughtPaths)!, preserveMulticursor: true }),
+ ],
+ )
+ },
}
const newSubthoughtCommand: Command = {
diff --git a/src/commands/newSubthoughtTop.ts b/src/commands/newSubthoughtTop.ts
index 1a460476c4f..0988f398a22 100644
--- a/src/commands/newSubthoughtTop.ts
+++ b/src/commands/newSubthoughtTop.ts
@@ -1,9 +1,14 @@
+import { last } from 'lodash'
import { Key } from 'ts-key-enum'
import Command from '../@types/Command'
+import { addMulticursorActionCreator as addMulticursor } from '../actions/addMulticursor'
import { newThoughtActionCreator as newThought } from '../actions/newThought'
+import { setCursorActionCreator as setCursor } from '../actions/setCursor'
import NewSubthoughtAboveIcon from '../components/icons/NewSubthoughtAboveIcon'
import isDocumentEditable from '../util/isDocumentEditable'
+const exec = newThought({ insertNewSubthought: true, insertBefore: true })
+
const newSubthoughtTopCommand: Command = {
id: 'newSubthoughtTop',
label: 'New Subthought (above)',
@@ -11,12 +16,29 @@ const newSubthoughtTopCommand: Command = {
gesture: 'rdu',
keyboard: { key: Key.Enter, shift: true, meta: true },
multicursor: {
- disallow: true,
- error: 'Cannot create a new subthought with multiple thoughts.',
+ // preventSetCursor and clearMulticursor disable the generic restore of the old cursor and the old selection at the end of the multicursor loop, since execMulticursor sets both itself.
+ preventSetCursor: true,
+ clearMulticursor: true,
+ // Each selected thought is a distinct insertion parent, so create a new subthought in each one.
+ // The new subthoughts must then be selected, since a selected thought expands its parent (see expandThoughts): otherwise every new subthought except the one under the cursor would be created inside a collapsed thought and never appear.
+ execMulticursor: (cursors, dispatch, getState) => {
+ // No path is recomputed between iterations, as creating a subthought does not move any of the selected thoughts.
+ const newSubthoughtPaths = cursors.map(path => {
+ dispatch([setCursor({ path }), exec])
+ // newThought sets the cursor to the new subthought.
+ return getState().cursor
+ })
+
+ dispatch([
+ ...newSubthoughtPaths.map(path => path && addMulticursor({ path })),
+ // The cursor is already in the last new subthought, but addMulticursor does not recalculate state.expanded, so re-setting it is what expands the selected thoughts and reveals their new subthoughts.
+ setCursor({ path: last(newSubthoughtPaths) ?? null, offset: 0, preserveMulticursor: true }),
+ ])
+ },
},
svg: NewSubthoughtAboveIcon,
canExecute: () => isDocumentEditable(),
- exec: newThought({ insertNewSubthought: true, insertBefore: true }),
+ exec,
}
export default newSubthoughtTopCommand
diff --git a/src/commands/newThought.ts b/src/commands/newThought.ts
index fdd7015b2d0..e498790b876 100644
--- a/src/commands/newThought.ts
+++ b/src/commands/newThought.ts
@@ -60,8 +60,8 @@ const exec: Command['exec'] = (dispatch, getState, e, { type }: { type: string }
}
}
+// Create a new empty thought after each selected thought. Each newThought in the multicursor loop sets the cursor to the thought it creates, so preventSetCursor keeps the cursor on the last created thought instead of restoring the pre-command cursor, and clearMulticursor drops the stale selection — leaving the user ready to type into the new thought.
const multicursor: Command['multicursor'] = {
- filter: 'last-sibling',
clearMulticursor: true,
preventSetCursor: true,
}
diff --git a/src/commands/newThoughtAbove.ts b/src/commands/newThoughtAbove.ts
index c0c0decfc2b..1e64385ff65 100644
--- a/src/commands/newThoughtAbove.ts
+++ b/src/commands/newThoughtAbove.ts
@@ -11,7 +11,6 @@ const newThoughtAboveCommand: Command = {
description: 'Create a new thought immediately above the current thought.',
gesture: 'rul',
multicursor: {
- filter: 'first-sibling',
clearMulticursor: true,
preventSetCursor: true,
},
diff --git a/src/commands/newUncle.ts b/src/commands/newUncle.ts
index 592d75fb1ab..45b925ea400 100644
--- a/src/commands/newUncle.ts
+++ b/src/commands/newUncle.ts
@@ -2,6 +2,7 @@ import { Key } from 'ts-key-enum'
import Command from '../@types/Command'
import { newThoughtActionCreator as newThought } from '../actions/newThought'
import NewSubthoughtNextIcon from '../components/icons/NewSubthoughtNextIcon'
+import hasMulticursor from '../selectors/hasMulticursor'
import isDocumentEditable from '../util/isDocumentEditable'
import parentOf from '../util/parentOf'
@@ -13,13 +14,16 @@ const newUncleCommand: Command = {
gesture: 'dl',
keyboard: { key: Key.Enter, meta: true, alt: true },
multicursor: {
- disallow: true,
- error: 'Cannot create a new subthought with multiple thoughts.',
+ // The cursor restore at the end of the multicursor loop would pull the caret off the empty thought created for the last selected thought. The newThought action places the cursor on each thought it creates, so preventing the restore leaves the caret there, ready to type — the same postcondition as a single-cursor invocation.
+ preventSetCursor: true,
+ // After execution the selection is stale — the user's next act is typing into the new empty thought. Clearing matches the single-cursor behavior, where the newThought action's own setCursor clears the selection.
+ clearMulticursor: true,
},
svg: NewSubthoughtNextIcon,
canExecute: state => {
const { cursor } = state
- return isDocumentEditable() && !!cursor && cursor.length > 1
+ // hasMulticursor allows the command to execute when thoughts are selected but the cursor is missing or on a root thought. Selected root thoughts (which have no parent to insert at) do not block the multicursor run: the loop's per-iteration setCursor clears the selection, so the per-iteration canExecute check fails for them and they are skipped.
+ return isDocumentEditable() && ((!!cursor && cursor.length > 1) || hasMulticursor(state))
},
exec: (dispatch, getState) => {
const { cursor } = getState()
diff --git a/src/commands/pin.ts b/src/commands/pin.ts
index 9864057a664..3f63b9aa713 100644
--- a/src/commands/pin.ts
+++ b/src/commands/pin.ts
@@ -16,6 +16,7 @@ const pinCommand: Command = {
description: 'Pins open a thought so its subthoughts are always visible.',
descriptionInverse: 'Unpins a thought so its subthoughts are automatically hidden.',
keyboard: { key: 'p', meta: true, alt: true },
+ gesture: 'ud',
svg: PinIcon,
canExecute: state => {
return !!state.cursor || hasMulticursor(state)
diff --git a/src/components/VirtualThought.tsx b/src/components/VirtualThought.tsx
index 9e349a41f97..8635689aa57 100644
--- a/src/components/VirtualThought.tsx
+++ b/src/components/VirtualThought.tsx
@@ -9,6 +9,7 @@ import State from '../@types/State'
import ThoughtId from '../@types/ThoughtId'
import { getAutoscrollPadding } from '../device/preventAutoscroll'
import useDelayedAutofocus from '../hooks/useDelayedAutofocus'
+import useFreshCallback from '../hooks/useFreshCallback'
import useLayoutAnimationFrameEffect from '../hooks/useLayoutAnimationFrameEffect'
import useSelectorEffect from '../hooks/useSelectorEffect'
import { hasChildren } from '../selectors/getChildren'
@@ -220,15 +221,15 @@ const VirtualThought = ({
}, [updateSize, value])
// trigger onResize with null on unmount to allow subscribers to clean up
- useEffect(
- () => {
- return () => {
- onResize?.({ height: null, width: null, id: id, isVisible: true, key: crossContextualKey })
- }
- },
- // these should be memoized and not change for the life of the component, so this is effectively componentWillUnmount
+ // onResize is not stable for the life of the component: TreeNode memoizes it on cliff, which changes whenever the
+ // thought's position in the tree changes. Listing it as a dependency would run the cleanup on every such change,
+ // momentarily dropping the thought's tracked size while it is still mounted. useFreshCallback keeps the reference
+ // stable so the cleanup is only run on unmount, while still calling the latest onResize.
+ const releaseSize = useFreshCallback(
+ () => onResize?.({ height: null, width: null, id: id, isVisible: true, key: crossContextualKey }),
[crossContextualKey, onResize, id],
)
+ useEffect(() => releaseSize, [releaseSize])
return (
{
+ vi.restoreAllMocks()
+ await cleanupTestApp()
+})
it('unmount TreeNodes on collapse', async () => {
await dispatch(
@@ -34,3 +38,35 @@ it('unmount TreeNodes on collapse', async () => {
// collapse a and unmount b
expect(document.querySelectorAll('[aria-label="tree-node"]').length).toBe(2)
})
+
+it('unmounts distant thoughts after navigation and remounts them on return', async () => {
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 20))
+
+ await dispatch(
+ importText({
+ text: `
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f
+ - g
+ `,
+ }),
+ )
+
+ await dispatch(setCursor(['a']))
+ expect(await queryThoughtByText('a')).not.toBeNull()
+
+ await dispatch(setCursor(['a', 'b', 'c', 'd', 'e', 'f']))
+ await act(vi.runOnlyPendingTimersAsync)
+
+ expect(await queryThoughtByText('a')).toBeNull()
+ // hide-parent thoughts stay mounted so they can fade back in when navigating up.
+ expect(await queryThoughtByText('d')).not.toBeNull()
+ expect(await queryThoughtByText('f')).not.toBeNull()
+
+ await dispatch(setCursor(['a']))
+ expect(await queryThoughtByText('a')).not.toBeNull()
+})
diff --git a/src/components/icons/LottieAnimation.tsx b/src/components/icons/LottieAnimation.tsx
index 61a1449b4c3..c183471f325 100644
--- a/src/components/icons/LottieAnimation.tsx
+++ b/src/components/icons/LottieAnimation.tsx
@@ -1,6 +1,6 @@
import _ from 'lodash'
-import Player, { LottieRefCurrentProps } from 'lottie-react'
-import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
+import { Lottie, LottieHandle } from 'lottie-react'
+import React, { useMemo, useRef } from 'react'
import AnimatedColor from '../../@types/lottie/AnimatedColor'
import ColorProperty from '../../@types/lottie/ColorProperty'
import LottieData from '../../@types/lottie/LottieData'
@@ -124,7 +124,7 @@ const changeLineColor = (data: LottieData, newColor: string): LottieData => {
/**
* LottieAnimation Component.
*
- * This component utilizes the 'lottie-react' Player to render a Lottie animation.
+ * This component utilizes the 'lottie-react' Lottie component to render a Lottie animation.
* It accepts animation data and supports customizable speed and color properties,
* allowing for dynamic control over the animation's playback rate and the stroke
* and fill colors of its elements.
@@ -137,45 +137,43 @@ const changeLineColor = (data: LottieData, newColor: string): LottieData => {
* This does not affect the Lottie animation's JSON content or alter its visual elements.
*/
const LottieAnimation: React.FC
= ({ animationData, speed = 1, color, onComplete }) => {
- const lottieRef = useRef(null)
+ const lottieRef = useRef(null)
const animationDataWithColor = useMemo(() => {
return animationData ? changeLineColor(animationData, color) : null
}, [animationData, color])
- // skip the animation in Puppeteer tests to avoid inconsistent snapshots
- if (navigator.webdriver) {
- useLayoutEffect(() => {
- if (!lottieRef.current) return
-
- const lastFrame = lottieRef.current.getDuration(true)! - 1
- lottieRef.current.goToAndStop(lastFrame)
- onComplete?.()
- }, [onComplete])
- }
-
- useEffect(() => {
- if (lottieRef.current && animationDataWithColor) {
- lottieRef.current.setSpeed(speed)
- }
- }, [speed, animationDataWithColor])
+ const subscriptions = useMemo(
+ () => ({
+ complete: onComplete,
+ // skip the animation in Puppeteer tests to avoid inconsistent snapshots
+ ...(navigator.webdriver && {
+ ready: () => {
+ lottieRef.current?.seek({ percent: 100 })
+ onComplete?.()
+ },
+ }),
+ }),
+ [onComplete],
+ )
if (!animationDataWithColor) {
return null
}
return (
-
)
}
diff --git a/src/e2e/puppeteer/__tests__/cursor.ts b/src/e2e/puppeteer/__tests__/cursor.ts
index 743f87461a8..bf4e48ccece 100644
--- a/src/e2e/puppeteer/__tests__/cursor.ts
+++ b/src/e2e/puppeteer/__tests__/cursor.ts
@@ -12,6 +12,19 @@ import { usePersistentTreecrdtStorage } from '../setup'
vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 })
usePersistentTreecrdtStorage()
+/** Returns the persistent tree node that contains the editable with the given value. */
+const getTreeNode = async (value: string) => {
+ const editable = (await waitForEditable(value)).asElement()
+ if (!editable) throw new Error(`Editable "${value}" not found.`)
+
+ const treeNode = (
+ await editable.evaluateHandle(element => (element as Element).closest('[aria-label="tree-node"]'))
+ ).asElement()
+ if (!treeNode) throw new Error(`Tree node for "${value}" not found.`)
+
+ return treeNode
+}
+
it('set the cursor to a thought in the home context on load', async () => {
const importText = `
- a
@@ -94,8 +107,10 @@ it('do nothing when clicking on a hidden ancestor', async () => {
- d`
await paste(importText)
await waitForEditable('d')
+ const ancestorTreeNode = await getTreeNode('a')
await clickThought('d')
- await clickThought('a')
+ await ancestorTreeNode.waitForSelector('[data-editable]', { hidden: true })
+ await click(ancestorTreeNode)
const thoughtValue = await getEditingText()
expect(thoughtValue).toBe('d')
@@ -109,6 +124,8 @@ it('do nothing when clicking on a hidden great uncle', async () => {
- d`
await paste(importText)
+ const greatUncleTreeNode = await getTreeNode('d')
+
// click a to expand b and c
await waitForEditable('a')
await clickThought('a')
@@ -117,7 +134,8 @@ it('do nothing when clicking on a hidden great uncle', async () => {
// for some reason we need to sleep before clicking c, otherwise the cursor is moved to d
await waitForEditable('c')
await clickThought('c')
- await clickThought('d')
+ await greatUncleTreeNode.waitForSelector('[data-editable]', { hidden: true })
+ await click(greatUncleTreeNode)
const thoughtValue = await getEditingText()
expect(thoughtValue).toBe('c')
diff --git a/src/e2e/puppeteer/__tests__/escape-html.ts b/src/e2e/puppeteer/__tests__/escape-html.ts
index 84d3af1daaa..fe3d59318ea 100644
--- a/src/e2e/puppeteer/__tests__/escape-html.ts
+++ b/src/e2e/puppeteer/__tests__/escape-html.ts
@@ -1,39 +1,13 @@
+import getEditingText from '../helpers/getEditingText'
+import getSelection from '../helpers/getSelection'
import keyboard from '../helpers/keyboard'
import press from '../helpers/press'
+import setClipboard from '../helpers/setClipboard'
import waitForEditable from '../helpers/waitForEditable'
-import { page } from '../session'
+import waitForEditingTextChange from '../helpers/waitForEditingTextChange'
vi.setConfig({ testTimeout: 20000 })
-/** Custom helper for pasting plain text, avoiding the existing `paste` helper that uses `importText` internally. */
-const pastePlainText = async (text: string) => {
- // Load text into clipboard
- await page.evaluate(async text => {
- await navigator.clipboard.write([
- new ClipboardItem({
- 'text/plain': new Blob([text], { type: 'text/plain' }),
- }),
- ])
- }, text)
-
- await press('Insert', { shift: true })
-}
-
-/** Custom helper for pasting HTML, avoiding the existing `paste` helper that uses `importText` internally. */
-const pasteHTML = async (html: string) => {
- // Load HTML into clipboard
- await page.evaluate(async html => {
- await navigator.clipboard.write([
- new ClipboardItem({
- 'text/plain': new Blob(['Plain text should be ignored when pasting as HTML.'], { type: 'text/plain' }),
- 'text/html': new Blob([html], { type: 'text/html' }),
- }),
- ])
- }, html)
-
- await press('Insert', { shift: true })
-}
-
it('escapes typed HTML', async () => {
await press('Enter', { delay: 10 })
await keyboard.type('hello world')
@@ -45,7 +19,8 @@ it('escapes typed HTML', async () => {
it('preserves pasted HTML as text/html in bold case', async () => {
await press('Enter', { delay: 10 })
- await pasteHTML('hello world')
+ await setClipboard({ html: 'hello world', text: 'Plain text should be ignored when pasting as HTML.' })
+ await press('Insert', { shift: true })
const editable = await waitForEditable('hello world')
expect(editable).toBeTruthy()
})
@@ -53,9 +28,11 @@ it('preserves pasted HTML as text/html in bold case', async () => {
// TODO: Broken in due to reverting related code. See: #2814.
it.skip('preserves pasted HTML as text/html with text color and background color', async () => {
await press('Enter', { delay: 10 })
- await pasteHTML(
- 'Hello World',
- )
+ await setClipboard({
+ html: 'Hello World',
+ text: 'Plain text should be ignored when pasting as HTML.',
+ })
+ await press('Insert', { shift: true })
const editable = await waitForEditable(
'Hello World',
@@ -65,8 +42,24 @@ it.skip('preserves pasted HTML as text/html with text color and background color
it('escapes pasted HTML as text/plain', async () => {
await press('Enter', { delay: 10 })
- await pastePlainText('hello world')
+ await setClipboard({ text: 'hello world' })
+ await press('Insert', { shift: true })
const editable = await waitForEditable('hello <b>world</b>')
expect(editable).toBeTruthy()
})
+
+// https://github.com/cybersemics/em/issues/4730
+it('inserts a space immediately after an emoji pasted at the beginning of a thought', async () => {
+ await press('Enter')
+ await keyboard.type('Hello')
+ await waitForEditable('Hello')
+ await press('Home')
+ await setClipboard({ text: '🧠' })
+
+ await press('Insert', { shift: true })
+
+ await waitForEditingTextChange('Hello')
+ expect(await getEditingText()).toBe('🧠 Hello')
+ expect(await getSelection().focusOffset).toBe('🧠 '.length)
+})
diff --git a/src/e2e/puppeteer/helpers/setClipboard.ts b/src/e2e/puppeteer/helpers/setClipboard.ts
new file mode 100644
index 00000000000..e0de7963db9
--- /dev/null
+++ b/src/e2e/puppeteer/helpers/setClipboard.ts
@@ -0,0 +1,18 @@
+import { page } from '../session'
+
+/** Loads plain text and optional HTML into the browser clipboard for a subsequent user paste action. */
+const setClipboard = async ({ html, text }: { html?: string; text: string }) => {
+ await page.evaluate(
+ async ({ html, text }) => {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ 'text/plain': new Blob([text], { type: 'text/plain' }),
+ ...(html ? { 'text/html': new Blob([html], { type: 'text/html' }) } : null),
+ }),
+ ])
+ },
+ { html, text },
+ )
+}
+
+export default setClipboard
diff --git a/src/e2e/puppeteer/helpers/waitForEditingTextChange.ts b/src/e2e/puppeteer/helpers/waitForEditingTextChange.ts
new file mode 100644
index 00000000000..f6379e766d3
--- /dev/null
+++ b/src/e2e/puppeteer/helpers/waitForEditingTextChange.ts
@@ -0,0 +1,11 @@
+import { page } from '../session'
+
+/** Waits for the current editable text to differ from the given value. */
+const waitForEditingTextChange = (previousValue: string) =>
+ page.waitForFunction(
+ previousValue => document.querySelector('[data-editing=true] [data-editable]')?.innerHTML !== previousValue,
+ {},
+ previousValue,
+ )
+
+export default waitForEditingTextChange
diff --git a/src/hooks/__tests__/useDelayedAutofocus.ts b/src/hooks/__tests__/useDelayedAutofocus.ts
new file mode 100644
index 00000000000..f27b961d4ba
--- /dev/null
+++ b/src/hooks/__tests__/useDelayedAutofocus.ts
@@ -0,0 +1,45 @@
+import { renderHook } from '@testing-library/react'
+import { act } from 'react'
+import Autofocus from '../../@types/Autofocus'
+import useDelayedAutofocus from '../useDelayedAutofocus'
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+it('updates the selected autofocus after the delay', () => {
+ const { result, rerender } = renderHook(
+ ({ autofocus }: { autofocus: Autofocus }) =>
+ useDelayedAutofocus(autofocus, {
+ delay: 750,
+ selector: autofocusNew => autofocusNew === 'hide',
+ }),
+ { initialProps: { autofocus: 'show' as Autofocus } },
+ )
+
+ rerender({ autofocus: 'hide' })
+
+ act(() => vi.advanceTimersByTime(749))
+ expect(result.current).toBe(false)
+
+ act(() => vi.advanceTimersByTime(1))
+ expect(result.current).toBe(true)
+})
+
+it('cancels a delayed update on unmount', () => {
+ const { rerender, unmount } = renderHook(
+ ({ autofocus }: { autofocus: Autofocus }) =>
+ useDelayedAutofocus(autofocus, { delay: 750, selector: value => value }),
+ { initialProps: { autofocus: 'show' as Autofocus } },
+ )
+
+ rerender({ autofocus: 'hide' })
+ expect(vi.getTimerCount()).toBe(1)
+
+ unmount()
+ expect(vi.getTimerCount()).toBe(0)
+})
diff --git a/src/hooks/__tests__/useDragLeave.ts b/src/hooks/__tests__/useDragLeave.ts
new file mode 100644
index 00000000000..c706a97e77f
--- /dev/null
+++ b/src/hooks/__tests__/useDragLeave.ts
@@ -0,0 +1,88 @@
+import { renderHook } from '@testing-library/react'
+import { act, createElement } from 'react'
+import { Provider } from 'react-redux'
+import { importTextActionCreator as importText } from '../../actions/importText'
+import { updateHoveringPathActionCreator as updateHoveringPath } from '../../actions/updateHoveringPath'
+import contextToPath from '../../selectors/contextToPath'
+import store from '../../stores/app'
+import initStore from '../../test-helpers/initStore'
+import useDragLeave from '../useDragLeave'
+
+/** Renders useDragLeave against the app store. */
+const renderDragLeave = (props: { isDeepHovering: boolean; canDropThought: boolean }) =>
+ renderHook((propsNew: { isDeepHovering: boolean; canDropThought: boolean }) => useDragLeave(propsNew), {
+ initialProps: props,
+ wrapper: ({ children }) => createElement(Provider, { store, children }),
+ })
+
+/** Imports two thoughts and sets hoveringPath to the first, as if a drag were in progress over it. */
+const startHovering = () => {
+ store.dispatch(importText({ text: '- a\n- b' }))
+ store.dispatch(updateHoveringPath({ path: contextToPath(store.getState(), ['a'])! }))
+}
+
+/** Advances past the hook's 50ms debounce. */
+const flushDebounce = () => act(() => vi.advanceTimersByTimeAsync(100))
+
+beforeEach(async () => {
+ await initStore()
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+it('keeps hoveringPath while a drop target is hovered and an unrelated thought mounts', async () => {
+ startHovering()
+
+ // the cursor enters a drop target
+ const target = renderDragLeave({ isDeepHovering: false, canDropThought: true })
+ target.rerender({ isDeepHovering: true, canDropThought: true })
+
+ // an unrelated thought mounts while the drag is still over the target
+ renderDragLeave({ isDeepHovering: false, canDropThought: true })
+
+ await flushDebounce()
+
+ expect(store.getState().hoveringPath).toBeDefined()
+})
+
+// Guards against over-correcting the hover count: ignoring mounts must not leave the count stuck above zero, or
+// hoveringPath would never be cleared.
+it('still clears hoveringPath after an unrelated thought mounts and the cursor leaves', async () => {
+ startHovering()
+
+ const target = renderDragLeave({ isDeepHovering: false, canDropThought: true })
+ target.rerender({ isDeepHovering: true, canDropThought: true })
+ renderDragLeave({ isDeepHovering: false, canDropThought: true })
+ target.rerender({ isDeepHovering: false, canDropThought: true })
+
+ await flushDebounce()
+
+ expect(store.getState().hoveringPath).toBeUndefined()
+})
+
+it('clears hoveringPath once the cursor leaves the drop target', async () => {
+ startHovering()
+
+ const target = renderDragLeave({ isDeepHovering: false, canDropThought: true })
+ target.rerender({ isDeepHovering: true, canDropThought: true })
+ target.rerender({ isDeepHovering: false, canDropThought: true })
+
+ await flushDebounce()
+
+ expect(store.getState().hoveringPath).toBeUndefined()
+})
+
+it('clears hoveringPath when a hovered drop target unmounts', async () => {
+ startHovering()
+
+ const target = renderDragLeave({ isDeepHovering: false, canDropThought: true })
+ target.rerender({ isDeepHovering: true, canDropThought: true })
+ target.unmount()
+
+ await flushDebounce()
+
+ expect(store.getState().hoveringPath).toBeUndefined()
+})
diff --git a/src/hooks/useDelayedAutofocus.ts b/src/hooks/useDelayedAutofocus.ts
index e780c22cae5..cbfabdbc10b 100644
--- a/src/hooks/useDelayedAutofocus.ts
+++ b/src/hooks/useDelayedAutofocus.ts
@@ -14,7 +14,6 @@ const useDelayedAutofocus = (
// This ensures that the component is only re-rendered when the selector result changes, not every time the delayed autofocus value changes.
const [autofocusDelayed, setAutofocusDelayed] = useState(selector(autofocus))
const lastAutofocusRef = useRef(autofocus)
- const unmounted = useRef(false)
const autofocusTimerRef = useRef(0)
useEffect(
() => {
@@ -25,7 +24,6 @@ const useDelayedAutofocus = (
(lastAutofocusRef.current === 'show' || lastAutofocusRef.current === 'dim')
) {
autofocusTimerRef.current = setTimeout(() => {
- if (unmounted.current) return
setAutofocusDelayed(selector(autofocus))
lastAutofocusRef.current = autofocus
}, delay) as unknown as number
@@ -35,7 +33,7 @@ const useDelayedAutofocus = (
}
return () => {
- unmounted.current = true
+ clearTimeout(autofocusTimerRef.current)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/src/hooks/useDragLeave.ts b/src/hooks/useDragLeave.ts
index c8fd2964429..9600246f3a1 100644
--- a/src/hooks/useDragLeave.ts
+++ b/src/hooks/useDragLeave.ts
@@ -21,7 +21,9 @@ const clearHoveringPath: Thunk = (dispatch, getState) => {
const useDragLeave = ({ isDeepHovering, canDropThought }: { isDeepHovering: boolean; canDropThought: boolean }) => {
const dispatch = useDispatch()
const hoverZone = useSelector(state => state.hoverZone)
- const prevIsDeepHoveringRef = useRef(isDeepHovering)
+ // Whether this drop target is currently counted in hoverCount. Starts false so that mounting contributes nothing
+ // until the cursor actually enters.
+ const isCountedRef = useRef(false)
const prevHoverZone = useRef(hoverZone)
// Initialize the debounced function if it hasn't been already
@@ -46,34 +48,46 @@ const useDragLeave = ({ isDeepHovering, canDropThought }: { isDeepHovering: bool
return
}
- if (isDeepHovering && !prevIsDeepHoveringRef.current) {
- // Cursor has entered a drop target, increase hover count
- hoverCount += 1
-
- // Cancel any pending debounce since we're over a drop target
- debouncedSetHoveringPath?.cancel()
- } else {
- // Cursor has left a drop target, decrease hover count
- hoverCount = Math.max(hoverCount - 1, 0)
- if (hoverCount === 0) {
- // No drop targets are being hovered over; start debounce
- debouncedSetHoveringPath?.()
- }
- }
-
- prevIsDeepHoveringRef.current = isDeepHovering
- prevHoverZone.current = hoverZone
-
- return () => {
- // Cleanup on unmount
+ // Only a change in isDeepHovering means the cursor entered or left this drop target. The effect also re-runs when
+ // the component mounts and when canDropThought changes, and those must not touch the shared hoverCount: otherwise
+ // any thought mounting mid-drag decrements the count to zero and clears hoveringPath while a target is still
+ // hovered, dropping the drop indicator.
+ if (isDeepHovering !== isCountedRef.current) {
+ isCountedRef.current = isDeepHovering
if (isDeepHovering) {
+ // Cursor has entered a drop target, increase hover count
+ hoverCount += 1
+
+ // Cancel any pending debounce since we're over a drop target
+ debouncedSetHoveringPath?.cancel()
+ } else {
+ // Cursor has left a drop target, decrease hover count
+ hoverCount = Math.max(hoverCount - 1, 0)
if (hoverCount === 0) {
- // Start debounce when unmounting and no more drop targets are hovered
+ // No drop targets are being hovered over; start debounce
debouncedSetHoveringPath?.()
}
}
}
+
+ prevHoverZone.current = hoverZone
}, [isDeepHovering, dispatch, hoverZone, canDropThought])
+
+ // Release this drop target's contribution to hoverCount when it unmounts mid-drag, e.g. when the layout unmounts a
+ // thought that the cursor is over. Empty deps are load-bearing: React runs an effect's cleanup before every re-run,
+ // not only on unmount, so this cannot be folded into the effect above. It closes over refs and module state only,
+ // so it never goes stale.
+ useEffect(
+ () => () => {
+ if (!isCountedRef.current) return
+ isCountedRef.current = false
+ hoverCount = Math.max(hoverCount - 1, 0)
+ if (hoverCount === 0) {
+ debouncedSetHoveringPath?.()
+ }
+ },
+ [],
+ )
}
export default useDragLeave
diff --git a/yarn.lock b/yarn.lock
index ea2c05deaae..7f55941cfce 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8193,12 +8193,12 @@ __metadata:
languageName: node
linkType: hard
-"@vitest/pretty-format@npm:4.1.9":
- version: 4.1.9
- resolution: "@vitest/pretty-format@npm:4.1.9"
+"@vitest/pretty-format@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/pretty-format@npm:4.1.10"
dependencies:
tinyrainbow: "npm:^3.1.0"
- checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de
+ checksum: 10c0/1a5daba730ffe23f2000bff484b4b2842f3b178d93663cb487b215516b8d3b62caa3e2bb2a3c63307b61a9fe58fb9bfff38559bc0c5e49d8aa403d6803a1d918
languageName: node
linkType: hard
@@ -8245,15 +8245,15 @@ __metadata:
languageName: node
linkType: hard
-"@vitest/snapshot@npm:^4.1.7":
- version: 4.1.9
- resolution: "@vitest/snapshot@npm:4.1.9"
+"@vitest/snapshot@npm:^4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/snapshot@npm:4.1.10"
dependencies:
- "@vitest/pretty-format": "npm:4.1.9"
- "@vitest/utils": "npm:4.1.9"
+ "@vitest/pretty-format": "npm:4.1.10"
+ "@vitest/utils": "npm:4.1.10"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
- checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995
+ checksum: 10c0/e71398725f51af5fd0c07bb4b957d0f987daf9b4c564ac24cb2a4d1afde1a6939f535ac17761a32dcc41b0a1e6d4088af66dc44df89fdebebb92aabed1a92b5f
languageName: node
linkType: hard
@@ -8274,14 +8274,14 @@ __metadata:
languageName: node
linkType: hard
-"@vitest/utils@npm:4.1.9":
- version: 4.1.9
- resolution: "@vitest/utils@npm:4.1.9"
+"@vitest/utils@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/utils@npm:4.1.10"
dependencies:
- "@vitest/pretty-format": "npm:4.1.9"
+ "@vitest/pretty-format": "npm:4.1.10"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
- checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5
+ checksum: 10c0/05b0ecec6997ec22fc08377e57dbd8fa37992e05961f3a7a916d98b1ab56d15c2a87dbd83d392b628242bdc156b1705e7fa60a3bf0c54bdb51158c153e05fc5d
languageName: node
linkType: hard
@@ -11865,7 +11865,7 @@ __metadata:
eslint-plugin-react: "npm:^7.37.5"
eslint-plugin-react-hooks: "npm:^7.1.1"
eslint-plugin-react-refresh: "npm:^0.5.4"
- expect-webdriverio: "npm:^5.7.0"
+ expect-webdriverio: "npm:^6.0.2"
fake-indexeddb: "npm:^6.2.5"
fast-json-patch: "npm:^3.1.1"
fp-and-or: "npm:^1.0.2"
@@ -11883,7 +11883,7 @@ __metadata:
jsdom: "npm:^30.0.1"
lockfile-lint: "npm:^5.0.0"
lodash: "npm:^4.18.1"
- lottie-react: "npm:^2.4.1"
+ lottie-react: "npm:^3.1.0"
marked: "npm:^18.0.9"
moize: "npm:^6.1.7"
motion: "npm:^13.1.0"
@@ -13357,11 +13357,11 @@ __metadata:
languageName: node
linkType: hard
-"expect-webdriverio@npm:^5.7.0":
- version: 5.7.0
- resolution: "expect-webdriverio@npm:5.7.0"
+"expect-webdriverio@npm:^6.0.2":
+ version: 6.0.3
+ resolution: "expect-webdriverio@npm:6.0.3"
dependencies:
- "@vitest/snapshot": "npm:^4.1.7"
+ "@vitest/snapshot": "npm:^4.1.10"
deep-eql: "npm:^5.0.2"
expect: "npm:^30.4.1"
jest-matcher-utils: "npm:^30.4.1"
@@ -13376,7 +13376,7 @@ __metadata:
optional: false
webdriverio:
optional: false
- checksum: 10c0/c895f2c451a1bf5a75a1e57ec07d0993203a7834abe85e45aeba8c91f0e779d736219a54702048582c647624cd6452625184810e51e75daa43276a4412cfb680
+ checksum: 10c0/a468020db6d4ce56d2c08d46ddd8c47d0f91e90465eb8655fbd7fba08f5d027b128f09bae7f12524d3150ab4cb470fe46b6d3a232306cb46ad2d3109e7ff36ab
languageName: node
linkType: hard
@@ -17696,22 +17696,22 @@ __metadata:
languageName: node
linkType: hard
-"lottie-react@npm:^2.4.1":
- version: 2.4.1
- resolution: "lottie-react@npm:2.4.1"
+"lottie-react@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "lottie-react@npm:3.1.0"
dependencies:
- lottie-web: "npm:^5.10.2"
+ lottie-web: "npm:^5.13.0"
peerDependencies:
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- checksum: 10c0/7f6a0a2cf2af25deabe7b4d4c9917e47d12016673613c59dcdd8ef52e10e914dd0c1f71d4a275d56aaba03fe9d85b333087f7c8d40a8f967ece4a0444c7a3cdf
+ react: ^18.2.0 || ^19.0.0
+ react-dom: ^18.2.0 || ^19.0.0
+ checksum: 10c0/501c06309c2e5ce2c3d445801d96359c68ca7ec6315f3c7c9c1be421716a548285c96b1915faf881bb4372b230a13ff47dcc4bda1ac12570ddb44a8b0a022766
languageName: node
linkType: hard
-"lottie-web@npm:^5.10.2":
- version: 5.12.2
- resolution: "lottie-web@npm:5.12.2"
- checksum: 10c0/0aeaf631b10a76afd025df70c2a1486543530708e07a316946c08e55891dac483ffbaf2bf3648ae0b9c54c733118a0a086fd150aa76f7848606214c67ad72c30
+"lottie-web@npm:^5.13.0":
+ version: 5.13.0
+ resolution: "lottie-web@npm:5.13.0"
+ checksum: 10c0/b463ad462169df3f7e04b7f3716274e269842554f1d72b8303ca7245628f2512982a59ae31698b3d57fbcda886228ae160fcb2aa518c6cecd8a95974db2458ee
languageName: node
linkType: hard