Implement support for editing notes' metadata, and ability to move notes into other directories.

Create separate pinia store for the editor cache functionality.
This commit is contained in:
Jonatan Heyman 2024-09-04 15:22:06 +02:00
parent 0da3e32171
commit 7e1f01471a
11 changed files with 513 additions and 53 deletions

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg baseProfile="tiny" height="24px" version="1.2" viewBox="0 0 24 24" width="24px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Layer_1"><path fill="#ddd" d="M13.293,7.293c-0.391,0.391-0.391,1.023,0,1.414L15.586,11H8c-0.552,0-1,0.448-1,1s0.448,1,1,1h7.586l-2.293,2.293 c-0.391,0.391-0.391,1.023,0,1.414C13.488,16.902,13.744,17,14,17s0.512-0.098,0.707-0.293L19.414,12l-4.707-4.707 C14.316,6.902,13.684,6.902,13.293,7.293z"/></g></svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@ -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)
}

View File

@ -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)
},

View File

@ -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 @@
<NoteSelector
v-if="showNoteSelector"
@openNote="openNote"
@close="closeDialog"
@close="closeNoteSelector"
/>
<Settings
v-if="showSettings"
@ -197,6 +202,10 @@
v-if="showCreateNote"
@close="closeDialog"
/>
<EditNote
v-if="showEditNote"
@close="closeDialog"
/>
<ErrorMessages />
</div>
</div>

274
src/components/EditNote.vue Normal file
View File

@ -0,0 +1,274 @@
<script>
import slugify from '@sindresorhus/slugify';
import { toRaw } from 'vue';
import { mapState, mapActions } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
import FolderSelector from './folder-selector/FolderSelector.vue'
export default {
data() {
return {
name: "",
filename: "",
tags: [],
directoryTree: null,
parentPath: "",
errors: {
name: null,
},
}
},
components: {
FolderSelector
},
async mounted() {
this.$refs.nameInput.focus()
this.updateNotes()
console.log("EditNote mounted", this.currentNote)
this.name = this.currentNote.name
// build directory tree
const directories = await window.heynote.buffer.getDirectoryList()
const rootNode = {
name: "Heynote Root",
path: "",
children: [],
open: true,
}
const getNodeFromList = (list, part) => list.find(node => node.name === part)
directories.forEach((path) => {
const parts = path.split("/")
let currentLevel = rootNode
let currentParts = []
parts.forEach(part => {
currentParts.push(part)
let node = getNodeFromList(currentLevel.children, part)
if (node) {
currentLevel = node
} else {
const currentPath = currentParts.join("/")
node = {
name: part,
children: [],
path: currentPath,
open: this.currentNotePath.startsWith(currentPath),
}
currentLevel.children.push(node)
currentLevel = node
}
})
})
//console.log("tree:", rootNode)
this.directoryTree = rootNode
},
computed: {
...mapState(useNotesStore, [
"notes",
"currentNotePath",
]),
currentNote() {
return this.notes[this.currentNotePath]
},
currentNoteDirectory() {
return this.currentNotePath.split("/").slice(0, -1).join("/")
},
nameInputClass() {
return {
"name-input": true,
"error": this.errors.name,
}
}
},
methods: {
...mapActions(useNotesStore, [
"updateNotes",
"updateNoteMetadata",
]),
onKeydown(event) {
if (event.key === "Escape") {
this.$emit("close")
event.preventDefault()
} if (event.key === "Enter") {
this.submit()
event.preventDefault()
}
},
onInputKeydown(event) {
// redirect arrow keys and page up/down to folder selector
const redirectKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp"]
if (redirectKeys.includes(event.key)) {
this.$refs.folderSelect.$el.dispatchEvent(new KeyboardEvent("keydown", {key: event.key}))
event.preventDefault()
}
},
submit() {
let slug = slugify(this.name)
if (slug === "") {
this.errors.name = true
return
}
const parentPathPrefix = this.parentPath === "" ? "" : this.parentPath + "/"
let path;
for (let i=0; i<1000; i++) {
let filename = slug + ".txt"
path = parentPathPrefix + filename
if (path === this.currentNotePath || !this.notes[path]) {
// file name is ok if it's the current note, or if it doesn't exist
break
}
slug = slugify(this.name + "-" + i)
}
if (path !== this.currentNotePath && this.notes[path]) {
console.error("Failed to edit note, path already exists", path)
this.errors.name = true
return
}
console.log("Update note", path)
this.updateNoteMetadata(this.currentNotePath, this.name, path)
this.$emit("close")
//this.$emit("create", this.$refs.input.value)
},
}
}
</script>
<template>
<div class="fader" @keydown.stop="onKeydown" tabindex="-1">
<form class="new-note" tabindex="-1" @focusout="onFocusOut" ref="container" @submit.prevent="submit">
<div class="container">
<h1>Edit Note</h1>
<input
placeholder="Name"
type="text"
v-model="name"
:class="nameInputClass"
ref="nameInput"
@keydown="onInputKeydown"
@input="errors.name = false"
/>
<label for="folder-select">Move to</label>
<FolderSelector
v-if="directoryTree"
:directoryTree="directoryTree"
:selectedPath="currentNoteDirectory"
id="folder-select"
v-model="parentPath"
ref="folderSelect"
/>
</div>
<div class="bottom-bar">
<button type="submit">Create Note</button>
</div>
</form>
</div>
</template>
<style scoped lang="sass">
.fader
position: fixed
top: 0
left: 0
bottom: 0
right: 0
background: rgba(0,0,0, 0.2)
.new-note
font-size: 13px
//background: #48b57e
background: #efefef
width: 420px
position: absolute
top: 0
left: 50%
transform: translateX(-50%)
border-radius: 0 0 5px 5px
box-shadow: 0 0 10px rgba(0,0,0,0.3)
display: flex
flex-direction: column
max-height: 100%
&:focus
outline: none
+dark-mode
background: #151516
box-shadow: 0 0 10px rgba(0,0,0,0.5)
color: rgba(255,255,255, 0.7)
+webapp-mobile
max-width: calc(100% - 80px)
.container
padding: 10px
min-height: 0
display: flex
flex-direction: column
h1
font-weight: bold
margin-bottom: 14px
label
display: block
margin-bottom: 6px
//padding-left: 2px
font-size: 12px
font-weight: 600
.name-input
width: 100%
background: #fff
padding: 4px 5px
border: 1px solid #ccc
box-sizing: border-box
border-radius: 2px
margin-bottom: 16px
&:focus
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
&.error
background: #ffe9e9
+dark-mode
background: #3b3b3b
color: rgba(255,255,255, 0.9)
border: 1px solid #5a5a5a
&:focus
border: 1px solid #3b3b3b
+webapp-mobile
font-size: 16px
max-width: 100%
.bottom-bar
border-radius: 0 0 5px 5px
//background: #e3e3e3
padding: 10px
padding-top: 0
display: flex
justify-content: flex-end
button
font-size: 12px
height: 28px
border: 1px solid #c5c5c5
border-radius: 3px
padding-left: 10px
padding-right: 10px
&:focus
outline-color: #48b57e
+dark-mode
background: #444
border: none
color: rgba(255,255,255, 0.75)
</style>

View File

@ -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)
},
},
}
</script>

View File

@ -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()
},
}
}
</script>
@ -167,6 +192,21 @@
>
<span class="name" v-html="item.name" />
<span class="path" v-html="item.folder" />
<span class="action-buttons">
<button
v-if="actionButton > 0 && idx === selected"
:class="{'selected':actionButton === 1}"
>Edit</button>
<button
v-if="actionButton > 0 && idx === selected"
:class="{'delete':true, 'selected':actionButton === 2}"
>Delete</button>
<button
class="show-actions"
v-if="item.path !== SCRATCH_FILE && (actionButton === 0 || idx !== selected)"
@click.stop.prevent="showActionButtons(idx)"
></button>
</span>
</li>
</ul>
</form>
@ -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
</style>

View File

@ -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)
}

View File

@ -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))

View File

@ -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)
},
},
})

View File

@ -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()
}
},
},
})