From ab339396d18262b7fb815ff0a91b9935a5153740 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 21 Apr 2025 15:26:24 -0700 Subject: [PATCH 1/6] Note: only set selection if empty --- src/components/Note.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Note.tsx b/src/components/Note.tsx index 03e1802f7ae..7651af4748f 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -56,13 +56,13 @@ const Note = React.memo( // set the caret on the note if editing this thought and noteFocus is true useEffect(() => { // cursor must be true if note is focused - if (hasFocus) { + if (note === '' && hasFocus) { selection.set(noteRef.current!, { end: true }) // deleting a note, then closing the keyboard, then creating a new note could result in lack of focus, // perhaps related to iOS Safari's internal management of selection ranges and focus if (isTouch && isSafari()) noteRef.current?.focus() } - }, [hasFocus]) + }, [hasFocus, note]) /** Handles note keyboard shortcuts. */ const onKeyDown = useCallback( From c90906605564b706cb307989513145384e4d0a81 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 22 Apr 2025 10:54:44 -0700 Subject: [PATCH 2/6] Note: undo empty note change --- src/components/Note.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Note.tsx b/src/components/Note.tsx index 7651af4748f..03e1802f7ae 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -56,13 +56,13 @@ const Note = React.memo( // set the caret on the note if editing this thought and noteFocus is true useEffect(() => { // cursor must be true if note is focused - if (note === '' && hasFocus) { + if (hasFocus) { selection.set(noteRef.current!, { end: true }) // deleting a note, then closing the keyboard, then creating a new note could result in lack of focus, // perhaps related to iOS Safari's internal management of selection ranges and focus if (isTouch && isSafari()) noteRef.current?.focus() } - }, [hasFocus, note]) + }, [hasFocus]) /** Handles note keyboard shortcuts. */ const onKeyDown = useCallback( From a07ccdce1c53032a0adc30a6b16f63b3ab1c17d3 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 23 Apr 2025 14:57:44 -0700 Subject: [PATCH 3/6] Add actionMetadata middleware to store ephemeral metadata --- src/actions/setCursor.ts | 2 ++ src/components/Note.tsx | 6 +++++ src/redux-middleware/updateActionMetadata.ts | 26 ++++++++++++++++++++ src/stores/actionMetadata.ts | 13 ++++++++++ src/stores/app.ts | 2 ++ 5 files changed, 49 insertions(+) create mode 100644 src/redux-middleware/updateActionMetadata.ts create mode 100644 src/stores/actionMetadata.ts diff --git a/src/actions/setCursor.ts b/src/actions/setCursor.ts index 3c305c80798..fc7bf0b7dfe 100644 --- a/src/actions/setCursor.ts +++ b/src/actions/setCursor.ts @@ -19,6 +19,7 @@ import expandThoughts from '../selectors/expandThoughts' import getSetting from '../selectors/getSetting' import getThoughtById from '../selectors/getThoughtById' import simplifyPath from '../selectors/simplifyPath' +import { SetCursorActionMetadata } from '../stores/actionMetadata' import editingValueStore from '../stores/editingValue' import equalPath from '../util/equalPath' import head from '../util/head' @@ -44,6 +45,7 @@ const setCursor = ( cursorHistoryClear?: boolean cursorHistoryPop?: boolean editing?: boolean | null + metadata?: SetCursorActionMetadata noteFocus?: boolean offset?: number | null path: Path | null diff --git a/src/components/Note.tsx b/src/components/Note.tsx index 03e1802f7ae..6faf27dab03 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -14,6 +14,7 @@ import { toggleNoteActionCreator as toggleNote } from '../actions/toggleNote' import { isSafari, isTouch } from '../browser' import * as selection from '../device/selection' import useFreshCallback from '../hooks/useFreshCallback' +import actionMetadataStore from '../stores/actionMetadata' import store from '../stores/app' import equalPathHead from '../util/equalPathHead' import head from '../util/head' @@ -48,6 +49,7 @@ const Note = React.memo( path, cursorHistoryClear: true, editing: true, + metadata: { userGenerated: true }, noteFocus: true, }), ) @@ -57,6 +59,10 @@ const Note = React.memo( useEffect(() => { // cursor must be true if note is focused if (hasFocus) { + const actionMetadata = actionMetadataStore.getState() + + if (actionMetadata?.type === 'setCursor' && actionMetadata.userGenerated) return + selection.set(noteRef.current!, { end: true }) // deleting a note, then closing the keyboard, then creating a new note could result in lack of focus, // perhaps related to iOS Safari's internal management of selection ranges and focus diff --git a/src/redux-middleware/updateActionMetadata.ts b/src/redux-middleware/updateActionMetadata.ts new file mode 100644 index 00000000000..5825a04d5f2 --- /dev/null +++ b/src/redux-middleware/updateActionMetadata.ts @@ -0,0 +1,26 @@ +import { ThunkMiddleware } from 'redux-thunk' +import State from '../@types/State' +import actionMetadataStore, { ActionMetadata, SetCursorActionMetadata } from '../stores/actionMetadata' + +interface SetCursorActionWithMetadata { + metadata?: SetCursorActionMetadata + type: 'setCursor' +} + +// This can eventually be a discriminated union +export type ActionWithMetadata = SetCursorActionWithMetadata + +const hasMetadata = (action: unknown): action is ActionWithMetadata => !!action && action.hasOwnProperty('metadata') + +/** Action metadata should be ephemeral and must be updated for every action. + * Update the actionMetadata store on every action, even if metadata is undefined. */ +const updateActionMetadata: ThunkMiddleware = () => { + return next => action => { + actionMetadataStore.update( + hasMetadata(action) ? ({ type: action.type, ...action.metadata } as ActionMetadata) : { type: '' }, + ) + next(action) + } +} + +export default updateActionMetadata diff --git a/src/stores/actionMetadata.ts b/src/stores/actionMetadata.ts new file mode 100644 index 00000000000..2c566c0fb77 --- /dev/null +++ b/src/stores/actionMetadata.ts @@ -0,0 +1,13 @@ +import reactMinistore from './react-ministore' + +export type SetCursorActionMetadata = { + userGenerated: boolean +} + +// This can eventually be a discriminated union +export type ActionMetadata = (SetCursorActionMetadata & { type: 'setCursor' }) | { type: '' } + +/** A store that hold optional metadata for the most-recently-dispatched action. */ +const actionMetadataStore = reactMinistore({ type: '' }) + +export default actionMetadataStore diff --git a/src/stores/app.ts b/src/stores/app.ts index 8ec385f9142..e7eaae70eb1 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -18,6 +18,7 @@ import multi from '../redux-middleware/multi' import multicursorAlertMiddleware from '../redux-middleware/multicursorAlertMiddleware' import pullQueue from '../redux-middleware/pullQueue' import scrollCursorIntoView from '../redux-middleware/scrollCursorIntoView' +import updateActionMetadata from '../redux-middleware/updateActionMetadata' import updateEditingValue from '../redux-middleware/updateEditingValue' import updateUrlHistory from '../redux-middleware/updateUrlHistory' @@ -40,6 +41,7 @@ const middlewareEnhancer = applyMiddleware( pullQueue, scrollCursorIntoView, clearSelection, + updateActionMetadata, updateEditingValue, updateUrlHistory, freeThoughts, From f9ee47c0bfa4b1ce3aa686fdb25aebf2a588572e Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 23 Apr 2025 15:04:55 -0700 Subject: [PATCH 4/6] Add JSDoc to hasMetadata function --- src/redux-middleware/updateActionMetadata.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/redux-middleware/updateActionMetadata.ts b/src/redux-middleware/updateActionMetadata.ts index 5825a04d5f2..bc026472fbe 100644 --- a/src/redux-middleware/updateActionMetadata.ts +++ b/src/redux-middleware/updateActionMetadata.ts @@ -10,6 +10,7 @@ interface SetCursorActionWithMetadata { // This can eventually be a discriminated union export type ActionWithMetadata = SetCursorActionWithMetadata +/** Casts an unknown action to an action with metadata. Needs some more work to make it flexible enough. */ const hasMetadata = (action: unknown): action is ActionWithMetadata => !!action && action.hasOwnProperty('metadata') /** Action metadata should be ephemeral and must be updated for every action. From c08564d9b91620c326917a2db19eb86c205ed5f9 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Fri, 25 Apr 2025 11:16:29 -0700 Subject: [PATCH 5/6] Remove middleware/microstore, add noteOffset to state, check noteOffset in Note --- src/@types/State.ts | 1 + src/actions/setCursor.ts | 5 ++-- src/actions/setNoteFocus.ts | 6 ++++- src/actions/toggleNote.ts | 5 +++- src/components/Note.tsx | 12 ++++----- src/redux-middleware/updateActionMetadata.ts | 27 -------------------- src/stores/actionMetadata.ts | 13 ---------- src/stores/app.ts | 2 -- src/util/initialState.ts | 1 + 9 files changed, 19 insertions(+), 53 deletions(-) delete mode 100644 src/redux-middleware/updateActionMetadata.ts delete mode 100644 src/stores/actionMetadata.ts diff --git a/src/@types/State.ts b/src/@types/State.ts index 584d847a3e7..1b1d4479c76 100644 --- a/src/@types/State.ts +++ b/src/@types/State.ts @@ -123,6 +123,7 @@ interface State { * Passed to Yjs and cleared on every action. * See: /redux-enhancers/pushQueue.ts. */ + noteOffset: number | null pushQueue: PushBatch[] recentlyEdited: RecentlyEditedTree /** Redo history. Contains diffs that can be applied to State to restore actions that were reverted with undo. State.redoPatches[0] is the oldest action that was undone. */ diff --git a/src/actions/setCursor.ts b/src/actions/setCursor.ts index fc7bf0b7dfe..183d19a6153 100644 --- a/src/actions/setCursor.ts +++ b/src/actions/setCursor.ts @@ -19,7 +19,6 @@ import expandThoughts from '../selectors/expandThoughts' import getSetting from '../selectors/getSetting' import getThoughtById from '../selectors/getThoughtById' import simplifyPath from '../selectors/simplifyPath' -import { SetCursorActionMetadata } from '../stores/actionMetadata' import editingValueStore from '../stores/editingValue' import equalPath from '../util/equalPath' import head from '../util/head' @@ -36,6 +35,7 @@ const setCursor = ( cursorHistoryPop, editing, noteFocus = false, + noteOffset = null, offset, path, replaceContextViews, @@ -45,8 +45,8 @@ const setCursor = ( cursorHistoryClear?: boolean cursorHistoryPop?: boolean editing?: boolean | null - metadata?: SetCursorActionMetadata noteFocus?: boolean + noteOffset?: number | null offset?: number | null path: Path | null replaceContextViews?: Index @@ -150,6 +150,7 @@ const setCursor = ( cursorOffset: updatedOffset, expanded, noteFocus, + noteOffset, cursorInitialized: true, ...(!preserveMulticursor ? { diff --git a/src/actions/setNoteFocus.ts b/src/actions/setNoteFocus.ts index 26bca01f13c..eea7512840e 100644 --- a/src/actions/setNoteFocus.ts +++ b/src/actions/setNoteFocus.ts @@ -3,13 +3,17 @@ import State from '../@types/State' import Thunk from '../@types/Thunk' import headValue from '../util/headValue' +type NoteFocusType = { value: false; offset?: never } | { value: true; offset: number | null } + /** Sets state.noteFocus to true or false, indicating if the caret is on a note. Sets state.cursorOffset to the end of the thought when disabling note focus so the selection gets placed back correctly on the thought. */ -const setNoteFocus = (state: State, { value }: { value: boolean }): State => ({ +const setNoteFocus = (state: State, { value, offset }: NoteFocusType): State => ({ ...state, // set the cursor offset to the end of the cursor thought // we cannot use state.editingValue since it is set to null when the Editable is blurred ...(!value && state.cursor ? { cursorOffset: headValue(state, state.cursor)?.length } : null), noteFocus: value, + // clear the offset when the caret leaves a note + noteOffset: value ? offset : null, // always enter edit mode when there is note focus // it will be set in the Note's onFocus anyway, but set it here so that we are not as dependent on what happens there ...(value ? { editing: true } : null), diff --git a/src/actions/toggleNote.ts b/src/actions/toggleNote.ts index e948a9ce2fb..9d6db1e6ced 100644 --- a/src/actions/toggleNote.ts +++ b/src/actions/toggleNote.ts @@ -5,6 +5,7 @@ import setDescendant from '../actions/setDescendant' import setNoteFocus from '../actions/setNoteFocus' import attribute from '../selectors/attribute' import findDescendant from '../selectors/findDescendant' +import getChildren from '../selectors/getChildren' import head from '../util/head' import reducerFlow from '../util/reducerFlow' @@ -12,6 +13,8 @@ import reducerFlow from '../util/reducerFlow' const toggleNote = (state: State) => { const thoughtId = head(state.cursor!) const hasNote = findDescendant(state, thoughtId, '=note') + const noteChildren = hasNote ? getChildren(state, hasNote) : [] + const offset = noteChildren.length ? noteChildren[0].value.length : 0 return reducerFlow([ // create an empty note if it doesn't exist @@ -26,7 +29,7 @@ const toggleNote = (state: State) => { : null, // toggle state.noteFocus, which will trigger the Editable and Note to re-render and set the selection appropriately - setNoteFocus({ value: !state.noteFocus }), + state.noteFocus ? setNoteFocus({ value: false }) : setNoteFocus({ value: true, offset }), ])(state) } diff --git a/src/components/Note.tsx b/src/components/Note.tsx index 6faf27dab03..255e8c8fedf 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -14,7 +14,6 @@ import { toggleNoteActionCreator as toggleNote } from '../actions/toggleNote' import { isSafari, isTouch } from '../browser' import * as selection from '../device/selection' import useFreshCallback from '../hooks/useFreshCallback' -import actionMetadataStore from '../stores/actionMetadata' import store from '../stores/app' import equalPathHead from '../util/equalPathHead' import head from '../util/head' @@ -41,6 +40,7 @@ const Note = React.memo( /** Gets the value of the note. Returns null if no note exists or if the context view is active. */ const note = useSelector(state => noteValue(state, thoughtId)) + const noteOffset = useSelector(state => state.noteOffset) /** Focus Handling with useFreshCallback. */ const onFocus = useFreshCallback(() => { @@ -49,7 +49,7 @@ const Note = React.memo( path, cursorHistoryClear: true, editing: true, - metadata: { userGenerated: true }, + noteOffset: null, noteFocus: true, }), ) @@ -59,16 +59,14 @@ const Note = React.memo( useEffect(() => { // cursor must be true if note is focused if (hasFocus) { - const actionMetadata = actionMetadataStore.getState() + if (noteOffset === null) return - if (actionMetadata?.type === 'setCursor' && actionMetadata.userGenerated) return - - selection.set(noteRef.current!, { end: true }) + selection.set(noteRef.current!, { offset: noteOffset }) // deleting a note, then closing the keyboard, then creating a new note could result in lack of focus, // perhaps related to iOS Safari's internal management of selection ranges and focus if (isTouch && isSafari()) noteRef.current?.focus() } - }, [hasFocus]) + }, [hasFocus, noteOffset]) /** Handles note keyboard shortcuts. */ const onKeyDown = useCallback( diff --git a/src/redux-middleware/updateActionMetadata.ts b/src/redux-middleware/updateActionMetadata.ts deleted file mode 100644 index bc026472fbe..00000000000 --- a/src/redux-middleware/updateActionMetadata.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ThunkMiddleware } from 'redux-thunk' -import State from '../@types/State' -import actionMetadataStore, { ActionMetadata, SetCursorActionMetadata } from '../stores/actionMetadata' - -interface SetCursorActionWithMetadata { - metadata?: SetCursorActionMetadata - type: 'setCursor' -} - -// This can eventually be a discriminated union -export type ActionWithMetadata = SetCursorActionWithMetadata - -/** Casts an unknown action to an action with metadata. Needs some more work to make it flexible enough. */ -const hasMetadata = (action: unknown): action is ActionWithMetadata => !!action && action.hasOwnProperty('metadata') - -/** Action metadata should be ephemeral and must be updated for every action. - * Update the actionMetadata store on every action, even if metadata is undefined. */ -const updateActionMetadata: ThunkMiddleware = () => { - return next => action => { - actionMetadataStore.update( - hasMetadata(action) ? ({ type: action.type, ...action.metadata } as ActionMetadata) : { type: '' }, - ) - next(action) - } -} - -export default updateActionMetadata diff --git a/src/stores/actionMetadata.ts b/src/stores/actionMetadata.ts deleted file mode 100644 index 2c566c0fb77..00000000000 --- a/src/stores/actionMetadata.ts +++ /dev/null @@ -1,13 +0,0 @@ -import reactMinistore from './react-ministore' - -export type SetCursorActionMetadata = { - userGenerated: boolean -} - -// This can eventually be a discriminated union -export type ActionMetadata = (SetCursorActionMetadata & { type: 'setCursor' }) | { type: '' } - -/** A store that hold optional metadata for the most-recently-dispatched action. */ -const actionMetadataStore = reactMinistore({ type: '' }) - -export default actionMetadataStore diff --git a/src/stores/app.ts b/src/stores/app.ts index e7eaae70eb1..8ec385f9142 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -18,7 +18,6 @@ import multi from '../redux-middleware/multi' import multicursorAlertMiddleware from '../redux-middleware/multicursorAlertMiddleware' import pullQueue from '../redux-middleware/pullQueue' import scrollCursorIntoView from '../redux-middleware/scrollCursorIntoView' -import updateActionMetadata from '../redux-middleware/updateActionMetadata' import updateEditingValue from '../redux-middleware/updateEditingValue' import updateUrlHistory from '../redux-middleware/updateUrlHistory' @@ -41,7 +40,6 @@ const middlewareEnhancer = applyMiddleware( pullQueue, scrollCursorIntoView, clearSelection, - updateActionMetadata, updateEditingValue, updateUrlHistory, freeThoughts, diff --git a/src/util/initialState.ts b/src/util/initialState.ts index dea1030f518..c629c613b31 100644 --- a/src/util/initialState.ts +++ b/src/util/initialState.ts @@ -132,6 +132,7 @@ const initialState = (created: Timestamp = timestamp()) => { modals: {}, multicursors: {}, noteFocus: false, + noteOffset: null, recentlyEdited: {}, redoPatches: [], resourceCache: {}, From 32ef3c49375eed94835b763530963c42274133a9 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 29 Apr 2025 09:17:36 -0700 Subject: [PATCH 6/6] JSDoc for noteOffset, fix useEffect condition --- src/@types/State.ts | 3 ++- src/components/Note.tsx | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/@types/State.ts b/src/@types/State.ts index 1b1d4479c76..33f760dd7a3 100644 --- a/src/@types/State.ts +++ b/src/@types/State.ts @@ -118,12 +118,13 @@ interface State { multicursors: Index /** NoteFocus is true if the caret is on the note. */ noteFocus: boolean + /** NoteOffset can be used to position the caret within a note. Setting it to null disables programmatic selection using selection.set. */ + noteOffset: number | null /** * Temporarily stores updates that need to be persisted. * Passed to Yjs and cleared on every action. * See: /redux-enhancers/pushQueue.ts. */ - noteOffset: number | null pushQueue: PushBatch[] recentlyEdited: RecentlyEditedTree /** Redo history. Contains diffs that can be applied to State to restore actions that were reverted with undo. State.redoPatches[0] is the oldest action that was undone. */ diff --git a/src/components/Note.tsx b/src/components/Note.tsx index 255e8c8fedf..06b6ccc9770 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -58,9 +58,7 @@ const Note = React.memo( // set the caret on the note if editing this thought and noteFocus is true useEffect(() => { // cursor must be true if note is focused - if (hasFocus) { - if (noteOffset === null) return - + if (hasFocus && noteOffset !== null) { selection.set(noteRef.current!, { offset: noteOffset }) // deleting a note, then closing the keyboard, then creating a new note could result in lack of focus, // perhaps related to iOS Safari's internal management of selection ranges and focus