diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d477a358..fc4cbf61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ on: push jobs: publish: runs-on: ${{ matrix.os }} - if: ${{ startsWith(github.ref, 'refs/tags/v') }} + if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.head_commit.message, '#build') }} permissions: contents: write @@ -50,8 +50,8 @@ jobs: # If the commit is tagged with a version (e.g. "v1.0.0"), # release the app after building - release: true - #release: ${{ startsWith(github.ref, 'refs/tags/v') }} + #release: true + release: ${{ startsWith(github.ref, 'refs/tags/v') }} env: # macOS notarization API key APPLE_API_KEY: ~/private_keys/AuthKey.p8 @@ -61,3 +61,26 @@ jobs: #- name: Print notarization-error.log # run: cat notarization-error.log + - name: Upload Linux artifact + if: ${{ matrix.os == 'ubuntu-latest' }} + uses: actions/upload-artifact@v3 + with: + name: heynote-linux-${{ github.sha }} + path: release/*/Heynote_*.AppImage + retention-days: 30 + + - name: Upload Mac artifact + if: ${{ matrix.os == 'macos-latest' }} + uses: actions/upload-artifact@v3 + with: + name: heynote-mac-arm64-${{ github.sha }} + path: release/*/Heynote_*_arm64.dmg + retention-days: 30 + + - name: Upload Windows artifact + if: ${{ matrix.os == 'windows-latest' }} + uses: actions/upload-artifact@v3 + with: + name: heynote-windows-${{ github.sha }} + path: release/*/Heynote_*.exe + retention-days: 30 diff --git a/README.md b/README.md index 94c55b38..26cfc944 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - Website: [heynote.com](https://heynote.com) - Documentation: [heynote.com](https://heynote.com/docs/) +- Changelog: [heynote.com](https://heynote.com/docs/changelog/) Heynote is a dedicated scratchpad for developers. It functions as a large persistent text buffer where you can write down anything you like. Works great for that Slack message you don't want to accidentally send, a JSON response from an API you're working with, notes from a meeting, your daily to-do list, etc. diff --git a/assets/icons/arrow-right-black.svg b/assets/icons/arrow-right-black.svg new file mode 100644 index 00000000..27ad162e --- /dev/null +++ b/assets/icons/arrow-right-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/arrow-right-grey.svg b/assets/icons/arrow-right-grey.svg new file mode 100644 index 00000000..b3110a43 --- /dev/null +++ b/assets/icons/arrow-right-grey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/arrow-right-white.svg b/assets/icons/arrow-right-white.svg new file mode 100644 index 00000000..9130299a --- /dev/null +++ b/assets/icons/arrow-right-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/caret-down-white.svg b/assets/icons/caret-down-white.svg new file mode 100644 index 00000000..c1347596 --- /dev/null +++ b/assets/icons/caret-down-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/caret-down.svg b/assets/icons/caret-down.svg new file mode 100644 index 00000000..44d204df --- /dev/null +++ b/assets/icons/caret-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/caret-right-white.svg b/assets/icons/caret-right-white.svg new file mode 100644 index 00000000..b8fc0be9 --- /dev/null +++ b/assets/icons/caret-right-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/caret-right.svg b/assets/icons/caret-right.svg new file mode 100644 index 00000000..355d0015 --- /dev/null +++ b/assets/icons/caret-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..bf27f981 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,89 @@ +# Changelog + +Here are the most notable changes in each release. For a more detailed list of changes, see the [Github Releases page](https://github.com/heyman/heynote/releases). + +## 2.0.0-beta (not yet released) + +### IMPORTANT (breaking change) + +The default path of the scratch file has changed. If you are running a previous version of Heynote with the buffer file synchronized across multiple machines using a file synching service such as Dropbox or OneDrive, you should make sure to upgrade all machines to Heynote 2.0 at the same time (closing Heynote before) in order for the file to stay synched, since the file path for the buffer file has changed. See below for more info. + +### Support for multiple note buffers. + +Apart from the default Scratch note, you can now create and switch between multiple notes. By pressing `Ctrl/Cmd+S` you can create a new note from the current block (the current block will be moved into the new note). New notes are saved to the note library which is basically a directory (with sub dirs) on the disk with a `.txt` file for each note. You switch between Notes by pressing `Ctrl/Cmd+P`. + +The first time you start the new version of Heynote, your existing buffer file will be migrated to the new note library. If you're using the default buffer location, that means that the existing Scratch note file will be moved from `%APP_DIR%/buffer.txt` to `%APP_DIR%/notes/scratch.txt`. If you are using a custom buffer location the existing scratch file will be moved from `%CUSTOM_DIR%/buffer.txt` to `%CUSTOM_DIR%/scratch.txt`. + +### Other changes + +- The file format for the buffer files has been updated to include some JSON metadata at the top of the file. +- The cursor(s) location is saved between sessions. +- Improvements when using a file syncing service (e.g. Dropbox, OneDrive) to sync the note library between machines. +- The setting for changing the color theme is now located in the program settings, instead of in the status bar. + +## 1.8.0 + +- Performance optimizations +- Add default redo cmd that works on all Platforms. Mod+Shift+Z +- Fix bug causing editing to break for empty blocks in some cases +- Add setting for configuring the default block language +- Vue language support +- Dart Syntax +- Fix error on startup for large buffers + +## 1.7.1 + +- Update to latest version of Electron. Fixes crash on MacOS 15 Developer Preview + +## 1.7.0 + +- Fix "white flash" effect when resizing window in dark mode +- Add prev variable to Math blocks that holds the previous value +- Add settings button to status bar +- Add version number to settings dialog +- Persist window location when opening the app +- Copy whole current line(s) when selection(s) are empty +- Fix block corruption when deleting block content using deleteLine command +- Add PowerShell and Diff language modes +- "Always on top" setting which makes Heynote stay on top of other programs + +## 1.6.0 + +- Added support for having Heynote in the Mac Menu Bar / Tray icon +- Ability to specify file system location of Heynote's buffer file. The buffer will automatically be reloaded if changed on disk, so this should make it possible to have the buffer automatically synced between machines using a file-syncing service such as Dropbox. +- Custom font and font size support. +- More key-binding for creating new blocks +- Syntax hightlighting support for new languages: + * Swift + * Kotlin + * Groovy +- Auto-close brackets functionality that can be turned on in settings +- Ability to change how calculations are formatted in Math blocks. See the [Docs](https://heynote.com/docs/#user-content-changing-how-the-results-of-math-blocks-are-formatted) for info on how to do this. +- There's now a Heynote webapp at [app.heynote.com](https://app.heynote.com). It's still work-in-progress, but should be usable. The buffer is stored in localStorage. +- Multiple bug fixes and minor improvement. + + +## 1.5.0 + +- Add support for the following languages + * TypeScript + * JSX + * TSX + * TOML + * C# + * Clojure + * Erlang + * Golang + * Lezer + * Ruby + * Shell + * YAML +- Various bug fixes and improvements + +## 1.4.1 + +- Fixed issue that would sometimes cause auto formatting to freeze the app for long periods. + +## 1.4.0 + +- Added ability to set a global hotkey for showing/hiding Heynote. diff --git a/docs/index.md b/docs/index.md index bef7a83d..2aaa4739 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,8 @@ # Heynote Documentation -Heynote is a dedicated scratchpad for developers. It functions as a large persistent text buffer where you can write down anything you like. Works great for that Slack message you don't want to accidentally send, a JSON response from an API you're working with, notes from a meeting, your daily to-do list, etc. +[Changelog](/docs/changelog/) + +Heynote is a dedicated scratchpad for developers. It functions as a large persistent text buffer where you can write down anything you like. Works great for that Slack message you don't want to accidentally send, a JSON response from an API you're working with, notes from a meeting, your daily to-do list, etc. The Heynote buffer is divided into blocks, and each block can have its own Language set (e.g. JavaScript, JSON, Markdown, etc.). This gives you syntax highlighting and lets you auto-format that JSON response. @@ -36,6 +38,8 @@ Available for Mac, Windows, and Linux. ⌥ + Shift + Enter Add new block at the start of the buffer ⌘ + ⌥ + Enter Split the current block at cursor position ⌘ + L Change block language +⌘ + S Create a new note from the current block +⌘ + P Open note selector ⌘ + Down Goto next block ⌘ + Up Goto previous block ⌘ + A Select all text in a note block. Press again to select the whole buffer @@ -52,6 +56,8 @@ Ctrl + Shift + Enter Add new block at the end of the buffer Alt + Shift + Enter Add new block at the start of the buffer Ctrl + Alt + Enter Split the current block at cursor position Ctrl + L Change block language +Ctrl + S Create a new note from the current block +Ctrl + P Open note selector Ctrl + Down Goto next block Ctrl + Up Goto previous block Ctrl + A Select all text in a note block. Press again to select the whole buffer @@ -95,16 +101,27 @@ format(x) = x.toLocaleString(); format(x) = x.toLocaleString('en-GB'); ``` - See the [Math.js format()](https://mathjs.org/docs/reference/functions/format.html) function for more info on what's supported. -## The buffer file -The default paths for the buffer data for the respective operating systems are: +## The notes library (only for Heynote 2.0, not yet released) + +The notes library is a directory (with sub dirs) on the disk with a `.txt` file for each note. It's created the first time you start Heynote, with the default note file `scratch.txt` in it. The default location for the library is: + +- Mac: `~/Library/Application Support/Heynote/notes/` +- Windows: `%APPDATA%\Heynote\notes\` +- Linux: `~/.config/Heynote/notes/` + +You can change the path of the notes library in the settings. Heynote expects reasonably fast disk access to the notes library, so it's not recommended to use a network drive, though file syncing services like Dropbox, OneDrive, etc. should work (see below). + +### Synchronizing the notes library + +Heynote is built to support synchronizing the notes library (or buffer file in the case of Heynote 1.x) through file-syncing services like Dropbox, OneDrive, etc. However, note that the synchronization logic is quite simple, so editing the same note on two different machines at the same time might lead to conflicts and unexpected results. + +When using a file synching service that support "offloading" of files in the cloud (removing them from the disk), it's recommended to mark the notes library as "always available offline". + +As always, backup things that are important. -- Mac: `~/Library/Application Support/Heynote/buffer.txt` -- Windows: `%APPDATA%\Heynote\buffer.txt` -- Linux: `~/.config/Heynote/buffer.txt` ## Linux @@ -118,7 +135,7 @@ libnss3 libnspr4 ``` -#### Wayland +### Wayland Due to [an issue in Electron](https://github.com/electron/electron/issues/38288), the global hotkey will not work in all applications running under Wayland. In KDE it is possible to work around this limitation by adding this Kwin script: @@ -144,3 +161,4 @@ registerShortcut('toggleHeynote', 'Toggle Heynote', 'Ctrl+Shift+H', toggleHeynot See the [KWin scripting tutorial](https://develop.kde.org/docs/plasma/kwin/) for instructions on how to install the script. Remember to enable the script in the KDE System Settings. It may also be necessary to go into the KDE System Settings and bind the "Toggle Heynote" key manually. + diff --git a/electron/initial-content.ts b/electron/initial-content.ts index d120afec..374f8124 100644 --- a/electron/initial-content.ts +++ b/electron/initial-content.ts @@ -1,9 +1,8 @@ import os from "os"; import { keyHelpStr } from "../shared-utils/key-helper"; -export const eraseInitialContent = !!process.env.ERASE_INITIAL_CONTENT - export const initialContent = ` +{"formatVersion":"1.0.0","name":"Scratch"} ∞∞∞markdown Welcome to Heynote! 👋 diff --git a/electron/main/buffer.js b/electron/main/buffer.js deleted file mode 100644 index b8799dd3..00000000 --- a/electron/main/buffer.js +++ /dev/null @@ -1,166 +0,0 @@ -import fs from "fs" -import os from "node:os" -import { join, dirname, basename } from "path" -import { app, ipcMain, dialog } from "electron" -import * as jetpack from "fs-jetpack"; - -import CONFIG from "../config" -import { isDev } from "../detect-platform" -import { win } from "./index" -import { eraseInitialContent, initialContent, initialDevContent } from '../initial-content' - -const untildify = (pathWithTilde) => { - const homeDirectory = os.homedir(); - return homeDirectory - ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) - : pathWithTilde; -} - -export function constructBufferFilePath(directoryPath) { - return join(untildify(directoryPath), isDev ? "buffer-dev.txt" : "buffer.txt") -} - -export function getBufferFilePath() { - let defaultPath = app.getPath("userData") - let configPath = CONFIG.get("settings.bufferPath") - let bufferPath = configPath.length ? configPath : defaultPath - let bufferFilePath = constructBufferFilePath(bufferPath) - try { - // use realpathSync to resolve a potential symlink - return fs.realpathSync(bufferFilePath) - } catch (err) { - // realpathSync will fail if the file does not exist, but that doesn't matter since the file will be created - if (err.code !== "ENOENT") { - throw err - } - return bufferFilePath - } -} - - -export class Buffer { - constructor({filePath, onChange}) { - this.filePath = filePath - this.onChange = onChange - this.watcher = null - this.setupWatcher() - this._lastSavedContent = null - } - - async load() { - const content = await jetpack.read(this.filePath, 'utf8') - this.setupWatcher() - return content - } - - async save(content) { - this._lastSavedContent = content - const saveResult = await jetpack.write(this.filePath, content, { - atomic: true, - mode: '600', - }) - return saveResult - } - - exists() { - return jetpack.exists(this.filePath) === "file" - } - - setupWatcher() { - if (!this.watcher && this.exists()) { - this.watcher = fs.watch( - dirname(this.filePath), - { - persistent: true, - recursive: false, - encoding: "utf8", - }, - async (eventType, filename) => { - if (filename !== basename(this.filePath)) { - return - } - - // read the file content and compare it to the last saved content - // (if the content is the same, then we can ignore the event) - const content = await jetpack.read(this.filePath, 'utf8') - - if (this._lastSavedContent !== content) { - // file has changed on disk, trigger onChange - this.onChange(content) - } - } - ) - } - } - - close() { - if (this.watcher) { - this.watcher.close() - this.watcher = null - } - } -} - - -// Buffer -let buffer -export function loadBuffer() { - if (buffer) { - buffer.close() - } - buffer = new Buffer({ - filePath: getBufferFilePath(), - onChange: (content) => { - win?.webContents.send("buffer-content:change", content) - }, - }) - return buffer -} - -ipcMain.handle('buffer-content:load', async () => { - if (buffer.exists() && !(eraseInitialContent && isDev)) { - return await buffer.load() - } else { - return isDev ? initialDevContent : initialContent - } -}); - -async function save(content) { - return await buffer.save(content) -} - -ipcMain.handle('buffer-content:save', async (event, content) => { - return await save(content) -}); - -export let contentSaved = false -ipcMain.handle('buffer-content:saveAndQuit', async (event, content) => { - await save(content) - contentSaved = true - app.quit() -}) - -ipcMain.handle("buffer-content:selectLocation", async () => { - let result = await dialog.showOpenDialog({ - title: "Select directory to store buffer", - properties: [ - "openDirectory", - "createDirectory", - "noResolveAliases", - ], - }) - if (result.canceled) { - return - } - const filePath = result.filePaths[0] - if (fs.existsSync(constructBufferFilePath(filePath))) { - if (dialog.showMessageBoxSync({ - type: "question", - message: "The selected directory already contains a buffer file. It will be loaded. Do you want to continue?", - buttons: ["Cancel", "Continue"], - }) === 0) { - return - } - } - return filePath -}) diff --git a/electron/main/file-library.js b/electron/main/file-library.js new file mode 100644 index 00000000..69995234 --- /dev/null +++ b/electron/main/file-library.js @@ -0,0 +1,372 @@ +import fs from "fs" +import os from "node:os" +import { join, basename } from "path" + +import * as jetpack from "fs-jetpack"; +import { app, ipcMain, dialog } from "electron" + +import CONFIG from "../config" +import { SCRATCH_FILE_NAME } from "../../src/common/constants" +import { NoteFormat } from "../../src/common/note-format" +import { isDev } from '../detect-platform'; +import { initialContent, initialDevContent } from '../initial-content' + +export const NOTES_DIR_NAME = isDev ? "notes-dev" : "notes" + + +let library + +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, win) { + this.win = win + 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 + this._onWindowFocus = null + + // create scratch.txt if it doesn't exist + if (!this.jetpack.exists(SCRATCH_FILE_NAME)) { + this.jetpack.write(SCRATCH_FILE_NAME, isDev ? initialDevContent : initialContent) + } + } + + async exists(path) { + return this.jetpack.exists(path) === "file" + } + + async load(path) { + if (this.files[path]) { + return this.files[path].load() + } + const fullPath = fs.realpathSync(join(this.basePath, path)) + this.files[path] = new NoteBuffer({fullPath, library:this}) + return await this.files[path].load() + } + + async save(path, content) { + if (!this.files[path]) { + throw new Error(`File not loaded: ${path}`) + } + 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 move(path, newPath) { + if (await this.exists(newPath)) { + throw new Error(`File already exists: ${newPath}`) + } + const fullOldPath = join(this.basePath, path) + const fullNewPath = join(this.basePath, newPath) + await this.jetpack.moveAsync(fullOldPath, fullNewPath) + } + + async delete(path) { + if (path === SCRATCH_FILE_NAME) { + throw new Error("Can't delete scratch file") + } + const fullPath = join(this.basePath, path) + await this.jetpack.removeAsync(fullPath) + } + + async getList() { + //console.log("Listing notes") + const notes = {} + const files = await this.jetpack.findAsync(".", { + 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 + } + + /** + * @returns {Array} List of path to all directories, but not the root directory. + */ + async getDirectoryList() { + const directories = await this.jetpack.findAsync("", { + files: false, + directories: true, + recursive: true, + }) + return directories + } + + 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.loadIfChanged() + if (content !== null) { + this.win.webContents.send("buffer:change", path, content) + } + } + } + } + ) + + // fs.watch() is unreliable in some cases, e.g. OneDrive on Windows. Therefor we'll load the open buffer files + // and check for changes when the window gets focus. + this._onWindowFocus = async (event) => { + for (const [path, buffer] of Object.entries(this.files)) { + const content = await buffer.loadIfChanged() + if (content !== null) { + this.win.webContents.send("buffer:change", path, content) + } + } + } + this.win.on("focus", this._onWindowFocus) + } + } + + 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 + } + if (this._onWindowFocus) { + this.win.off("focus", this._onWindowFocus) + this._onWindowFocus = null + } + } +} + + + +export class NoteBuffer { + constructor({fullPath, library}) { + this.fullPath = fullPath + this._lastKnownContent = null + this.library = library + } + + async read() { + return await this.library.jetpack.read(this.fullPath, 'utf8') + } + + /** + * load() assumes that the actual note buffer is actually updated with the new content, otherwise + * _lastKnownContent will be out of sync. If you just want to read the content, use read() instead. + */ + async load() { + const content = await this.read() + this._lastKnownContent = content + return content + } + + /** + * loadIfChanged() will only return the content if it has changed since the last time it was loaded. + * If content is returned, the note buffer must be updated with the new content in order to keep the + * _lastKnownContent in sync. + */ + async loadIfChanged() { + const content = await this.read() + // if the file was removed (e.g. during an atomic save) the content will be undefined + if (content !== undefined && this._lastKnownContent !== content) { + this._lastKnownContent = content + return content + } + return null + } + + async save(content) { + this._lastKnownContent = 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 setCurrentFileLibrary(lib) { + library = lib +} + +export function setupFileLibraryEventHandlers() { + 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:create', async (event, path, content) => { + return await library.create(path, content) + }); + + ipcMain.handle('buffer:getList', async (event) => { + return await library.getList() + }); + + ipcMain.handle('buffer:getDirectoryList', async (event) => { + return await library.getDirectoryList() + }); + + 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() + }) + + ipcMain.handle('buffer:move', async (event, path, newPath) => { + return await library.move(path, newPath) + }); + + ipcMain.handle('buffer:delete', async (event, path) => { + return await library.delete(path) + }); + + ipcMain.handle("library:selectLocation", async () => { + let result = await dialog.showOpenDialog({ + title: "Select directory to store buffer", + properties: [ + "openDirectory", + "createDirectory", + "noResolveAliases", + ], + }) + if (result.canceled) { + return + } + const filePath = result.filePaths[0] + return filePath + }) +} + + +export async function migrateBufferFileToLibrary(app) { + async function ensureBufferFileMetadata(filePath) { + const metadata = await readNoteMetadata(filePath) + //console.log("Metadata", metadata) + if (!metadata || !metadata.name) { + console.log("Adding metadata to", filePath) + const note = NoteFormat.load(jetpack.read(filePath)) + note.metadata.name = "Scratch" + jetpack.write(filePath, note.serialize()) + } else { + console.log("Metadata already exists for", filePath) + } + } + + const defaultLibraryPath = join(app.getPath("userData"), NOTES_DIR_NAME) + const customBufferPath = CONFIG.get("settings.bufferPath") + const oldBufferFile = isDev ? "buffer-dev.txt" : "buffer.txt" + if (customBufferPath) { + // if the new buffer file exists, no need to migrate + if (jetpack.exists(join(customBufferPath, SCRATCH_FILE_NAME)) === "file") { + return + } + const oldBufferFileFullPath = join(customBufferPath, oldBufferFile) + if (jetpack.exists(oldBufferFileFullPath) === "file") { + const newFileFullPath = join(customBufferPath, SCRATCH_FILE_NAME); + console.log(`Migrating file ${oldBufferFileFullPath} to ${newFileFullPath}`) + // rename buffer file to scratch.txt + jetpack.move(oldBufferFileFullPath, newFileFullPath) + // add metadata to scratch.txt (just to be sure, we'll double check that it's needed first) + await ensureBufferFileMetadata(newFileFullPath) + } + } else { + // if the new buffer file exists, no need to migrate + if (jetpack.exists(join(defaultLibraryPath, SCRATCH_FILE_NAME)) === "file") { + return + } + // check if the old buffer file exists, while the default *library* path doesn't exist + const oldBufferFileFullPath = join(app.getPath("userData"), oldBufferFile) + if (jetpack.exists(oldBufferFileFullPath) === "file" && jetpack.exists(defaultLibraryPath) !== "dir") { + const newFileFullPath = join(defaultLibraryPath, SCRATCH_FILE_NAME); + console.log(`Migrating buffer file ${oldBufferFileFullPath} to ${newFileFullPath}`) + // create the default library path + jetpack.dir(defaultLibraryPath) + // move the buffer file to the library path + jetpack.move(oldBufferFileFullPath, newFileFullPath) + // add metadata to scratch.txt + await ensureBufferFileMetadata(newFileFullPath) + } + } +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 6d98db32..2dd5624b 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -9,7 +9,13 @@ import CONFIG from "../config" 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, + setCurrentFileLibrary, + migrateBufferFileToLibrary, + NOTES_DIR_NAME +} from './file-library'; // The built directory structure @@ -49,7 +55,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 +146,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 { @@ -307,6 +315,9 @@ function registerAlwaysOnTop() { } app.whenReady().then(createWindow).then(async () => { + initFileLibrary(win).then(() => { + setupFileLibraryEventHandlers() + }) initializeAutoUpdate(win) registerGlobalHotkey() registerShowInDock() @@ -348,8 +359,36 @@ ipcMain.handle('dark-mode:set', (event, mode) => { ipcMain.handle('dark-mode:get', () => nativeTheme.themeSource) -// load buffer on app start -loadBuffer() +ipcMain.handle("setWindowTitle", (event, title) => { + win?.setTitle(title) +}) + +// Initialize note/file library +async function initFileLibrary(win) { + await migrateBufferFileToLibrary(app) + + const customLibraryPath = CONFIG.get("settings.bufferPath") + const defaultLibraryPath = join(app.getPath("userData"), NOTES_DIR_NAME) + const libraryPath = customLibraryPath ? customLibraryPath : defaultLibraryPath + //console.log("libraryPath", libraryPath) + + // if we're using the default library path, and it doesn't exist (e.g. first time run), create it + if (!customLibraryPath && !fs.existsSync(defaultLibraryPath)) { + fs.mkdirSync(defaultLibraryPath) + } + + try { + fileLibrary = new FileLibrary(libraryPath, win) + fileLibrary.setupWatcher() + } catch (error) { + initErrors.push(`Error: ${error.message}`) + } + setCurrentFileLibrary(fileLibrary) +} + +ipcMain.handle("getInitErrors", () => { + return initErrors +}) ipcMain.handle('settings:set', async (event, settings) => { @@ -378,9 +417,10 @@ ipcMain.handle('settings:set', async (event, settings) => { registerAlwaysOnTop() } if (bufferPathChanged) { - const buffer = loadBuffer() - if (buffer.exists()) { - win?.webContents.send("buffer-content:change", await buffer.load()) - } + console.log("bufferPath changed, closing existing file library") + fileLibrary.close() + console.log("initializing new file library") + initFileLibrary(win) + await win.webContents.send("library:pathChanged") } }) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 64e82629..3f4410c0 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,67 @@ contextBridge.exposeInMainWorld("heynote", { }, buffer: { - async load() { - return await ipcRenderer.invoke("buffer-content:load") + async exists(path) { + return await ipcRenderer.invoke("buffer:exists", path) + }, + + async getList() { + return await ipcRenderer.invoke("buffer:getList") + }, + + async getDirectoryList() { + return await ipcRenderer.invoke("buffer:getDirectoryList") }, - async save(content) { - return await ipcRenderer.invoke("buffer-content:save", content) + async load(path) { + return await ipcRenderer.invoke("buffer:load", path) }, - async saveAndQuit(content) { - return await ipcRenderer.invoke("buffer-content:saveAndQuit", content) + async save(path, content) { + return await ipcRenderer.invoke("buffer:save", path, content) }, - onChangeCallback(callback) { - ipcRenderer.on("buffer-content:change", callback) + async delete(path) { + return await ipcRenderer.invoke("buffer:delete", path) + }, + + async move(path, newPath) { + return await ipcRenderer.invoke("buffer:move", path, newPath) + }, + + async create(path, content) { + return await ipcRenderer.invoke("buffer:create", 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") - } + return await ipcRenderer.invoke("library:selectLocation") + }, + + setLibraryPathChangeCallback(callback) { + ipcRenderer.on("library:pathChanged", callback) + }, }, settings: CONFIG.get("settings"), @@ -103,7 +156,15 @@ contextBridge.exposeInMainWorld("heynote", { async getVersion() { return await ipcRenderer.invoke("getVersion") - } + }, + + async getInitErrors() { + return await ipcRenderer.invoke("getInitErrors") + }, + + setWindowTitle(title) { + ipcRenderer.invoke("setWindowTitle", title) + }, }) diff --git a/package-lock.json b/package-lock.json index 4a376c0c..db7dde77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,19 @@ { "name": "Heynote", - "version": "1.8.0", + "version": "2.0.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Heynote", - "version": "1.8.0", + "version": "2.0.0-alpha.2", "license": "Commons Clause MIT", "dependencies": { - "electron-log": "^5.0.1" + "@sindresorhus/slugify": "^2.2.1", + "electron-log": "^5.0.1", + "fuzzysort": "^3.0.2", + "pinia": "^2.1.7", + "semver": "^7.6.3" }, "devDependencies": { "@codemirror/autocomplete": "^6.11.1", @@ -241,7 +245,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" }, @@ -572,6 +575,15 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@electron/universal": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.2.1.tgz", @@ -1003,8 +1015,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", @@ -1508,6 +1519,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", @@ -1722,7 +1784,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", @@ -1734,7 +1795,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" @@ -1744,7 +1804,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", @@ -1762,12 +1821,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", @@ -1821,7 +1884,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" } @@ -1830,7 +1892,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", @@ -1843,7 +1904,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" @@ -1853,7 +1913,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", @@ -1864,7 +1923,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" @@ -1876,8 +1934,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", @@ -2089,21 +2146,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/app-builder-lib/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -2721,21 +2763,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/conf/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -2776,8 +2803,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", @@ -3465,21 +3491,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/electron-updater/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/electron-updater/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -3605,8 +3616,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", @@ -3824,6 +3834,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzysort": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.0.2.tgz", + "integrity": "sha512-ZyahVgxvckB1Qosn7YGWLDJJp2XlyaQ2WmZeI+d0AzW0AMqVYnz5N89G6KAKa6m/LOtv+kzJn4lhDF/yVg11Cg==" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3914,22 +3929,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "optional": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globalthis": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", @@ -4495,7 +4494,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" }, @@ -4662,7 +4660,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -4897,8 +4894,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", @@ -4912,6 +4908,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", @@ -4986,7 +5032,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", @@ -5367,12 +5412,14 @@ "dev": true }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -5492,7 +5539,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" } @@ -5798,7 +5844,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" @@ -6344,7 +6390,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", @@ -6388,21 +6433,6 @@ "typescript": "*" } }, - "node_modules/vue-tsc/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 48493c7c..785f5f6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Heynote", - "version": "1.8.0", + "version": "2.0.0-alpha.2", "main": "dist-electron/main/index.js", "description": "A dedicated scratch pad", "author": "Jonatan Heyman (https://heyman.info)", @@ -78,6 +78,10 @@ "vue-tsc": "^1.0.16" }, "dependencies": { - "electron-log": "^5.0.1" + "@sindresorhus/slugify": "^2.2.1", + "electron-log": "^5.0.1", + "fuzzysort": "^3.0.2", + "pinia": "^2.1.7", + "semver": "^7.6.3" } } 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/shared-utils/key-helper.ts b/shared-utils/key-helper.ts index 99300427..900a78f8 100644 --- a/shared-utils/key-helper.ts +++ b/shared-utils/key-helper.ts @@ -9,6 +9,8 @@ export const keyHelpStr = (platform: string) => { [`${altChar} + Shift + Enter`, "Add new block at the start of the buffer"], [`${modChar} + ${altChar} + Enter`, "Split the current block at cursor position"], [`${modChar} + L`, "Change block language"], + [`${modChar} + S`, "Create a new note from the current block"], + [`${modChar} + P`, "Open note selector"], [`${modChar} + Down`, "Goto next block"], [`${modChar} + Up`, "Goto previous block"], [`${modChar} + A`, "Select all text in a note block. Press again to select the whole buffer"], diff --git a/src/common/constants.js b/src/common/constants.js new file mode 100644 index 00000000..a1359aa6 --- /dev/null +++ b/src/common/constants.js @@ -0,0 +1,2 @@ +export const SCRATCH_FILE_NAME = "scratch.txt" +export const AUTO_SAVE_INTERVAL = 2000 diff --git a/src/common/note-format.js b/src/common/note-format.js new file mode 100644 index 00000000..cb5ceb26 --- /dev/null +++ b/src/common/note-format.js @@ -0,0 +1,45 @@ +import { major } from "semver"; + + +const FORMAT_VERSION = "1.0.0" + + +export class NoteFormat { + constructor() { + this.content = ''; + this.metadata = {formatVersion: "0.0.0"}; + } + + static load(data) { + const note = new NoteFormat(); + + note.content = data + const firstSeparator = data.indexOf("\n∞∞∞") + if (firstSeparator !== -1) { + const metadataContent = data.slice(0, firstSeparator).trim() + if (metadataContent !== "") { + note.metadata = JSON.parse(metadataContent) + } + note.content = data.slice(firstSeparator) + } + + if (major(note.metadata.formatVersion) > major(FORMAT_VERSION)) { + throw new Error(`Unsupported Heynote format version: ${note.metadata.formatVersion}. You probably need to update Heynote.`) + } + + return note + } + + serialize() { + this.metadata.formatVersion = FORMAT_VERSION + return JSON.stringify(this.metadata) + this.content + } + + set cursors(cursors) { + this.metadata.cursors = cursors + } + + get cursors() { + return this.metadata.cursors + } +} diff --git a/src/components/App.vue b/src/components/App.vue index 08234d50..1de94f0f 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,8 +1,18 @@ + + + + diff --git a/src/components/EditBuffer.vue b/src/components/EditBuffer.vue new file mode 100644 index 00000000..4c03486f --- /dev/null +++ b/src/components/EditBuffer.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/src/components/Editor.vue b/src/components/Editor.vue index b3bc9389..7bf8543b 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -1,6 +1,13 @@ + + + + + + diff --git a/src/components/LanguageSelector.vue b/src/components/LanguageSelector.vue index ac91760a..b579ef2a 100644 --- a/src/components/LanguageSelector.vue +++ b/src/components/LanguageSelector.vue @@ -1,4 +1,5 @@ + + + + diff --git a/src/components/StatusBar.vue b/src/components/StatusBar.vue index 0676dd91..170ba09f 100644 --- a/src/components/StatusBar.vue +++ b/src/components/StatusBar.vue @@ -1,19 +1,14 @@ + + + + diff --git a/src/components/folder-selector/FolderSelector.vue b/src/components/folder-selector/FolderSelector.vue new file mode 100644 index 00000000..30417e31 --- /dev/null +++ b/src/components/folder-selector/FolderSelector.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/src/components/folder-selector/NewFolderItem.vue b/src/components/folder-selector/NewFolderItem.vue new file mode 100644 index 00000000..06e28403 --- /dev/null +++ b/src/components/folder-selector/NewFolderItem.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/components/folder-selector/sanitize-filename.js b/src/components/folder-selector/sanitize-filename.js new file mode 100644 index 00000000..7693e7c3 --- /dev/null +++ b/src/components/folder-selector/sanitize-filename.js @@ -0,0 +1,14 @@ +const illegalRe = /[\/\?<>\\:\*\|"]/g; +const controlRe = /[\x00-\x1f\x80-\x9f]/g; +const reservedRe = /^\.+$/; +const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; +const windowsTrailingRe = /[\. ]+$/; + +export default function sanitizeFilename(input, replacement) { + return input.trim() + .replace(illegalRe, replacement) + .replace(controlRe, replacement) + .replace(reservedRe, replacement) + .replace(windowsReservedRe, replacement) + .replace(windowsTrailingRe, replacement) +} diff --git a/src/components/settings/Settings.vue b/src/components/settings/Settings.vue index 57d98e95..f142b704 100644 --- a/src/components/settings/Settings.vue +++ b/src/components/settings/Settings.vue @@ -14,6 +14,7 @@ props: { initialKeymap: String, initialSettings: Object, + themeSetting: String, }, components: { KeyboardHotkey, @@ -60,6 +61,7 @@ systemFonts: [[defaultFontFamily, defaultFontFamily + " (default)"]], defaultFontSize: defaultFontSize, appVersion: "", + theme: this.themeSetting, } }, @@ -109,6 +111,9 @@ if (!this.showInDock) { this.showInMenu = true } + if (this.theme != this.themeSetting) { + this.$emit("setTheme", this.theme) + } }, async selectBufferLocation() { @@ -293,6 +298,16 @@ +
+
+

Color Theme

+ +
+

Gutters

diff --git a/src/editor/annotation.js b/src/editor/annotation.js index 6b4e83cb..fae768ef 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 57e2bb11..cb382969 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 { heynoteEvent, LANGUAGE_CHANGE } from "../annotation.js"; -import { SelectionChangeEvent } from "../event.js" +import { useHeynoteStore } from "../../stores/heynote-store.js" +import { heynoteEvent, LANGUAGE_CHANGE, CURSOR_CHANGE } from "../annotation.js"; import { mathBlock } from "./math.js" import { emptyBlockSelected } from "./select-all.js"; @@ -404,32 +404,43 @@ 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 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, - })) +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 heynoteStore = useHeynoteStore() + 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 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( + (sel) => getSelectionSize(update.state, sel) + ).reduce((a, b) => a + b, 0) + + const block = getActiveNoteBlock(update.state) + if (block && cursorLine) { + heynoteStore.currentCursorLine = cursorLine + heynoteStore.currentSelectionSize = selectionSize + heynoteStore.currentLanguage = block.language.name + heynoteStore.currentLanguageAuto = block.language.auto + heynoteStore.currentBufferName = editor.name + } } } } - } -) + ) +} export const noteBlockExtension = (editor) => { return [ diff --git a/src/editor/block/commands.js b/src/editor/block/commands.js index b55c50bf..fe9bf3bb 100644 --- a/src/editor/block/commands.js +++ b/src/editor/block/commands.js @@ -1,5 +1,6 @@ -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 +8,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 +318,26 @@ 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 = "" + let newSelection = block.delimiter.from + if (blocks.length == 1) { + replace = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect) + newSelection = replace.length + } + dispatch(state.update({ + changes: { + from: block.range.from, + to: block.range.to, + insert: replace, + }, + selection: EditorSelection.cursor(newSelection), + annotations: [heynoteEvent.of(DELETE_BLOCK)], })) } diff --git a/src/editor/editor.js b/src/editor/editor.js index ecc1f5ba..85317f68 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -1,4 +1,4 @@ -import { Annotation, EditorState, Compartment, Facet } 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" @@ -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" @@ -21,8 +21,11 @@ import { languageDetection } from "./language-detection/autodetect.js" import { autoSaveContent } from "./save.js" import { todoCheckboxPlugin} from "./todo-checkbox.ts" import { links } from "./links.js" +import { NoteFormat } from "../common/note-format.js" +import { AUTO_SAVE_INTERVAL } from "../common/constants.js" +import { useHeynoteStore } from "../stores/heynote-store.js"; +import { useErrorStore } from "../stores/error-store.js"; -export const LANGUAGE_SELECTOR_EVENT = "openLanguageSelector" function getKeymapExtensions(editor, keymap) { if (keymap === "emacs") { @@ -36,10 +39,10 @@ function getKeymapExtensions(editor, keymap) { export class HeynoteEditor { constructor({ element, + path, content, focus=true, theme="light", - saveFunction=null, keymap="default", emacsMetaKey, showLineNumberGutter=true, @@ -47,8 +50,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 @@ -59,11 +65,15 @@ export class HeynoteEditor { this.deselectOnCopy = keymap === "emacs" this.emacsMetaKey = emacsMetaKey this.fontTheme = new Compartment - this.defaultBlockToken = "text" - this.defaultBlockAutoDetect = true + this.setDefaultBlockLanguage(defaultBlockToken, defaultBlockAutoDetect) + this.contentLoaded = false + this.notesStore = useHeynoteStore() + this.errorStore = useErrorStore() + this.name = "" + const state = EditorState.create({ - doc: content || "", + doc: "", extensions: [ this.keymapCompartment.of(getKeymapExtensions(this, keymap)), heynoteCopyCut(this), @@ -86,7 +96,7 @@ export class HeynoteEditor { }), heynoteLang(), noteBlockExtension(this), - languageDetection(() => this), + languageDetection(path, () => this), // set cursor blink rate to 1 second drawSelection({cursorBlinkRate:1000}), @@ -96,7 +106,7 @@ export class HeynoteEditor { return {class: view.state.facet(EditorView.darkTheme) ? "dark-theme" : "light-theme"} }), - saveFunction ? autoSaveContent(saveFunction, 2000) : [], + autoSaveContent(this, AUTO_SAVE_INTERVAL), todoCheckboxPlugin, markdown(), @@ -105,49 +115,118 @@ export class HeynoteEditor { }) // make sure saveFunction is called when page is unloaded - if (saveFunction) { - window.addEventListener("beforeunload", () => { - saveFunction(this.getContent()) - }) - } + window.addEventListener("beforeunload", () => { + this.save() + }) this.view = new EditorView({ state: state, parent: element, }) - - // Ensure we have a parsed syntax tree when buffer is loaded. This prevents errors for large buffers - // when moving the cursor to the end of the buffer when the program starts - ensureSyntaxTree(state, state.doc.length, 5000) + + //this.setContent(content) + this.setReadOnly(true) + this.loadContent().then(() => { + this.setReadOnly(false) + }) if (focus) { - this.view.dispatch({ - selection: {anchor: this.view.state.doc.length, head: this.view.state.doc.length}, - scrollIntoView: true, - }) this.view.focus() } } + 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() { - return this.view.state.sliceDoc() + 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) { - this.view.dispatch({ - changes: { - from: 0, - to: this.view.state.doc.length, - insert: content, - }, - annotations: [heynoteEvent.of(SET_CONTENT)], - }) - this.view.dispatch({ - selection: {anchor: this.view.state.doc.length, head: this.view.state.doc.length}, - scrollIntoView: true, + try { + this.note = NoteFormat.load(content) + this.setReadOnly(false) + } catch (e) { + this.setReadOnly(true) + this.errorStore.addError(`Failed to load note: ${e.message}`) + throw new Error(`Failed to load note: ${e.message}`) + } + this.name = this.note.metadata?.name || this.path + + return new Promise((resolve) => { + // set buffer content + this.view.dispatch({ + changes: { + from: 0, + to: this.view.state.doc.length, + insert: this.note.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 + // when moving the cursor to the end of the buffer when the program starts + ensureSyntaxTree(this.view.state, this.view.state.doc.length, 5000) + + // Set cursor positions + // We use requestAnimationFrame to avoid a race condition causing the scrollIntoView to sometimes not work + requestAnimationFrame(() => { + if (this.note.cursors) { + this.view.dispatch({ + selection: EditorSelection.fromJSON(this.note.cursors), + scrollIntoView: true, + }) + } else { + // if metadata doesn't contain cursor position, we set the cursor to the end of the buffer + this.view.dispatch({ + selection: {anchor: this.view.state.doc.length, head: this.view.state.doc.length}, + scrollIntoView: true, + }) + } + resolve() + }) }) } + setName(name) { + this.note.metadata.name = name + this.name = name + triggerCursorChange(this.view) + } + getBlocks() { return this.view.state.facet(blockState) } @@ -187,7 +266,44 @@ export class HeynoteEditor { } openLanguageSelector() { - this.element.dispatchEvent(new Event(LANGUAGE_SELECTOR_EVENT)) + this.notesStore.openLanguageSelector() + } + + openBufferSelector() { + this.notesStore.openBufferSelector() + } + + openCreateBuffer(createMode) { + this.notesStore.openCreateBuffer(createMode) + } + + async createNewBuffer(path, name) { + const data = getBlockDelimiter(this.defaultBlockToken, this.defaultBlockAutoDetect) + await this.notesStore.saveNewBuffer(path, name, data) + + // 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.openBuffer(path) + }) + } + + async createNewBufferFromActiveBlock(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.saveNewBuffer(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.openBuffer(path) + }) } setCurrentLanguage(lang, auto=false) { @@ -227,6 +343,27 @@ export class HeynoteEditor { currenciesLoaded() { triggerCurrenciesLoaded(this.view.state, this.view.dispatch) } + + destroy(save=true) { + if (this.onChange) { + window.heynote.buffer.removeOnChangeCallback(this.path, this.onChange) + } + if (save) { + this.save() + } + 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/emacs.js b/src/editor/emacs.js index b0022330..53fdfb23 100644 --- a/src/editor/emacs.js +++ b/src/editor/emacs.js @@ -103,8 +103,6 @@ export function emacsKeymap(editor) { { key: "Ctrl-b", run: emacsMoveCommand(cursorCharLeft, selectCharLeft), shift: selectCharLeft }, { key: "Ctrl-f", run: emacsMoveCommand(cursorCharRight, selectCharRight), shift: selectCharRight }, - { key: "Ctrl-p", run: emacsMoveCommand(cursorLineUp, selectLineUp), shift: selectLineUp }, - { key: "Ctrl-n", run: emacsMoveCommand(cursorLineDown, selectLineDown), shift: selectLineDown }, { key: "Ctrl-a", run: emacsMoveCommand(cursorLineStart, selectLineStart), shift: selectLineStart }, { key: "Ctrl-e", run: emacsMoveCommand(cursorLineEnd, selectLineEnd), shift: selectLineEnd }, ])), diff --git a/src/editor/event.js b/src/editor/event.js deleted file mode 100644 index 34f59601..00000000 --- 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 a7e32ed1..15914ef3 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" @@ -57,6 +58,10 @@ export function heynoteKeymap(editor) { ["Alt-ArrowUp", moveLineUp], ["Alt-ArrowDown", moveLineDown], ["Mod-l", () => editor.openLanguageSelector()], + ["Mod-p", () => editor.openBufferSelector()], + ["Mod-s", () => editor.openCreateBuffer("currentBlock")], + ["Mod-n", () => editor.openCreateBuffer("new")], + ["Mod-Shift-d", deleteBlock(editor)], ["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 81b74220..4763f6f3 100644 --- a/src/editor/save.js +++ b/src/editor/save.js @@ -1,20 +1,24 @@ import { ViewPlugin } from "@codemirror/view" import { debounce } from "debounce" +import { SET_CONTENT }  from "./annotation" -export const autoSaveContent = (saveFunction, interval) => { - const save = debounce((view) => { +export const autoSaveContent = (editor, interval) => { + const save = debounce(() => { //console.log("saving buffer") - saveFunction(view.state.sliceDoc()) + editor.save() }, interval); return ViewPlugin.fromClass( class { update(update) { if (update.docChanged) { - save(update.view) + 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..dd29cd10 100644 --- a/src/main.js +++ b/src/main.js @@ -1,17 +1,32 @@ 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' +import { useHeynoteStore, initHeynoteStore } from './stores/heynote-store' +import { useEditorCacheStore } from './stores/editor-cache' +const pinia = createPinia() const app = createApp(App) +app.use(pinia) app.mount('#app').$nextTick(() => { // hide loading screen postMessage({ payload: 'removeLoading' }, '*') }) +const errorStore = useErrorStore() +const editorCacheStore = useEditorCacheStore() +//errorStore.addError("test error") +window.heynote.getInitErrors().then((errors) => { + errors.forEach((e) => errorStore.addError(e)) +}) + +initHeynoteStore() @@ -19,3 +34,4 @@ app.mount('#app').$nextTick(() => { loadCurrencies() setInterval(loadCurrencies, 1000 * 3600 * 4) +window.heynote.init() diff --git a/src/stores/editor-cache.js b/src/stores/editor-cache.js new file mode 100644 index 00000000..a8b41e9c --- /dev/null +++ b/src/stores/editor-cache.js @@ -0,0 +1,57 @@ +import { toRaw } from 'vue'; +import { defineStore } from "pinia" +import { NoteFormat } from "../common/note-format" + +const NUM_EDITOR_INSTANCES = 5 + +export const useEditorCacheStore = defineStore("editorCache", { + state: () => ({ + editorCache: { + lru: [], + cache: {}, + }, + }), + + actions: { + getEditor(path) { + // move to end of LRU + this.editorCache.lru = this.editorCache.lru.filter(p => p !== path) + this.editorCache.lru.push(path) + + if (this.editorCache.cache[path]) { + return this.editorCache.cache[path] + } + }, + + addEditor(path, editor) { + if (this.editorCache.lru.length >= NUM_EDITOR_INSTANCES) { + const pathToFree = this.editorCache.lru.shift() + this.freeEditor(pathToFree) + } + + this.editorCache.cache[path] = editor + }, + + freeEditor(pathToFree) { + if (!this.editorCache.cache[pathToFree]) { + return + } + this.editorCache.cache[pathToFree].destroy() + delete this.editorCache.cache[pathToFree] + this.editorCache.lru = this.editorCache.lru.filter(p => p !== pathToFree) + }, + + eachEditor(fn) { + Object.values(toRaw(this.editorCache.cache)).forEach(fn) + }, + + clearCache(save=true) { + console.log("Clearing editor cache") + this.eachEditor((editor) => { + editor.destroy(save=save) + }) + this.editorCache.cache = {} + this.editorCache.lru = [] + }, + }, +}) 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/src/stores/heynote-store.js b/src/stores/heynote-store.js new file mode 100644 index 00000000..589d5fc4 --- /dev/null +++ b/src/stores/heynote-store.js @@ -0,0 +1,169 @@ +import { toRaw } from 'vue'; +import { defineStore } from "pinia" +import { NoteFormat } from "../common/note-format" +import { useEditorCacheStore } from "./editor-cache" +import { SCRATCH_FILE_NAME } from "../common/constants" + + +export const useHeynoteStore = defineStore("heynote", { + state: () => ({ + buffers: {}, + recentBufferPaths: [SCRATCH_FILE_NAME], + + currentEditor: null, + currentBufferPath: SCRATCH_FILE_NAME, + currentBufferName: null, + currentLanguage: null, + currentLanguageAuto: null, + currentCursorLine: null, + currentSelectionSize: null, + libraryId: 0, + createBufferParams: { + mode: "new", + nameSuggestion: "" + }, + + showBufferSelector: false, + showLanguageSelector: false, + showCreateBuffer: false, + showEditBuffer: false, + }), + + actions: { + async updateBuffers() { + this.setBuffers(await window.heynote.buffer.getList()) + }, + + setBuffers(buffers) { + this.buffers = buffers + }, + + openBuffer(path) { + this.closeDialog() + this.currentBufferPath = path + + const recent = this.recentBufferPaths.filter((p) => p !== path) + recent.unshift(path) + this.recentBufferPaths = recent.slice(0, 100) + }, + + openLanguageSelector() { + this.closeDialog() + this.showLanguageSelector = true + }, + openBufferSelector() { + this.closeDialog() + this.showBufferSelector = true + }, + openCreateBuffer(createMode, nameSuggestion) { + createMode = createMode || "new" + this.closeDialog() + this.createBufferParams = { + mode: createMode || "new", + name: nameSuggestion || "" + } + this.showCreateBuffer = true + }, + closeDialog() { + this.showCreateBuffer = false + this.showBufferSelector = false + this.showLanguageSelector = false + this.showEditBuffer = false + }, + + closeBufferSelector() { + this.showBufferSelector = false + }, + + editBufferMetadata(path) { + if (this.currentBufferPath !== path) { + this.openBuffer(path) + } + this.closeDialog() + this.showEditBuffer = true + }, + + /** + * Create a new note file at `path` with name `name` from the current block of the current open editor, + * and switch to it + */ + async createNewBufferFromActiveBlock(path, name) { + await toRaw(this.currentEditor).createNewBufferFromActiveBlock(path, name) + }, + + /** + * Create a new empty note file at `path` with name `name`, and switch to it + */ + async createNewBuffer(path, name) { + await toRaw(this.currentEditor).createNewBuffer(path, name) + }, + + /** + * Create a new note file at path, with name `name`, and content content + * @param {*} path: File path relative to Heynote root + * @param {*} name Name of the note + * @param {*} content Contents (without metadata) + */ + async saveNewBuffer(path, name, content) { + if (this.buffers[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.updateBuffers() + }, + + async updateBufferMetadata(path, name, newPath) { + const editorCacheStore = useEditorCacheStore() + + if (this.currentEditor.path !== path) { + throw new Error(`Can't update note (${path}) since it's not the active one (${this.currentEditor.path})`) + } + //console.log("currentEditor", this.currentEditor) + toRaw(this.currentEditor).setName(name) + await (toRaw(this.currentEditor)).save() + if (newPath && path !== newPath) { + //console.log("moving note", path, newPath) + editorCacheStore.freeEditor(path) + await window.heynote.buffer.move(path, newPath) + this.openBuffer(newPath) + this.updateBuffers() + } + }, + + async deleteBuffer(path) { + if (path === SCRATCH_FILE_NAME) { + throw new Error("Can't delete scratch file") + } + const editorCacheStore = useEditorCacheStore() + if (this.currentEditor.path === path) { + this.currentEditor = null + this.currentBufferPath = SCRATCH_FILE_NAME + } + editorCacheStore.freeEditor(path) + await window.heynote.buffer.delete(path) + await this.updateBuffers() + }, + + async reloadLibrary() { + const editorCacheStore = useEditorCacheStore() + await this.updateBuffers() + editorCacheStore.clearCache(false) + this.currentEditor = null + this.currentBufferPath = SCRATCH_FILE_NAME + this.libraryId++ + }, + }, +}) + +export async function initHeynoteStore() { + const heynoteStore = useHeynoteStore() + window.heynote.buffer.setLibraryPathChangeCallback(() => { + heynoteStore.reloadLibrary() + }) + await heynoteStore.updateBuffers() +} diff --git a/tests/block-creation.spec.js b/tests/block-creation.spec.js index 2d9e54c4..b00a55f1 100644 --- a/tests/block-creation.spec.js +++ b/tests/block-creation.spec.js @@ -8,7 +8,7 @@ test.beforeEach(async ({page}) => { await heynotePage.goto() expect((await heynotePage.getBlocks()).length).toBe(1) - heynotePage.setContent(` + await heynotePage.setContent(` ∞∞∞text Block A ∞∞∞text diff --git a/tests/buffer-creation.spec.js b/tests/buffer-creation.spec.js new file mode 100644 index 00000000..61b94b6e --- /dev/null +++ b/tests/buffer-creation.spec.js @@ -0,0 +1,97 @@ +import {expect, test} from "@playwright/test"; +import {HeynotePage} from "./test-utils.js"; + +import { AUTO_SAVE_INTERVAL } from "../src/common/constants.js" +import { NoteFormat } from "../src/common/note-format.js" +import exp from "constants"; + +let heynotePage + +test.beforeEach(async ({page}) => { + heynotePage = new HeynotePage(page) + await heynotePage.goto() + + expect((await heynotePage.getBlocks()).length).toBe(1) + await heynotePage.setContent(` +∞∞∞text +Block A +∞∞∞text +Block B +∞∞∞text +Block C`) + await page.waitForTimeout(100); + // check that blocks are created + expect((await heynotePage.getBlocks()).length).toBe(3) + + // check that visual block layers are created + await expect(page.locator("css=.heynote-blocks-layer > div")).toHaveCount(3) +}); + + +test("default buffer saved", async ({page}) => { + // make some change and make sure content is auto saved in default scratch buffer + await page.locator("body").pressSequentially("YAY") + await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50); + const bufferList = await heynotePage.getStoredBufferList() + expect(Object.keys(bufferList).length).toBe(1) + expect(bufferList["scratch.txt"]).toBeTruthy() +}) + +test("create new buffer from block", async ({page}) => { + await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) + await page.waitForTimeout(50) + await page.locator("body").pressSequentially("My New Buffer") + await page.locator("body").press("Enter") + await page.waitForTimeout(50) + await page.locator("body").press("Enter") + await page.locator("body").pressSequentially("New buffer content") + await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50); + + const buffers = Object.keys(await heynotePage.getStoredBufferList()) + expect(buffers).toContain("scratch.txt") + expect(buffers).toContain("my-new-buffer.txt") + + const defaultBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("scratch.txt")) + const newBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("my-new-buffer.txt")) + + expect(defaultBuffer.content).toBe(` +∞∞∞text +Block A +∞∞∞text +Block B`) + + expect(newBuffer.content).toBe(` +∞∞∞text +Block C +New buffer content`) + +}) + + +test("create new empty note", async ({page}) => { + await page.locator("body").press("Enter") + await page.locator("body").press("Backspace") + await page.locator("body").press(heynotePage.agnosticKey("Mod+N")) + await page.locator("body").pressSequentially("New Empty Buffer") + await page.locator("body").press("Enter") + await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50); + + const buffers = Object.keys(await heynotePage.getStoredBufferList()) + expect(buffers).toContain("scratch.txt") + expect(buffers).toContain("new-empty-buffer.txt") + + const defaultBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("scratch.txt")) + const newBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("new-empty-buffer.txt")) + + expect(defaultBuffer.content).toBe(` +∞∞∞text +Block A +∞∞∞text +Block B +∞∞∞text +Block C`) + + expect(newBuffer.content).toBe(` +∞∞∞text-a +`) +}) diff --git a/tests/formatting.spec.js b/tests/formatting.spec.js index 5b5bbac4..4a633100 100644 --- a/tests/formatting.spec.js +++ b/tests/formatting.spec.js @@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => { test("JSON formatting", async ({ page }) => { - heynotePage.setContent(` + await heynotePage.setContent(` ∞∞∞json {"test": 1, "key2": "hey!"} `) @@ -25,7 +25,7 @@ test("JSON formatting", async ({ page }) => { }) test("JSON formatting (cursor at start)", async ({ page }) => { - heynotePage.setContent(` + await heynotePage.setContent(` ∞∞∞json {"test": 1, "key2": "hey!"} `) diff --git a/tests/note-format.spec.js b/tests/note-format.spec.js new file mode 100644 index 00000000..9822d039 --- /dev/null +++ b/tests/note-format.spec.js @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; +import { HeynotePage } from "./test-utils.js"; +import { NoteFormat } from "../src/common/note-format.js"; + +let heynotePage + +test.beforeEach(async ({ page }) => { + heynotePage = new HeynotePage(page) + await heynotePage.goto() +}); + + +test("test restore cursor position", async ({ page, browserName }) => { + await heynotePage.setContent(`{"formatVersion":"1.0.0", "cursors":{"ranges":[{"anchor":13,"head":13}],"main":0}} +∞∞∞text +Textblock`) + await page.locator("body").press((heynotePage.isMac ? "Meta" : "Control") + "+Alt+Enter") + expect(await heynotePage.getContent()).toBe(` +∞∞∞text +Text +∞∞∞text +block`) +}) + + +test("test save cursor positions", async ({ page, browserName }) => { + await heynotePage.setContent(`{"formatVersion":"1.0.0", "cursors":{"ranges":[{"anchor":9,"head":9}],"main":0}} +∞∞∞text +this +is +a +text +block`) + await page.locator("body").press((heynotePage.isMac ? "Meta" : "Control") + "+Alt+ArrowDown") + await page.locator("body").press((heynotePage.isMac ? "Meta" : "Control") + "+Alt+ArrowDown") + await page.locator("body").press("Delete") + expect(await heynotePage.getContent()).toBe(` +∞∞∞text +his +s + +text +block`) + + const bufferData = await heynotePage.getBufferData() + const note = NoteFormat.load(bufferData) + expect(note.cursors.ranges.length).toBe(3) +}) + +test("unknown note metadata keys is kept", async ({ page, browserName }) => { + await heynotePage.setContent(`{"yoda":[123], "formatVersion":"1.0.0", "cursors":{"ranges":[{"anchor":15,"head":15}],"main":0}} +∞∞∞text +block 1`) + await page.locator("body").pressSequentially("hello") + expect(await heynotePage.getContent()).toBe(` +∞∞∞text +block hello1`) + + const bufferData = await heynotePage.getBufferData() + const note = NoteFormat.load(bufferData) + expect(note.metadata.yoda).toStrictEqual([123]) +}) \ No newline at end of file diff --git a/tests/test-utils.js b/tests/test-utils.js index 9e841c47..209701ac 100644 --- a/tests/test-utils.js +++ b/tests/test-utils.js @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { NoteFormat } from '../src/common/note-format.js'; export function pageErrorGetter(page) { let messages = []; @@ -26,10 +27,15 @@ export class HeynotePage { return await this.page.evaluate(() => window._heynote_editor.getBlocks()) } - async getContent() { + async getBufferData() { return await this.page.evaluate(() => window._heynote_editor.getContent()) } + async getContent() { + const note = NoteFormat.load(await this.getBufferData()) + return note.content + } + async setContent(content) { await expect(this.page.locator("css=.cm-editor")).toBeVisible() await this.page.evaluate((content) => window._heynote_editor.setContent(content), content) @@ -50,4 +56,16 @@ export class HeynotePage { async getStoredSettings() { return await this.page.evaluate(() => JSON.parse(window.localStorage.getItem("settings"))) } + + async getStoredBufferList() { + return await this.page.evaluate(() => window.heynote.buffer.getList()) + } + + async getStoredBuffer(path) { + return await this.page.evaluate((path) => window.heynote.buffer.load(path), path) + } + + agnosticKey(key) { + return key.replace("Mod", this.isMac ? "Meta" : "Control") + } } diff --git a/webapp/bridge.js b/webapp/bridge.js index 06ef81b2..21866c57 100644 --- a/webapp/bridge.js +++ b/webapp/bridge.js @@ -1,4 +1,8 @@ +import { Exception } from "sass"; import { SETTINGS_CHANGE_EVENT, OPEN_SETTINGS_EVENT } from "../electron/constants"; +import { NoteFormat } from "../src/common/note-format"; + +const NOTE_KEY_PREFIX = "heynote-library__" const mediaMatch = window.matchMedia('(prefers-color-scheme: dark)') let themeCallback = null @@ -73,6 +77,49 @@ if (settingsData !== null) { initialSettings = Object.assign(initialSettings, JSON.parse(settingsData)) } +function noteKey(path) { + return NOTE_KEY_PREFIX + path +} + +function getNoteMetadata(content) { + const firstSeparator = content.indexOf("\n∞∞∞") + if (firstSeparator === -1) { + return null + } + try { + const metadata = JSON.parse(content.slice(0, firstSeparator).trim()) + return {"name": metadata.name} + } catch (e) { + return {} + } +} + +// Migrate single buffer (Heynote pre 2.0) in localStorage to notes library +// At some point we can remove this migration code +function migrateBufferFileToLibrary() { + if (!("buffer" in localStorage)) { + // nothing to migrate + return + } + if (Object.keys(localStorage).filter(key => key.startsWith(NOTE_KEY_PREFIX)).length > 0) { + // already migrated + return + } + + console.log("Migrating single buffer to notes library") + + let content = localStorage.getItem("buffer") + const metadata = getNoteMetadata(content) + if (!metadata || !metadata.name) { + console.log("Adding metadata to Scratch note") + const note = NoteFormat.load(content) + note.metadata.name = "Scratch" + content = note.serialize() + } + localStorage.setItem("heynote-library__scratch.txt", content) + localStorage.removeItem("buffer") +} +migrateBufferFileToLibrary() const Heynote = { platform: platform, @@ -80,20 +127,77 @@ const Heynote = { defaultFontSize: isMobileDevice ? 16 : 12, buffer: { - async load() { - const content = localStorage.getItem("buffer") - return content === null ? "\n∞∞∞text-a\n" : content + async load(path) { + //console.log("loading", path) + const content = localStorage.getItem(noteKey(path)) + return content === null ? '{"formatVersion":"1.0.0","name":"Scratch"}\n∞∞∞text-a\n' : content + }, + + async save(path, content) { + //console.log("saving", path, content) + localStorage.setItem(noteKey(path), content) + }, + + async create(path, content) { + localStorage.setItem(noteKey(path), content) + }, + + async delete(path) { + localStorage.removeItem(noteKey(path)) + }, + + async move(path, newPath) { + const content = localStorage.getItem(noteKey(path)) + localStorage.setItem(noteKey(newPath), content) + localStorage.removeItem(noteKey(path)) + }, + + async saveAndQuit(contents) { + + }, + + async exists(path) { + return localStorage.getItem(noteKey(path)) !== null + }, + + async getList() { + //return {"scratch.txt": {name:"Scratch"}} + const notes = {} + for (let [key, content] of Object.entries(localStorage)) { + if (key.startsWith(NOTE_KEY_PREFIX)) { + const path = key.slice(NOTE_KEY_PREFIX.length) + notes[path] = getNoteMetadata(content) + } + } + return notes }, - async save(content) { - localStorage.setItem("buffer", content) + async getDirectoryList() { + const directories = new Set() + for (let key in localStorage) { + if (key.startsWith(NOTE_KEY_PREFIX)) { + const path = key.slice(NOTE_KEY_PREFIX.length) + const parts = path.split("/") + if (parts.length > 1) { + for (let i = 1; i < parts.length; i++) { + directories.add(parts.slice(0, i).join("/")) + } + } + } + } + //console.log("directories", directories) + return [...directories] }, - async saveAndQuit(content) { + async close(path) { }, - onChangeCallback(callback) { + _onChangeCallbacks: {}, + addOnChangeCallback(path, callback) { + + }, + removeOnChangeCallback(path, callback) { }, }, @@ -121,7 +225,7 @@ const Heynote = { set: (mode) => { localStorage.setItem("theme", mode) themeCallback(mode) - console.log("set theme to", mode) + //console.log("set theme to", mode) }, get: async () => { const theme = localStorage.getItem("theme") || "system" @@ -152,6 +256,14 @@ const Heynote = { async getVersion() { return __APP_VERSION__ + " (" + __GIT_HASH__ + ")" }, + + async getInitErrors() { + + }, + + setWindowTitle(title) { + document.title = title + " - Heynote" + }, } export { Heynote, ipcRenderer} diff --git a/webapp/main.js b/webapp/main.js index 01d18da9..f02d7936 100644 --- a/webapp/main.js +++ b/webapp/main.js @@ -1,10 +1,14 @@ import '../src/css/application.sass' import { createApp } from 'vue' +import { createPinia } from 'pinia' + import App from '../src/components/App.vue' import { loadCurrencies } from '../src/currency' +const pinia = createPinia() const app = createApp(App) +app.use(pinia) app.mount('#app') //console.log("test:", app.hej.test) diff --git a/webapp/vite.config.js b/webapp/vite.config.js index 7191747b..748e3ead 100644 --- a/webapp/vite.config.js +++ b/webapp/vite.config.js @@ -26,7 +26,6 @@ const middleware = () => { } } - // https://vitejs.dev/config/ export default defineConfig({ publicDir: "../public", @@ -54,5 +53,6 @@ export default defineConfig({ define: { '__APP_VERSION__': JSON.stringify(process.env.npm_package_version), '__GIT_HASH__': JSON.stringify(child.execSync('git rev-parse --short HEAD').toString().trim()), + '__TESTS__': process.env.HEYNOTE_TESTS, }, })