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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 56 additions & 19 deletions src/application/services/useNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ interface UseNoteComposableState {
/**
* Creates/updates the note
*/
save: (content: NoteContent, parentId: NoteId | undefined) => Promise<void>;
save: (content: NoteContent, parentId: NoteId | undefined, currentNoteId: NoteId | null) => Promise<void>;

/**
* Returns list of tools used in note
Expand Down Expand Up @@ -96,6 +96,13 @@ interface UseNoteComposableState {
* Note hierarchy
*/
noteHierarchy: Ref<NoteHierarchy | null>;

/**
* Returns the id of the note created by the most recent save() on a new note
* Used to distinguish "same note just got an id after save" from
* "switched to a different existing note"
*/
getLastCreatedNoteId: () => NoteId | null;
}

interface UseNoteComposableOptions {
Expand Down Expand Up @@ -142,10 +149,11 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
const route = useRoute();

/**
* Is there any note currently saving
* Used to prevent re-load note after draft is saved
* Incremented on each new load request to discard stale async results
* Prevents race conditions when rapidly switching between notes causes
* multiple concurrent load() invocations to resolve out of order
*/
const isNoteSaving = ref<boolean>(false);
let currentLoadId = 0;

/**
* Note Title identifier
Expand Down Expand Up @@ -183,6 +191,12 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
*/
const noteHierarchy = ref<NoteHierarchy | null>(null);

/**
* Id of the note created by the most recent save() on a new note
* Used to skip the reload after save so the editor doesn't get recreated
*/
let lastCreatedNoteId: NoteId | null = null;

/**
* get note hierarchy
* @param id - note id
Expand All @@ -198,10 +212,21 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
* @param id - Note identifier got from composable argument
*/
async function load(id: NoteId): Promise<void> {
const loadId = ++currentLoadId;

try {
const response = await noteService.getNoteById(id);

/**
* If a newer load request has superseded this one — discard stale results
* to prevent mismatched content/tools state when switching notes quickly
*/
if (loadId !== currentLoadId) {
return;
}

note.value = response.note;
lastUpdateContent.value = response.note.content;
canEdit.value = response.accessRights.canEdit;
noteTools.value = response.tools;
parentNote.value = response.parentNote;
Expand Down Expand Up @@ -244,8 +269,9 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
* Saves the note
* @param content - Note content (Editor.js data)
* @param parentId - Id of the parent note. If null, then it's a root note
* @param currentNoteId - Id of the current note
*/
async function save(content: NoteContent, parentId: NoteId | undefined): Promise<void> {
async function save(content: NoteContent, parentId: NoteId | undefined, currentNoteId: NoteId | null): Promise<void> {
if (note.value === null) {
throw new Error('Note is not loaded yet');
}
Expand All @@ -255,14 +281,25 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
*/
const specifiedNoteTools = resolveToolsByContent(content);

isNoteSaving.value = true;

if (currentId.value === null) {
if (currentNoteId === null) {
/**
* @todo try-catch domain errors
*/
const noteCreated = await noteService.createNote(content, specifiedNoteTools, parentId);

/**
* Remember the created note id so the editor can avoid
* recreating itself when the route switches from "new note" to the newly created note id
*/
lastCreatedNoteId = noteCreated.id;

/**
* Store the saved content so the navbar title reflects it
*/
if (currentId.value === currentNoteId) {
lastUpdateContent.value = content;
}

/**
* Replace the current route with note id
*/
Expand All @@ -285,15 +322,16 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
*/
void getNoteHierarchy(noteCreated.id);
} else {
await noteService.updateNoteContentAndTools(currentId.value, content, specifiedNoteTools);
await noteService.updateNoteContentAndTools(currentNoteId, content, specifiedNoteTools);
}

/**
* Store just saved content in memory
* Store just saved content in memory only if the current note hasn't changed
* This prevents race conditions when switching between notes quickly
*/
lastUpdateContent.value = content;

isNoteSaving.value = false;
if (currentId.value === currentNoteId) {
lastUpdateContent.value = content;
}
}

/**
Expand Down Expand Up @@ -366,7 +404,7 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
}
}

watch(currentId, (newId, prevId) => {
watch(currentId, (newId, _prevId) => {
/**
* One note is open, user clicks on "+" to create another new note
* Clear existing note
Expand All @@ -377,13 +415,11 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
return;
}

const isDraftSaving = prevId === null && isNoteSaving.value;

/**
* Case for newly created note,
* we don't need to re-load it
* If the note was just created via save() and is still a draft (no id yet),
* skip the reload to avoid recreating the editor with the same content.
*/
if (isDraftSaving) {
if (newId === lastCreatedNoteId && note.value !== null && !('id' in note.value)) {
return;
}

Expand Down Expand Up @@ -414,5 +450,6 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
noteParents,
parentNote,
noteHierarchy,
getLastCreatedNoteId: () => lastCreatedNoteId,
};
}
36 changes: 35 additions & 1 deletion src/application/services/useNoteEditor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import type { MaybeRefOrGetter } from 'vue';
import { type Ref, computed, ref, toValue, watch } from 'vue';
import { useAppState } from './useAppState';
import type EditorTool from '@/domain/entities/EditorTool';
import type { NoteId } from '@/domain/entities/Note';
import { type NoteContent } from '@/domain/entities/Note';
import { editorToolsService } from '@/domain';
import type { EditorjsToolsConfig } from '@/domain/entities/EditorTool';
import { useI18n } from 'vue-i18n';

interface UseNoteEditorOptions {
/**
* Null for new note, id for reading existing note
*/
noteId: MaybeRefOrGetter<NoteId | null>;

/**
* Tools used in the note
*/
Expand All @@ -27,6 +34,13 @@ interface UseNoteEditorOptions {
* Flag indicating that user can edit the note
*/
canEdit: Ref<boolean>;

/**
* Returns the id of the note created by the most recent save() on a new note
* Used to distinguish "same note just got an id after save" from
* "switched to a different existing note"
*/
getLastCreatedNoteId?: () => NoteId | null;
}

interface UseNoteEditorComposableState {
Expand Down Expand Up @@ -83,6 +97,27 @@ export const useNoteEditor = function useNoteEditor(options: UseNoteEditorOption
*/
let currentLoadId = 0;

/**
* Reset the editor when the note changes.
* Exception — new note save: the route switches from null to createdId
* for the same note, so the editor must NOT be recreated
* (avoids blinking and losing the cursor).
*/
watch(
() => toValue(options.noteId),
(newId, oldId) => {
/**
* Same note just got an id after save — keep the editor as-is
*/
if (oldId === null && newId !== null && options.getLastCreatedNoteId !== undefined && newId === options.getLastCreatedNoteId()) {
return;
}

isEditorReady.value = false;
},
{ immediate: true }
);

/**
* Combine note and user tools
* Undefined when user or note is not loaded
Expand Down Expand Up @@ -148,7 +183,6 @@ export const useNoteEditor = function useNoteEditor(options: UseNoteEditorOption

const loadId = ++currentLoadId;

isEditorReady.value = false;
toolsUserConfigLoaded.value = false;

try {
Expand Down
3 changes: 2 additions & 1 deletion src/presentation/pages/HistoryVersion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const { noteTitle, save } = useNote({
const canEdit = ref(false);

const { isEditorReady, editorConfig } = useNoteEditor({
noteId,
noteTools: historyTools,
isDraftResolver: () => false,
noteContentResolver: () => historyContent.value,
Expand All @@ -104,7 +105,7 @@ async function useThisVersion() {
const editorElement = editor.value ? editor.value.element : null;

if (historyContent.value !== undefined) {
await save(historyContent.value, undefined);
await save(historyContent.value, undefined, props.noteId);
/**
* In case if we do not have note id, we can change its cover, and we need successful data for cover
* We need to do it after saving in case of note creation
Expand Down
18 changes: 13 additions & 5 deletions src/presentation/pages/Note.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { computed, ref, toRef, watch } from 'vue';
import { Button, Editor, PageBlock, VerticalMenu, type VerticalMenuItem } from '@codexteam/ui/vue';
import useNote from '@/application/services/useNote';
import { useRoute, useRouter } from 'vue-router';
import { NoteContent } from '@/domain/entities/Note';
import { NoteContent, type NoteId } from '@/domain/entities/Note';
import { useHead } from 'unhead';
import { useI18n } from 'vue-i18n';
import { makeElementScreenshot } from '@/infrastructure/utils/screenshot';
Expand Down Expand Up @@ -99,7 +99,7 @@ const props = defineProps<{

const noteId = toRef(props, 'id');

const { note, noteTools, save, noteTitle, canEdit, noteParents, noteHierarchy } = useNote({
const { note, noteTools, save, noteTitle, canEdit, noteParents, noteHierarchy, getLastCreatedNoteId } = useNote({
id: noteId,
});

Expand All @@ -126,10 +126,12 @@ function redirectToNoteSettings(): void {
const { updateCover } = useNoteSettings();

const { isEditorReady, editorConfig } = useNoteEditor({
noteId,
noteTools,
isDraftResolver: () => noteId.value === null,
noteContentResolver: () => note.value?.content,
canEdit,
getLastCreatedNoteId,
});

/**
Expand All @@ -153,7 +155,13 @@ async function noteChanged(data: NoteContent): Promise<void> {
const editorElement = editor.value ? editor.value.element : null;

if (!isEmpty) {
await save(data, props.parentId);
/**
* Capture the current note id at the time of the call
* to avoid race conditions when fast switching between notes
*/
const noteIdAtCallTime = props.id;

await save(data, props.parentId, noteIdAtCallTime);
/**
* In case if we do not have note id, we can change its cover, and we need successful data for cover
* We need to do it after saving in case of note creation
Expand All @@ -169,8 +177,8 @@ async function noteChanged(data: NoteContent): Promise<void> {
paddingTop: '100px',
});
}
if (updatedNoteCover !== null && props.id !== null) {
await updateCover(props.id, updatedNoteCover);
if (updatedNoteCover !== null && noteIdAtCallTime !== null && noteIdAtCallTime === props.id) {
await updateCover(noteIdAtCallTime as NoteId, updatedNoteCover);
}
}
}
Expand Down
Loading