From f11f3604961a18d9661c525a89421006eb646614 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 24 Jul 2024 13:52:44 +0200 Subject: [PATCH] WIP: Implement support for multiple notes Refactor Vue <-> Editor <-> CodeMirror code. Introduce Pinia store to keep global state, in order to get rid of a lot of event juggling between Editor class/child components and the root App component. --- electron/main/buffer.js | 48 +++-- electron/main/file-library.js | 210 ++++++++++++++++++++ electron/main/index.ts | 17 +- electron/preload/index.ts | 60 +++++- public/langdetect-worker.js | 3 + src/components/App.vue | 109 +++++++--- src/components/Editor.vue | 147 +++++++------- src/components/NewNote.vue | 158 +++++++++++++++ src/components/NoteSelector.vue | 186 +++++++++++++++++ src/components/StatusBar.vue | 47 +++-- src/components/form/FolderSelect.vue | 53 +++++ src/editor/block/block.js | 45 +++-- src/editor/editor.js | 92 +++++++-- src/editor/event.js | 9 - src/editor/keymap.js | 2 + src/editor/language-detection/autodetect.js | 181 +++++++++-------- src/editor/save.js | 8 +- src/main.js | 4 + src/stores/notes-store.js | 65 ++++++ webapp/bridge.js | 30 ++- 20 files changed, 1179 insertions(+), 295 deletions(-) create mode 100644 electron/main/file-library.js create mode 100644 src/components/NewNote.vue create mode 100644 src/components/NoteSelector.vue create mode 100644 src/components/form/FolderSelect.vue delete mode 100644 src/editor/event.js create mode 100644 src/stores/notes-store.js diff --git a/electron/main/buffer.js b/electron/main/buffer.js index b8799dd..0ecc5a7 100644 --- a/electron/main/buffer.js +++ b/electron/main/buffer.js @@ -16,15 +16,15 @@ const untildify = (pathWithTilde) => { : pathWithTilde; } -export function constructBufferFilePath(directoryPath) { - return join(untildify(directoryPath), isDev ? "buffer-dev.txt" : "buffer.txt") +export function constructBufferFilePath(directoryPath, path) { + return join(untildify(directoryPath), path) } -export function getBufferFilePath() { +export function getFullBufferFilePath(path) { let defaultPath = app.getPath("userData") let configPath = CONFIG.get("settings.bufferPath") let bufferPath = configPath.length ? configPath : defaultPath - let bufferFilePath = constructBufferFilePath(bufferPath) + let bufferFilePath = constructBufferFilePath(bufferPath, path) try { // use realpathSync to resolve a potential symlink return fs.realpathSync(bufferFilePath) @@ -103,39 +103,45 @@ export class Buffer { // Buffer -let buffer -export function loadBuffer() { - if (buffer) { - buffer.close() +let buffers = {} +export function loadBuffer(path) { + if (buffers[path]) { + buffers[path].close() } - buffer = new Buffer({ - filePath: getBufferFilePath(), + buffers[path] = new Buffer({ + filePath: getFullBufferFilePath(path), onChange: (content) => { - win?.webContents.send("buffer-content:change", content) + console.log("Old buffer.js onChange") + win?.webContents.send("buffer-content:change", path, content) }, }) - return buffer + return buffers[path] } -ipcMain.handle('buffer-content:load', async () => { - if (buffer.exists() && !(eraseInitialContent && isDev)) { - return await buffer.load() +ipcMain.handle('buffer-content:load', async (event, path) => { + if (!buffers[path]) { + loadBuffer(path) + } + if (buffers[path].exists() && !(eraseInitialContent && isDev)) { + return await buffers[path].load() } else { return isDev ? initialDevContent : initialContent } }); -async function save(content) { - return await buffer.save(content) +async function save(path, content) { + return await buffers[path].save(content) } -ipcMain.handle('buffer-content:save', async (event, content) => { - return await save(content) +ipcMain.handle('buffer-content:save', async (event, path, content) => { + return await save(path, content) }); export let contentSaved = false -ipcMain.handle('buffer-content:saveAndQuit', async (event, content) => { - await save(content) +ipcMain.handle('buffer-content:saveAndQuit', async (event, contents) => { + for (const [path, content] of contents) { + await save(path, content) + } contentSaved = true app.quit() }) diff --git a/electron/main/file-library.js b/electron/main/file-library.js new file mode 100644 index 0000000..e1bb7f1 --- /dev/null +++ b/electron/main/file-library.js @@ -0,0 +1,210 @@ +import fs from "fs" +import os from "node:os" +import { join, dirname, basename } from "path" + +import * as jetpack from "fs-jetpack"; +import { app, ipcMain, dialog } from "electron" + + +const untildify = (pathWithTilde) => { + const homeDir = os.homedir() + return homeDir ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDir) : pathWithTilde +} + +async function readNoteMetadata(filePath) { + const chunks = [] + for await (let chunk of fs.createReadStream(filePath, { start: 0, end:4000 })) { + chunks.push(chunk) + } + const headContent = Buffer.concat(chunks).toString("utf8") + const firstSeparator = headContent.indexOf("\n∞∞∞") + if (firstSeparator === -1) { + return null + } + try { + const metadata = JSON.parse(headContent.slice(0, firstSeparator).trim()) + return {"name": metadata.name, "tags": metadata.tags} + } catch (e) { + return {} + } +} + + +export class FileLibrary { + constructor(basePath) { + basePath = untildify(basePath) + if (jetpack.exists(basePath) !== "dir") { + throw new Error(`Path directory does not exist: ${basePath}`) + } + this.basePath = fs.realpathSync(basePath) + this.jetpack = jetpack.cwd(this.basePath) + this.files = {}; + this.watcher = null; + this.contentSaved = false + this.onChangeCallback = null + } + + async exists(path) { + return this.jetpack.exists(path) === "file" + } + + async load(path) { + if (this.files[path]) { + return this.files[path].read() + } + const fullPath = fs.realpathSync(join(this.basePath, path)) + this.files[path] = new NoteBuffer({fullPath, library:this}) + return await this.files[path].read() + } + + async save(path, content) { + if (!this.files[path]) { + throw new Error(`File not loaded: ${path}`) + } + return await this.files[path].save(content) + } + + async getList() { + console.log("Loading notes") + const notes = {} + const files = await this.jetpack.findAsync(this.basePath, { + matching: "*.txt", + recursive: true, + }) + const promises = [] + for (const file of files) { + promises.push(readNoteMetadata(join(this.basePath, file))) + } + const metadataList = await Promise.all(promises) + metadataList.forEach((metadata, i) => { + const path = files[i] + notes[path] = metadata + }) + return notes + } + + setupWatcher(win) { + if (!this.watcher) { + this.watcher = fs.watch( + this.basePath, + { + persistent: true, + recursive: true, + encoding: "utf8", + }, + async (eventType, changedPath) => { + console.log("File changed", eventType, changedPath) + //if (changedPath.toLowerCase().endsWith(".txt")) { + // console.log("txt", this.notes) + // if (await this.exists(changedPath)) { + // console.log("file exists!") + // const newMetadata = await readNoteMetadata(join(this.basePath, changedPath)) + // if (!(changedPath in this.notes) || newMetadata.name !== this.notes[changedPath].name) { + // this.notes[changedPath] = newMetadata + // win.webContents.send("buffer:noteMetadataChanged", changedPath, newMetadata) + // console.log("metadata changed") + // } else { + // console.log("no metadata change") + // } + // } else if (changedPath in this.notes) { + // console.log("note removed", changedPath) + // delete this.notes[changedPath] + // win.webContents.send("buffer:noteRemoved", changedPath) + // } + //} + for (const [path, buffer] of Object.entries(this.files)) { + if (changedPath === basename(path)) { + const content = await buffer.read() + if (buffer._lastSavedContent !== content) { + win.webContents.send("buffer:change", path, content) + } + } + } + } + ) + } + } + + closeFile(path) { + if (this.files[path]) { + delete this.files[path] + } + } + + close() { + for (const buffer of Object.values(this.files)) { + this.closeFile(buffer.filePath) + } + this.stopWatcher() + } + + stopWatcher() { + if (this.watcher) { + this.watcher.close() + this.watcher = null + } + } +} + + + +export class NoteBuffer { + constructor({fullPath, library}) { + this.fullPath = fullPath + this._lastSavedContent = null + this.library = library + } + + async read() { + return await this.library.jetpack.read(this.fullPath, 'utf8') + } + + async save(content) { + this._lastSavedContent = content + const saveResult = await this.library.jetpack.write(this.fullPath, content, { + atomic: true, + mode: '600', + }) + return saveResult + } + + exists() { + return jetpack.exists(this.fullPath) === "file" + } +} + + +export function setupFileLibraryEventHandlers(library, win) { + ipcMain.handle('buffer:load', async (event, path) => { + console.log("buffer:load", path) + return await library.load(path) + }); + + + ipcMain.handle('buffer:save', async (event, path, content) => { + return await library.save(path, content) + }); + + ipcMain.handle('buffer:getList', async (event) => { + return await library.getList() + }); + + ipcMain.handle('buffer:exists', async (event, path) => { + return await library.exists(path) + }); + + ipcMain.handle('buffer:close', async (event, path) => { + return await library.closeFile(path) + }); + + ipcMain.handle('buffer:saveAndQuit', async (event, contents) => { + library.stopWatcher() + for (const [path, content] of contents) { + await library.save(path, content) + } + library.contentSaved = true + app.quit() + }) + + library.setupWatcher(win) +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 00be981..8927ca5 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -10,6 +10,7 @@ import { isDev, isLinux, isMac, isWindows } from '../detect-platform'; import { initializeAutoUpdate, checkForUpdates } from './auto-update'; import { fixElectronCors } from './cors'; import { loadBuffer, contentSaved } from './buffer'; +import { FileLibrary, setupFileLibraryEventHandlers } from './file-library'; // The built directory structure @@ -49,6 +50,7 @@ Menu.setApplicationMenu(menu) // process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' export let win: BrowserWindow | null = null +let fileLibrary: FileLibrary | null = null let tray: Tray | null = null; let initErrors: string[] = [] // Here, you can also use other preload @@ -139,7 +141,7 @@ async function createWindow() { } // Prevent the window from closing, and send a message to the renderer which will in turn // send a message to the main process to save the current buffer and close the window. - if (!contentSaved) { + if (!!fileLibrary && !fileLibrary.contentSaved) { event.preventDefault() win?.webContents.send(WINDOW_CLOSE_EVENT) } else { @@ -303,6 +305,7 @@ function registerAlwaysOnTop() { } app.whenReady().then(createWindow).then(async () => { + setupFileLibraryEventHandlers(fileLibrary, win) initializeAutoUpdate(win) registerGlobalHotkey() registerShowInDock() @@ -344,8 +347,16 @@ ipcMain.handle('dark-mode:set', (event, mode) => { ipcMain.handle('dark-mode:get', () => nativeTheme.themeSource) -// load buffer on app start -loadBuffer() +// Initialize note/file library +const customLibraryPath = CONFIG.get("settings.bufferPath") +const libraryPath = customLibraryPath ? customLibraryPath : join(app.getPath("userData"), "notes") +console.log("libraryPath", libraryPath) +try { + fileLibrary = new FileLibrary(libraryPath) +} catch (error) { + initErrors.push(`Error: ${error.message}`) +} + ipcMain.handle("getInitErrors", () => { return initErrors }) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 7d5f242..45baa60 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,6 +1,6 @@ const { contextBridge } = require('electron') import themeMode from "./theme-mode" -import { isMac, isWindows, isLinux } from "../detect-platform" +import { isMac, isWindows, isLinux, isDev } from "../detect-platform" import { ipcRenderer } from "electron" import { WINDOW_CLOSE_EVENT, @@ -29,9 +29,20 @@ contextBridge.exposeInMainWorld("heynote", { isLinux, isWebApp: false, }, - + + isDev: isDev, themeMode: themeMode, + init() { + ipcRenderer.on("buffer:change", (event, path, content) => { + // called on all changes to open buffer files + // go through all registered callbacks for this path and call them + if (this.buffer._onChangeCallbacks[path]) { + this.buffer._onChangeCallbacks[path].forEach(callback => callback(content)) + } + }) + }, + quit() { console.log("quitting") //ipcRenderer.invoke("app_quit") @@ -46,25 +57,52 @@ contextBridge.exposeInMainWorld("heynote", { }, buffer: { - async load() { - return await ipcRenderer.invoke("buffer-content:load") + async exists(path) { + return await ipcRenderer.invoke("buffer:exists", path) }, - async save(content) { - return await ipcRenderer.invoke("buffer-content:save", content) + async getList() { + return await ipcRenderer.invoke("buffer:getList") }, - async saveAndQuit(content) { - return await ipcRenderer.invoke("buffer-content:saveAndQuit", content) + async load(path) { + return await ipcRenderer.invoke("buffer:load", path) }, - onChangeCallback(callback) { - ipcRenderer.on("buffer-content:change", callback) + async save(path, content) { + return await ipcRenderer.invoke("buffer:save", path, content) + }, + + async saveAndQuit(contents) { + return await ipcRenderer.invoke("buffer:saveAndQuit", contents) + }, + + async close(path) { + return await ipcRenderer.invoke("buffer:close", path) + }, + + _onChangeCallbacks: {}, + addOnChangeCallback(path, callback) { + // register a callback to be called when the buffer content changes for a specific file + if (!this._onChangeCallbacks[path]) { + this._onChangeCallbacks[path] = [] + } + this._onChangeCallbacks[path].push(callback) + }, + removeOnChangeCallback(path, callback) { + if (this._onChangeCallbacks[path]) { + this._onChangeCallbacks[path] = this._onChangeCallbacks[path].filter(cb => cb !== callback) + } }, async selectLocation() { return await ipcRenderer.invoke("buffer-content:selectLocation") - } + }, + + callbacks(callbacks) { + ipcRenderer.on("buffer:noteMetadataChanged", (event, path, info) => callbacks?.noteMetadataChanged(path, info)) + ipcRenderer.on("buffer:noteRemoved", (event, path) => callbacks?.noteRemoved(path)) + }, }, settings: CONFIG.get("settings"), diff --git a/public/langdetect-worker.js b/public/langdetect-worker.js index a519b4a..15d736f 100644 --- a/public/langdetect-worker.js +++ b/public/langdetect-worker.js @@ -28,6 +28,7 @@ onmessage = (event) => { }, content: content, idx: event.data.idx, + path: event.data.path, }) return } @@ -53,6 +54,7 @@ onmessage = (event) => { }, content: content, idx: event.data.idx, + path: event.data.path, }) return } @@ -66,6 +68,7 @@ onmessage = (event) => { }, content: content, idx: event.data.idx, + path: event.data.path, }) return } diff --git a/src/components/App.vue b/src/components/App.vue index 08234d5..7e51dfe 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,8 +1,17 @@ + + + + diff --git a/src/components/NoteSelector.vue b/src/components/NoteSelector.vue new file mode 100644 index 0000000..126b14b --- /dev/null +++ b/src/components/NoteSelector.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/src/components/StatusBar.vue b/src/components/StatusBar.vue index 0676dd9..a6b4df1 100644 --- a/src/components/StatusBar.vue +++ b/src/components/StatusBar.vue @@ -1,17 +1,14 @@ + + + + diff --git a/src/editor/block/block.js b/src/editor/block/block.js index 57e2bb1..2c5341b 100644 --- a/src/editor/block/block.js +++ b/src/editor/block/block.js @@ -4,8 +4,8 @@ import { EditorState, RangeSetBuilder, StateField, Facet , StateEffect, RangeSet 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 { SelectionChangeEvent } from "../event.js" import { mathBlock } from "./math.js" import { emptyBlockSelected } from "./select-all.js"; @@ -404,32 +404,33 @@ function getSelectionSize(state, sel) { return count } -const emitCursorChange = (editor) => ViewPlugin.fromClass( - class { - 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 cursorLine = getBlockLineFromPos(update.state, update.state.selection.main.head) +const emitCursorChange = (editor) => { + const notesStore = useNotesStore() + return ViewPlugin.fromClass( + class { + 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 cursorLine = getBlockLineFromPos(update.state, update.state.selection.main.head) - const selectionSize = update.state.selection.ranges.map( - (sel) => getSelectionSize(update.state, sel) - ).reduce((a, b) => a + b, 0) + const selectionSize = update.state.selection.ranges.map( + (sel) => getSelectionSize(update.state, sel) + ).reduce((a, b) => a + b, 0) - const block = getActiveNoteBlock(update.state) - if (block && cursorLine) { - editor.element.dispatchEvent(new SelectionChangeEvent({ - cursorLine, - selectionSize, - language: block.language.name, - languageAuto: block.language.auto, - })) + const block = getActiveNoteBlock(update.state) + if (block && cursorLine) { + notesStore.currentCursorLine = cursorLine + notesStore.currentSelectionSize = selectionSize + notesStore.currentLanguage = block.language.name + notesStore.currentLanguageAuto = block.language.auto + } } } } - } -) + ) +} export const noteBlockExtension = (editor) => { return [ diff --git a/src/editor/editor.js b/src/editor/editor.js index 8df85a9..484176d 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -1,4 +1,4 @@ -import { Annotation, EditorState, Compartment, Facet, EditorSelection } from "@codemirror/state" +import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction } from "@codemirror/state" import { EditorView, keymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view" import { indentUnit, forceParsing, foldGutter, ensureSyntaxTree } from "@codemirror/language" import { markdown } from "@codemirror/lang-markdown" @@ -22,8 +22,8 @@ import { autoSaveContent } from "./save.js" import { todoCheckboxPlugin} from "./todo-checkbox.ts" import { links } from "./links.js" import { NoteFormat } from "./note-format.js" +import { useNotesStore } from "../stores/notes-store.js"; -export const LANGUAGE_SELECTOR_EVENT = "openLanguageSelector" function getKeymapExtensions(editor, keymap) { if (keymap === "emacs") { @@ -37,10 +37,10 @@ function getKeymapExtensions(editor, keymap) { export class HeynoteEditor { constructor({ element, + path, content, focus=true, theme="light", - saveFunction=null, keymap="default", emacsMetaKey, showLineNumberGutter=true, @@ -48,8 +48,11 @@ export class HeynoteEditor { bracketClosing=false, fontFamily, fontSize, + defaultBlockToken, + defaultBlockAutoDetect, }) { this.element = element + this.path = path this.themeCompartment = new Compartment this.keymapCompartment = new Compartment this.lineNumberCompartmentPre = new Compartment @@ -60,9 +63,10 @@ export class HeynoteEditor { this.deselectOnCopy = keymap === "emacs" this.emacsMetaKey = emacsMetaKey this.fontTheme = new Compartment - this.defaultBlockToken = "text" - this.defaultBlockAutoDetect = true - this.saveFunction = saveFunction + this.setDefaultBlockLanguage(defaultBlockToken, defaultBlockAutoDetect) + this.contentLoaded = false + this.notesStore = useNotesStore() + const state = EditorState.create({ doc: "", @@ -88,7 +92,7 @@ export class HeynoteEditor { }), heynoteLang(), noteBlockExtension(this), - languageDetection(() => this), + languageDetection(path, () => this), // set cursor blink rate to 1 second drawSelection({cursorBlinkRate:1000}), @@ -98,7 +102,7 @@ export class HeynoteEditor { return {class: view.state.facet(EditorView.darkTheme) ? "dark-theme" : "light-theme"} }), - this.saveFunction ? autoSaveContent(this, 2000) : [], + autoSaveContent(this, 2000), todoCheckboxPlugin, markdown(), @@ -107,34 +111,66 @@ export class HeynoteEditor { }) // make sure saveFunction is called when page is unloaded - if (saveFunction) { - window.addEventListener("beforeunload", () => { - this.save() - }) - } + window.addEventListener("beforeunload", () => { + this.save() + }) this.view = new EditorView({ state: state, parent: element, }) - this.setContent(content) - + //this.setContent(content) + this.setReadOnly(true) + this.loadContent().then(() => { + this.setReadOnly(false) + }) + if (focus) { this.view.focus() } } - save() { - this.saveFunction(this.getContent()) + async save() { + if (!this.contentLoaded) { + return + } + const content = this.getContent() + if (content === this.diskContent) { + return + } + console.log("saving:", this.path) + this.diskContent = content + await window.heynote.buffer.save(this.path, content) } getContent() { this.note.content = this.view.state.sliceDoc() this.note.cursors = this.view.state.selection.toJSON() + + const ranges = this.note.cursors.ranges + if (ranges.length == 1 && ranges[0].anchor == 0 && ranges[0].head == 0) { + console.log("DEBUG!! Cursor is at 0,0") + console.trace() + } return this.note.serialize() } + async loadContent() { + console.log("loading content", this.path) + const content = await window.heynote.buffer.load(this.path) + this.diskContent = content + this.contentLoaded = true + this.setContent(content) + + // set up content change listener + this.onChange = (content) => { + this.diskContent = content + this.setContent(content) + } + window.heynote.buffer.addOnChangeCallback(this.path, this.onChange) + } + setContent(content) { try { this.note = NoteFormat.load(content) @@ -143,6 +179,7 @@ export class HeynoteEditor { this.setReadOnly(true) throw e } + this.notesStore.currentNoteName = this.note.metadata?.name || this.path return new Promise((resolve) => { // set buffer content this.view.dispatch({ @@ -151,7 +188,7 @@ export class HeynoteEditor { to: this.view.state.doc.length, insert: this.note.content, }, - annotations: [heynoteEvent.of(SET_CONTENT)], + annotations: [heynoteEvent.of(SET_CONTENT), Transaction.addToHistory.of(false)], }) // Ensure we have a parsed syntax tree when buffer is loaded. This prevents errors for large buffers @@ -217,7 +254,15 @@ export class HeynoteEditor { } openLanguageSelector() { - this.element.dispatchEvent(new Event(LANGUAGE_SELECTOR_EVENT)) + this.notesStore.openLanguageSelector() + } + + openNoteSelector() { + this.notesStore.openNoteSelector() + } + + openCreateNote() { + this.notesStore.openCreateNote() } setCurrentLanguage(lang, auto=false) { @@ -257,6 +302,15 @@ export class HeynoteEditor { currenciesLoaded() { triggerCurrenciesLoaded(this.view.state, this.view.dispatch) } + + destroy() { + if (this.onChange) { + window.heynote.buffer.removeOnChangeCallback(this.path, this.onChange) + } + this.save() + this.view.destroy() + window.heynote.buffer.close(this.path) + } } diff --git a/src/editor/event.js b/src/editor/event.js deleted file mode 100644 index 34f5960..0000000 --- a/src/editor/event.js +++ /dev/null @@ -1,9 +0,0 @@ -export class SelectionChangeEvent extends Event { - constructor({cursorLine, language, languageAuto, selectionSize}) { - super("selectionChange") - this.cursorLine = cursorLine - this.selectionSize = selectionSize - this.language = language - this.languageAuto = languageAuto - } -} diff --git a/src/editor/keymap.js b/src/editor/keymap.js index a7e32ed..64b4cea 100644 --- a/src/editor/keymap.js +++ b/src/editor/keymap.js @@ -57,6 +57,8 @@ export function heynoteKeymap(editor) { ["Alt-ArrowUp", moveLineUp], ["Alt-ArrowDown", moveLineDown], ["Mod-l", () => editor.openLanguageSelector()], + ["Mod-p", () => editor.openNoteSelector()], + ["Mod-s", () => editor.openCreateNote()], ["Alt-Shift-f", formatBlockContent], ["Mod-Alt-ArrowDown", newCursorBelow], ["Mod-Alt-ArrowUp", newCursorAbove], diff --git a/src/editor/language-detection/autodetect.js b/src/editor/language-detection/autodetect.js index 771170e..4e08237 100644 --- a/src/editor/language-detection/autodetect.js +++ b/src/editor/language-detection/autodetect.js @@ -1,5 +1,5 @@ import { EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import { EditorView, ViewPlugin } from "@codemirror/view"; import { redoDepth } from "@codemirror/commands"; import { getActiveNoteBlock, blockState } from "../block/block"; import { levenshtein_distance } from "./levenshtein"; @@ -25,95 +25,112 @@ function cancelIdleCallbackCompat(id) { } } -export function languageDetection(getEditor) { - const previousBlockContent = {} - let idleCallbackId = null - - const detectionWorker = new Worker('langdetect-worker.js?worker'); - detectionWorker.onmessage = (event) => { - //console.log("event:", event.data) - if (!event.data.guesslang.language) { - return - } - const editor = getEditor() - const view = editor.view - const state = view.state - const block = getActiveNoteBlock(state) - const newLang = GUESSLANG_TO_TOKEN[event.data.guesslang.language] - if (block.language.auto === true && block.language.name !== newLang) { - console.log("New auto detected language:", newLang, "Confidence:", event.data.guesslang.confidence) - let content = state.doc.sliceString(block.content.from, block.content.to) - const threshold = content.length * 0.1 - if (levenshtein_distance(content, event.data.content) <= threshold) { - // the content has not changed significantly so it's safe to change the language - if (redoDepth(state) === 0) { - console.log("Changing language to", newLang) - changeLanguageTo(state, view.dispatch, block, newLang, true) - } else { - console.log("Not changing language because the user has undo:ed and has redo history") - } +// we'll use a shared global web worker for the language detection, for multiple Editor instances +const editorInstances = {} +const detectionWorker = new Worker('langdetect-worker.js?worker'); +detectionWorker.onmessage = (event) => { + //console.log("event:", event.data) + if (!event.data.guesslang.language) { + return + } + + const editor = editorInstances[event.data.path] + //const editor = getEditor() + const view = editor.view + const state = view.state + const block = getActiveNoteBlock(state) + const newLang = GUESSLANG_TO_TOKEN[event.data.guesslang.language] + if (block.language.auto === true && block.language.name !== newLang) { + console.log("New auto detected language:", newLang, "Confidence:", event.data.guesslang.confidence) + let content = state.doc.sliceString(block.content.from, block.content.to) + const threshold = content.length * 0.1 + if (levenshtein_distance(content, event.data.content) <= threshold) { + // the content has not changed significantly so it's safe to change the language + if (redoDepth(state) === 0) { + console.log("Changing language to", newLang) + changeLanguageTo(state, view.dispatch, block, newLang, true) } else { - console.log("Content has changed significantly, not setting new language") + console.log("Not changing language because the user has undo:ed and has redo history") } + } else { + console.log("Content has changed significantly, not setting new language") } } +} - const plugin = EditorView.updateListener.of(update => { - if (update.docChanged) { - if (idleCallbackId !== null) { - cancelIdleCallbackCompat(idleCallbackId) - idleCallbackId = null +export function languageDetection(path, getEditor) { + const previousBlockContent = {} + let idleCallbackId = null + const editor = getEditor() + editorInstances[path] = editor + + //const plugin = EditorView.updateListener.of(update => { + const plugin = ViewPlugin.fromClass( + class { + update(update) { + if (update.docChanged) { + if (idleCallbackId !== null) { + cancelIdleCallbackCompat(idleCallbackId) + idleCallbackId = null + } + + idleCallbackId = requestIdleCallbackCompat(() => { + idleCallbackId = null + + const range = update.state.selection.asSingle().ranges[0] + const blocks = update.state.facet(blockState) + let block = null, idx = null; + for (let i=0; i= range.from) { + block = blocks[i] + idx = i + break + } + } + if (block === null) { + return + } else if (block.language.auto === false) { + // if language is not auto, set it's previousBlockContent to null so that we'll trigger a language detection + // immediately if the user changes the language to auto + delete previousBlockContent[idx] + return + } + + const content = update.state.doc.sliceString(block.content.from, block.content.to) + if (content === "" && redoDepth(update.state) === 0) { + // if content is cleared, set language to default + //const editor = getEditor() + const view = editor.view + const block = getActiveNoteBlock(view.state) + if (block.language.name !== editor.defaultBlockToken) { + changeLanguageTo(view.state, view.dispatch, block, editor.defaultBlockToken, true) + } + delete previousBlockContent[idx] + } + if (content.length <= 8) { + return + } + const threshold = content.length * 0.1 + if (!previousBlockContent[idx] || levenshtein_distance(previousBlockContent[idx], content) >= threshold) { + // the content has changed significantly, so schedule a language detection + //console.log("Scheduling language detection for block", idx, "with threshold", threshold) + detectionWorker.postMessage({ + content: content, + idx: idx, + path: path, + }) + previousBlockContent[idx] = content + } + }) + } } - idleCallbackId = requestIdleCallbackCompat(() => { - idleCallbackId = null - - const range = update.state.selection.asSingle().ranges[0] - const blocks = update.state.facet(blockState) - let block = null, idx = null; - for (let i=0; i= range.from) { - block = blocks[i] - idx = i - break - } - } - if (block === null) { - return - } else if (block.language.auto === false) { - // if language is not auto, set it's previousBlockContent to null so that we'll trigger a language detection - // immediately if the user changes the language to auto - delete previousBlockContent[idx] - return - } - - const content = update.state.doc.sliceString(block.content.from, block.content.to) - if (content === "" && redoDepth(update.state) === 0) { - // if content is cleared, set language to default - const editor = getEditor() - const view = editor.view - const block = getActiveNoteBlock(view.state) - if (block.language.name !== editor.defaultBlockToken) { - changeLanguageTo(view.state, view.dispatch, block, editor.defaultBlockToken, true) - } - delete previousBlockContent[idx] - } - if (content.length <= 8) { - return - } - const threshold = content.length * 0.1 - if (!previousBlockContent[idx] || levenshtein_distance(previousBlockContent[idx], content) >= threshold) { - // the content has changed significantly, so schedule a language detection - //console.log("Scheduling language detection for block", idx, "with threshold", threshold) - detectionWorker.postMessage({ - content: content, - idx: idx, - }) - previousBlockContent[idx] = content - } - }) + destroy() { + console.log("Removing editorInstance for:", path) + delete editorInstances[path] + } } - }) + ) return plugin } diff --git a/src/editor/save.js b/src/editor/save.js index 415a8ee..4763f6f 100644 --- a/src/editor/save.js +++ b/src/editor/save.js @@ -1,5 +1,6 @@ import { ViewPlugin } from "@codemirror/view" import { debounce } from "debounce" +import { SET_CONTENT }  from "./annotation" export const autoSaveContent = (editor, interval) => { @@ -12,9 +13,12 @@ export const autoSaveContent = (editor, interval) => { class { update(update) { if (update.docChanged) { - save() + const initialSetContent = update.transactions.flatMap(t => t.annotations).some(a => a.value === SET_CONTENT) + if (!initialSetContent) { + save() + } } } } ) -} \ No newline at end of file +} diff --git a/src/main.js b/src/main.js index 456d02e..9994a49 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ import { createPinia } from 'pinia' import App from './components/App.vue' import { loadCurrencies } from './currency' import { useErrorStore } from './stores/error-store' +import { useNotesStore, initNotesStore } from './stores/notes-store' const pinia = createPinia() @@ -18,10 +19,12 @@ app.mount('#app').$nextTick(() => { }) const errorStore = useErrorStore() +//errorStore.addError("test error") window.heynote.getInitErrors().then((errors) => { errors.forEach((e) => errorStore.addError(e)) }) +initNotesStore() @@ -29,3 +32,4 @@ window.heynote.getInitErrors().then((errors) => { loadCurrencies() setInterval(loadCurrencies, 1000 * 3600 * 4) +window.heynote.init() diff --git a/src/stores/notes-store.js b/src/stores/notes-store.js new file mode 100644 index 0000000..7f4bbb8 --- /dev/null +++ b/src/stores/notes-store.js @@ -0,0 +1,65 @@ +import { defineStore } from "pinia" + +export const useNotesStore = defineStore("notes", { + state: () => ({ + notes: {}, + currentNotePath: window.heynote.isDev ? "buffer-dev.txt" : "buffer.txt", + currentNoteName: null, + currentLanguage: null, + currentLanguageAuto: null, + currentCursorLine: null, + currentSelectionSize: null, + + showNoteSelector: false, + showLanguageSelector: false, + showCreateNote: false, + }), + + actions: { + async updateNotes() { + this.setNotes(await window.heynote.buffer.getList()) + }, + + setNotes(notes) { + this.notes = notes + }, + + createNewNote(path, content) { + //window.heynote.buffer.save(path, content) + this.updateNotes() + }, + + openNote(path) { + this.showNoteSelector = false + this.showLanguageSelector = false + this.showCreateNote = false + this.currentNotePath = path + }, + + openLanguageSelector() { + this.showLanguageSelector = true + this.showNoteSelector = false + this.showCreateNote = false + }, + openNoteSelector() { + this.showNoteSelector = true + this.showLanguageSelector = false + this.showCreateNote = false + }, + openCreateNote() { + this.showCreateNote = true + this.showNoteSelector = false + this.showLanguageSelector = false + }, + closeDialog() { + this.showCreateNote = false + this.showNoteSelector = false + this.showLanguageSelector = false + }, + }, +}) + +export async function initNotesStore() { + const notesStore = useNotesStore() + await notesStore.updateNotes() +} diff --git a/webapp/bridge.js b/webapp/bridge.js index 57d9098..107a955 100644 --- a/webapp/bridge.js +++ b/webapp/bridge.js @@ -80,20 +80,38 @@ const Heynote = { defaultFontSize: isMobileDevice ? 16 : 12, buffer: { - async load() { - const content = localStorage.getItem("buffer") + async load(path) { + const content = localStorage.getItem(path) return content === null ? "\n∞∞∞text-a\n" : content }, - async save(content) { - localStorage.setItem("buffer", content) + async save(path, content) { + console.log("saving", path, content) + localStorage.setItem(path, content) }, - async saveAndQuit(content) { + async saveAndQuit(contents) { }, - onChangeCallback(callback) { + + async exists(path) { + return true + }, + + async getList(path) { + return [{"path":"buffer.txt", "metadata":{}}] + }, + + async close(path) { + + }, + + _onChangeCallbacks: {}, + addOnChangeCallback(path, callback) { + + }, + removeOnChangeCallback(path, callback) { }, },