From 7e1f01471a1873b979465712e3a18bdb851a3f85 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 4 Sep 2024 15:22:06 +0200 Subject: [PATCH] Implement support for editing notes' metadata, and ability to move notes into other directories. Create separate pinia store for the editor cache functionality. --- assets/icons/arrow-right.svg | 1 + electron/main/file-library.js | 13 ++ electron/preload/index.ts | 4 + src/components/App.vue | 13 +- src/components/EditNote.vue | 274 ++++++++++++++++++++++++++++++++ src/components/Editor.vue | 46 ++---- src/components/NoteSelector.vue | 102 +++++++++++- src/editor/editor.js | 6 + src/main.js | 2 + src/stores/editor-cache.js | 48 ++++++ src/stores/notes-store.js | 57 +++++-- 11 files changed, 513 insertions(+), 53 deletions(-) create mode 100644 assets/icons/arrow-right.svg create mode 100644 src/components/EditNote.vue create mode 100644 src/stores/editor-cache.js diff --git a/assets/icons/arrow-right.svg b/assets/icons/arrow-right.svg new file mode 100644 index 0000000..b3110a4 --- /dev/null +++ b/assets/icons/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/main/file-library.js b/electron/main/file-library.js index b00edc9..bc06a18 100644 --- a/electron/main/file-library.js +++ b/electron/main/file-library.js @@ -72,6 +72,15 @@ export class FileLibrary { await this.jetpack.writeAsync(fullPath, content) } + async move(path, newPath) { + if (await this.exists(newPath)) { + throw new Error(`File already exists: ${newPath}`) + } + const fullOldPath = join(this.basePath, path) + const fullNewPath = join(this.basePath, newPath) + await this.jetpack.moveAsync(fullOldPath, fullNewPath) + } + async getList() { console.log("Loading notes") const notes = {} @@ -231,5 +240,9 @@ export function setupFileLibraryEventHandlers(library, win) { app.quit() }) + ipcMain.handle('buffer:move', async (event, path, newPath) => { + return await library.move(path, newPath) + }); + library.setupWatcher(win) } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 9d5bd8b..bb1e553 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("heynote", { return await ipcRenderer.invoke("buffer:save", path, content) }, + async move(path, newPath) { + return await ipcRenderer.invoke("buffer:move", path, newPath) + }, + async create(path, content) { return await ipcRenderer.invoke("buffer:create", path, content) }, diff --git a/src/components/App.vue b/src/components/App.vue index 7e51dfe..982b80a 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -12,6 +12,7 @@ import Settings from './settings/Settings.vue' import ErrorMessages from './ErrorMessages.vue' import NewNote from './NewNote.vue' + import EditNote from './EditNote.vue' export default { components: { @@ -22,6 +23,7 @@ NoteSelector, ErrorMessages, NewNote, + EditNote, }, data() { @@ -67,6 +69,7 @@ showLanguageSelector(value) { this.dialogWatcher(value) }, showNoteSelector(value) { this.dialogWatcher(value) }, showCreateNote(value) { this.dialogWatcher(value) }, + showEditNote(value) { this.dialogWatcher(value) }, currentNotePath() { this.focusEditor() @@ -79,10 +82,11 @@ "showLanguageSelector", "showNoteSelector", "showCreateNote", + "showEditNote", ]), editorInert() { - return this.showCreateNote || this.showSettings + return this.showCreateNote || this.showSettings || this.showEditNote }, }, @@ -92,6 +96,7 @@ "openNoteSelector", "openCreateNote", "closeDialog", + "closeNoteSelector", "openNote", ]), @@ -186,7 +191,7 @@ + diff --git a/src/components/EditNote.vue b/src/components/EditNote.vue new file mode 100644 index 0000000..cfbd5d8 --- /dev/null +++ b/src/components/EditNote.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 68a9ce5..93c52af 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -5,6 +5,7 @@ import { mapState, mapWritableState, mapActions } from 'pinia' import { useErrorStore } from "../stores/error-store" import { useNotesStore } from "../stores/notes-store" + import { useEditorCacheStore } from "../stores/editor-cache" const NUM_EDITOR_INSTANCES = 5 @@ -45,10 +46,6 @@ return { syntaxTreeDebugContent: null, editor: null, - editorCache: { - lru: [], - cache: {} - }, } }, @@ -164,36 +161,21 @@ methods: { ...mapActions(useErrorStore, ["addError"]), + ...mapActions(useEditorCacheStore, ["getEditor", "addEditor", "eachEditor"]), loadBuffer(path) { + console.log("loadBuffer", path) if (this.editor) { this.editor.hide() } - if (this.editorCache.cache[path]) { - // editor is already loaded, just switch to it - console.log("Switching to cached editor", path) - toRaw(this.editor).hide() - this.editor = this.editorCache.cache[path] + let cachedEditor = this.getEditor(path) + if (cachedEditor) { + console.log("show cached editor") + this.editor = cachedEditor toRaw(this.editor).show() - //toRaw(this.editor).currenciesLoaded() - this.currentEditor = toRaw(this.editor) - window._heynote_editor = toRaw(this.editor) - // move to end of LRU - this.editorCache.lru = this.editorCache.lru.filter(p => p !== path) - this.editorCache.lru.push(path) } else { - // check if we need to free up a slot - if (this.editorCache.lru.length >= NUM_EDITOR_INSTANCES) { - const pathToFree = this.editorCache.lru.shift() - console.log("Freeing up editor slot", pathToFree) - this.editorCache.cache[pathToFree].destroy() - delete this.editorCache.cache[pathToFree] - this.editorCache.lru = this.editorCache.lru.filter(p => p !== pathToFree) - } - - // create new Editor instance - console.log("Loading new editor", path) + console.log("create new editor") try { this.editor = new HeynoteEditor({ element: this.$refs.editor, @@ -209,15 +191,15 @@ defaultBlockToken: this.defaultBlockLanguage, defaultBlockAutoDetect: this.defaultBlockLanguageAutoDetect, }) - this.currentEditor = toRaw(this.editor) - window._heynote_editor = toRaw(this.editor) - this.editorCache.cache[path] = this.editor - this.editorCache.lru.push(path) } catch (e) { this.addError("Error! " + e.message) throw e } + this.addEditor(path, toRaw(this.editor)) } + + this.currentEditor = toRaw(this.editor) + window._heynote_editor = toRaw(this.editor) }, setLanguage(language) { @@ -245,10 +227,6 @@ focus() { toRaw(this.editor).focus() }, - - eachEditor(fn) { - Object.values(toRaw(this.editorCache).cache).forEach(fn) - }, }, } diff --git a/src/components/NoteSelector.vue b/src/components/NoteSelector.vue index c667a6e..61e9d80 100644 --- a/src/components/NoteSelector.vue +++ b/src/components/NoteSelector.vue @@ -3,14 +3,16 @@ import { mapState, mapActions } from 'pinia' import { toRaw } from 'vue'; - import { useNotesStore } from "../stores/notes-store" + import { useNotesStore, SCRATCH_FILE } from "../stores/notes-store" export default { data() { return { selected: 0, + actionButton: 0, filter: "", items: [], + SCRATCH_FILE: SCRATCH_FILE, } }, @@ -23,7 +25,7 @@ "path": path, "name": metadata?.name || path, "folder": path.split("/").slice(0, -1).join("/"), - "scratch": path === "buffer-dev.txt", + "scratch": path === SCRATCH_FILE, } }) if (this.items.length > 1) { @@ -84,9 +86,11 @@ methods: { ...mapActions(useNotesStore, [ "updateNotes", + "editNote", ]), onKeydown(event) { + const path = this.filteredItems[this.selected].path if (event.key === "ArrowDown") { if (this.selected === this.filteredItems.length - 1) { this.selected = 0 @@ -99,7 +103,7 @@ } else { this.$refs.item[this.selected].scrollIntoView({block: "nearest"}) } - + this.actionButton = 0 } else if (event.key === "ArrowUp") { if (this.selected === 0) { this.selected = this.filteredItems.length - 1 @@ -112,9 +116,23 @@ } else { this.$refs.item[this.selected].scrollIntoView({block: "nearest"}) } - } else if (event.key === "Enter") { - this.selectItem(this.filteredItems[this.selected].path) + this.actionButton = 0 + } else if (event.key === "ArrowRight" && path !== SCRATCH_FILE) { event.preventDefault() + this.actionButton = Math.min(2, this.actionButton + 1) + } else if (event.key === "ArrowLeft" && path !== SCRATCH_FILE) { + event.preventDefault() + this.actionButton = Math.max(0, this.actionButton - 1) + } else if (event.key === "Enter") { + event.preventDefault() + if (this.actionButton === 1) { + console.log("edit file:", path) + this.editNote(path) + } else if (this.actionButton === 2) { + console.log("delete file:", path) + } else { + this.selectItem(path) + } } else if (event.key === "Escape") { this.$emit("close") event.preventDefault() @@ -140,9 +158,16 @@ getItemClass(item, idx) { return { "selected": idx === this.selected, + "action-buttons-visible": this.actionButton > 0, "scratch": item.scratch, } - } + }, + + showActionButtons(idx) { + this.selected = idx + this.actionButton = 1 + this.$refs.input.focus() + }, } } @@ -167,6 +192,21 @@ > + + + + + @@ -228,16 +268,20 @@ .items overflow-y: auto > li + position: relative border-radius: 3px padding: 5px 12px - cursor: pointer display: flex align-items: center &:hover background: #e2e2e2 + .action-buttons .show-actions + display: inline-block &.selected background: #48b57e color: #fff + .action-buttons .show-actions + display: inline-block &.scratch font-weight: 600 +dark-mode @@ -247,6 +291,10 @@ &.selected background: #1b6540 color: rgba(255,255,255, 0.87) + &.action-buttons-visible + background: none + border: 1px solid #1b6540 + padding: 4px 11px .name margin-right: 12px flex-shrink: 0 @@ -264,4 +312,44 @@ text-wrap: nowrap ::v-deep(b) font-weight: 700 + .action-buttons + position: absolute + top: 1px + right: 1px + button + padding: 1px 10px + font-size: 12px + background: none + border: none + border-radius: 2px + margin-right: 2px + cursor: pointer + &:last-child + margin-right: 0 + &:hover + background: rgba(255,255,255, 0.1) + +dark-mode + //background: #1b6540 + //&:hover + // background: + &.selected + background: #1b6540 + &:hover + background: #1f7449 + &.delete + background: #ae1e1e + &:hover + background: #bf2222 + &.show-actions + display: none + position: relative + top: 1px + padding: 1px 8px + //cursor: default + background-image: url(@/assets/icons/arrow-right.svg) + width: 22px + height: 19px + background-size: 19px + background-position: center center + background-repeat: no-repeat diff --git a/src/editor/editor.js b/src/editor/editor.js index 80801b9..754e5c8 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -217,6 +217,12 @@ export class HeynoteEditor { }) } + setName(name) { + this.note.metadata.name = name + this.name = name + triggerCursorChange(this.view) + } + getBlocks() { return this.view.state.facet(blockState) } diff --git a/src/main.js b/src/main.js index 9994a49..fa49f2b 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ import App from './components/App.vue' import { loadCurrencies } from './currency' import { useErrorStore } from './stores/error-store' import { useNotesStore, initNotesStore } from './stores/notes-store' +import { useEditorCacheStore } from './stores/editor-cache' const pinia = createPinia() @@ -19,6 +20,7 @@ app.mount('#app').$nextTick(() => { }) const errorStore = useErrorStore() +const editorCacheStore = useEditorCacheStore() //errorStore.addError("test error") window.heynote.getInitErrors().then((errors) => { errors.forEach((e) => errorStore.addError(e)) diff --git a/src/stores/editor-cache.js b/src/stores/editor-cache.js new file mode 100644 index 0000000..26bb186 --- /dev/null +++ b/src/stores/editor-cache.js @@ -0,0 +1,48 @@ +import { toRaw } from 'vue'; +import { defineStore } from "pinia" +import { NoteFormat } from "../editor/note-format" + +const NUM_EDITOR_INSTANCES = 5 + +export const useEditorCacheStore = defineStore("editorCache", { + state: () => ({ + editorCache: { + lru: [], + cache: {}, + }, + }), + + actions: { + getEditor(path) { + // move to end of LRU + this.editorCache.lru = this.editorCache.lru.filter(p => p !== path) + this.editorCache.lru.push(path) + + if (this.editorCache.cache[path]) { + return this.editorCache.cache[path] + } + }, + + addEditor(path, editor) { + if (this.editorCache.lru.length >= NUM_EDITOR_INSTANCES) { + const pathToFree = this.editorCache.lru.shift() + this.freeEditor(pathToFree) + } + + this.editorCache.cache[path] = editor + }, + + freeEditor(pathToFree) { + if (!this.editorCache.cache[pathToFree]) { + return + } + this.editorCache.cache[pathToFree].destroy() + delete this.editorCache.cache[pathToFree] + this.editorCache.lru = this.editorCache.lru.filter(p => p !== pathToFree) + }, + + eachEditor(fn) { + Object.values(this.editorCache.cache).forEach(fn) + }, + }, +}) diff --git a/src/stores/notes-store.js b/src/stores/notes-store.js index 4da1e8f..bdd1187 100644 --- a/src/stores/notes-store.js +++ b/src/stores/notes-store.js @@ -1,8 +1,9 @@ import { toRaw } from 'vue'; import { defineStore } from "pinia" import { NoteFormat } from "../editor/note-format" +import { useEditorCacheStore } from "./editor-cache" -const SCRATCH_FILE = window.heynote.isDev ? "buffer-dev.txt" : "buffer.txt" +export const SCRATCH_FILE = window.heynote.isDev ? "buffer-dev.txt" : "buffer.txt" export const useNotesStore = defineStore("notes", { state: () => ({ @@ -20,6 +21,7 @@ export const useNotesStore = defineStore("notes", { showNoteSelector: false, showLanguageSelector: false, showCreateNote: false, + showEditNote: false, }), actions: { @@ -32,9 +34,7 @@ export const useNotesStore = defineStore("notes", { }, openNote(path) { - this.showNoteSelector = false - this.showLanguageSelector = false - this.showCreateNote = false + this.closeDialog() this.currentNotePath = path const recent = this.recentNotePaths.filter((p) => p !== path) @@ -43,30 +43,49 @@ export const useNotesStore = defineStore("notes", { }, openLanguageSelector() { + this.closeDialog() this.showLanguageSelector = true - this.showNoteSelector = false - this.showCreateNote = false }, openNoteSelector() { + this.closeDialog() this.showNoteSelector = true - this.showLanguageSelector = false - this.showCreateNote = false }, openCreateNote() { + this.closeDialog() this.showCreateNote = true - this.showNoteSelector = false - this.showLanguageSelector = false }, closeDialog() { this.showCreateNote = false this.showNoteSelector = false this.showLanguageSelector = false + this.showEditNote = false }, + closeNoteSelector() { + this.showNoteSelector = false + }, + + editNote(path) { + if (this.currentNotePath !== path) { + this.openNote(path) + } + this.closeDialog() + this.showEditNote = true + }, + + /** + * Create a new note file at `path` with name `name` from the current block of the current open editor + */ async createNewNoteFromActiveBlock(path, name) { await toRaw(this.currentEditor).createNewNoteFromActiveBlock(path, name) }, + /** + * Create a new note file at path, with name `name`, and content content + * @param {*} path: File path relative to Heynote root + * @param {*} name Name of the note + * @param {*} content Contents (without metadata) + */ async saveNewNote(path, name, content) { //window.heynote.buffer.save(path, content) //this.updateNotes() @@ -82,6 +101,24 @@ export const useNotesStore = defineStore("notes", { await window.heynote.buffer.create(path, note.serialize()) this.updateNotes() }, + + async updateNoteMetadata(path, name, newPath) { + const editorCacheStore = useEditorCacheStore() + + if (this.currentEditor.path !== path) { + throw new Error(`Can't update note (${path}) since it's not the active one (${this.currentEditor.path})`) + } + console.log("currentEditor", this.currentEditor) + toRaw(this.currentEditor).setName(name) + await (toRaw(this.currentEditor)).save() + if (newPath && path !== newPath) { + console.log("moving note", path, newPath) + editorCacheStore.freeEditor(path) + await window.heynote.buffer.move(path, newPath) + this.openNote(newPath) + this.updateNotes() + } + }, }, })