WIP: Implement ability to create new notes.

Support cache of multiple Editor instances.

Change so that current note name is included in the event data dispatched by emitCursorChange.
This commit is contained in:
Jonatan Heyman 2024-07-26 11:30:25 +02:00
parent 5e34656c1d
commit 5b61a0a234
14 changed files with 298 additions and 50 deletions

View File

@ -64,6 +64,14 @@ export class FileLibrary {
return await this.files[path].save(content)
}
async create(path, content) {
if (await this.exists(path)) {
throw new Error(`File already exists: ${path}`)
}
const fullPath = join(this.basePath, path)
await this.jetpack.writeAsync(fullPath, content)
}
async getList() {
console.log("Loading notes")
const notes = {}
@ -194,6 +202,10 @@ export function setupFileLibraryEventHandlers(library, win) {
return await library.save(path, content)
});
ipcMain.handle('buffer:create', async (event, path, content) => {
return await library.create(path, content)
});
ipcMain.handle('buffer:getList', async (event) => {
return await library.getList()
});

View File

@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("heynote", {
return await ipcRenderer.invoke("buffer:save", path, content)
},
async create(path, content) {
return await ipcRenderer.invoke("buffer:create", path, content)
},
async saveAndQuit(contents) {
return await ipcRenderer.invoke("buffer:saveAndQuit", contents)
},

52
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "1.8.0",
"license": "Commons Clause MIT",
"dependencies": {
"@sindresorhus/slugify": "^2.2.1",
"electron-log": "^5.0.1",
"pinia": "^2.1.7",
"semver": "^7.6.3"
@ -1517,6 +1518,57 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sindresorhus/slugify": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
"integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==",
"dependencies": {
"@sindresorhus/transliterate": "^1.0.0",
"escape-string-regexp": "^5.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@sindresorhus/slugify/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@sindresorhus/transliterate": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz",
"integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==",
"dependencies": {
"escape-string-regexp": "^5.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",

View File

@ -78,6 +78,7 @@
"vue-tsc": "^1.0.16"
},
"dependencies": {
"@sindresorhus/slugify": "^2.2.1",
"electron-log": "^5.0.1",
"pinia": "^2.1.7",
"semver": "^7.6.3"

View File

@ -2,9 +2,12 @@
import { HeynoteEditor } from '../editor/editor.js'
import { syntaxTree } from "@codemirror/language"
import { toRaw } from 'vue';
import { mapState } from 'pinia'
import { mapState, mapWritableState, mapActions } from 'pinia'
import { useErrorStore } from "../stores/error-store"
import { useNotesStore } from "../stores/notes-store"
const NUM_EDITOR_INSTANCES = 5
export default {
props: {
theme: String,
@ -41,8 +44,11 @@
data() {
return {
syntaxTreeDebugContent: null,
bufferFilePath: null,
editor: null,
editorCache: {
lru: [],
cache: {}
},
}
},
@ -130,34 +136,67 @@
...mapState(useNotesStore, [
"currentNotePath",
]),
...mapWritableState(useNotesStore, [
"currentEditor",
"currentNoteName",
]),
},
methods: {
...mapActions(useErrorStore, ["addError"]),
loadBuffer(path) {
if (this.editor) {
this.editor.destroy()
this.editor.hide()
}
// load buffer content and create editor
this.bufferFilePath = path
try {
this.editor = new HeynoteEditor({
element: this.$refs.editor,
path: this.bufferFilePath,
theme: this.theme,
keymap: this.keymap,
emacsMetaKey: this.emacsMetaKey,
showLineNumberGutter: this.showLineNumberGutter,
showFoldGutter: this.showFoldGutter,
bracketClosing: this.bracketClosing,
fontFamily: this.fontFamily,
fontSize: this.fontSize,
defaultBlockToken: this.defaultBlockLanguage,
defaultBlockAutoDetect: this.defaultBlockLanguageAutoDetect,
})
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]
toRaw(this.editor).show()
//toRaw(this.editor).currenciesLoaded()
this.currentEditor = toRaw(this.editor)
window._heynote_editor = toRaw(this.editor)
} catch (e) {
alert("Error! " + e.message)
throw e
// 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)
try {
this.editor = new HeynoteEditor({
element: this.$refs.editor,
path: path,
theme: this.theme,
keymap: this.keymap,
emacsMetaKey: this.emacsMetaKey,
showLineNumberGutter: this.showLineNumberGutter,
showFoldGutter: this.showFoldGutter,
bracketClosing: this.bracketClosing,
fontFamily: this.fontFamily,
fontSize: this.fontSize,
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
}
}
},

View File

@ -1,4 +1,6 @@
<script>
import slugify from '@sindresorhus/slugify';
import { mapState, mapActions } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
@ -12,6 +14,9 @@
tags: [],
directoryTree: null,
parentPath: "",
errors: {
name: null,
},
}
},
components: {
@ -64,17 +69,27 @@
currentNoteDirectory() {
return this.currentNotePath.split("/").slice(0, -1).join("/")
},
nameInputClass() {
return {
"name-input": true,
"error": this.errors.name,
}
}
},
methods: {
...mapActions(useNotesStore, [
"updateNotes",
"createNewNoteFromActiveBlock",
]),
onKeydown(event) {
if (event.key === "Escape") {
this.$emit("close")
event.preventDefault()
} if (event.key === "Enter") {
this.submit()
}
},
@ -87,9 +102,29 @@
}
},
onSubmit(event) {
event.preventDefault()
console.log("Creating note", this.name)
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 (!this.notes[path]) {
break
}
slug = slugify(this.name + "-" + i)
}
if (this.notes[path]) {
console.error("Failed to create note, path already exists", path)
this.errors.name = true
return
}
console.log("Creating note", path)
this.createNewNoteFromActiveBlock(path, this.name)
this.$emit("close")
//this.$emit("create", this.$refs.input.value)
},
@ -99,16 +134,17 @@
<template>
<div class="fader" @keydown="onKeydown" tabindex="-1">
<form class="new-note" tabindex="-1" @focusout="onFocusOut" ref="container" @submit="onSubmit">
<form class="new-note" tabindex="-1" @focusout="onFocusOut" ref="container" @submit.prevent="submit">
<div class="container">
<h1>New Note from Block</h1>
<input
placeholder="Name"
type="text"
v-model="name"
class="name-input"
:class="nameInputClass"
ref="nameInput"
@keydown="onInputKeydown"
@input="errors.name = false"
/>
<label for="folder-select">Create in</label>
@ -189,6 +225,8 @@
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
&.error
background: #ffe9e9
+dark-mode
background: #3b3b3b
color: rgba(255,255,255, 0.9)
@ -219,4 +257,3 @@
outline-color: #48b57e
</style>
./folder-selector/FolderSelector.vue

View File

@ -19,6 +19,8 @@
return {
"path": path,
"name": metadata?.name || path,
"folder": path.split("/").slice(0, -1).join("/"),
"scratch": path === "buffer-dev.txt",
}
})
},
@ -82,6 +84,13 @@
this.$emit("close")
}
},
getItemClass(item, idx) {
return {
"selected": idx === this.selected,
"scratch": item.scratch,
}
}
}
}
</script>
@ -100,12 +109,12 @@
<li
v-for="item, idx in filteredItems"
:key="item.path"
:class="idx === selected ? 'selected' : ''"
:class="getItemClass(item, idx)"
@click="selectItem(item.path)"
ref="item"
>
<span class="name">{{ item.name }}</span>
<span class="path">{{ item.path }}</span>
<span class="path">{{ item.folder }}</span>
</li>
</ul>
</form>
@ -128,6 +137,7 @@
position: absolute
top: 0
left: 50%
width: 420px
transform: translateX(-50%)
max-height: 100%
box-sizing: border-box
@ -176,6 +186,8 @@
&.selected
background: #48b57e
color: #fff
&.scratch
font-weight: 600
+dark-mode
color: rgba(255,255,255, 0.53)
&:hover
@ -185,7 +197,15 @@
color: rgba(255,255,255, 0.87)
.name
margin-right: 12px
flex-shrink: 0
overflow: hidden
text-overflow: ellipsis
text-wrap: nowrap
.path
opacity: 0.6
font-size: 12px
flex-shrink: 1
overflow: hidden
text-overflow: ellipsis
text-wrap: nowrap
</style>

View File

@ -5,3 +5,5 @@ export const LANGUAGE_CHANGE = "heynote-change"
export const CURRENCIES_LOADED = "heynote-currencies-loaded"
export const SET_CONTENT = "heynote-set-content"
export const ADD_NEW_BLOCK = "heynote-add-new-block"
export const DELETE_BLOCK = "heynote-delete-block"
export const CURSOR_CHANGE = "heynote-cursor-change"

View File

@ -1,11 +1,11 @@
import { ViewPlugin, EditorView, Decoration, WidgetType, lineNumbers } from "@codemirror/view"
import { layer, RectangleMarker } from "@codemirror/view"
import { EditorState, RangeSetBuilder, StateField, Facet , StateEffect, RangeSet} from "@codemirror/state";
import { EditorState, RangeSetBuilder, StateField, Facet , StateEffect, RangeSet, Transaction} from "@codemirror/state";
import { syntaxTree, ensureSyntaxTree, syntaxTreeAvailable } from "@codemirror/language"
import { Note, Document, NoteDelimiter } from "../lang-heynote/parser.terms.js"
import { IterMode } from "@lezer/common";
import { useNotesStore } from "../../stores/notes-store.js"
import { heynoteEvent, LANGUAGE_CHANGE } from "../annotation.js";
import { heynoteEvent, LANGUAGE_CHANGE, CURSOR_CHANGE } from "../annotation.js";
import { mathBlock } from "./math.js"
import { emptyBlockSelected } from "./select-all.js";
@ -404,6 +404,15 @@ function getSelectionSize(state, sel) {
return count
}
export function triggerCursorChange({state, dispatch}) {
// Trigger empty change transaction that is annotated with CURRENCIES_LOADED
// This will make Math blocks re-render so that currency conversions are applied
dispatch(state.update({
changes:{from: 0, to: 0, insert:""},
annotations: [heynoteEvent.of(CURSOR_CHANGE), Transaction.addToHistory.of(false)],
}))
}
const emitCursorChange = (editor) => {
const notesStore = useNotesStore()
return ViewPlugin.fromClass(
@ -411,8 +420,8 @@ const emitCursorChange = (editor) => {
update(update) {
// if the selection changed or the language changed (can happen without selection change),
// emit a selection change event
const langChange = update.transactions.some(tr => tr.annotations.some(a => a.value == LANGUAGE_CHANGE))
if (update.selectionSet || langChange) {
const shouldUpdate = update.transactions.some(tr => tr.annotations.some(a => a.value == LANGUAGE_CHANGE || a.value == CURSOR_CHANGE))
if (update.selectionSet || shouldUpdate) {
const cursorLine = getBlockLineFromPos(update.state, update.state.selection.main.head)
const selectionSize = update.state.selection.ranges.map(
@ -425,6 +434,7 @@ const emitCursorChange = (editor) => {
notesStore.currentSelectionSize = selectionSize
notesStore.currentLanguage = block.language.name
notesStore.currentLanguageAuto = block.language.auto
notesStore.currentNoteName = editor.name
}
}
}

View File

@ -1,5 +1,5 @@
import { EditorSelection } from "@codemirror/state"
import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK } from "../annotation.js";
import { EditorSelection, Transaction } from "@codemirror/state"
import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK, DELETE_BLOCK } from "../annotation.js";
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./block"
import { moveLineDown, moveLineUp } from "./move-lines.js";
import { selectAll } from "./select-all.js";
@ -7,7 +7,7 @@ import { selectAll } from "./select-all.js";
export { moveLineDown, moveLineUp, selectAll }
function getBlockDelimiter(defaultToken, autoDetect) {
export function getBlockDelimiter(defaultToken, autoDetect) {
return `\n∞∞∞${autoDetect ? defaultToken + '-a' : defaultToken}\n`
}
@ -317,6 +317,24 @@ export function triggerCurrenciesLoaded(state, dispatch) {
// This will make Math blocks re-render so that currency conversions are applied
dispatch(state.update({
changes:{from: 0, to: 0, insert:""},
annotations: [heynoteEvent.of(CURRENCIES_LOADED)],
annotations: [heynoteEvent.of(CURRENCIES_LOADED), Transaction.addToHistory.of(false)],
}))
}
export const deleteBlock = (editor) => ({state, dispatch}) => {
const block = getActiveNoteBlock(state)
const blocks = state.facet(blockState)
let replace = ""
if (blocks.length == 1) {
replace = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect)
}
dispatch(state.update({
changes: {
from: block.range.from,
to: block.range.to,
insert: replace,
},
selection: EditorSelection.cursor(block.delimiter.from),
annotations: [heynoteEvent.of(DELETE_BLOCK)],
}))
}

View File

@ -10,9 +10,9 @@ import { heynoteBase } from "./theme/base.js"
import { getFontTheme } from "./theme/font-theme.js";
import { customSetup } from "./setup.js"
import { heynoteLang } from "./lang-heynote/heynote.js"
import { noteBlockExtension, blockLineNumbers, blockState } from "./block/block.js"
import { heynoteEvent, SET_CONTENT } from "./annotation.js";
import { changeCurrentBlockLanguage, triggerCurrenciesLoaded } from "./block/commands.js"
import { noteBlockExtension, blockLineNumbers, blockState, getActiveNoteBlock, triggerCursorChange } from "./block/block.js"
import { heynoteEvent, SET_CONTENT, DELETE_BLOCK } from "./annotation.js";
import { changeCurrentBlockLanguage, triggerCurrenciesLoaded, getBlockDelimiter, deleteBlock } from "./block/commands.js"
import { formatBlockContent } from "./block/format-code.js"
import { heynoteKeymap } from "./keymap.js"
import { emacsKeymap } from "./emacs.js"
@ -66,6 +66,7 @@ export class HeynoteEditor {
this.setDefaultBlockLanguage(defaultBlockToken, defaultBlockAutoDetect)
this.contentLoaded = false
this.notesStore = useNotesStore()
this.name = ""
const state = EditorState.create({
@ -179,7 +180,8 @@ export class HeynoteEditor {
this.setReadOnly(true)
throw new Error(`Failed to load note: ${e.message}`)
}
this.notesStore.currentNoteName = this.note.metadata?.name || this.path
this.name = this.note.metadata?.name || this.path
return new Promise((resolve) => {
// set buffer content
this.view.dispatch({
@ -262,7 +264,24 @@ export class HeynoteEditor {
}
openCreateNote() {
this.notesStore.openCreateNote()
this.notesStore.openCreateNote(this)
}
async createNewNoteFromActiveBlock(path, name) {
const block = getActiveNoteBlock(this.view.state)
if (!block) {
return
}
const data = this.view.state.sliceDoc(block.range.from, block.range.to)
await this.notesStore.saveNewNote(path, name, data)
deleteBlock(this)(this.view)
// by using requestAnimationFrame we avoid a race condition where rendering the block backgrounds
// would fail if we immediately opened the new note (since the block UI wouldn't have time to update
// after the block was deleted)
requestAnimationFrame(() => {
this.notesStore.openNote(path)
})
}
setCurrentLanguage(lang, auto=false) {
@ -311,6 +330,16 @@ export class HeynoteEditor {
this.view.destroy()
window.heynote.buffer.close(this.path)
}
hide() {
console.log("hiding element", this.view.dom)
this.view.dom.style.setProperty("display", "none", "important")
}
show() {
console.log("showing element", this.view.dom)
this.view.dom.style.setProperty("display", "")
triggerCursorChange(this.view)
}
}

View File

@ -15,6 +15,7 @@ import {
gotoPreviousParagraph, gotoNextParagraph,
selectNextParagraph, selectPreviousParagraph,
newCursorBelow, newCursorAbove,
deleteBlock,
} from "./block/commands.js"
import { pasteCommand, copyCommand, cutCommand } from "./copy-paste.js"
@ -59,6 +60,7 @@ export function heynoteKeymap(editor) {
["Mod-l", () => editor.openLanguageSelector()],
["Mod-p", () => editor.openNoteSelector()],
["Mod-s", () => editor.openCreateNote()],
["Mod-Shift-d", deleteBlock(editor)],
["Alt-Shift-f", formatBlockContent],
["Mod-Alt-ArrowDown", newCursorBelow],
["Mod-Alt-ArrowUp", newCursorAbove],

View File

@ -1,8 +1,11 @@
import { toRaw } from 'vue';
import { defineStore } from "pinia"
import { NoteFormat } from "../editor/note-format"
export const useNotesStore = defineStore("notes", {
state: () => ({
notes: {},
currentEditor: null,
currentNotePath: window.heynote.isDev ? "buffer-dev.txt" : "buffer.txt",
currentNoteName: null,
currentLanguage: null,
@ -24,11 +27,6 @@ export const useNotesStore = defineStore("notes", {
this.notes = notes
},
createNewNote(path, content) {
//window.heynote.buffer.save(path, content)
this.updateNotes()
},
openNote(path) {
this.showNoteSelector = false
this.showLanguageSelector = false
@ -56,6 +54,26 @@ export const useNotesStore = defineStore("notes", {
this.showNoteSelector = false
this.showLanguageSelector = false
},
async createNewNoteFromActiveBlock(path, name) {
await toRaw(this.currentEditor).createNewNoteFromActiveBlock(path, name)
},
async saveNewNote(path, name, content) {
//window.heynote.buffer.save(path, content)
//this.updateNotes()
if (this.notes[path]) {
throw new Error(`Note already exists: ${path}`)
}
const note = new NoteFormat()
note.content = content
note.metadata.name = name
console.log("saving", path, note.serialize())
await window.heynote.buffer.create(path, note.serialize())
this.updateNotes()
},
},
})

View File

@ -1,3 +1,4 @@
import { Exception } from "sass";
import { SETTINGS_CHANGE_EVENT, OPEN_SETTINGS_EVENT } from "../electron/constants";
const mediaMatch = window.matchMedia('(prefers-color-scheme: dark)')
@ -90,11 +91,14 @@ const Heynote = {
localStorage.setItem(path, content)
},
async create(path, content) {
throw Exception("Not implemented")
},
async saveAndQuit(contents) {
},
async exists(path) {
return true
},