diff --git a/electron/main/buffer.js b/electron/main/buffer.js index b8799dd3..0ecc5a75 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 00000000..d7c365b0 --- /dev/null +++ b/electron/main/file-library.js @@ -0,0 +1,166 @@ +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 +} + + +export class FileLibrary { + constructor(basePath) { + this.basePath = fs.realpathSync(untildify(basePath)) + this.jetpack = jetpack.cwd(this.basePath) + this.files = {}; + this.watcher = null; + this.contentSaved = false + this.onChangeCallback = null + + if (jetpack.exists(this.basePath) !== "dir") { + throw new Error(`Invalid base path: ${this.basePath}`) + } + + this.setupWatcher() + } + + 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 Buffer({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 listFiles() { + return await this.jetpack.findAsync(this.basePath, { + matching: "*.txt", + recursive: true, + }) + } + + setupWatcher() { + if (!this.watcher) { + this.watcher = fs.watch( + this.basePath, + { + persistent: true, + recursive: true, + encoding: "utf8", + }, + async (eventType, changedPath) => { + console.log("File changed", eventType, changedPath) + for (const [path, buffer] of Object.entries(this.files)) { + if (changedPath === basename(path)) { + const content = await buffer.read() + if (buffer._lastSavedContent !== content) { + this.onChangeCallback(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 Buffer { + 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:listFiles', async (event) => { + return await library.listFiles() + }); + + 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.onChangeCallback = (path, content) => { + win.webContents.send("buffer:change", path, content) + } +} diff --git a/electron/main/index.ts b/electron/main/index.ts index dd7a26f4..efd5fd28 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,7 +50,9 @@ 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 const preload = join(__dirname, '../preload/index.js') const url = process.env.VITE_DEV_SERVER_URL @@ -138,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.contentSaved) { event.preventDefault() win?.webContents.send(WINDOW_CLOSE_EVENT) } else { @@ -302,6 +305,7 @@ function registerAlwaysOnTop() { } app.whenReady().then(createWindow).then(async () => { + setupFileLibraryEventHandlers(fileLibrary, win) initializeAutoUpdate(win) registerGlobalHotkey() registerShowInDock() @@ -343,8 +347,19 @@ 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 = join(app.getPath("userData"), "notes") +console.log("libraryPath", libraryPath) +fileLibrary = new FileLibrary(libraryPath) +fileLibrary.listFiles().then((files) => { + console.log("files", files) +}) +initErrors.push("Could not load file library") + +ipcMain.handle("getInitErrors", () => { + return initErrors +}) ipcMain.handle('settings:set', async (event, settings) => { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 64e82629..07c0f832 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,20 +57,42 @@ contextBridge.exposeInMainWorld("heynote", { }, buffer: { - async load() { - return await ipcRenderer.invoke("buffer-content:load") + async exists(path) { + return await ipcRenderer.invoke("buffer:exists", path) + }, + + async listFiles(path) { + return await ipcRenderer.invoke("buffer:listFiles") + }, + + async load(path) { + return await ipcRenderer.invoke("buffer:load", path) }, - async save(content) { - return await ipcRenderer.invoke("buffer-content:save", content) + async save(path, content) { + return await ipcRenderer.invoke("buffer:save", path, content) }, - async saveAndQuit(content) { - return await ipcRenderer.invoke("buffer-content:saveAndQuit", content) + async saveAndQuit(contents) { + return await ipcRenderer.invoke("buffer:saveAndQuit", contents) }, - onChangeCallback(callback) { - ipcRenderer.on("buffer-content:change", callback) + 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() { @@ -103,7 +136,11 @@ contextBridge.exposeInMainWorld("heynote", { async getVersion() { return await ipcRenderer.invoke("getVersion") - } + }, + + async getInitErrors() { + return await ipcRenderer.invoke("getInitErrors") + }, }) diff --git a/package-lock.json b/package-lock.json index 810c8fbf..34812c29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Commons Clause MIT", "dependencies": { "electron-log": "^5.0.1", + "pinia": "^2.1.7", "semver": "^7.6.3" }, "devDependencies": { @@ -242,7 +243,6 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.5.tgz", "integrity": "sha512-hOOqoiNXrmGdFbhgCzu6GiURxUgM27Xwd/aPuu8RfHEZPBzL1Z54okAHAQjXfcQNwvrlkAmAp4SlRTZ45vlthQ==", - "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -1013,8 +1013,7 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@lezer/common": { "version": "1.2.1", @@ -1732,7 +1731,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.11.tgz", "integrity": "sha512-h97/TGWBilnLuRaj58sxNrsUU66fwdRKLOLQ9N/5iNDfp+DZhYH9Obhe0bXxhedl8fjAgpRANpiZfbgWyruQ0w==", - "dev": true, "dependencies": { "@babel/parser": "^7.23.5", "@vue/shared": "3.3.11", @@ -1744,7 +1742,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.11.tgz", "integrity": "sha512-zoAiUIqSKqAJ81WhfPXYmFGwDRuO+loqLxvXmfUdR5fOitPoUiIeFI9cTTyv9MU5O1+ZZglJVTusWzy+wfk5hw==", - "dev": true, "dependencies": { "@vue/compiler-core": "3.3.11", "@vue/shared": "3.3.11" @@ -1754,7 +1751,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.11.tgz", "integrity": "sha512-U4iqPlHO0KQeK1mrsxCN0vZzw43/lL8POxgpzcJweopmqtoYy9nljJzWDIQS3EfjiYhfdtdk9Gtgz7MRXnz3GA==", - "dev": true, "dependencies": { "@babel/parser": "^7.23.5", "@vue/compiler-core": "3.3.11", @@ -1772,12 +1768,16 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.11.tgz", "integrity": "sha512-Zd66ZwMvndxRTgVPdo+muV4Rv9n9DwQ4SSgWWKWkPFebHQfVYRrVjeygmmDmPewsHyznCNvJ2P2d6iOOhdv8Qg==", - "dev": true, "dependencies": { "@vue/compiler-dom": "3.3.11", "@vue/shared": "3.3.11" } }, + "node_modules/@vue/devtools-api": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.3.tgz", + "integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==" + }, "node_modules/@vue/language-core": { "version": "1.8.25", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.25.tgz", @@ -1831,7 +1831,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.11.tgz", "integrity": "sha512-D5tcw091f0nuu+hXq5XANofD0OXnBmaRqMYl5B3fCR+mX+cXJIGNw/VNawBqkjLNWETrFW0i+xH9NvDbTPVh7g==", - "dev": true, "dependencies": { "@vue/shared": "3.3.11" } @@ -1840,7 +1839,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.11.tgz", "integrity": "sha512-fPGjH0wqJo68A0wQ1k158utDq/cRyZNlFoxGwNScE28aUFOKFEnCBsvyD8jHn+0kd0UKVpuGuaZEQ6r9FJRqCg==", - "dev": true, "dependencies": { "@babel/parser": "^7.23.5", "@vue/compiler-core": "3.3.11", @@ -1853,7 +1851,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.11.tgz", "integrity": "sha512-g9ztHGwEbS5RyWaOpXuyIVFTschclnwhqEbdy5AwGhYOgc7m/q3NFwr50MirZwTTzX55JY8pSkeib9BX04NIpw==", - "dev": true, "dependencies": { "@vue/reactivity": "3.3.11", "@vue/shared": "3.3.11" @@ -1863,7 +1860,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.11.tgz", "integrity": "sha512-OlhtV1PVpbgk+I2zl+Y5rQtDNcCDs12rsRg71XwaA2/Rbllw6mBLMi57VOn8G0AjOJ4Mdb4k56V37+g8ukShpQ==", - "dev": true, "dependencies": { "@vue/runtime-core": "3.3.11", "@vue/shared": "3.3.11", @@ -1874,7 +1870,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.11.tgz", "integrity": "sha512-AIWk0VwwxCAm4wqtJyxBylRTXSy1wCLOKbWxHaHiu14wjsNYtiRCSgVuqEPVuDpErOlRdNnuRgipQfXRLjLN5A==", - "dev": true, "dependencies": { "@vue/compiler-ssr": "3.3.11", "@vue/shared": "3.3.11" @@ -1886,8 +1881,7 @@ "node_modules/@vue/shared": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.11.tgz", - "integrity": "sha512-u2G8ZQ9IhMWTMXaWqZycnK4UthG1fA238CD+DP4Dm4WJi5hdUKKLg0RMRaRpDPNMdkTwIDkp7WtD0Rd9BH9fLw==", - "dev": true + "integrity": "sha512-u2G8ZQ9IhMWTMXaWqZycnK4UthG1fA238CD+DP4Dm4WJi5hdUKKLg0RMRaRpDPNMdkTwIDkp7WtD0Rd9BH9fLw==" }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", @@ -2756,8 +2750,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/de-indent": { "version": "1.0.2", @@ -3570,8 +3563,7 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/extract-zip": { "version": "2.0.1", @@ -4444,7 +4436,6 @@ "version": "0.30.5", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, @@ -4611,7 +4602,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -4846,8 +4836,7 @@ "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -4861,6 +4850,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinia": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz", + "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "dependencies": { + "@vue/devtools-api": "^6.5.0", + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.8", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz", + "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/pkg-up": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", @@ -4935,7 +4974,6 @@ "version": "8.4.32", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5443,7 +5481,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5749,7 +5786,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6295,7 +6332,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.11.tgz", "integrity": "sha512-d4oBctG92CRO1cQfVBZp6WJAs0n8AK4Xf5fNjQCBeKCvMI1efGQ5E3Alt1slFJS9fZuPcFoiAiqFvQlv1X7t/w==", - "dev": true, "dependencies": { "@vue/compiler-dom": "3.3.11", "@vue/compiler-sfc": "3.3.11", diff --git a/package.json b/package.json index d3186170..1688cc12 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ }, "dependencies": { "electron-log": "^5.0.1", + "pinia": "^2.1.7", "semver": "^7.6.3" } } diff --git a/playwright.config.ts b/playwright.config.ts index f4ea00db..173cc961 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -42,15 +42,15 @@ export default defineConfig({ }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + //{ + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + //}, + // + //{ + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + //}, /* Test against mobile viewports. */ // { diff --git a/public/langdetect-worker.js b/public/langdetect-worker.js index a519b4af..15d736f9 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 08234d50..c6817bc3 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,8 +1,14 @@ + + + + + + diff --git a/src/components/NoteSelector.vue b/src/components/NoteSelector.vue new file mode 100644 index 00000000..00845244 --- /dev/null +++ b/src/components/NoteSelector.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/components/StatusBar.vue b/src/components/StatusBar.vue index 0676dd91..3a504f22 100644 --- a/src/components/StatusBar.vue +++ b/src/components/StatusBar.vue @@ -37,6 +37,10 @@ return LANGUAGE_NAMES[this.language] || this.language }, + noteName() { + return "Scratch" + }, + className() { return `status` }, @@ -54,6 +58,10 @@ return `Format Block Content (Alt + Shift + F)` }, + changeNoteTitle() { + return `Change Note (${this.cmdKey} + P)` + }, + changeLanguageTitle() { return `Change language for current block (${this.cmdKey} + L)` }, @@ -76,7 +84,14 @@
+ {{ noteName }} +
+
@@ -85,7 +100,7 @@
@@ -100,7 +115,7 @@
diff --git a/src/editor/editor.js b/src/editor/editor.js index 8df85a96..fc0bbc8e 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" @@ -24,6 +24,7 @@ import { links } from "./links.js" import { NoteFormat } from "./note-format.js" export const LANGUAGE_SELECTOR_EVENT = "openLanguageSelector" +export const NOTE_SELECTOR_EVENT = "openNoteSelector" function getKeymapExtensions(editor, keymap) { if (keymap === "emacs") { @@ -37,10 +38,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 +49,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 +64,9 @@ 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 + 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) @@ -151,7 +187,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 @@ -220,6 +256,10 @@ export class HeynoteEditor { this.element.dispatchEvent(new Event(LANGUAGE_SELECTOR_EVENT)) } + openNoteSelector() { + this.element.dispatchEvent(new Event(NOTE_SELECTOR_EVENT)) + } + setCurrentLanguage(lang, auto=false) { changeCurrentBlockLanguage(this.view.state, this.view.dispatch, lang, auto) } @@ -257,6 +297,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/keymap.js b/src/editor/keymap.js index a7e32ed1..b440c626 100644 --- a/src/editor/keymap.js +++ b/src/editor/keymap.js @@ -57,6 +57,7 @@ export function heynoteKeymap(editor) { ["Alt-ArrowUp", moveLineUp], ["Alt-ArrowDown", moveLineDown], ["Mod-l", () => editor.openLanguageSelector()], + ["Mod-p", () => editor.openNoteSelector()], ["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 771170ea..4e08237f 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 - 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 + //const plugin = EditorView.updateListener.of(update => { + const plugin = ViewPlugin.fromClass( + class { + update(update) { + if (update.docChanged) { + if (idleCallbackId !== null) { + cancelIdleCallbackCompat(idleCallbackId) + idleCallbackId = null } - } - 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, + 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 + } }) - 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 415a8eec..4763f6f3 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 7836648a..e7bf5b98 100644 --- a/src/main.js +++ b/src/main.js @@ -1,17 +1,27 @@ import './css/application.sass' import { createApp } from 'vue' +import { createPinia } from 'pinia' + import App from './components/App.vue' import { loadCurrencies } from './currency' +import { useErrorStore } from './stores/error-store' +const pinia = createPinia() const app = createApp(App) +app.use(pinia) app.mount('#app').$nextTick(() => { // hide loading screen postMessage({ payload: 'removeLoading' }, '*') }) +const errorStore = useErrorStore() +//errorStore.addError("test error") +window.heynote.getInitErrors().then((errors) => { + errors.forEach((e) => errorStore.addError(e)) +}) @@ -19,3 +29,4 @@ app.mount('#app').$nextTick(() => { loadCurrencies() setInterval(loadCurrencies, 1000 * 3600 * 4) +window.heynote.init() diff --git a/src/stores/error-store.js b/src/stores/error-store.js new file mode 100644 index 00000000..cc3aa1c6 --- /dev/null +++ b/src/stores/error-store.js @@ -0,0 +1,21 @@ +import { defineStore } from 'pinia' + +export const useErrorStore = defineStore("errors", { + state: () => ({ + errors: [], + }), + + actions: { + setErrors(errors) { + this.errors = errors + }, + + addError(error) { + this.errors.push(error) + }, + + popError() { + this.errors.splice(0, 1) + }, + }, +}) diff --git a/webapp/bridge.js b/webapp/bridge.js index 06ef81b2..c65b5c07 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 listFiles(path) { + return ["buffer.txt", "other.txt"] + }, + + async close(path) { + + }, + + _onChangeCallbacks: {}, + addOnChangeCallback(path, callback) { + + }, + removeOnChangeCallback(path, callback) { }, },