Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
82 changes: 63 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,16 +212,31 @@ 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;
Comment thread
7eliassen marked this conversation as resolved.
}
Comment thread
7eliassen marked this conversation as resolved.

note.value = response.note;
lastUpdateContent.value = response.note.content;
canEdit.value = response.accessRights.canEdit;
noteTools.value = response.tools;
parentNote.value = response.parentNote;
noteParents.value = response.parents;
void getNoteHierarchy(id);
} catch (error) {
if (loadId !== currentLoadId) {
return;
}

deleteOpenedPageByUrl(route.path);
if (error instanceof DomainError) {
void router.push(`/error/${error.statusCode}`);
Expand Down Expand Up @@ -244,8 +273,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 +285,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 +326,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,27 +408,28 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
}
}

watch(currentId, (newId, prevId) => {
watch(currentId, (newId, _prevId) => {
Comment thread
7eliassen marked this conversation as resolved.
/**
* One note is open, user clicks on "+" to create another new note
* Clear existing note
*/
if (newId === null) {
currentLoadId += 1;
lastCreatedNoteId = null;
resetNote();

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;
Comment thread
7eliassen marked this conversation as resolved.
}

lastCreatedNoteId = null;
void load(newId);
});

Expand Down Expand Up @@ -414,5 +457,6 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt
noteParents,
parentNote,
noteHierarchy,
getLastCreatedNoteId: () => lastCreatedNoteId,
};
}
37 changes: 36 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,28 @@ 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;
}

currentLoadId += 1;
isEditorReady.value = false;
Comment thread
Copilot marked this conversation as resolved.
},
{ immediate: true }
);

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

const loadId = ++currentLoadId;

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

try {
Expand Down
13 changes: 9 additions & 4 deletions 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,
Comment thread
7eliassen marked this conversation as resolved.
noteTools: historyTools,
isDraftResolver: () => false,
noteContentResolver: () => historyContent.value,
Expand All @@ -104,7 +105,9 @@ async function useThisVersion() {
const editorElement = editor.value ? editor.value.element : null;

if (historyContent.value !== undefined) {
await save(historyContent.value, undefined);
const noteIdAtCallTime = props.noteId;

await save(historyContent.value, undefined, 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 @@ -120,11 +123,13 @@ async function useThisVersion() {
paddingTop: '100px',
});
}
if (updatedNoteCover !== null && props.noteId !== null) {
updateCover(props.noteId, updatedNoteCover);
if (updatedNoteCover !== null && props.noteId === noteIdAtCallTime) {
await updateCover(noteIdAtCallTime, updatedNoteCover);
}

router.push(`/note/${noteId.value}`);
if (props.noteId === noteIdAtCallTime) {
await router.push(`/note/${noteIdAtCallTime}`);
}
}
}
}
Expand Down
18 changes: 14 additions & 4 deletions src/presentation/pages/Note.vue
Original file line number Diff line number Diff line change
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,15 @@ 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);
const savedNoteId = noteIdAtCallTime ?? getLastCreatedNoteId();

/**
* 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 +179,8 @@ async function noteChanged(data: NoteContent): Promise<void> {
paddingTop: '100px',
});
}
if (updatedNoteCover !== null && props.id !== null) {
await updateCover(props.id, updatedNoteCover);
if (updatedNoteCover !== null && savedNoteId !== null && savedNoteId === props.id) {
await updateCover(savedNoteId, updatedNoteCover);
}
}
}
Expand Down
Loading