diff --git a/electron/main/file-library.js b/electron/main/file-library.js index 4a8dd94..b00edc9 100644 --- a/electron/main/file-library.js +++ b/electron/main/file-library.js @@ -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() }); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 5bd74c3..9d5bd8b 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 create(path, content) { + return await ipcRenderer.invoke("buffer:create", path, content) + }, + async saveAndQuit(contents) { return await ipcRenderer.invoke("buffer:saveAndQuit", contents) }, diff --git a/package-lock.json b/package-lock.json index 34812c2..030fdab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1688cc1..f75dd77 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/Editor.vue b/src/components/Editor.vue index b81c6aa..e384b43 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -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 + } } }, diff --git a/src/components/NewNote.vue b/src/components/NewNote.vue index 4122970..c348554 100644 --- a/src/components/NewNote.vue +++ b/src/components/NewNote.vue @@ -1,4 +1,6 @@ @@ -100,12 +109,12 @@
  • {{ item.name }} - {{ item.path }} + {{ item.folder }}
  • @@ -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 diff --git a/src/editor/annotation.js b/src/editor/annotation.js index 6b4e83c..fae768e 100644 --- a/src/editor/annotation.js +++ b/src/editor/annotation.js @@ -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" diff --git a/src/editor/block/block.js b/src/editor/block/block.js index 2c5341b..0f3141c 100644 --- a/src/editor/block/block.js +++ b/src/editor/block/block.js @@ -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 } } } diff --git a/src/editor/block/commands.js b/src/editor/block/commands.js index b55c50b..42192c3 100644 --- a/src/editor/block/commands.js +++ b/src/editor/block/commands.js @@ -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)], })) } diff --git a/src/editor/editor.js b/src/editor/editor.js index 8bfc2aa..80801b9 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -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) + } } diff --git a/src/editor/keymap.js b/src/editor/keymap.js index 64b4cea..9b077b1 100644 --- a/src/editor/keymap.js +++ b/src/editor/keymap.js @@ -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], diff --git a/src/stores/notes-store.js b/src/stores/notes-store.js index 7f4bbb8..c8cb662 100644 --- a/src/stores/notes-store.js +++ b/src/stores/notes-store.js @@ -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() + }, }, }) diff --git a/webapp/bridge.js b/webapp/bridge.js index 2cf646f..00db2e9 100644 --- a/webapp/bridge.js +++ b/webapp/bridge.js @@ -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 },