Merge branch 'main' into feat/move-block

This commit is contained in:
Jonatan Heyman 2025-04-22 18:00:32 +02:00
commit f1070a125a
103 changed files with 11413 additions and 3584 deletions

View File

@ -5,7 +5,7 @@ on: push
jobs: jobs:
publish: publish:
runs-on: ${{ matrix.os }} 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: permissions:
contents: write contents: write
@ -32,7 +32,7 @@ jobs:
- name: Build/release Electron app - name: Build/release Electron app
#continue-on-error: true #continue-on-error: true
uses: samuelmeuli/action-electron-builder@v1 uses: johannesjo/action-electron-builder@v1
with: with:
# Specify electron-builder config file # Specify electron-builder config file
args: -c electron-builder.json5 args: -c electron-builder.json5
@ -50,14 +50,38 @@ jobs:
# If the commit is tagged with a version (e.g. "v1.0.0"), # If the commit is tagged with a version (e.g. "v1.0.0"),
# release the app after building # release the app after building
release: true #release: true
#release: ${{ startsWith(github.ref, 'refs/tags/v') }} release: ${{ startsWith(github.ref, 'refs/tags/v') }}
env: env:
# macOS notarization API key # macOS notarization API key
APPLE_API_KEY: ~/private_keys/AuthKey.p8 APPLE_API_KEY: ~/private_keys/AuthKey.p8
APPLE_API_KEY_ID: ${{ secrets.apple_api_key_id }} APPLE_API_KEY_ID: ${{ secrets.apple_api_key_id }}
APPLE_API_KEY_ISSUER: ${{ secrets.apple_api_key_issuer_id }} APPLE_API_ISSUER: ${{ secrets.apple_api_key_issuer_id }}
#- name: Print notarization-error.log #- name: Print notarization-error.log
# if: ${{ matrix.os == 'macos-latest' }}
# run: cat notarization-error.log # run: cat notarization-error.log
- name: Upload Linux artifact
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: actions/upload-artifact@v4
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@v4
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@v4
with:
name: heynote-windows-${{ github.sha }}
path: release/*/Heynote_*.exe
retention-days: 30

View File

@ -0,0 +1,27 @@
name: Trigger Website Build
on:
push:
branches:
- main
release:
types: [published]
jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: Trigger Website Build
run: |
# Set the required variables
repo_owner="heyman"
repo_name="heynote-website"
event_type="trigger-workflow"
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.HEYNOTE_WEBSITE_GITHUB_ACCESS_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/$repo_owner/$repo_name/dispatches \
-d "{\"event_type\": \"$event_type\"}"

View File

@ -3,6 +3,13 @@
[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/heyman/heynote)](https://github.com/heyman/heynote/releases) [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/heyman/heynote)](https://github.com/heyman/heynote/releases)
[![Build Status](https://github.com/heyman/heynote/workflows/Tests/badge.svg)](https://github.com/heyman/heynote/actions?query=workflow%3ATests) [![Build Status](https://github.com/heyman/heynote/workflows/Tests/badge.svg)](https://github.com/heyman/heynote/actions?query=workflow%3ATests)
<img src="https://heynote.com/img/logo.png" style="width:79px;">
## General Information
- [Website](https://heynote.com)
- [Documentation](https://heynote.com/docs/)
- [Changelog](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. 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.
@ -16,7 +23,7 @@ Available for Mac, Windows, and Linux.
- Block-based - Block-based
- Syntax highlighting: - Syntax highlighting:
C++, C#, Clojure, CSS, Erlang, Go, Groovy, HTML, Java, JavaScript, JSX, Kotlin, TypeScript, TOML, TSX, JSON, Lezer, Markdown, PHP, Python, Ruby, Rust, Shell, SQL, Swift, XML, YAML C++, C#, Clojure, CSS, Erlang, Dart, Go, Groovy, HTML, Java, JavaScript, JSX, Kotlin, TypeScript, TOML, TSX, JSON, Lezer, Markdown, PHP, Python, Ruby, Rust, Scala, Shell, SQL, Swift, Vue, XML, YAML
- Language auto-detection - Language auto-detection
- Auto-formatting - Auto-formatting
@ -28,19 +35,9 @@ Available for Mac, Windows, and Linux.
- Default or Emacs-like key bindings - Default or Emacs-like key bindings
## Installation ## Documentation
Download the appropriate (Mac, Windows or Linux) version from the latest Github release (or from [heynote.com](https://heynote.com)). The Windows build is not signed, so you might see some scary warning (I can not justify paying a yearly fee for a certificate just to get rid of that). [Documentation](https://heynote.com/docs/) is available on the Heynote website.
### Notes on Linux installation
It's been reported [(#48)](https://github.com/heyman/heynote/issues/48) that ChromeOS's Debian VM need the following packages installed to run the Heynote AppImage:
```
libfuse2
libnss3
libnspr4
```
## Development ## Development
@ -70,87 +67,20 @@ To run the tests in the Playwright UI:
I'm happy to merge contributions that fit my vision for the app. Bug fixes are always welcome. I'm happy to merge contributions that fit my vision for the app. Bug fixes are always welcome.
## Math Blocks
Heynote's Math blocks are powered by [Math.js expressions](https://mathjs.org/docs/expressions). Checkout their [documentation](https://mathjs.org/docs/) to see what [syntax](https://mathjs.org/docs/expressions/syntax.html), [functions](https://mathjs.org/docs/reference/functions.html), and [constants](https://mathjs.org/docs/reference/constants.html) are available.
### Accessing the previous result
The variable `prev` can be used to access the previous result. For example:
```
128
prev * 2 # 256
```
### Changing how the results of Math blocks are formatted?
You can define a custom `format` function within the Math block like this:
```
_format = format # store reference to the built in format
format(x) = _format(x, {notation:"exponential"})
```
See the [Math.js format()](https://mathjs.org/docs/reference/functions/format.html) function for more info on what's supported.
## FAQ ## FAQ
### Where is the buffer data stored? ### Where is the buffer data stored?
The default paths for the buffer data for the respective OS are: See the [documentation](https://heynote.com/docs/#user-content-the-notes-library).
- Mac: `~/Library/Application Support/Heynote/buffer.txt`
- Windows: `%APPDATA%\Heynote\buffer.txt`
- Linux: `~/.config/Heynote/buffer.txt`
From version >=1.5.0, symlinks will be supported and you'll be able to configure the path where `buffer.txt` is stored.
### Can you make a mobile app? ### Can you make a mobile app?
No, at the moment this is out of scope, sorry. No, at the moment this is out of scope, sorry.
### Can you add a feature for naming blocks and/or adding tags? [(#44)](https://github.com/heyman/heynote/issues/44)
Currently, I'm not planning on adding this. The main reason is that it goes against the scratchpadness of the program.
I can totally see the usefulness of such a feature, and it's definitely something that I would expect from a more traditional Notes app. However a large part of Heynote's appeal is it's simplicity, and if that is to remain so, I'm going to have to say no to a lot of actually useful features.
### What are the default keyboard shortcuts? ### What are the default keyboard shortcuts?
**On Mac** See the [documentation](https://heynote.com/docs/#user-content-default-key-bindings).
```
⌘ + Enter Add new block below the current block
⌥ + Enter Add new block before the current block
⌘ + Shift + Enter Add new block at the end of the buffer
⌥ + Shift + Enter Add new block at the start of the buffer
⌘ + ⌥ + Enter Split the current block at cursor position
⌘ + L Change block language
⌘ + Down Goto next block
⌘ + Up Goto previous block
⌘ + A Select all text in a note block. Press again to select the whole buffer
⌘ + ⌥ + Up/Down Add additional cursor above/below
⌥ + Shift + F Format block content (works for JSON, JavaScript, HTML, CSS and Markdown)
```
**On Windows and Linux**
```
Ctrl + Enter Add new block below the current block
Alt + Enter Add new block before the current block
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 + 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
Ctrl + Alt + Up/Down Add additional cursor above/below
Alt + Shift + F Format block content (works for JSON, JavaScript, HTML, CSS and Markdown)
Alt Show menu
```
## Thanks! ## Thanks!

View File

@ -0,0 +1,80 @@
/* BEGIN Light */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/Light/OpenSans-Light.woff2?v=1.1.0") format("woff2"), url("./fonts/Light/OpenSans-Light.woff?v=1.1.0") format("woff");
font-weight: 300;
font-style: normal;
}
/* END Light */
/* BEGIN Light Italic */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/LightItalic/OpenSans-LightItalic.woff2?v=1.1.0") format("woff2"), url("./fonts/LightItalic/OpenSans-LightItalic.woff?v=1.1.0") format("woff");
font-weight: 300;
font-style: italic;
}
/* END Light Italic */
/* BEGIN Regular */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/Regular/OpenSans-Regular.woff2?v=1.1.0") format("woff2"), url("./fonts/Regular/OpenSans-Regular.woff?v=1.1.0") format("woff");
font-weight: normal;
font-style: normal;
}
/* END Regular */
/* BEGIN Italic */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/Italic/OpenSans-Italic.woff2?v=1.1.0") format("woff2"), url("./fonts/Italic/OpenSans-Italic.woff?v=1.1.0") format("woff");
font-weight: normal;
font-style: italic;
}
/* END Italic */
/* BEGIN Semibold */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/Semibold/OpenSans-Semibold.woff2?v=1.1.0") format("woff2"), url("./fonts/Semibold/OpenSans-Semibold.woff?v=1.1.0") format("woff");
font-weight: 600;
font-style: normal;
}
/* END Semibold */
/* BEGIN Semibold Italic */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/SemiboldItalic/OpenSans-SemiboldItalic.woff2?v=1.1.0") format("woff2"), url("./fonts/SemiboldItalic/OpenSans-SemiboldItalic.woff?v=1.1.0") format("woff");
font-weight: 600;
font-style: italic;
}
/* END Semibold Italic */
/* BEGIN Bold */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/Bold/OpenSans-Bold.woff2?v=1.1.0") format("woff2"), url("./fonts/Bold/OpenSans-Bold.woff?v=1.1.0") format("woff");
font-weight: bold;
font-style: normal;
}
/* END Bold */
/* BEGIN Bold Italic */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/BoldItalic/OpenSans-BoldItalic.woff2?v=1.1.0") format("woff2"), url("./fonts/BoldItalic/OpenSans-BoldItalic.woff?v=1.1.0") format("woff");
font-weight: bold;
font-style: italic;
}
/* END Bold Italic */
/* BEGIN Extrabold */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/ExtraBold/OpenSans-ExtraBold.woff2?v=1.1.0") format("woff2"), url("./fonts/ExtraBold/OpenSans-ExtraBold.woff?v=1.1.0") format("woff");
font-weight: 800;
font-style: normal;
}
/* END Extrabold */
/* BEGIN Extrabold Italic */
@font-face {
font-family: 'Open Sans';
src: url("./fonts/ExtraBoldItalic/OpenSans-ExtraBoldItalic.woff2?v=1.1.0") format("woff2"), url("./fonts/ExtraBoldItalic/OpenSans-ExtraBoldItalic.woff?v=1.1.0") format("woff");
font-weight: 800;
font-style: italic;
}
/* END Extrabold Italic */

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg baseProfile="tiny" height="24px" version="1.2" viewBox="0 0 24 24" width="24px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Layer_1"><path fill="#222" d="M13.293,7.293c-0.391,0.391-0.391,1.023,0,1.414L15.586,11H8c-0.552,0-1,0.448-1,1s0.448,1,1,1h7.586l-2.293,2.293 c-0.391,0.391-0.391,1.023,0,1.414C13.488,16.902,13.744,17,14,17s0.512-0.098,0.707-0.293L19.414,12l-4.707-4.707 C14.316,6.902,13.684,6.902,13.293,7.293z"/></g></svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg baseProfile="tiny" height="24px" version="1.2" viewBox="0 0 24 24" width="24px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Layer_1"><path fill="#ddd" d="M13.293,7.293c-0.391,0.391-0.391,1.023,0,1.414L15.586,11H8c-0.552,0-1,0.448-1,1s0.448,1,1,1h7.586l-2.293,2.293 c-0.391,0.391-0.391,1.023,0,1.414C13.488,16.902,13.744,17,14,17s0.512-0.098,0.707-0.293L19.414,12l-4.707-4.707 C14.316,6.902,13.684,6.902,13.293,7.293z"/></g></svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg baseProfile="tiny" height="24px" version="1.2" viewBox="0 0 24 24" width="24px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Layer_1"><path fill="#fff" d="M13.293,7.293c-0.391,0.391-0.391,1.023,0,1.414L15.586,11H8c-0.552,0-1,0.448-1,1s0.448,1,1,1h7.586l-2.293,2.293 c-0.391,0.391-0.391,1.023,0,1.414C13.488,16.902,13.744,17,14,17s0.512-0.098,0.707-0.293L19.414,12l-4.707-4.707 C14.316,6.902,13.684,6.902,13.293,7.293z"/></g></svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><rect fill="none" height="256" width="256"/><polyline fill="none" points="208 96 128 176 48 96" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><rect fill="none" height="256" width="256"/><polyline fill="none" points="208 96 128 176 48 96" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><rect fill="none" height="256" width="256"/><polyline fill="none" points="96 48 176 128 96 208" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><rect fill="none" height="256" width="256"/><polyline fill="none" points="96 48 176 128 96 208" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><circle cx="4.5" cy="2.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="4.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="6.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="8.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="10.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="12.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="2.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="4.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="6.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="8.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="10.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="12.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="2.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="4.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="6.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="8.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="10.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="12.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="2.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="4.5" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="6.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="8.499" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="10.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="12.498" fill="#e6e6e6" r=".6" class="fill-000000"></circle></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><circle cx="4.5" cy="2.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="4.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="6.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="8.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="10.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="4.5" cy="12.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="2.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="4.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="6.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="8.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="10.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="6.5" cy="12.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="2.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="4.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="6.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="8.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="10.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="8.499" cy="12.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="2.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="4.5" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="6.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="8.499" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="10.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle><circle cx="10.499" cy="12.498" fill="#b0b0b0" r=".6" class="fill-000000"></circle></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

138
docs/changelog.md Normal file
View File

@ -0,0 +1,138 @@
# 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.2.0-beta.2
### New Features
- Added support for custom key bindings. See [the documentation](https://heynote.com/docs/#user-content-custom-key-bindings) for more info.
- Added a "command palette" that can be accessed by pressing `Ctrl/Cmd+Shift+P`, or just typing `>` in the buffer selector. The command palette allows you to discover all available commands in the app, and to quickly execute them.
- Added support for configuring the tab size.
### Other changes
- Upgraded to latest version of Electron, Vue, electron-builder and other dependencies.
## 2.1.4
- Fix issue with positioning and size of todo list checkboxes in Markdown blocks when using a non-default font size, or a non-monospaced font.
- Fix issue when pressing `Ctrl/Cmd+A` in a text input inside a modal dialog (e.g. the buffer selector). Previously the select all command would be sent to the editor.
## 2.1.3
- Fix escaping issue in buffer selector (properly this time, hopefully)
## 2.1.2 (yanked)
- Fix issue where buffer name wasn't properly escaped in buffer selector
## 2.1.1
- Fix bug on Windows causing sub directories in the note library to not work correctly
## 2.1.0
### New Features
- Added support for moving the current block to another (or new) buffer. Pressing `Ctrl/Cmd+S` will now pop up a dialog where you can search for and select another buffer to which the block will be moved. It's also possible to select to create a new buffer to which the block will be moved.
- Add right click context menu with undo/redo/cut/copy/paste/select all as well as Delete Block and Move block to another buffer.
- Add File menu item for switching buffer
- When deleting a block, the cursor will now end up at the beginning of the next block, instead of at the end of the previous block.
- Added support for the following languages:
* Elixir
* Scala
- PHP blocks no longer requires `<?php` for syntax highlighting to work
### Bug Fixes
- MacOS: Clicking the Heynote icon in the dock when using menu bar mode didn't bring up the window
- Redo from the application menu didn't work
## 2.0.0
### IMPORTANT (breaking change)
The default path of the scratch file has changed. 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 buffer 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`. Before the migration, the existing buffer file will be backed up to `%APP_DIR%/buffer.txt.bak` or `%CUSTOM_DIR%/buffer.txt.bak`.
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 in sync, since the file path for the buffer file has changed.
### Support for multiple note buffers.
Apart from the default Scratch buffer, you can now create and switch between multiple note buffers. `Ctrl/Cmd+N` opens up a dialog for creating a new buffer. 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 note buffers are saved to the note library which is basically a directory (with sub dirs) on the disk with a `.txt` file for each buffer. You switch between buffers by pressing `Ctrl/Cmd+P`.
### 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.
- Improvements to the language selector's search feature (it's now possible to search for languages by their file extension).
## 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.

174
docs/index.md Normal file
View File

@ -0,0 +1,174 @@
# Heynote Documentation
[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.
Available for Mac, Windows, and Linux.
## Features
- Persistent text buffer
- Block-based
- Syntax highlighting:
C++, C#, Clojure, CSS, Elixir, Erlang, Dart, Go, Groovy, HTML, Java, JavaScript, JSX, Kotlin, TypeScript, TOML, TSX, JSON, Lezer, Markdown, PHP, Python, Ruby, Rust, Scala, Shell, SQL, Swift, Vue, XML, YAML
- Language auto-detection
- Auto-formatting
- Math/Calculator mode
- Currency conversion
- Multi-cursor editing
- Dark & Light themes
- Option to set a global hotkey to show/hide the app
- Default or Emacs-like key bindings
## Default Key Bindings
<!-- keyboard_shortcuts -->
**On Mac**
```
⌘ + Enter Add new block below the current block
⌥ + Enter Add new block before the current block
⌘ + Shift + Enter Add new block at the end of the buffer
⌥ + Shift + Enter Add new block at the start of the buffer
⌘ + ⌥ + Enter Split the current block at cursor position
⌘ + L Change block language
⌘ + N Create a new note buffer
⌘ + S Move the current block to another (or new) buffer
⌘ + 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
⌘ + ⌥ + Up/Down Add additional cursor above/below
⌥ + Shift + F Format block content (works for JSON, JavaScript, HTML, CSS and Markdown)
```
**On Windows and Linux**
```
Ctrl + Enter Add new block below the current block
Alt + Enter Add new block before the current block
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 + N Create a new note buffer
Ctrl + S Move the current block to another (or new) buffer
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
Ctrl + Alt + Up/Down Add additional cursor above/below
Alt + Shift + F Format block content (works for JSON, JavaScript, HTML, CSS and Markdown)
Alt Show menu
```
## Custom Key Bindings
Heynote supports custom key bindings which you can configure in the settings. The key bindings are evaluated from top to bottom, so a binding that comes before another one will take precedence. Most commands will stop the event from propagating, but some commands only applies in certain contexts and might not stop the event from propagating to a later key binding.
To disable one of the built in key bindings, you can add a new key binding with the same key combination for the command "Do nothing". This will stop the event from propagating to the built in key binding.
## Download/Installation
Download the appropriate (Mac, Windows or Linux) version from [heynote.com](https://heynote.com). The Windows build is not signed, so you might see some scary warning (I can not justify paying a yearly fee for a certificate just to get rid of that).
If installing Heynote on Linux in ChromeOS, see the [notes](#user-content-linux-on-chromeos) below about some packages that are needed.
On macOS, [Homebrew](https://brew.sh) users can utilize an unofficial [Homebrew Cask](https://formulae.brew.sh/cask/heynote#default): `brew install --cask heynote`
## Math Blocks
Heynote's Math blocks are powered by [Math.js expressions](https://mathjs.org/docs/expressions). Checkout their [documentation](https://mathjs.org/docs/) to see what [syntax](https://mathjs.org/docs/expressions/syntax.html), [functions](https://mathjs.org/docs/reference/functions.html), and [constants](https://mathjs.org/docs/reference/constants.html) are available.
### Accessing the previous result
The variable `prev` can be used to access the previous result. For example:
```
128
prev * 2 # 256
```
### Changing how the results of Math blocks are formatted
You can define a custom `format` function within the Math block like this:
```
_format = format # store reference to the built in format
format(x) = _format(x, {notation:"exponential"})
```
You can also do something like this to show the number with your default locale or provide a [custom one](https://www.w3.org/International/articles/language-tags/):
```
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 notes library
The notes library is a directory (with sub dirs) on the disk with a `.txt` file for each buffer. It's created the first time you start Heynote, with the default buffer 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 buffer 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.
## Linux
### Linux on ChromeOS
It's been reported [(#48)](https://github.com/heyman/heynote/issues/48) that ChromeOS's Debian VM need the following packages installed to run the Heynote AppImage:
```
libfuse2
libnss3
libnspr4
```
### 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:
```javascript
function toggleHeynote() {
var client = workspace.clientList().find((c) => c.resourceClass.toLowerCase() === 'heynote');
if (client) {
if (client.minimized) {
client.minimized = false;
workspace.activeClient = client;
} else {
if (workspace.activeClient == client) {
client.minimized = true;
} else {
workspace.activeClient = client;
}
}
}
}
registerShortcut('toggleHeynote', 'Toggle Heynote', 'Ctrl+Shift+H', toggleHeynote);
```
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.

View File

@ -13,7 +13,6 @@
"dist-electron", "dist-electron",
"dist" "dist"
], ],
"afterSign": "electron-builder-notarize",
"mac": { "mac": {
"artifactName": "${productName}_${version}_${arch}.${ext}", "artifactName": "${productName}_${version}_${arch}.${ext}",
"target": [ "target": [

View File

@ -24,6 +24,19 @@ const schema = {
properties: { properties: {
"keymap": { "enum": ["default", "emacs"], default:"default" }, "keymap": { "enum": ["default", "emacs"], default:"default" },
"emacsMetaKey": { "enum": [null, "alt", "meta"], default: null }, "emacsMetaKey": { "enum": [null, "alt", "meta"], default: null },
"keyBindings": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "command"],
"properties": {
"key": { "type": "string" },
"command": { "type": "string" }
},
"additionalProperties": false
}
},
"showLineNumberGutter": {type: "boolean", default:true}, "showLineNumberGutter": {type: "boolean", default:true},
"showFoldGutter": {type: "boolean", default:true}, "showFoldGutter": {type: "boolean", default:true},
"autoUpdate": {type: "boolean", default: true}, "autoUpdate": {type: "boolean", default: true},
@ -35,6 +48,9 @@ const schema = {
"showInMenu": {type: "boolean", default: false}, "showInMenu": {type: "boolean", default: false},
"alwaysOnTop": {type: "boolean", default: false}, "alwaysOnTop": {type: "boolean", default: false},
"bracketClosing": {type: "boolean", default: false}, "bracketClosing": {type: "boolean", default: false},
"tabSize": {type: "integer", default: 4},
"defaultBlockLanguage": {type: "string"},
"defaultBlockLanguageAutoDetect": {type: "boolean"},
// when default font settings are used, fontFamily and fontSize is not specified in the // when default font settings are used, fontFamily and fontSize is not specified in the
// settings file, so that it's possible for us to change the default settings in the // settings file, so that it's possible for us to change the default settings in the
@ -59,6 +75,7 @@ const defaults = {
settings: { settings: {
keymap: "default", keymap: "default",
emacsMetaKey: isMac ? "meta" : "alt", emacsMetaKey: isMac ? "meta" : "alt",
keyBindings: [],
showLineNumberGutter: true, showLineNumberGutter: true,
showFoldGutter: true, showFoldGutter: true,
autoUpdate: true, autoUpdate: true,
@ -70,6 +87,7 @@ const defaults = {
showInMenu: false, showInMenu: false,
alwaysOnTop: false, alwaysOnTop: false,
bracketClosing: false, bracketClosing: false,
tabSize: 4,
}, },
theme: "system", theme: "system",
} }

View File

@ -1,13 +1,14 @@
import os from "os"; import os from "os";
import { keyHelpStr } from "../shared-utils/key-helper"; import { keyHelpStr } from "../shared-utils/key-helper";
export const eraseInitialContent = !!process.env.ERASE_INITIAL_CONTENT
export const initialContent = ` export const initialContent = `
markdown {"formatVersion":"1.0.0","name":"Scratch"}
text
Welcome to Heynote! 👋 Welcome to Heynote! 👋
${keyHelpStr(os.platform())} ${keyHelpStr(os.platform())}
markdown
Read full documentation at https://heynote.com/docs
math math
This is a Math block. Here, rows are evaluated as math expressions. This is a Math block. Here, rows are evaluated as math expressions.

View File

@ -11,7 +11,7 @@ import {
UPDATE_START_DOWNLOAD, UPDATE_START_DOWNLOAD,
UPDATE_INSTALL_AND_RESTART, UPDATE_INSTALL_AND_RESTART,
UPDATE_CHECK_FOR_UPDATES, UPDATE_CHECK_FOR_UPDATES,
} from '../constants' } from '@/src/common/constants'
import { setForceQuit } from "./index"; import { setForceQuit } from "./index";

View File

@ -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
})

View File

@ -0,0 +1,394 @@
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<string>} 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)
}
}
function getBackupFile(filePath) {
// Get a backup file path by adding a .bak suffix. If the file already exists, add a number suffix.
let backupFile = filePath + ".bak";
for (let i = 1; i < 1000; i++) {
if (jetpack.exists(backupFile) !== "file") {
return backupFile;
}
backupFile = `${filePath}.bak.${i}`;
}
throw new Error(`Unable to find an available file path after 1000 attempts for base path: ${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)
const backupFile = getBackupFile(oldBufferFileFullPath)
if (jetpack.exists(oldBufferFileFullPath) === "file") {
// make a backup copy of the old buffer file
console.log(`Taking backup of ${oldBufferFileFullPath} to ${backupFile}`)
jetpack.copy(oldBufferFileFullPath, backupFile)
// rename buffer file to scratch.txt
const newFileFullPath = join(customBufferPath, SCRATCH_FILE_NAME);
console.log(`Migrating file ${oldBufferFileFullPath} to ${newFileFullPath}`)
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)
const backupFile = getBackupFile(oldBufferFileFullPath)
if (jetpack.exists(oldBufferFileFullPath) === "file" && jetpack.exists(defaultLibraryPath) !== "dir") {
// make a backup copy of the old buffer file
console.log(`Taking backup of ${oldBufferFileFullPath} to ${backupFile}`)
jetpack.copy(oldBufferFileFullPath, backupFile)
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)
}
}
}

View File

@ -3,13 +3,20 @@ import { release } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import fs from "fs" import fs from "fs"
import { menu, getTrayMenu } from './menu' import { WINDOW_CLOSE_EVENT, SETTINGS_CHANGE_EVENT } from '@/src/common/constants'
import { WINDOW_CLOSE_EVENT, SETTINGS_CHANGE_EVENT } from '../constants';
import { menu, getTrayMenu, getEditorContextMenu } from './menu'
import CONFIG from "../config" import CONFIG from "../config"
import { isDev, isLinux, isMac, isWindows } from '../detect-platform'; import { isDev, isLinux, isMac, isWindows } from '../detect-platform';
import { initializeAutoUpdate, checkForUpdates } from './auto-update'; import { initializeAutoUpdate, checkForUpdates } from './auto-update';
import { fixElectronCors } from './cors'; import { fixElectronCors } from './cors';
import { loadBuffer, contentSaved } from './buffer'; import {
FileLibrary,
setupFileLibraryEventHandlers,
setCurrentFileLibrary,
migrateBufferFileToLibrary,
NOTES_DIR_NAME
} from './file-library';
// The built directory structure // The built directory structure
@ -49,14 +56,14 @@ Menu.setApplicationMenu(menu)
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' // process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
export let win: BrowserWindow | null = null export let win: BrowserWindow | null = null
let fileLibrary: FileLibrary | null = null
let tray: Tray | null = null; let tray: Tray | null = null;
let initErrors: string[] = []
// Here, you can also use other preload // Here, you can also use other preload
const preload = join(__dirname, '../preload/index.js') const preload = join(__dirname, '../preload/index.js')
const url = process.env.VITE_DEV_SERVER_URL const url = process.env.VITE_DEV_SERVER_URL
const indexHtml = join(process.env.DIST, 'index.html') const indexHtml = join(process.env.DIST, 'index.html')
let currentKeymap = CONFIG.get("settings.keymap")
// if this version is a beta version, set the release channel to beta // if this version is a beta version, set the release channel to beta
const isBetaVersion = app.getVersion().includes("beta") const isBetaVersion = app.getVersion().includes("beta")
if (isBetaVersion) { if (isBetaVersion) {
@ -76,8 +83,8 @@ export function quit() {
async function createWindow() { async function createWindow() {
// read any stored window settings from config, or use defaults // read any stored window settings from config, or use defaults
let windowConfig = { let windowConfig = {
width: CONFIG.get("windowConfig.width", 900) as number, width: CONFIG.get("windowConfig.width", 940) as number,
height: CONFIG.get("windowConfig.height", 680) as number, height: CONFIG.get("windowConfig.height", 720) as number,
isMaximized: CONFIG.get("windowConfig.isMaximized", false) as boolean, isMaximized: CONFIG.get("windowConfig.isMaximized", false) as boolean,
isFullScreen: CONFIG.get("windowConfig.isFullScreen", false) as boolean, isFullScreen: CONFIG.get("windowConfig.isFullScreen", false) as boolean,
x: CONFIG.get("windowConfig.x"), x: CONFIG.get("windowConfig.x"),
@ -106,9 +113,17 @@ async function createWindow() {
} }
} }
const pngSystems: NodeJS.Platform[] = ["linux", "freebsd", "openbsd", "netbsd"]
const icon = join(
process.env.PUBLIC,
pngSystems.includes(process.platform)
? "favicon-linux.png"
: "favicon.ico",
)
win = new BrowserWindow(Object.assign({ win = new BrowserWindow(Object.assign({
title: 'heynote', title: 'heynote',
icon: join(process.env.PUBLIC, 'favicon.ico'), icon,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#262B37' : '#FFFFFF', backgroundColor: nativeTheme.shouldUseDarkColors ? '#262B37' : '#FFFFFF',
//titleBarStyle: 'customButtonsOnHover', //titleBarStyle: 'customButtonsOnHover',
autoHideMenuBar: true, autoHideMenuBar: true,
@ -138,7 +153,7 @@ async function createWindow() {
} }
// Prevent the window from closing, and send a message to the renderer which will in turn // 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. // send a message to the main process to save the current buffer and close the window.
if (!contentSaved) { if (!!fileLibrary && !fileLibrary.contentSaved) {
event.preventDefault() event.preventDefault()
win?.webContents.send(WINDOW_CLOSE_EVENT) win?.webContents.send(WINDOW_CLOSE_EVENT)
} else { } else {
@ -233,6 +248,11 @@ function registerGlobalHotkey() {
// if alwaysOnTop is on, calling app.hide() won't hide the window // if alwaysOnTop is on, calling app.hide() won't hide the window
win.hide() win.hide()
} }
} else if (isLinux) {
win.blur()
// If we don't hide the window, it will stay on top of the stack even though it's not visible
// and pressing the hotkey again won't do anything
win.hide()
} else { } else {
win.blur() win.blur()
if (CONFIG.get("settings.showInMenu") || CONFIG.get("settings.alwaysOnTop")) { if (CONFIG.get("settings.showInMenu") || CONFIG.get("settings.alwaysOnTop")) {
@ -302,6 +322,9 @@ function registerAlwaysOnTop() {
} }
app.whenReady().then(createWindow).then(async () => { app.whenReady().then(createWindow).then(async () => {
initFileLibrary(win).then(() => {
setupFileLibraryEventHandlers()
})
initializeAutoUpdate(win) initializeAutoUpdate(win)
registerGlobalHotkey() registerGlobalHotkey()
registerShowInDock() registerShowInDock()
@ -327,10 +350,14 @@ app.on('second-instance', () => {
} }
}) })
app.on('activate', () => { app.on('activate', (event, hasVisibleWindows) => {
const allWindows = BrowserWindow.getAllWindows() const allWindows = BrowserWindow.getAllWindows()
if (allWindows.length) { if (allWindows.length) {
allWindows[0].focus() allWindows[0].focus()
// show the window if it's hidden (e.g. the window was closed with "show in menu bar" setting turned on)
if (!allWindows[0].isVisible()) {
allWindows[0].show()
}
} else { } else {
createWindow() createWindow()
} }
@ -343,14 +370,43 @@ ipcMain.handle('dark-mode:set', (event, mode) => {
ipcMain.handle('dark-mode:get', () => nativeTheme.themeSource) ipcMain.handle('dark-mode:get', () => nativeTheme.themeSource)
// load buffer on app start ipcMain.handle("setWindowTitle", (event, title) => {
loadBuffer() win?.setTitle(title)
})
ipcMain.handle("showEditorContextMenu", () => {
getEditorContextMenu(win).popup({window:win});
})
// 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) => { ipcMain.handle('settings:set', async (event, settings) => {
if (settings.keymap !== CONFIG.get("settings.keymap")) {
currentKeymap = settings.keymap
}
let globalHotkeyChanged = settings.enableGlobalHotkey !== CONFIG.get("settings.enableGlobalHotkey") || settings.globalHotkey !== CONFIG.get("settings.globalHotkey") let globalHotkeyChanged = settings.enableGlobalHotkey !== CONFIG.get("settings.enableGlobalHotkey") || settings.globalHotkey !== CONFIG.get("settings.globalHotkey")
let showInDockChanged = settings.showInDock !== CONFIG.get("settings.showInDock"); let showInDockChanged = settings.showInDock !== CONFIG.get("settings.showInDock");
let showInMenuChanged = settings.showInMenu !== CONFIG.get("settings.showInMenu"); let showInMenuChanged = settings.showInMenu !== CONFIG.get("settings.showInMenu");
@ -373,9 +429,10 @@ ipcMain.handle('settings:set', async (event, settings) => {
registerAlwaysOnTop() registerAlwaysOnTop()
} }
if (bufferPathChanged) { if (bufferPathChanged) {
const buffer = loadBuffer() console.log("bufferPath changed, closing existing file library")
if (buffer.exists()) { fileLibrary.close()
win?.webContents.send("buffer-content:change", await buffer.load()) console.log("initializing new file library")
} initFileLibrary(win)
await win.webContents.send("library:pathChanged")
} }
}) })

View File

@ -1,10 +1,59 @@
const { app, Menu } = require("electron") const { app, Menu } = require("electron")
import { OPEN_SETTINGS_EVENT } from "../constants"; import { OPEN_SETTINGS_EVENT, UNDO_EVENT, REDO_EVENT, MOVE_BLOCK_EVENT, DELETE_BLOCK_EVENT, CHANGE_BUFFER_EVENT, SELECT_ALL_EVENT } from '@/src/common/constants'
import { openAboutWindow } from "./about"; import { openAboutWindow } from "./about";
import { quit } from "./index" import { quit } from "./index"
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
const undoMenuItem = {
label: 'Undo',
accelerator: 'CommandOrControl+z',
click: (menuItem, window, event) => {
window?.webContents.send(UNDO_EVENT)
},
}
const redoMenuItem = {
label: 'Redo',
accelerator: 'CommandOrControl+Shift+z',
click: (menuItem, window, event) => {
window?.webContents.send(REDO_EVENT)
},
}
const selectAllMenuItem = {
label: 'Select All',
accelerator: 'CommandOrControl+a',
click: (menuItem, window, event) => {
window?.webContents.send(SELECT_ALL_EVENT)
},
}
const deleteBlockMenuItem = {
label: 'Delete block',
accelerator: 'CommandOrControl+Shift+D',
click: (menuItem, window, event) => {
window?.webContents.send(DELETE_BLOCK_EVENT)
},
}
const moveBlockMenuItem = {
label: 'Move block to another buffer…',
accelerator: 'CommandOrControl+S',
click: (menuItem, window, event) => {
window?.webContents.send(MOVE_BLOCK_EVENT)
},
}
const changeBufferMenuItem = {
label: 'Switch buffer…',
accelerator: 'CommandOrControl+P',
click: (menuItem, window, event) => {
window?.webContents.send(CHANGE_BUFFER_EVENT)
},
}
const template = [ const template = [
// { role: 'appMenu' } // { role: 'appMenu' }
...(isMac ? [{ ...(isMac ? [{
@ -18,6 +67,7 @@ const template = [
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
changeBufferMenuItem,
{ {
label: 'Settings', label: 'Settings',
click: (menuItem, window, event) => { click: (menuItem, window, event) => {
@ -37,6 +87,7 @@ const template = [
}] : [{ }] : [{
role: 'fileMenu', role: 'fileMenu',
submenu: [ submenu: [
changeBufferMenuItem,
{ {
label: 'Settings', label: 'Settings',
click: (menuItem, window, event) => { click: (menuItem, window, event) => {
@ -62,8 +113,11 @@ const template = [
{ {
label: 'Edit', label: 'Edit',
submenu: [ submenu: [
{ role: 'undo' }, undoMenuItem,
{ role: 'redo' }, redoMenuItem,
{ type: 'separator' },
deleteBlockMenuItem,
moveBlockMenuItem,
{ type: 'separator' }, { type: 'separator' },
{ role: 'cut' }, { role: 'cut' },
{ role: 'copy' }, { role: 'copy' },
@ -71,7 +125,7 @@ const template = [
...(isMac ? [ ...(isMac ? [
{ role: 'pasteAndMatchStyle' }, { role: 'pasteAndMatchStyle' },
{ role: 'delete' }, { role: 'delete' },
{ role: 'selectAll' }, selectAllMenuItem,
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Speech', label: 'Speech',
@ -83,7 +137,7 @@ const template = [
] : [ ] : [
{ role: 'delete' }, { role: 'delete' },
{ type: 'separator' }, { type: 'separator' },
{ role: 'selectAll' } selectAllMenuItem,
]) ])
] ]
}, },
@ -131,7 +185,14 @@ const template = [
role: 'help', role: 'help',
submenu: [ submenu: [
{ {
label: 'Learn More', label: 'Documentation',
click: async () => {
const { shell } = require('electron')
await shell.openExternal('https://heynote.com/docs/')
}
},
{
label: 'Website',
click: async () => { click: async () => {
const { shell } = require('electron') const { shell } = require('electron')
await shell.openExternal('https://heynote.com') await shell.openExternal('https://heynote.com')
@ -164,3 +225,18 @@ export function getTrayMenu(win) {
]) ])
} }
export function getEditorContextMenu(win) {
return Menu.buildFromTemplate([
undoMenuItem,
redoMenuItem,
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
{type: 'separator'},
selectAllMenuItem,
{type: 'separator'},
deleteBlockMenuItem,
moveBlockMenuItem,
])
}

View File

@ -1,11 +1,9 @@
const { contextBridge } = require('electron') const { contextBridge } = require('electron')
import { sep } from "path"
import themeMode from "./theme-mode" 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 { ipcRenderer } from "electron"
import { import {
WINDOW_CLOSE_EVENT,
OPEN_SETTINGS_EVENT,
SETTINGS_CHANGE_EVENT,
UPDATE_AVAILABLE_EVENT, UPDATE_AVAILABLE_EVENT,
UPDATE_ERROR, UPDATE_ERROR,
UPDATE_DOWNLOAD_PROGRESS, UPDATE_DOWNLOAD_PROGRESS,
@ -14,7 +12,7 @@ import {
UPDATE_INSTALL_AND_RESTART, UPDATE_INSTALL_AND_RESTART,
UPDATE_DOWNLOADED, UPDATE_DOWNLOADED,
UPDATE_CHECK_FOR_UPDATES, UPDATE_CHECK_FOR_UPDATES,
} from "../constants" } from "@/src/common/constants"
import CONFIG from "../config" import CONFIG from "../config"
import getCurrencyData from "./currency" import getCurrencyData from "./currency"
@ -30,41 +28,97 @@ contextBridge.exposeInMainWorld("heynote", {
isWebApp: false, isWebApp: false,
}, },
isDev: isDev,
themeMode: themeMode, themeMode: themeMode,
quit() { init() {
console.log("quitting") ipcRenderer.on("buffer:change", (event, path, content) => {
//ipcRenderer.invoke("app_quit") // 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))
}
})
}, },
onWindowClose(callback) { mainProcess: {
ipcRenderer.on(WINDOW_CLOSE_EVENT, callback) on(event, callback) {
ipcRenderer.on(event, callback)
}, },
onOpenSettings(callback) { off(event, callback) {
ipcRenderer.on(OPEN_SETTINGS_EVENT, callback) ipcRenderer.off(event, callback)
},
invoke(event, ...args) {
return ipcRenderer.invoke(event, ...args)
}
}, },
buffer: { buffer: {
async load() { async exists(path) {
return await ipcRenderer.invoke("buffer-content:load") return await ipcRenderer.invoke("buffer:exists", path)
}, },
async save(content) { async getList() {
return await ipcRenderer.invoke("buffer-content:save", content) return await ipcRenderer.invoke("buffer:getList")
}, },
async saveAndQuit(content) { async getDirectoryList() {
return await ipcRenderer.invoke("buffer-content:saveAndQuit", content) return await ipcRenderer.invoke("buffer:getDirectoryList")
}, },
onChangeCallback(callback) { async load(path) {
ipcRenderer.on("buffer-content:change", callback) return await ipcRenderer.invoke("buffer:load", path)
},
async save(path, content) {
return await ipcRenderer.invoke("buffer:save", path, content)
},
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() { async selectLocation() {
return await ipcRenderer.invoke("buffer-content:selectLocation") return await ipcRenderer.invoke("library:selectLocation")
} },
setLibraryPathChangeCallback(callback) {
ipcRenderer.on("library:pathChanged", callback)
},
pathSeparator: sep,
}, },
settings: CONFIG.get("settings"), settings: CONFIG.get("settings"),
@ -77,10 +131,6 @@ contextBridge.exposeInMainWorld("heynote", {
return await getCurrencyData() return await getCurrencyData()
}, },
onSettingsChange(callback) {
ipcRenderer.on(SETTINGS_CHANGE_EVENT, (event, settings) => callback(settings))
},
autoUpdate: { autoUpdate: {
callbacks(callbacks) { callbacks(callbacks) {
ipcRenderer.on(UPDATE_AVAILABLE_EVENT, (event, info) => callbacks?.updateAvailable(info)) ipcRenderer.on(UPDATE_AVAILABLE_EVENT, (event, info) => callbacks?.updateAvailable(info))
@ -103,11 +153,19 @@ contextBridge.exposeInMainWorld("heynote", {
async getVersion() { async getVersion() {
return await ipcRenderer.invoke("getVersion") return await ipcRenderer.invoke("getVersion")
} },
async getInitErrors() {
return await ipcRenderer.invoke("getInitErrors")
},
setWindowTitle(title) {
ipcRenderer.invoke("setWindowTitle", title)
},
}) })
function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) { function domReady(condition=['complete', 'interactive']) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (condition.includes(document.readyState)) { if (condition.includes(document.readyState)) {
resolve(true) resolve(true)
@ -122,12 +180,12 @@ function domReady(condition: DocumentReadyState[] = ['complete', 'interactive'])
} }
const safeDOM = { const safeDOM = {
append(parent: HTMLElement, child: HTMLElement) { append(parent, child) {
if (!Array.from(parent.children).find(e => e === child)) { if (!Array.from(parent.children).find(e => e === child)) {
return parent.appendChild(child) return parent.appendChild(child)
} }
}, },
remove(parent: HTMLElement, child: HTMLElement) { remove(parent, child) {
if (Array.from(parent.children).find(e => e === child)) { if (Array.from(parent.children).find(e => e === child)) {
return parent.removeChild(child) return parent.removeChild(child)
} }

7281
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "Heynote", "name": "Heynote",
"version": "1.7.0", "version": "2.2.0-beta.2",
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
"description": "A dedicated scratch pad", "description": "A dedicated scratch pad",
"author": "Jonatan Heyman (https://heyman.info)", "author": "Jonatan Heyman (https://heyman.info)",
@ -32,7 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.11.1", "@codemirror/autocomplete": "^6.11.1",
"@codemirror/commands": "^6.3.2", "@codemirror/commands": "^6.8.1",
"@codemirror/lang-cpp": "^6.0.2", "@codemirror/lang-cpp": "^6.0.2",
"@codemirror/lang-html": "^6.4.7", "@codemirror/lang-html": "^6.4.7",
"@codemirror/lang-java": "^6.0.1", "@codemirror/lang-java": "^6.0.1",
@ -44,39 +44,47 @@
"@codemirror/lang-python": "^6.1.3", "@codemirror/lang-python": "^6.1.3",
"@codemirror/lang-rust": "^6.0.1", "@codemirror/lang-rust": "^6.0.1",
"@codemirror/lang-sql": "^6.5.4", "@codemirror/lang-sql": "^6.5.4",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-xml": "^6.0.2", "@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.9.3", "@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "^6.3.3", "@codemirror/legacy-modes": "^6.5.1",
"@codemirror/lint": "^6.4.2", "@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.5", "@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.3.3", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.22.2", "@codemirror/view": "^6.36.5",
"@electron/asar": "^3.2.2", "@electron/asar": "^3.2.2",
"@lezer/generator": "^1.5.1", "@lezer/generator": "^1.5.1",
"@lezer/markdown": "^1.1.2", "@lezer/markdown": "^1.1.2",
"@playwright/test": "^1.40.1", "@playwright/test": "^1.51.1",
"@replit/codemirror-lang-csharp": "^6.2.0", "@replit/codemirror-lang-csharp": "^6.2.0",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^5.2.3",
"codemirror-lang-elixir": "^4.0.0",
"debounce": "^1.2.1", "debounce": "^1.2.1",
"electron": "^28.0.0", "electron": "^35.2.0",
"electron-builder": "^23.6.0", "electron-builder": "^26.0.12",
"electron-builder-notarize": "^1.5.1",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.1.7", "electron-updater": "^6.6.2",
"fs-jetpack": "^5.1.0", "fs-jetpack": "^5.1.0",
"prettier": "^3.1.1", "lezer-elixir": "^1.1.2",
"prettier": "^3.3.2",
"primevue": "^4.3.3",
"rollup-plugin-license": "^3.0.1", "rollup-plugin-license": "^3.0.1",
"sass": "^1.57.1", "sass-embedded": "^1.87.0",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"vite": "^4.5.2", "vite": "^6.3.2",
"vite-plugin-electron": "^0.11.1", "vite-plugin-electron": "^0.11.1",
"vite-plugin-electron-renderer": "^0.11.4", "vite-plugin-electron-renderer": "^0.11.4",
"vue": "^3.2.45", "vue": "^3.5.13",
"vue-tsc": "^1.0.16" "vue-tsc": "^1.0.16",
"vuedraggable": "^4.1.0"
}, },
"dependencies": { "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"
} }
} }

View File

@ -1,5 +1,7 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
process.env["HEYNOTE_TESTS"] = "1"
/** /**
* Read environment variables from file. * Read environment variables from file.
* https://github.com/motdotla/dotenv * https://github.com/motdotla/dotenv
@ -18,7 +20,8 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, //workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? undefined : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? [['github'], ['html']] : 'list', reporter: process.env.CI ? [['github'], ['html']] : 'list',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@ -1,6 +1,6 @@
importScripts("guesslang.min.js") importScripts("guesslang.min.js")
GUESSLANG_LANGUAGES = ["json","py","html","sql","md","java","php","css","xml","cpp","rs","cs","rb","sh","yaml","toml","go","clj","erl","js","ts","swift","kt","groovy","ps1"] GUESSLANG_LANGUAGES = ["json","py","html","sql","md","java","php","css","xml","cpp","rs","cs","rb","sh","yaml","toml","go","clj","ex","erl","js","ts","swift","kt","groovy","ps1","dart","scala"]
const guessLang = new self.GuessLang() const guessLang = new self.GuessLang()
@ -28,6 +28,7 @@ onmessage = (event) => {
}, },
content: content, content: content,
idx: event.data.idx, idx: event.data.idx,
path: event.data.path,
}) })
return return
} }
@ -53,6 +54,7 @@ onmessage = (event) => {
}, },
content: content, content: content,
idx: event.data.idx, idx: event.data.idx,
path: event.data.path,
}) })
return return
} }
@ -66,6 +68,7 @@ onmessage = (event) => {
}, },
content: content, content: content,
idx: event.data.idx, idx: event.data.idx,
path: event.data.path,
}) })
return return
} }

11
public/site.webmanifest Normal file
View File

@ -0,0 +1,11 @@
{
"name": "Heynote",
"short_name": "Heynote",
"icons": [
{
"src": "/icon.ico",
"sizes": "256x256"
}
],
"display": "standalone"
}

View File

@ -9,6 +9,9 @@ export const keyHelpStr = (platform: string) => {
[`${altChar} + Shift + Enter`, "Add new block at the start of the buffer"], [`${altChar} + Shift + Enter`, "Add new block at the start of the buffer"],
[`${modChar} + ${altChar} + Enter`, "Split the current block at cursor position"], [`${modChar} + ${altChar} + Enter`, "Split the current block at cursor position"],
[`${modChar} + L`, "Change block language"], [`${modChar} + L`, "Change block language"],
[`${modChar} + N`, "Create a new note buffer"],
[`${modChar} + S`, "Move the current block to another (or new) buffer"],
[`${modChar} + P`, "Open note selector"],
[`${modChar} + Down`, "Goto next block"], [`${modChar} + Down`, "Goto next block"],
[`${modChar} + Up`, "Goto previous block"], [`${modChar} + Up`, "Goto previous block"],
[`${modChar} + A`, "Select all text in a note block. Press again to select the whole buffer"], [`${modChar} + A`, "Select all text in a note block. Press again to select the whole buffer"],

View File

@ -1,6 +1,15 @@
export const SCRATCH_FILE_NAME = "scratch.txt"
export const AUTO_SAVE_INTERVAL = 2000
export const WINDOW_CLOSE_EVENT = "window-close" export const WINDOW_CLOSE_EVENT = "window-close"
export const OPEN_SETTINGS_EVENT = "open-settings" export const OPEN_SETTINGS_EVENT = "open-settings"
export const SETTINGS_CHANGE_EVENT = "settings-change" export const SETTINGS_CHANGE_EVENT = "settings-change"
export const REDO_EVENT = "redo"
export const UNDO_EVENT = "undo"
export const MOVE_BLOCK_EVENT = "move-block"
export const DELETE_BLOCK_EVENT = "delete-block"
export const CHANGE_BUFFER_EVENT = "change-buffer"
export const SELECT_ALL_EVENT = "select-all"
export const UPDATE_AVAILABLE_EVENT = "update-available" export const UPDATE_AVAILABLE_EVENT = "update-available"
export const UPDATE_NOT_AVAILABLE_EVENT = "update-not-available" export const UPDATE_NOT_AVAILABLE_EVENT = "update-not-available"

45
src/common/note-format.js Normal file
View File

@ -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
}
}

View File

@ -1,8 +1,22 @@
<script> <script>
import { mapState, mapActions } from 'pinia'
import { mapWritableState, mapStores } from 'pinia'
import { useHeynoteStore } from "../stores/heynote-store"
import { useErrorStore } from "../stores/error-store"
import { useSettingsStore } from "../stores/settings-store"
import { useEditorCacheStore } from '../stores/editor-cache'
import { OPEN_SETTINGS_EVENT, MOVE_BLOCK_EVENT, CHANGE_BUFFER_EVENT } from '@/src/common/constants'
import StatusBar from './StatusBar.vue' import StatusBar from './StatusBar.vue'
import Editor from './Editor.vue' import Editor from './Editor.vue'
import LanguageSelector from './LanguageSelector.vue' import LanguageSelector from './LanguageSelector.vue'
import BufferSelector from './BufferSelector.vue'
import Settings from './settings/Settings.vue' import Settings from './settings/Settings.vue'
import ErrorMessages from './ErrorMessages.vue'
import NewBuffer from './NewBuffer.vue'
import EditBuffer from './EditBuffer.vue'
export default { export default {
components: { components: {
@ -10,99 +24,128 @@
StatusBar, StatusBar,
LanguageSelector, LanguageSelector,
Settings, Settings,
BufferSelector,
ErrorMessages,
NewBuffer,
EditBuffer,
}, },
data() { data() {
return { return {
line: 1,
column: 1,
selectionSize: 0,
language: "plaintext",
languageAuto: true,
theme: window.heynote.themeMode.initial,
initialTheme: window.heynote.themeMode.initial,
themeSetting: 'system',
development: window.location.href.indexOf("dev=1") !== -1, development: window.location.href.indexOf("dev=1") !== -1,
showLanguageSelector: false,
showSettings: false, showSettings: false,
settings: window.heynote.settings, settings: window.heynote.settings,
} }
}, },
mounted() { mounted() {
window.heynote.themeMode.get().then((mode) => { this.settingsStore.setUp()
this.theme = mode.computed
this.themeSetting = mode.theme window.heynote.mainProcess.on(OPEN_SETTINGS_EVENT, () => {
})
const onThemeChange = (theme) => {
this.theme = theme
if (theme === "system") {
document.documentElement.setAttribute("theme", window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
} else {
document.documentElement.setAttribute("theme", theme)
}
}
onThemeChange(window.heynote.themeMode.initial)
window.heynote.themeMode.onChange(onThemeChange)
window.heynote.onSettingsChange((settings) => {
this.settings = settings
})
window.heynote.onOpenSettings(() => {
this.showSettings = true this.showSettings = true
}) })
window.heynote.mainProcess.on(MOVE_BLOCK_EVENT, (path) => {
this.openMoveToBufferSelector()
})
window.heynote.mainProcess.on(CHANGE_BUFFER_EVENT, () => {
this.openBufferSelector()
})
}, },
beforeUnmount() { beforeUnmount() {
window.heynote.themeMode.removeListener() this.settingsStore.tearDown()
},
watch: {
// when a dialog is closed, we want to focus the editor
showLanguageSelector(value) { this.dialogWatcher(value) },
showBufferSelector(value) { this.dialogWatcher(value) },
showCreateBuffer(value) { this.dialogWatcher(value) },
showEditBuffer(value) { this.dialogWatcher(value) },
showMoveToBufferSelector(value) { this.dialogWatcher(value) },
showCommandPalette(value) { this.dialogWatcher(value) },
currentBufferPath() {
this.focusEditor()
},
currentBufferName() {
window.heynote.setWindowTitle(this.currentBufferName)
},
},
computed: {
...mapStores(useSettingsStore, useEditorCacheStore),
...mapState(useHeynoteStore, [
"currentBufferPath",
"currentBufferName",
"showLanguageSelector",
"showBufferSelector",
"showCreateBuffer",
"showEditBuffer",
"showMoveToBufferSelector",
"showCommandPalette",
]),
dialogVisible() {
return this.showLanguageSelector || this.showBufferSelector || this.showCreateBuffer || this.showEditBuffer || this.showMoveToBufferSelector || this.showCommandPalette || this.showSettings
},
editorInert() {
return this.dialogVisible
},
}, },
methods: { methods: {
...mapActions(useHeynoteStore, [
"openLanguageSelector",
"openBufferSelector",
"openCreateBuffer",
"closeDialog",
"closeBufferSelector",
"openBuffer",
"closeMoveToBufferSelector",
]),
// Used as a watcher for the booleans that control the visibility of editor dialogs.
// When a dialog is closed, we want to focus the editor
dialogWatcher(value) {
if (!value) {
this.focusEditor()
}
},
focusEditor() {
// we need to wait for the next tick for the cases when we set the inert attribute on the editor
// in which case issuing a focus() call immediately would not work
this.$nextTick(() => {
this.$refs.editor.focus()
})
},
openSettings() { openSettings() {
this.showSettings = true this.showSettings = true
}, },
closeSettings() { closeSettings() {
this.showSettings = false this.showSettings = false
this.$refs.editor.focus() this.focusEditor()
},
toggleTheme() {
let newTheme
// when the "system" theme is used, make sure that the first click always results in amn actual theme change
if (this.initialTheme === "light") {
newTheme = this.themeSetting === "system" ? "dark" : (this.themeSetting === "dark" ? "light" : "system")
} else {
newTheme = this.themeSetting === "system" ? "light" : (this.themeSetting === "light" ? "dark" : "system")
}
window.heynote.themeMode.set(newTheme)
this.themeSetting = newTheme
this.$refs.editor.focus()
},
onCursorChange(e) {
this.line = e.cursorLine.line
this.column = e.cursorLine.col
this.selectionSize = e.selectionSize
this.language = e.language
this.languageAuto = e.languageAuto
},
openLanguageSelector() {
this.showLanguageSelector = true
},
closeLanguageSelector() {
this.showLanguageSelector = false
this.$refs.editor.focus()
}, },
onSelectLanguage(language) { onSelectLanguage(language) {
this.showLanguageSelector = false this.closeDialog()
this.$refs.editor.setLanguage(language) this.$refs.editor.setLanguage(language)
}, },
formatCurrentBlock() { formatCurrentBlock() {
this.$refs.editor.formatCurrentBlock() this.$refs.editor.formatCurrentBlock()
}, },
onMoveCurrentBlockToOtherEditor(path) {
this.editorCacheStore.moveCurrentBlockToOtherEditor(path)
this.closeMoveToBufferSelector()
},
}, },
} }
@ -111,48 +154,61 @@
<template> <template>
<div class="container"> <div class="container">
<Editor <Editor
@cursorChange="onCursorChange" :theme="settingsStore.theme"
:theme="theme"
:development="development" :development="development"
:debugSyntaxTree="false" :debugSyntaxTree="false"
:keymap="settings.keymap" :inert="editorInert"
:emacsMetaKey="settings.emacsMetaKey"
:showLineNumberGutter="settings.showLineNumberGutter"
:showFoldGutter="settings.showFoldGutter"
:bracketClosing="settings.bracketClosing"
:fontFamily="settings.fontFamily"
:fontSize="settings.fontSize"
class="editor" class="editor"
ref="editor" ref="editor"
@openLanguageSelector="openLanguageSelector"
/> />
<StatusBar <StatusBar
:line="line"
:column="column"
:selectionSize="selectionSize"
:language="language"
:languageAuto="languageAuto"
:theme="theme"
:themeSetting="themeSetting"
:autoUpdate="settings.autoUpdate" :autoUpdate="settings.autoUpdate"
:allowBetaVersions="settings.allowBetaVersions" :allowBetaVersions="settings.allowBetaVersions"
@toggleTheme="toggleTheme" @openBufferSelector="openBufferSelector"
@openLanguageSelector="openLanguageSelector" @openLanguageSelector="openLanguageSelector"
@formatCurrentBlock="formatCurrentBlock" @formatCurrentBlock="formatCurrentBlock"
@openSettings="showSettings = true" @openSettings="showSettings = true"
@click="() => {$refs.editor.focus()}"
class="status" class="status"
/> />
<div class="overlay"> <div class="overlay">
<LanguageSelector <LanguageSelector
v-if="showLanguageSelector" v-if="showLanguageSelector"
@selectLanguage="onSelectLanguage" @selectLanguage="onSelectLanguage"
@close="closeLanguageSelector" @close="closeDialog"
/>
<BufferSelector
v-if="showBufferSelector || showCommandPalette"
:initialFilter="showCommandPalette ? '>' : ''"
:commandsEnabled="true"
@openBuffer="openBuffer"
@openCreateBuffer="(nameSuggestion) => openCreateBuffer('new', nameSuggestion)"
@close="closeBufferSelector"
/>
<BufferSelector
v-if="showMoveToBufferSelector"
headline="Move block to..."
:commandsEnabled="false"
@openBuffer="onMoveCurrentBlockToOtherEditor"
@openCreateBuffer="(nameSuggestion) => openCreateBuffer('currentBlock', nameSuggestion)"
@close="closeMoveToBufferSelector"
/> />
<Settings <Settings
v-if="showSettings" v-if="showSettings"
:initialSettings="settings" :initialSettings="settingsStore.settings"
:themeSetting="settingsStore.themeSetting"
@closeSettings="closeSettings" @closeSettings="closeSettings"
@setTheme="settingsStore.setTheme"
/> />
<NewBuffer
v-if="showCreateBuffer"
@close="closeDialog"
/>
<EditBuffer
v-if="showEditBuffer"
@close="closeDialog"
/>
<ErrorMessages />
</div> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,531 @@
<script>
import fuzzysort from 'fuzzysort'
import { mapState, mapActions } from 'pinia'
import { SCRATCH_FILE_NAME } from "../common/constants"
import { useHeynoteStore } from "../stores/heynote-store"
import { HEYNOTE_COMMANDS } from '../editor/commands'
const pathSep = window.heynote.buffer.pathSeparator
function escapeHTML(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export default {
props: {
headline: String,
initialFilter: String,
commandsEnabled: Boolean,
},
data() {
return {
selected: 0,
actionButton: 0,
filter: this.initialFilter || "",
items: [],
SCRATCH_FILE_NAME: SCRATCH_FILE_NAME,
deleteConfirm: false,
}
},
async mounted() {
await this.updateBuffers()
this.$refs.container.focus()
this.$refs.input.focus()
this.buildItems()
if (this.items.length > 1) {
this.selected = 1
}
},
computed: {
...mapState(useHeynoteStore, [
"buffers",
"recentBufferPaths",
]),
commands() {
const commands = Object.entries(HEYNOTE_COMMANDS)
// sort array first by category, then by description
commands.sort((a, b) => {
const aCategory = a[1].category || ""
const bCategory = b[1].category || ""
if (aCategory === bCategory) {
return a[1].description.localeCompare(b[1].description)
} else {
return aCategory.localeCompare(bCategory)
}
})
return commands.map(([cmdKey, cmd]) => ({
name: `${cmd.category}: ${cmd.description}`,
cmd: cmdKey,
isCommand: true,
}))
},
orderedItems() {
const sortKeys = Object.fromEntries(this.recentBufferPaths.map((item, idx) => [item, idx]))
const getSortScore = (item) => sortKeys[item.path] !== undefined ? sortKeys[item.path] : 1000
const compareFunc = (a, b) => {
const sortScore = getSortScore(a) - getSortScore(b)
if (sortScore !== 0) {
// sort by recency first
return sortScore
} else {
// then notes in root
const aIsRoot = a.path.indexOf(pathSep) === -1
const bIsRoot = b.path.indexOf(pathSep) === -1
if (aIsRoot && !bIsRoot) {
return -1
} else if (!aIsRoot && bIsRoot) {
return 1
} else {
// last sort by path
return a.path.localeCompare(b.path)
}
}
}
return [...this.items].sort(compareFunc)
},
filteredItems() {
if (this.commandsEnabled && this.filter.startsWith(">")) {
// command mode if the first character is ">"
if (this.filter.length < 2) {
return this.commands
}
const searchResults = fuzzysort.go(this.filter.slice(1), this.commands, {
keys: ["name"],
})
return searchResults.map((result) => {
const obj = {...result.obj}
const nameHighlight = result[0].highlight("<b>", "</b>")
obj.name = nameHighlight !== "" ? nameHighlight : obj.name
return obj
})
} else {
let items
if (this.filter === "") {
items = this.orderedItems
} else {
const searchResults = fuzzysort.go(this.filter, this.items, {
keys: ["name", "folder"],
})
items = searchResults.map((result) => {
const obj = {...result.obj}
const nameHighlight = result[0].highlight("<b>", "</b>")
const folderHighlight = result[1].highlight("<b>", "</b>")
obj.name = nameHighlight !== "" ? nameHighlight : obj.name
obj.folder = folderHighlight !== "" ? folderHighlight : obj.folder
return obj
})
}
const newNoteItem = {
name: "Create new…",
createNew:true,
}
return [
...items,
newNoteItem,
]
}
},
},
methods: {
...mapActions(useHeynoteStore, [
"updateBuffers",
"editBufferMetadata",
"deleteBuffer",
"executeCommand",
]),
buildItems() {
//console.log("buildItems", Object.entries(this.buffers))
this.items = Object.entries(this.buffers).map(([path, metadata]) => {
return {
"path": path,
"name": escapeHTML(metadata?.name || path),
"folder": escapeHTML(path.split(pathSep).slice(0, -1).join(pathSep)),
"scratch": path === SCRATCH_FILE_NAME,
}
})
},
onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault()
if (this.actionButton !== 0) {
this.hideActionButtons()
} else {
this.$emit("close")
}
return
}
if (this.filteredItems.length === 0) {
return
}
const item = this.filteredItems[this.selected]
if (event.key === "ArrowDown") {
if (this.selected === this.filteredItems.length - 1) {
this.selected = 0
} else {
this.selected = Math.min(this.selected + 1, this.filteredItems.length - 1)
}
event.preventDefault()
this.$nextTick(() => {
this.$refs.container.querySelector(".selected").scrollIntoView({block: "nearest"})
})
this.actionButton = 0
} else if (event.key === "ArrowUp") {
if (this.selected === 0) {
this.selected = this.filteredItems.length - 1
} else {
this.selected = Math.max(this.selected - 1, 0)
}
event.preventDefault()
this.$nextTick(() => {
this.$refs.container.querySelector(".selected").scrollIntoView({block: "nearest"})
})
this.actionButton = 0
} else if (event.key === "ArrowRight" && this.itemHasActionButtons(item)) {
event.preventDefault()
this.actionButton = Math.min(2, this.actionButton + 1)
} else if (event.key === "ArrowLeft" && this.itemHasActionButtons(item)) {
event.preventDefault()
this.actionButton = Math.max(0, this.actionButton - 1)
this.deleteConfirm = false
} else if (event.key === "Enter") {
event.preventDefault()
if (this.actionButton === 1) {
//console.log("edit file:", path)
this.editBufferMetadata(item.path)
} else if (this.actionButton === 2) {
this.deleteConfirmNote(item.path)
} else {
this.selectItem(item)
}
}
},
selectItem(item) {
if (item.createNew) {
if (this.filteredItems.length === 1) {
this.$emit("openCreateBuffer", this.filter)
} else {
this.$emit("openCreateBuffer", "")
}
} else if (item.isCommand) {
this.$emit("close")
this.$nextTick(() => {
this.executeCommand(item.cmd)
})
} else {
this.$emit("openBuffer", item.path)
}
},
itemHasActionButtons(item) {
return !item.createNew && item.path !== SCRATCH_FILE_NAME && !item.isCommand
},
onInput(event) {
// reset selection
this.selected = 0
},
onFocusOut(event) {
let container = this.$refs.container
if (container !== event.relatedTarget && !container.contains(event.relatedTarget)) {
this.$emit("close")
}
},
getItemClass(item, idx) {
return {
"item": true,
"selected": idx === this.selected,
"action-buttons-visible": this.actionButton > 0,
"scratch": item.scratch,
"new-note": item.createNew,
}
},
showActionButtons(idx) {
this.selected = idx
this.actionButton = 1
this.deleteConfirm = false
this.$refs.input.focus()
},
hideActionButtons() {
this.actionButton = 0
this.deleteConfirm = false
},
async deleteConfirmNote(path) {
if (this.deleteConfirm) {
//console.log("delete file:", path)
await this.deleteBuffer(path)
this.hideActionButtons()
this.buildItems()
this.selected = Math.min(this.selected, this.items.length - 1)
} else {
this.deleteConfirm = true
this.actionButton = 2
this.$refs.input.focus()
}
},
}
}
</script>
<template>
<form class="note-selector" tabindex="-1" @focusout="onFocusOut" ref="container" @submit.prevent>
<div class="input-container">
<h1 v-if="headline">{{headline}}</h1>
<input
type="text"
ref="input"
@keydown="onKeydown"
@input="onInput"
v-model="filter"
autocomplete="off"
/>
</div>
<div class="scroller">
<ul class="items" ref="itemsContainer">
<template
v-for="item, idx in filteredItems"
:key="item.path"
>
<li v-if="item.createNew" class="line-separator"></li>
<li
:class="getItemClass(item, idx)"
@click="selectItem(item)"
ref="item"
>
<span class="name" v-html="item.name" />
<span class="path" v-html="item.folder" />
<span :class="{'action-buttons':true, 'visible':actionButton > 0 && idx === selected}">
<button
v-if="actionButton > 0 && idx === selected"
:class="{'selected':actionButton === 1}"
@click.stop.prevent="editBufferMetadata(item.path)"
>Edit</button>
<button
v-if="actionButton > 0 && idx === selected"
:class="{'delete':true, 'selected':actionButton === 2, 'confirm':deleteConfirm}"
@click.stop.prevent="deleteConfirmNote(item.path)"
>
<template v-if="deleteConfirm">
Really Delete?
</template>
<template v-else>
Delete
</template>
</button>
<button
class="show-actions"
v-if="itemHasActionButtons(item) && (actionButton === 0 || idx !== selected)"
@click.stop.prevent="showActionButtons(idx)"
></button>
</span>
</li>
</template>
</ul>
</div>
</form>
</template>
<style scoped lang="sass">
.note-selector
font-size: 13px
background: #efefef
position: absolute
top: 0
left: 50%
width: 440px
transform: translateX(-50%)
max-height: 100%
box-sizing: border-box
display: flex
flex-direction: column
border-radius: 0 0 5px 5px
box-shadow: 0 0 10px rgba(0,0,0,0.3)
+dark-mode
background: #151516
box-shadow: 0 0 10px rgba(0,0,0,0.5)
color: rgba(255,255,255, 0.7)
+webapp-mobile
max-width: calc(100% - 80px)
.input-container
padding: 10px
h1
font-weight: bold
margin-bottom: 14px
input
background: #fff
padding: 4px 5px
border: 1px solid #ccc
box-sizing: border-box
border-radius: 2px
width: 100%
&:focus
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
+dark-mode
background: #3b3b3b
color: rgba(255,255,255, 0.9)
border: 1px solid #5a5a5a
&:focus
border: 1px solid #3b3b3b
+webapp-mobile
font-size: 16px
max-width: 100%
.scroller
overflow-y: auto
padding: 0 10px 5px 10px
.items
> li.line-separator
height: 1px
background: rgba(0,0,0, 0.05)
margin-left: -10px
margin-right: -10px
margin-top: 3px
margin-bottom: 3px
+dark-mode
background: rgba(255,255,255, 0.1)
> li.item
position: relative
border-radius: 3px
padding: 3px 12px
line-height: 18px
display: flex
align-items: center
scroll-margin-top: 6px
scroll-margin-bottom: 6px
&:hover
background: #e2e2e2
.action-buttons .show-actions
display: inline-block
background-image: url(@/assets/icons/arrow-right-black.svg)
&.selected .action-buttons .show-actions
background-image: url(@/assets/icons/arrow-right-white.svg)
+dark-mode
color: rgba(255,255,255, 0.65)
&:hover
background: #29292a
.action-buttons button
color: #fff
&.selected
background: #48b57e
color: #fff
&.action-buttons-visible
background: none
border: 1px solid #48b57e
padding: 2px 11px
color: #444
.action-buttons .show-actions
display: inline-block
+dark-mode
background: #1b6540
color: rgba(255,255,255, 0.87)
&.action-buttons-visible
background: none
border: 1px solid #1b6540
color: rgba(255,255,255, 0.65)
&.scratch
font-weight: 600
&.new-note
//font-size: 12px
.name
margin-right: 12px
flex-shrink: 0
overflow: hidden
text-overflow: ellipsis
text-wrap: nowrap
::v-deep(b)
font-weight: 700
.path
opacity: 0.6
font-size: 12px
flex-shrink: 1
overflow: hidden
text-overflow: ellipsis
text-wrap: nowrap
::v-deep(b)
font-weight: 700
.action-buttons
position: absolute
top: 1px
right: 0px
padding: 0 1px
&.visible
background: #efefef
+dark-mode
background: #151516
button
padding: 0 10px
height: 20px
font-size: 12px
background: none
border: none
border-radius: 2px
margin-right: 2px
cursor: pointer
&:last-child
margin-right: 0
&:hover
background: rgba(0,0,0, 0.1)
+dark-mode
&:hover
background-color: rgba(255,255,255, 0.1)
&.selected
background: #48b57e
color: #fff
&:hover
background: #3ea471
&.delete
background: #e95050
&:hover
background: #ce4848
+dark-mode
background: #1b6540
&:hover
background: #1f7449
&.delete
background: #ae1e1e
&:hover
background: #bf2222
&.confirm
font-weight: 600
&.show-actions
display: none
position: relative
top: 1px
padding: 1px 8px
//cursor: default
background-image: url(@/assets/icons/arrow-right-white.svg)
width: 22px
height: 19px
background-size: 19px
background-position: center center
background-repeat: no-repeat
+dark-mode
background-image: url(@/assets/icons/arrow-right-grey.svg)
</style>

View File

@ -0,0 +1,303 @@
<script>
import slugify from '@sindresorhus/slugify';
import { toRaw } from 'vue';
import { mapState, mapActions } from 'pinia'
import { useHeynoteStore } from "../stores/heynote-store"
import FolderSelector from './folder-selector/FolderSelector.vue'
const pathSep = window.heynote.buffer.pathSeparator
export default {
data() {
return {
name: "",
filename: "",
tags: [],
directoryTree: null,
parentPath: "",
errors: {
name: null,
},
}
},
components: {
FolderSelector
},
async mounted() {
this.$refs.nameInput.focus()
this.updateBuffers()
console.log("EditNote mounted", this.currentNote)
this.name = this.currentNote.name
// build directory tree
const directories = await window.heynote.buffer.getDirectoryList()
const rootNode = {
name: "Heynote Root",
path: "",
children: [],
open: true,
}
const getNodeFromList = (list, part) => list.find(node => node.name === part)
directories.forEach((path) => {
const parts = path.split(pathSep)
let currentLevel = rootNode
let currentParts = []
parts.forEach(part => {
currentParts.push(part)
let node = getNodeFromList(currentLevel.children, part)
if (node) {
currentLevel = node
} else {
const currentPath = currentParts.join(pathSep)
node = {
name: part,
children: [],
path: currentPath,
open: this.currentBufferPath.startsWith(currentPath),
}
currentLevel.children.push(node)
currentLevel = node
}
})
})
//console.log("tree:", rootNode)
this.directoryTree = rootNode
},
computed: {
...mapState(useHeynoteStore, [
"buffers",
"currentBufferPath",
]),
currentNote() {
return this.buffers[this.currentBufferPath]
},
currentNoteDirectory() {
return this.currentBufferPath.split(pathSep).slice(0, -1).join(pathSep)
},
nameInputClass() {
return {
"name-input": true,
"error": this.errors.name,
}
}
},
methods: {
...mapActions(useHeynoteStore, [
"updateBuffers",
"updateBufferMetadata",
]),
onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault()
this.cancel()
} if (event.key === "Enter") {
event.preventDefault()
this.submit()
}
},
onCancelKeydown(event) {
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
this.cancel()
}
},
cancel() {
this.$emit("close")
},
onInputKeydown(event) {
// support Ctrl/Cmd+A to select all
if (event.key === "a" && event[window.heynote.platform.isMac ? "metaKey" : "ctrlKey"]) {
event.preventDefault()
event.srcElement.select()
}
// redirect arrow keys and page up/down to folder selector
const redirectKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp"]
if (redirectKeys.includes(event.key)) {
this.$refs.folderSelect.$el.dispatchEvent(new KeyboardEvent("keydown", {key: event.key}))
event.preventDefault()
}
},
submit() {
let slug = slugify(this.name)
if (slug === "") {
this.errors.name = true
return
}
const parentPathPrefix = this.parentPath === "" ? "" : this.parentPath + pathSep
let path;
for (let i=0; i<1000; i++) {
let filename = slug + ".txt"
path = parentPathPrefix + filename
if (path === this.currentBufferPath || !this.buffers[path]) {
// file name is ok if it's the current note, or if it doesn't exist
break
}
slug = slugify(this.name + "-" + i)
}
if (path !== this.currentBufferPath && this.buffers[path]) {
console.error("Failed to edit note, path already exists", path)
this.errors.name = true
return
}
console.log("Update note", path)
this.updateBufferMetadata(this.currentBufferPath, this.name, path)
this.$emit("close")
//this.$emit("create", this.$refs.input.value)
},
}
}
</script>
<template>
<div class="fader" @keydown.stop="onKeydown" tabindex="-1">
<form class="new-note" tabindex="-1" @focusout="onFocusOut" ref="container" @submit.prevent="submit">
<div class="container">
<h1>Edit Note</h1>
<input
placeholder="Name"
type="text"
v-model="name"
:class="nameInputClass"
ref="nameInput"
@keydown="onInputKeydown"
@input="errors.name = false"
/>
<label for="folder-select">Move to</label>
<FolderSelector
v-if="directoryTree"
:directoryTree="directoryTree"
:selectedPath="currentNoteDirectory"
id="folder-select"
v-model="parentPath"
ref="folderSelect"
/>
</div>
<div class="bottom-bar">
<button type="submit">Update Note</button>
<button
class="cancel"
@keydown="onCancelKeydown"
@click.stop.prevent="cancel"
>Cancel</button>
</div>
</form>
</div>
</template>
<style scoped lang="sass">
.fader
position: fixed
top: 0
left: 0
bottom: 0
right: 0
background: rgba(0,0,0, 0.2)
.new-note
font-size: 13px
//background: #48b57e
background: #efefef
width: 420px
position: absolute
top: 0
left: 50%
transform: translateX(-50%)
border-radius: 0 0 5px 5px
box-shadow: 0 0 10px rgba(0,0,0,0.3)
display: flex
flex-direction: column
max-height: 100%
&:focus
outline: none
+dark-mode
background: #151516
box-shadow: 0 0 10px rgba(0,0,0,0.5)
color: rgba(255,255,255, 0.7)
+webapp-mobile
max-width: calc(100% - 80px)
.container
padding: 10px
min-height: 0
display: flex
flex-direction: column
h1
font-weight: bold
margin-bottom: 14px
label
display: block
margin-bottom: 6px
//padding-left: 2px
font-size: 12px
font-weight: 600
.name-input
width: 100%
background: #fff
padding: 4px 5px
border: 1px solid #ccc
box-sizing: border-box
border-radius: 2px
margin-bottom: 16px
&:focus
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
&.error
background: #ffe9e9
+dark-mode
background: #3b3b3b
color: rgba(255,255,255, 0.9)
border: 1px solid #5a5a5a
&:focus
border: 1px solid #3b3b3b
+webapp-mobile
font-size: 16px
max-width: 100%
.bottom-bar
border-radius: 0 0 5px 5px
//background: #e3e3e3
padding: 10px
padding-top: 0
display: flex
justify-content: space-between
button
font-size: 12px
height: 28px
border: 1px solid #c5c5c5
border-radius: 3px
padding-left: 10px
padding-right: 10px
&:focus
outline-color: #48b57e
+dark-mode
background: #444
border: none
color: rgba(255,255,255, 0.75)
&[type="submit"]
order: 1
&.cancel
order: 0
</style>

View File

@ -1,34 +1,18 @@
<script> <script>
import { HeynoteEditor, LANGUAGE_SELECTOR_EVENT } from '../editor/editor.js'
import { syntaxTree } from "@codemirror/language" import { syntaxTree } from "@codemirror/language"
import { toRaw } from 'vue';
import { mapState, mapWritableState, mapActions, mapStores } from 'pinia'
import { useErrorStore } from "../stores/error-store"
import { useHeynoteStore } from "../stores/heynote-store.js"
import { useEditorCacheStore } from "../stores/editor-cache"
import { REDO_EVENT, WINDOW_CLOSE_EVENT, DELETE_BLOCK_EVENT, UNDO_EVENT, SELECT_ALL_EVENT } from '@/src/common/constants';
const NUM_EDITOR_INSTANCES = 5
export default { export default {
props: { props: {
theme: String,
development: Boolean, development: Boolean,
debugSyntaxTree: Boolean, debugSyntaxTree: Boolean,
keymap: {
type: String,
default: "default",
},
emacsMetaKey: {
type: String,
default: "alt",
},
showLineNumberGutter: {
type: Boolean,
default: true,
},
showFoldGutter: {
type: Boolean,
default: true,
},
bracketClosing: {
type: Boolean,
default: false,
},
fontFamily: String,
fontSize: Number,
}, },
components: {}, components: {},
@ -36,59 +20,63 @@
data() { data() {
return { return {
syntaxTreeDebugContent: null, syntaxTreeDebugContent: null,
editor: null,
onWindowClose: null,
onUndo: null,
onRedo: null,
onDeleteBlock: null,
onSelectAll: null,
} }
}, },
mounted() { mounted() {
this.$refs.editor.addEventListener("selectionChange", (e) => { // initialize editorCacheStore (sets up watchers for settings changes, propagating them to all editors)
//console.log("selectionChange:", e) this.editorCacheStore.setUp(this.$refs.editor);
this.$emit("cursorChange", {
cursorLine: e.cursorLine,
selectionSize: e.selectionSize,
language: e.language,
languageAuto: e.languageAuto,
})
})
this.$refs.editor.addEventListener(LANGUAGE_SELECTOR_EVENT, (e) => { this.loadBuffer(this.currentBufferPath)
this.$emit("openLanguageSelector")
})
// load buffer content and create editor
window.heynote.buffer.load().then((content) => {
let diskContent = content
this.editor = new HeynoteEditor({
element: this.$refs.editor,
content: content,
theme: this.theme,
saveFunction: (content) => {
if (content === diskContent) {
return
}
diskContent = content
window.heynote.buffer.save(content)
},
keymap: this.keymap,
emacsMetaKey: this.emacsMetaKey,
showLineNumberGutter: this.showLineNumberGutter,
showFoldGutter: this.showFoldGutter,
bracketClosing: this.bracketClosing,
fontFamily: this.fontFamily,
fontSize: this.fontSize,
})
window._heynote_editor = this.editor
window.document.addEventListener("currenciesLoaded", this.onCurrenciesLoaded)
// set up buffer change listener
window.heynote.buffer.onChangeCallback((event, content) => {
diskContent = content
this.editor.setContent(content)
})
})
// set up window close handler that will save the buffer and quit // set up window close handler that will save the buffer and quit
window.heynote.onWindowClose(() => { this.onWindowClose = () => {
window.heynote.buffer.saveAndQuit(this.editor.getContent()) window.heynote.buffer.saveAndQuit([
}) [this.editor.path, this.editor.getContent()],
])
}
window.heynote.mainProcess.on(WINDOW_CLOSE_EVENT, this.onWindowClose)
this.onUndo = () => {
if (this.editor) {
toRaw(this.editor).undo()
}
}
window.heynote.mainProcess.on(UNDO_EVENT, this.onUndo)
this.onRedo = () => {
if (this.editor) {
toRaw(this.editor).redo()
}
}
window.heynote.mainProcess.on(REDO_EVENT, this.onRedo)
this.onDeleteBlock = () => {
if (this.editor) {
toRaw(this.editor).deleteActiveBlock()
}
}
window.heynote.mainProcess.on(DELETE_BLOCK_EVENT, this.onDeleteBlock)
this.onSelectAll = () => {
const activeEl = document.activeElement
if (activeEl && activeEl.tagName === "INPUT") {
// if the active element is an input, select all text in it
activeEl.select()
} else if (this.editor) {
// make sure the editor is focused
if (this.$refs.editor.contains(activeEl)) {
toRaw(this.editor).selectAll()
}
}
}
window.heynote.mainProcess.on(SELECT_ALL_EVENT, this.onSelectAll)
// if debugSyntaxTree prop is set, display syntax tree for debugging // if debugSyntaxTree prop is set, display syntax tree for debugging
if (this.debugSyntaxTree) { if (this.debugSyntaxTree) {
@ -111,63 +99,82 @@
}, },
beforeUnmount() { beforeUnmount() {
window.document.removeEventListener("currenciesLoaded", this.onCurrenciesLoaded) window.heynote.mainProcess.off(WINDOW_CLOSE_EVENT, this.onWindowClose)
window.heynote.mainProcess.off(UNDO_EVENT, this.onUndo)
window.heynote.mainProcess.off(REDO_EVENT, this.onRedo)
window.heynote.mainProcess.off(DELETE_BLOCK_EVENT, this.onDeleteBlock)
window.heynote.mainProcess.off(SELECT_ALL_EVENT, this.onSelectAll)
this.editorCacheStore.tearDown();
}, },
watch: { watch: {
theme(newTheme) { loadNewEditor() {
this.editor.setTheme(newTheme) //console.log("currentBufferPath changed to", path)
this.loadBuffer(this.currentBufferPath)
},
}, },
keymap() { computed: {
this.editor.setKeymap(this.keymap, this.emacsMetaKey) ...mapStores(useEditorCacheStore),
}, ...mapState(useHeynoteStore, [
"currentBufferPath",
"libraryId",
]),
...mapWritableState(useHeynoteStore, [
"currentEditor",
"currentBufferName",
]),
emacsMetaKey() { loadNewEditor() {
this.editor.setKeymap(this.keymap, this.emacsMetaKey) return `${this.currentBufferPath}|${this.libraryId}`
},
showLineNumberGutter(show) {
this.editor.setLineNumberGutter(show)
},
showFoldGutter(show) {
this.editor.setFoldGutter(show)
},
bracketClosing(value) {
this.editor.setBracketClosing(value)
},
fontFamily() {
this.editor.setFont(this.fontFamily, this.fontSize)
},
fontSize() {
this.editor.setFont(this.fontFamily, this.fontSize)
}, },
}, },
methods: { methods: {
setLanguage(language) { loadBuffer(path) {
if (language === "auto") { //console.log("loadBuffer", path)
this.editor.setCurrentLanguage("text", true) if (this.editor) {
} else { this.editor.hide()
this.editor.setCurrentLanguage(language, false)
} }
this.editor.focus()
let cachedEditor = this.editorCacheStore.getEditor(path)
if (cachedEditor) {
//console.log("show cached editor")
this.editor = cachedEditor
toRaw(this.editor).show()
} else {
//console.log("create new editor")
this.editor = this.editorCacheStore.createEditor(path)
this.editorCacheStore.addEditor(path, toRaw(this.editor))
}
this.currentEditor = toRaw(this.editor)
window._heynote_editor = toRaw(this.editor)
},
setLanguage(language) {
const editor = toRaw(this.editor)
if (language === "auto") {
editor.setCurrentLanguage(null, true)
} else {
editor.setCurrentLanguage(language, false)
}
editor.focus()
}, },
formatCurrentBlock() { formatCurrentBlock() {
this.editor.formatCurrentBlock() const editor = toRaw(this.editor)
this.editor.focus() editor.formatCurrentBlock()
}, editor.focus()
onCurrenciesLoaded() {
this.editor.currenciesLoaded()
}, },
focus() { focus() {
this.editor.focus() toRaw(this.editor).focus()
},
onContextMenu(event) {
event.preventDefault()
window.heynote.mainProcess.invoke("showEditorContextMenu")
}, },
}, },
} }
@ -175,7 +182,7 @@
<template> <template>
<div> <div>
<div class="editor" ref="editor"></div> <div class="editor" ref="editor" @contextmenu="onContextMenu"></div>
<div <div
v-if="debugSyntaxTree" v-if="debugSyntaxTree"
v-html="syntaxTreeDebugContent" v-html="syntaxTreeDebugContent"

View File

@ -0,0 +1,114 @@
<script>
import { mapState, mapActions } from 'pinia'
import { useErrorStore } from "../stores/error-store"
export default {
computed: {
...mapState(useErrorStore, ["errors"]),
},
methods: {
...mapActions(useErrorStore, ["popError"]),
pluralize(count, singular, plural) {
return count === 1 ? singular : plural
},
},
}
</script>
<template>
<div
class="error-messages"
v-if="errors && errors.length > 0"
>
<div class="dialog">
<div class="dialog-content">
<h1>Error</h1>
<p>
{{ errors[0] }}
</p>
</div>
<div class="bottom-bar">
<div style="flex-grow:1;">
<div
v-if="errors.length > 1"
class="count"
>
{{ errors.length-1 }} more {{ pluralize(errors.length-1, "error", "errors") }}
</div>
</div>
<button
@click="popError"
class="close"
>Close</button>
</div>
</div>
<div class="shader"></div>
</div>
</template>
<style lang="sass" scoped>
.error-messages
position: fixed
top: 0
left: 0
bottom: 0
right: 0
.shader
z-index: 1
position: absolute
top: 0
left: 0
bottom: 0
right: 0
background: rgba(0, 0, 0, 0.5)
.dialog
box-sizing: border-box
z-index: 2
position: absolute
left: 50%
top: 50%
transform: translate(-50%, -50%)
width: 440px
height: 200px
max-width: 100%
max-height: 100%
display: flex
flex-direction: column
border-radius: 5px
background: #fff
color: #333
box-shadow: 0 0 25px rgba(0, 0, 0, 0.2)
overflow-y: auto
&:active, &:selected, &:focus, &:focus-visible
border: none
outline: none
+dark-mode
background: #333
color: #eee
box-shadow: 0 0 25px rgba(0, 0, 0, 0.3)
.dialog-content
flex-grow: 1
padding: 30px
h1
font-size: 14px
font-weight: 700
display: block
margin-bottom: 1em
.bottom-bar
border-radius: 0 0 5px 5px
background: #eee
padding: 10px 20px
display: flex
align-items: center
+dark-mode
background: #222
.close
height: 28px
</style>

View File

@ -1,16 +1,22 @@
<script> <script>
import fuzzysort from 'fuzzysort'
import { LANGUAGES } from '../editor/languages.js' import { LANGUAGES } from '../editor/languages.js'
const items = LANGUAGES.map(l => { const items = LANGUAGES.map(l => {
return { return {
"token": l.token, "token": l.token,
"name": l.name "name": l.name,
"guesslang": l.guesslang,
} }
}).sort((a, b) => { }).sort((a, b) => {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
items.unshift({token: "auto", name:"Auto-detect"}) items.unshift({token: "auto", name:"Auto-detect"})
items.forEach((item, idx) => {
item.preparedName = fuzzysort.prepare(item.name)
})
export default { export default {
data() { data() {
return { return {
@ -26,8 +32,18 @@
computed: { computed: {
filteredItems() { filteredItems() {
return items.filter((lang) => { if (this.filter === "") {
return lang.name.toLowerCase().indexOf(this.filter.toLowerCase()) !== -1 return items
}
const searchResults = fuzzysort.go(this.filter, items, {
keys: ['name', 'guesslang'],
})
return searchResults.map(result => {
const highlight = result[0].highlight("<b>", "</b>")
return {
"token": result.obj.token,
"name": highlight || result.obj.name,
}
}) })
}, },
}, },
@ -96,9 +112,8 @@
:class="idx === selected ? 'selected' : ''" :class="idx === selected ? 'selected' : ''"
@click="selectItem(item.token)" @click="selectItem(item.token)"
ref="item" ref="item"
> v-html="item.name"
{{ item.name }} />
</li>
</ul> </ul>
</form> </form>
</div> </div>
@ -106,12 +121,12 @@
<style scoped lang="sass"> <style scoped lang="sass">
.scroller .scroller
overflow: auto //overflow: auto
position: fixed //position: fixed
top: 0 //top: 0
left: 0 //left: 0
bottom: 0 //bottom: 0
right: 0 //right: 0
.language-selector .language-selector
font-size: 13px font-size: 13px
padding: 10px padding: 10px
@ -121,6 +136,10 @@
top: 0 top: 0
left: 50% left: 50%
transform: translateX(-50%) transform: translateX(-50%)
max-height: 100%
box-sizing: border-box
display: flex
flex-direction: column
border-radius: 0 0 5px 5px border-radius: 0 0 5px 5px
box-shadow: 0 0 10px rgba(0,0,0,0.3) box-shadow: 0 0 10px rgba(0,0,0,0.3)
+dark-mode +dark-mode
@ -135,7 +154,7 @@
border: 1px solid #ccc border: 1px solid #ccc
box-sizing: border-box box-sizing: border-box
border-radius: 2px border-radius: 2px
width: 400px width: 300px
margin-bottom: 10px margin-bottom: 10px
&:focus &:focus
outline: none outline: none
@ -152,6 +171,7 @@
max-width: 100% max-width: 100%
.items .items
overflow-y: auto
> li > li
border-radius: 3px border-radius: 3px
padding: 5px 12px padding: 5px 12px
@ -162,10 +182,12 @@
background: #48b57e background: #48b57e
color: #fff color: #fff
+dark-mode +dark-mode
color: rgba(255,255,255, 0.53) color: rgba(255,255,255, 0.65)
&:hover &:hover
background: #29292a background: #29292a
&.selected &.selected
background: #1b6540 background: #1b6540
color: rgba(255,255,255, 0.87) color: rgba(255,255,255, 0.87)
::v-deep(b)
font-weight: 700
</style> </style>

View File

@ -0,0 +1,319 @@
<script>
import slugify from '@sindresorhus/slugify';
import { mapState, mapActions } from 'pinia'
import { useHeynoteStore } from "../stores/heynote-store"
import FolderSelector from './folder-selector/FolderSelector.vue'
const pathSep = window.heynote.buffer.pathSeparator
export default {
data() {
return {
name: "",
filename: "",
tags: [],
directoryTree: null,
parentPath: "",
errors: {
name: null,
},
}
},
components: {
FolderSelector
},
async mounted() {
if (!!this.createBufferParams.name) {
this.name = this.createBufferParams.name
this.$refs.nameInput.focus()
this.$nextTick(() => {
this.$refs.nameInput.select()
})
} else {
this.$refs.nameInput.focus()
}
this.updateBuffers()
// build directory tree
const directories = await window.heynote.buffer.getDirectoryList()
const rootNode = {
name: "Heynote Root",
path: "",
children: [],
open: true,
}
const getNodeFromList = (list, part) => list.find(node => node.name === part)
directories.forEach((path) => {
const parts = path.split(pathSep)
let currentLevel = rootNode
let currentParts = []
parts.forEach(part => {
currentParts.push(part)
let node = getNodeFromList(currentLevel.children, part)
if (node) {
currentLevel = node
} else {
const currentPath = currentParts.join(pathSep)
node = {
name: part,
children: [],
path: currentPath,
open: this.currentBufferPath.startsWith(currentPath),
}
currentLevel.children.push(node)
currentLevel = node
}
})
})
//console.log("tree:", rootNode)
this.directoryTree = rootNode
},
computed: {
...mapState(useHeynoteStore, [
"buffers",
"currentBufferPath",
"createBufferParams",
]),
currentNoteDirectory() {
return this.currentBufferPath.split(pathSep).slice(0, -1).join(pathSep)
},
nameInputClass() {
return {
"name-input": true,
"error": this.errors.name,
}
},
dialogTitle() {
return this.createBufferParams.mode === "currentBlock" ? "Move Block to New Buffer" : "New Buffer"
},
},
methods: {
...mapActions(useHeynoteStore, [
"updateBuffers",
"createNewBuffer",
"createNewBufferFromActiveBlock",
]),
onKeydown(event) {
if (event.key === "Escape") {
this.$emit("close")
event.preventDefault()
} if (event.key === "Enter") {
// without preventDefault, the editor will receive a Enter keydown event on webapp (not in Electron)
event.preventDefault()
this.submit()
}
},
onCancelKeydown(event) {
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
this.cancel()
}
},
cancel() {
this.$emit("close")
},
onInputKeydown(event) {
// support Ctrl/Cmd+A to select all
if (event.key === "a" && event[window.heynote.platform.isMac ? "metaKey" : "ctrlKey"]) {
event.preventDefault()
event.srcElement.select()
}
// redirect arrow keys and page up/down to folder selector
const redirectKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp"]
if (redirectKeys.includes(event.key)) {
this.$refs.folderSelect.$el.dispatchEvent(new KeyboardEvent("keydown", {key: event.key}))
event.preventDefault()
}
},
submit() {
let slug = slugify(this.name)
if (slug === "") {
this.errors.name = true
return
}
const parentPathPrefix = this.parentPath === "" ? "" : this.parentPath + pathSep
let path;
for (let i=0; i<1000; i++) {
let filename = slug + ".txt"
path = parentPathPrefix + filename
if (!this.buffers[path]) {
break
}
slug = slugify(this.name + "-" + i)
}
if (this.buffers[path]) {
console.error("Failed to create buffer, path already exists", path)
this.errors.name = true
return
}
//console.log("Creating buffer", path, this.createBufferParams)
if (this.createBufferParams.mode === "currentBlock") {
this.createNewBufferFromActiveBlock(path, this.name)
} else if (this.createBufferParams.mode === "new") {
this.createNewBuffer(path, this.name)
} else {
throw new Error("Unknown createBuffer Mode: " + this.createBufferParams.mode)
}
this.$emit("close")
//this.$emit("create", this.$refs.input.value)
},
}
}
</script>
<template>
<div class="fader" @keydown="onKeydown" tabindex="-1">
<form class="new-buffer" tabindex="-1" @focusout="onFocusOut" ref="container" @submit.prevent="submit">
<div class="container">
<h1>{{ dialogTitle }}</h1>
<input
placeholder="Name"
type="text"
v-model="name"
:class="nameInputClass"
ref="nameInput"
@keydown="onInputKeydown"
@input="errors.name = false"
autocomplete="off"
data-1p-ignore
/>
<label for="folder-select">Create in</label>
<FolderSelector
v-if="directoryTree"
:directoryTree="directoryTree"
:selectedPath="currentNoteDirectory"
id="folder-select"
v-model="parentPath"
ref="folderSelect"
/>
</div>
<div class="bottom-bar">
<button type="submit">Create New Buffer</button>
<button
class="cancel"
@keydown="onCancelKeydown"
@click.stop.prevent="cancel"
>Cancel</button>
</div>
</form>
</div>
</template>
<style scoped lang="sass">
.fader
position: fixed
top: 0
left: 0
bottom: 0
right: 0
background: rgba(0,0,0, 0.2)
.new-buffer
font-size: 13px
//background: #48b57e
background: #efefef
width: 420px
position: absolute
top: 0
left: 50%
transform: translateX(-50%)
border-radius: 0 0 5px 5px
box-shadow: 0 0 10px rgba(0,0,0,0.3)
display: flex
flex-direction: column
max-height: 100%
&:focus
outline: none
+dark-mode
background: #151516
box-shadow: 0 0 10px rgba(0,0,0,0.5)
color: rgba(255,255,255, 0.7)
+webapp-mobile
max-width: calc(100% - 80px)
.container
padding: 10px
min-height: 0
display: flex
flex-direction: column
h1
font-weight: bold
margin-bottom: 14px
label
display: block
margin-bottom: 6px
//padding-left: 2px
font-size: 12px
font-weight: 600
.name-input
width: 100%
background: #fff
padding: 4px 5px
border: 1px solid #ccc
box-sizing: border-box
border-radius: 2px
margin-bottom: 16px
&:focus
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
&.error
background: #ffe9e9
+dark-mode
background: #3b3b3b
color: rgba(255,255,255, 0.9)
border: 1px solid #5a5a5a
&:focus
border: 1px solid #3b3b3b
+webapp-mobile
font-size: 16px
max-width: 100%
.bottom-bar
border-radius: 0 0 5px 5px
//background: #e3e3e3
padding: 10px
padding-top: 0
display: flex
justify-content: space-between
button
font-size: 12px
height: 28px
border: 1px solid #c5c5c5
border-radius: 3px
padding-left: 10px
padding-right: 10px
&:focus
outline-color: #48b57e
+dark-mode
background: #444
border: none
color: rgba(255,255,255, 0.75)
&[type="submit"]
order: 1
&.cancel
order: 0
</style>

View File

@ -1,19 +1,14 @@
<script> <script>
import { mapState } from 'pinia'
import UpdateStatusItem from './UpdateStatusItem.vue' import UpdateStatusItem from './UpdateStatusItem.vue'
import { LANGUAGES } from '../editor/languages.js' import { LANGUAGES } from '../editor/languages.js'
import { useHeynoteStore } from "../stores/heynote-store"
const LANGUAGE_MAP = Object.fromEntries(LANGUAGES.map(l => [l.token, l])) const LANGUAGE_MAP = Object.fromEntries(LANGUAGES.map(l => [l.token, l]))
const LANGUAGE_NAMES = Object.fromEntries(LANGUAGES.map(l => [l.token, l.name])) const LANGUAGE_NAMES = Object.fromEntries(LANGUAGES.map(l => [l.token, l.name]))
export default { export default {
props: [ props: [
"line",
"column",
"selectionSize",
"language",
"languageAuto",
"theme",
"themeSetting",
"autoUpdate", "autoUpdate",
"allowBetaVersions", "allowBetaVersions",
], ],
@ -33,8 +28,17 @@
}, },
computed: { computed: {
...mapState(useHeynoteStore, [
"currentBufferName",
"currentCursorLine",
"currentLanguage",
"currentSelectionSize",
"currentLanguage",
"currentLanguageAuto",
]),
languageName() { languageName() {
return LANGUAGE_NAMES[this.language] || this.language return LANGUAGE_NAMES[this.currentLanguage] || this.currentLanguage
}, },
className() { className() {
@ -42,7 +46,7 @@
}, },
supportsFormat() { supportsFormat() {
const lang = LANGUAGE_MAP[this.language] const lang = LANGUAGE_MAP[this.currentLanguage]
return !!lang ? lang.supportsFormat : false return !!lang ? lang.supportsFormat : false
}, },
@ -54,6 +58,10 @@
return `Format Block Content (Alt + Shift + F)` return `Format Block Content (Alt + Shift + F)`
}, },
changeNoteTitle() {
return `Change Note (${this.cmdKey} + P)`
},
changeLanguageTitle() { changeLanguageTitle() {
return `Change language for current block (${this.cmdKey} + L)` return `Change language for current block (${this.cmdKey} + L)`
}, },
@ -68,24 +76,31 @@
<template> <template>
<div :class="className"> <div :class="className">
<div class="status-block line-number"> <div class="status-block line-number">
Ln <span class="num">{{ line }}</span> Ln <span class="num">{{ currentCursorLine?.line }}</span>
Col <span class="num">{{ column }}</span> Col <span class="num">{{ currentCursorLine?.col }}</span>
<template v-if="selectionSize > 0"> <template v-if="currentSelectionSize > 0">
Sel <span class="num">{{ selectionSize }}</span> Sel <span class="num">{{ currentSelectionSize }}</span>
</template> </template>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<div <div
@click="$emit('openLanguageSelector')" @click.stop="$emit('openBufferSelector')"
class="status-block note clickable"
:title="changeNoteTitle"
>
{{ currentBufferName }}
</div>
<div
@click.stop="$emit('openLanguageSelector')"
class="status-block lang clickable" class="status-block lang clickable"
:title="changeLanguageTitle" :title="changeLanguageTitle"
> >
{{ languageName }} {{ languageName }}
<span v-if="languageAuto" class="auto">(auto)</span> <span v-if="currentLanguageAuto" class="auto">(auto)</span>
</div> </div>
<div <div
v-if="supportsFormat" v-if="supportsFormat"
@click="$emit('formatCurrentBlock')" @click.stop="$emit('formatCurrentBlock')"
class="status-block format clickable" class="status-block format clickable"
:title="formatBlockTitle" :title="formatBlockTitle"
> >
@ -96,11 +111,8 @@
:autoUpdate="autoUpdate" :autoUpdate="autoUpdate"
:allowBetaVersions="allowBetaVersions" :allowBetaVersions="allowBetaVersions"
/> />
<div class="status-block theme clickable" @click="$emit('toggleTheme')" title="Toggle dark/light mode">
<span :class="'icon ' + themeSetting"></span>
</div>
<div <div
@click="$emit('openSettings')" @click.stop="$emit('openSettings')"
class="status-block settings clickable" class="status-block settings clickable"
title="Settings" title="Settings"
> >
@ -159,19 +171,6 @@
color: rgba(255, 255, 255, 0.7) color: rgba(255, 255, 255, 0.7)
+dark-mode +dark-mode
color: rgba(255, 255, 255, 0.55) color: rgba(255, 255, 255, 0.55)
.theme
padding-top: 0
padding-bottom: 0
.icon
background-size: 14px
background-repeat: no-repeat
background-position: center center
&.dark
background-image: url("@/assets/icons/dark-mode.png")
&.light
background-image: url("@/assets/icons/light-mode.png")
&.system
background-image: url("@/assets/icons/both-mode.png")
.format .format
padding-top: 0 padding-top: 0

View File

@ -0,0 +1,124 @@
<script>
export default {
props: {
name: String,
level: Number,
selected: Boolean,
newFolder: Boolean,
open: Boolean,
},
watch: {
selected() {
if (this.selected) {
// scrollIntoViewIfNeeded is not supported in all browsers
// See: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded
if (this.$el.scrollIntoViewIfNeeded) {
this.$el.scrollIntoViewIfNeeded({
behavior: "auto",
block: "nearest",
})
} else {
this.$el.scrollIntoView({
behavior: "auto",
block: "nearest",
})
}
}
}
},
computed: {
className() {
return {
folder: true,
selected: this.selected,
new: this.newFolder,
open: this.open,
}
},
style() {
return {
"--indent-level": this.level,
}
}
},
}
</script>
<template>
<div
:class="className"
:style="style"
>
<span class="name">{{ name }}</span>
<button class="new-folder" tabindex="-1" @click.stop.prevent="$emit('new-folder')">New folder (+)</button>
</div>
</template>
<style lang="sass" scoped>
.folder
padding: 3px 6px
font-size: 13px
padding-left: calc(18px + var(--indent-level) * 16px)
display: flex
scroll-margin-top: 5px
scroll-margin-bottom: 5px
background-image: url('@/assets/icons/caret-right.svg')
background-size: 13px
background-repeat: no-repeat
background-position-y: 5px
background-position-x: calc(2px + var(--indent-level) * 16px)
+dark-mode
background-image: url('@/assets/icons/caret-right-white.svg')
color: rgba(255,255,255, 0.87)
&:hover
background-color: #f1f1f1
+dark-mode
background-color: #39393a
&.open
background-image: url('@/assets/icons/caret-down.svg')
+dark-mode
background-image: url('@/assets/icons/caret-down-white.svg')
&.selected
background-color: #48b57e
color: #fff
background-image: url('@/assets/icons/caret-right-white.svg')
&.open
background-image: url('@/assets/icons/caret-down-white.svg')
&:hover
background-color: #40a773
+dark-mode
background-color: #1b6540
color: rgba(255,255,255, 0.87)
&:hover
background-color: #1f6f47
.new-folder
display: block
color: rgba(255,255,255, 0.9)
&.new
font-style: italic
color: rgba(0,0,0, 0.5)
&.selected
color: rgba(255,255,255, 0.8)
+dark-mode
color: rgba(255,255,255, 0.5)
&.selected
color: rgba(255,255,255, 0.8)
.name
flex-grow: 1
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.new-folder
background: rgba(0,0,0, 0.15)
border: none
border-radius: 2px
font-size: 10px
display: none
flex-shrink: 0
cursor: pointer
</style>

View File

@ -0,0 +1,253 @@
<script>
import FolderItem from "./FolderItem.vue"
import NewFolderItem from "./NewFolderItem.vue"
const pathSep = window.heynote.buffer.pathSeparator
export default {
props: {
directoryTree: Object,
selectedPath: String,
},
components: {
FolderItem,
NewFolderItem,
},
data() {
return {
tree: this.directoryTree,
selected: 0,
filter: "",
filterSearchStart: 0,
filterTimeout: null,
}
},
mounted() {
this.selected = this.listItems.findIndex(item => item.path === this.selectedPath)
},
watch: {
directoryTree(newVal) {
this.tree = newVal
},
selected() {
this.$emit("update:modelValue", this.listItems[this.selected].path)
}
},
computed: {
listItems() {
const items = []
const getListItems = (node, level) => {
items.push({
name: node.name,
level: level,
path: node.path,
type: "folder",
createNewFolder: node.createNewFolder,
newFolder: node.newFolder,
open: node.open,
})
if (node.createNewFolder) {
items.push({
level: level + 1,
type: "new-folder",
path: node.path,
})
}
if (node.open && node.children) {
for (const child of node.children) {
getListItems(child, level + 1)
}
}
}
getListItems(this.tree, 0)
return items
},
},
methods: {
onKeyDown(event) {
//console.log("Keydown", event.key)
if (event.key === "Enter") {
event.preventDefault()
this.$emit("click")
} else if (event.key === "ArrowDown") {
event.preventDefault()
this.selected = Math.min(this.selected + 1, this.listItems.length - 1)
} else if (event.key === "ArrowUp") {
event.preventDefault()
this.selected = Math.max(this.selected - 1, 0)
} else if (event.key === "ArrowRight") {
event.preventDefault()
const node = this.getNode(this.listItems[this.selected].path)
node.open = true
} else if (event.key === "ArrowLeft") {
event.preventDefault()
const node = this.getNode(this.listItems[this.selected].path)
node.open = false
} else if (event.key === "+") {
event.preventDefault()
this.newFolderDialog(this.listItems[this.selected].path)
} else if (event.key === "-") {
event.preventDefault()
this.removeNewFolder(this.listItems[this.selected].path)
} else if (event.key === "PageDown") {
event.preventDefault()
this.selected = Math.min(this.selected + this.pageCount(), this.listItems.length - 1)
} else if (event.key === "PageUp") {
event.preventDefault()
this.selected = Math.max(this.selected - this.pageCount(), 0)
} else {
if (event.key.length === 1) {
this.filter += event.key
if (this.filter === "") {
this.filterSearchStart = this.selected
}
let idx = this.listItems.findIndex((item, idx) => idx > this.filterSearchStart && item.name.toLowerCase().startsWith(this.filter))
if (idx === -1) {
idx = this.listItems.findIndex((item, idx) => idx < this.filterSearchStart && item.name.toLowerCase().startsWith(this.filter))
}
if (idx !== -1) {
this.selected = idx
this.scheduleFilterReset()
} else {
this.filter = ""
}
}
}
},
scheduleFilterReset() {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout)
this.filterTimeout = null
}
this.filterTimeout = setTimeout(() => {
this.filter = ""
this.filterSearchStart = 0
}, 1000)
},
newFolderDialog(parentPath) {
//console.log("Create new folder in", parentPath)
const node = this.getNode(parentPath)
node.createNewFolder = true
node.open = true
},
createNewFolder(parentPath, name) {
//console.log("Create new folder", name, "in", parentPath)
const node = this.getNode(parentPath)
node.createNewFolder = false
node.children.unshift({
name: name,
path: parentPath === "" ? name : parentPath + pathSep + name,
children: [],
newFolder: true,
})
this.selected++
this.$refs.container.focus()
},
cancelNewFolder(path) {
//console.log("Cancel new folder in", path)
const node = this.getNode(path)
node.createNewFolder = false
this.$refs.container.focus()
},
removeNewFolder(path) {
//console.log("Remove newly created folder:", path)
const node = this.getNode(path)
if (node.newFolder && path) {
const parentPath = path.split(pathSep).slice(0, -1).join(pathSep)
const parent = this.getNode(parentPath)
parent.children = parent.children.filter(child => child.path !== path)
this.selected--
}
this.$refs.container.focus()
},
getNode(path) {
const getNodeFromList = (list, part) => list.find(node => node.name === part)
const parts = path.split(pathSep)
let currentLevel = this.tree
for (const part of parts) {
const node = getNodeFromList(currentLevel.children, part)
if (!node) {
return currentLevel
}
currentLevel = node
}
return currentLevel
},
pageCount() {
return Math.max(1, Math.floor(this.$refs.container.clientHeight / 24) - 1)
},
folderClick(idx) {
const node = this.getNode(this.listItems[idx].path)
node.open = !node.open
this.selected = idx
},
},
}
</script>
<template>
<button
class="folder-select-container"
ref="container"
@keydown="onKeyDown"
@click.stop.prevent="()=>{}"
>
<!--<div class="folder root selected">
Heynote Root
</div>
<div class="folder indent">New Folder&hellip;</div>-->
<template v-for="(item, idx) in listItems">
<FolderItem
v-if="item.type === 'folder'"
:name="item.name"
:level="item.level"
:selected="idx === selected && !item.createNewFolder"
:newFolder="item.newFolder"
:open="item.open"
@click="folderClick(idx)"
@new-folder="newFolderDialog(item.path)"
/>
<NewFolderItem
v-else-if="item.type === 'new-folder'"
:parentPath="item.path"
:level="item.level"
@cancel="() => cancelNewFolder(item.path)"
@create-folder="createNewFolder"
/>
</template>
</button>
</template>
<style lang="sass" scoped>
.folder-select-container
width: 100%
overflow-y: auto
background: #fff
border: 1px solid #ccc
border-radius: 2px
padding: 5px 5px
text-align: left
&:focus, &:focus-within
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
+dark-mode
background: #262626
border: 1px solid #363636
</style>

View File

@ -0,0 +1,110 @@
<script>
import sanitizeFilename from "./sanitize-filename.js"
export default {
props: {
parentPath: String,
level: Number,
},
data() {
return {
name: "",
eventTriggered: false,
}
},
mounted() {
this.$refs.input.focus()
},
computed: {
className() {
return {
folder: true,
selected: true
}
},
style() {
return {
"--indent-level": this.level,
}
}
},
methods: {
onKeyDown(event) {
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
this.finish()
} else if (event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
this.name = ""
this.finish()
}
},
finish() {
if (this.eventTriggered) {
return
}
this.eventTriggered = true
if (this.name === "") {
this.$emit("cancel")
} else {
this.$emit("create-folder", this.parentPath, sanitizeFilename(this.name, "_"))
}
},
},
}
</script>
<template>
<div
:class="className"
:style="style"
>
<input
type="text"
v-model="name"
ref="input"
placeholder="New folder name"
maxlength="60"
@keydown.stop="onKeyDown"
@blur="finish"
/>
</div>
</template>
<style lang="sass" scoped>
.folder
padding: 3px 6px
font-size: 13px
padding-left: calc(0px + var(--indent-level) * 16px)
display: flex
background: #f1f1f1
+dark-mode
background-color: #39393a
input
width: 100%
background: #fff
border: none
border-radius: 2px
font-size: 13px
height: 16px
padding: 2px 4px
font-style: italic
border: 2px solid #48b57e
&:focus
outline: none
&::placeholder
font-size: 12px
+dark-mode
background: #3b3b3b
</style>

View File

@ -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)
}

View File

@ -0,0 +1,211 @@
<script>
import fuzzysort from 'fuzzysort'
import AutoComplete from 'primevue/autocomplete'
import { HEYNOTE_COMMANDS } from '@/src/editor/commands'
import RecordKeyInput from './RecordKeyInput.vue'
export default {
name: "AddKeyBind",
components: {
AutoComplete,
RecordKeyInput,
},
data() {
return {
key: "",
command: "",
commandSuggestions: [],
}
},
computed: {
commands() {
return Object.entries(HEYNOTE_COMMANDS).map(([key, cmd]) => {
const description = cmd.category + ": " + cmd.description
return {
name: key,
category: cmd.category,
description: description,
key: cmd.description,
label: description,
}
})
},
},
mounted() {
window.addEventListener("keydown", this.onKeyDown)
this.$refs.keys.$el.focus()
},
beforeUnmount() {
window.removeEventListener("keydown", this.onKeyDown)
},
methods: {
onKeyDown(event) {
if (event.key === "Escape" && document.activeElement !== this.$refs.keys.$el) {
this.$emit("close")
}
},
onCommandSearch(event) {
if (event.query === "") {
this.commandSuggestions = [...this.commands]
} else {
const searchResults = fuzzysort.go(event.query, this.commands, {
keys: ["description", "name"],
})
this.commandSuggestions = searchResults.map((result) => {
const obj = {...result.obj}
const nameHighlight = result[0].highlight("<b>", "</b>")
obj.label = nameHighlight !== "" ? nameHighlight : obj.description
return obj
})
}
},
onSave() {
if (this.key === "" || this.command === "") {
return
}
this.$emit("save", {
key: this.key,
command: this.command.name,
})
},
focusCommandSelector() {
this.$refs.autocomplete.$el.querySelector("input").focus()
},
},
}
</script>
<template>
<div class="container add-key-binding-dialog">
<div class="dialog">
<div class="dialog-content">
<h3>Add key binding</h3>
<div class="form">
<div class="field">
<label>Key</label>
<RecordKeyInput
v-model="key"
@enter="focusCommandSelector"
@close="$emit('close')"
ref="keys"
/>
</div>
<div class="field">
<label>Command</label>
<AutoComplete
dropdown
forceSelection
v-model="command"
:suggestions="commandSuggestions"
:autoOptionFocus="true"
optionLabel="key"
:delay="0"
@complete="onCommandSearch"
ref="autocomplete"
emptySearchMessage="No commands found"
class="command-autocomplete"
>
<template #option="slotProps">
<div class="command-option">
<span v-html="slotProps.option.label" />
</div>
</template>
</AutoComplete>
</div>
</div>
</div>
<div class="footer">
<button
@click="onSave"
class="save"
>Save</button>
<button
@click="$emit('close')"
class="cancel"
>Cancel</button>
</div>
</div>
</div>
</template>
<style lang="sass" scoped>
.container
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: rgba(255,255,255, 0.7)
+dark-mode
background: rgba(51,51,51, 0.7)
.dialog
width: 400px
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
display: flex
flex-direction: column
background: #fff
border-radius: 5px
box-shadow: 0 5px 30px rgba(0,0,0, 0.3)
border: 2px solid #c0c0c0
+dark-mode
background: #333
box-shadow: 0 5px 30px rgba(0,0,0, 0.7)
border: 2px solid #555
.dialog-content
flex-grow: 1
padding: 20px
h3
font-size: 14px
font-weight: 600
text-align: center
margin: 0
margin-bottom: 30px
.form
//display: flex
.field
//width: 50%
margin-bottom: 20px
&:last-child
margin-bottom: 0
label
display: block
margin-bottom: 8px
input.keys
width: 100%
padding: 4px
border-radius: 2px
border: 1px solid #ccc
&:focus
border: 1px solid rgba(0,0,0, 0)
outline: 2px solid var(--highlight-color)
//border: 1px solid var(--highlight-color)
+dark-mode
background: #202020
color: #fff
border: 1px solid #5a5a5a
&:focus
border: 1px solid rgba(0,0,0, 0)
.command-autocomplete
width: 100%
.footer
padding: 10px
background: #f1f1f1
border-radius: 0 0 5px 5px
text-align: right
+dark-mode
background: #2c2c2c
.cancel
float: left
</style>

View File

@ -0,0 +1,105 @@
<script>
import { HEYNOTE_COMMANDS } from '@/src/editor/commands'
export default {
props: [
"keys",
"command",
"isDefault",
"source",
],
computed: {
formattedKeys() {
return this.keys.replaceAll(
"Mod",
window.heynote.platform.isMac ? "⌘" : "Ctrl",
)
},
commandLabel() {
const cmd = HEYNOTE_COMMANDS[this.command]
if (cmd) {
return `${cmd.category}: ${cmd.description}`
}
return HEYNOTE_COMMANDS[this.command]?.description ||this.command
},
className() {
return this.isDefault ? "keybind-default" : "keybind-user"
},
},
}
</script>
<template>
<tr :class="className">
<td class="source">
{{ source }}
</td>
<td class="key">
<template v-if="keys">
{{ formattedKeys }}
</template>
</td>
<td class="command">
<span class="command-name">{{ commandLabel }}</span>
</td>
<td class="actions">
<button
v-if="!isDefault"
@click="$emit('delete')"
class="delete"
>Delete</button>
</td>
<td v-if="!isDefault" class="drag-handle"></td>
<td v-else></td>
</tr>
</template>
<style lang="sass" scoped>
tr
&.overridden
text-decoration: line-through
color: rgba(0,0,0, 0.4)
+dark-mode
color: rgba(255,255,255, 0.4)
td
&.key
//letter-spacing: 1px
&.command
//
&.drag-handle
width: 24px
padding: 0
cursor: ns-resize
background-color: rgba(0,0,0, 0.02)
background-size: 20px
background-repeat: no-repeat
background-position: center center
background-image: url(@/assets/icons/drag-vertical-light.svg)
+dark-mode
background-color: rgba(0,0,0, 0.08)
background-image: url(@/assets/icons/drag-vertical-dark.svg)
&:hover
background-color: rgba(0,0,0, 0.05)
+dark-mode
background-color: rgba(0,0,0, 0.25)
button.delete
padding: 0 10px
height: 22px
font-size: 12px
background: none
border: none
border-radius: 2px
cursor: pointer
background: #ddd
&:hover
background: #ccc
+dark-mode
background: #555
color: #fff
&:hover
background: #666
</style>

View File

@ -0,0 +1,200 @@
<script>
import { mapState} from 'pinia'
import draggable from 'vuedraggable'
import { DEFAULT_KEYMAP, EMACS_KEYMAP } from "@/src/editor/keymap"
import { useSettingsStore } from "@/src/stores/settings-store"
import KeyBindRow from "./KeyBindRow.vue"
import AddKeyBind from "./AddKeyBind.vue"
export default {
props: [
"userKeys",
"modelValue",
],
components: {
draggable,
KeyBindRow,
AddKeyBind,
},
data() {
return {
keymap: this.modelValue,
addKeyBinding: false,
}
},
mounted() {
},
watch: {
addKeyBinding(newValue) {
this.$emit("addKeyBindingDialogVisible", newValue)
},
},
computed: {
...mapState(useSettingsStore, [
"settings",
]),
fixedKeymap() {
const defaultKeymap = (this.settings.keymap === "emacs" ? EMACS_KEYMAP : []).map((km) => ({
key: km.key,
command: km.command,
isDefault: true,
source: "Emacs",
}))
return defaultKeymap.concat(
DEFAULT_KEYMAP.map((km) => ({
key: km.key,
command: km.command,
isDefault: true,
source: "Default",
}))
)
},
},
methods: {
onDragEnd(event) {
this.$emit("update:modelValue", this.keymap)
},
onSaveKeyBinding(event) {
this.keymap = [
{
key: event.key,
command: event.command,
},
...(this.keymap ? this.keymap : []),
]
//console.log("keymap", this.keymap)
this.$emit("update:modelValue", this.keymap)
this.addKeyBinding = false
},
deleteKeyBinding(index) {
this.keymap = this.keymap.toSpliced(index, 1)
this.$emit("update:modelValue", this.keymap)
},
},
}
</script>
<template>
<div class="container">
<div class="header" :inert="addKeyBinding">
<h2>Keyboard Bindings</h2>
<!--<p>User key bindings can be reordered. Bindings that appear first take precedence</p>-->
<div class="button-container">
<button
class="add-keybinding"
@click="addKeyBinding = !addKeyBinding"
>Add Keybinding</button>
</div>
</div>
<AddKeyBind
v-if="addKeyBinding"
@close="addKeyBinding = false"
@save="onSaveKeyBinding"
/>
<table :inert="addKeyBinding">
<thead>
<tr>
<th>Source</th>
<th>Key</th>
<th>Command</th>
<th></th>
<th></th>
</tr>
</thead>
<draggable
v-model="keymap"
tag="tbody"
group="keymap"
ghost-class="ghost"
handle=".drag-handle"
@start="drag=true"
@end="onDragEnd"
item-key="key"
>
<template #item="{element, index}">
<KeyBindRow
:keys="element.key"
:command="element.command"
:isDefault="element.isDefault"
:index="index"
@delete="deleteKeyBinding(index)"
source="User"
/>
</template>
</draggable>
<tbody>
<KeyBindRow
v-for="key in fixedKeymap"
:key="key.source + '_' + key.key"
:keys="key.key"
:command="key.command"
:isDefault="key.isDefault"
:source="key.source"
/>
</tbody>
</table>
</div>
</template>
<style lang="sass" scoped>
.header
display: flex
margin-bottom: 12px
h2
flex-grow: 1
font-weight: 600
font-size: 14px
margin: 0
.button-container
.add-keybinding
font-size: 12px
height: 26px
cursor: pointer
table
width: 100%
background: #f1f1f1
border: 2px solid #f1f1f1
+dark-mode
background: #3c3c3c
background: #333
border: 2px solid #3c3c3c
::v-deep(tr)
background: #fff
border-bottom: 2px solid #f1f1f1
+dark-mode
background: #333
border-bottom: 2px solid #3c3c3c
&.ghost
background: #48b57e
color: #fff
+dark-mode
background: #1b6540
th
text-align: left
font-weight: 600
th, td
padding: 8px
&.actions
padding: 6px
button
height: 20px
font-size: 11px
tbody
margin-bottom: 20px
</style>

View File

@ -0,0 +1,88 @@
<script>
import { keyName, base } from "w3c-keyname"
export default {
props: [
"modelValue",
],
data() {
return {
keys: this.modelValue ? this.modelValue.split(" ") : [],
}
},
computed: {
key() {
return this.keys.join(" ")
},
},
watch: {
modelValue(newValue) {
this.keys = this.modelValue ? this.modelValue.split(" ") : []
},
key(newValue) {
this.$emit("update:model-value", newValue)
},
},
methods: {
onKeyDown(event) {
event.preventDefault()
//console.log("event", event, event.code, keyName(event))
if (event.key === "Enter") {
this.$emit("enter")
} else if (event.key === "Escape") {
if (this.keys.length > 0) {
this.keys = []
} else {
// setTimeout is used to ensure that the settings dialog's keydown listener
// doesn't close the whole settings dialog
setTimeout(() => {
this.$emit("close")
}, 0)
}
} else if (["Alt", "Control", "Meta", "Shift"].includes(event.key)) {
} else {
if (this.keys.length >= 2) {
this.keys = []
}
let keyCombo = ""
if (event.altKey) {
keyCombo += "Alt-"
}
if (event.ctrlKey) {
keyCombo += "Control-"
}
if (event.metaKey) {
keyCombo += "Meta-"
}
if (event.shiftKey) {
keyCombo += "Shift-"
}
let key = base[event.keyCode]
if (key) {
if (key === " ") {
key = "Space"
}
keyCombo += key
this.keys.push(keyCombo)
}
}
},
},
}
</script>
<template>
<input
type="text"
:value="key"
@keydown.prevent="onKeyDown"
class="keys"
readonly
>
</template>

View File

@ -1,29 +1,39 @@
<script> <script>
import { toRaw} from 'vue';
import { LANGUAGES } from '../../editor/languages.js'
import KeyboardHotkey from "./KeyboardHotkey.vue" import KeyboardHotkey from "./KeyboardHotkey.vue"
import TabListItem from "./TabListItem.vue" import TabListItem from "./TabListItem.vue"
import TabContent from "./TabContent.vue" import TabContent from "./TabContent.vue"
import KeyboardBindings from './KeyboardBindings.vue'
const defaultFontFamily = window.heynote.defaultFontFamily const defaultFontFamily = window.heynote.defaultFontFamily
const defaultFontSize = window.heynote.defaultFontSize const defaultFontSize = window.heynote.defaultFontSize
const defaultDefaultBlockLanguage = "text"
const defaultDefaultBlockLanguageAutoDetect = true
export default { export default {
props: { props: {
initialKeymap: String, initialKeymap: String,
initialSettings: Object, initialSettings: Object,
themeSetting: String,
}, },
components: { components: {
KeyboardHotkey, KeyboardHotkey,
TabListItem, TabListItem,
TabContent, TabContent,
KeyboardBindings,
}, },
data() { data() {
//console.log("settings:", this.initialSettings)
return { return {
keymaps: [ keymaps: [
{ name: "Default", value: "default" }, { name: "Default", value: "default" },
{ name: "Emacs", value: "emacs" }, { name: "Emacs", value: "emacs" },
], ],
keymap: this.initialSettings.keymap, keymap: this.initialSettings.keymap,
keyBindings: this.initialSettings.keyBindings,
metaKey: this.initialSettings.emacsMetaKey, metaKey: this.initialSettings.emacsMetaKey,
isMac: window.heynote.platform.isMac, isMac: window.heynote.platform.isMac,
showLineNumberGutter: this.initialSettings.showLineNumberGutter, showLineNumberGutter: this.initialSettings.showLineNumberGutter,
@ -35,10 +45,21 @@
showInMenu: this.initialSettings.showInMenu, showInMenu: this.initialSettings.showInMenu,
alwaysOnTop: this.initialSettings.alwaysOnTop, alwaysOnTop: this.initialSettings.alwaysOnTop,
bracketClosing: this.initialSettings.bracketClosing, bracketClosing: this.initialSettings.bracketClosing,
tabSize: this.initialSettings.tabSize || 4,
autoUpdate: this.initialSettings.autoUpdate, autoUpdate: this.initialSettings.autoUpdate,
bufferPath: this.initialSettings.bufferPath, bufferPath: this.initialSettings.bufferPath,
fontFamily: this.initialSettings.fontFamily || defaultFontFamily, fontFamily: this.initialSettings.fontFamily || defaultFontFamily,
fontSize: this.initialSettings.fontSize || defaultFontSize, fontSize: this.initialSettings.fontSize || defaultFontSize,
languageOptions: LANGUAGES.map(l => {
return {
"value": l.token,
"name": l.token == "text" ? l.name + " (default)" : l.name,
}
}).sort((a, b) => {
return a.name.localeCompare(b.name)
}),
defaultBlockLanguage: this.initialSettings.defaultBlockLanguage || defaultDefaultBlockLanguage,
defaultBlockLanguageAutoDetect: this.initialSettings.defaultBlockLanguageAutoDetect === false ? false : defaultDefaultBlockLanguageAutoDetect,
activeTab: "general", activeTab: "general",
isWebApp: window.heynote.platform.isWebApp, isWebApp: window.heynote.platform.isWebApp,
@ -46,28 +67,37 @@
systemFonts: [[defaultFontFamily, defaultFontFamily + " (default)"]], systemFonts: [[defaultFontFamily, defaultFontFamily + " (default)"]],
defaultFontSize: defaultFontSize, defaultFontSize: defaultFontSize,
appVersion: "", appVersion: "",
theme: this.themeSetting,
// tracks if the add key binding dialog is visible (so that we can set inert on the save button)
addKeyBindingDialogVisible: false,
} }
}, },
async mounted() { async mounted() {
window.addEventListener("keydown", this.onKeyDown);
this.appVersion = await window.heynote.getVersion()
if (window.queryLocalFonts !== undefined) { if (window.queryLocalFonts !== undefined) {
let localFonts = [... new Set((await window.queryLocalFonts()).map(f => f.family))].filter(f => f !== "Hack") let localFonts = [... new Set((await window.queryLocalFonts()).map(f => f.family))].filter(f => f !== "Hack")
localFonts = [...new Set(localFonts)].map(f => [f, f]) localFonts = [...new Set(localFonts)].map(f => [f, f])
this.systemFonts = [[defaultFontFamily, defaultFontFamily + " (default)"], ...localFonts] this.systemFonts = [[defaultFontFamily, defaultFontFamily + " (default)"], ...localFonts]
} }
window.addEventListener("keydown", this.onKeyDown);
this.$refs.keymapSelector.focus()
this.appVersion = await window.heynote.getVersion()
}, },
beforeUnmount() { beforeUnmount() {
window.removeEventListener("keydown", this.onKeyDown); window.removeEventListener("keydown", this.onKeyDown);
}, },
watch: {
keyBindings(newKeyBindings) {
this.updateSettings()
}
},
methods: { methods: {
onKeyDown(event) { onKeyDown(event) {
if (event.key === "Escape") { if (event.key === "Escape" && !this.addKeyBindingDialogVisible) {
this.$emit("closeSettings") this.$emit("closeSettings")
} }
}, },
@ -77,6 +107,7 @@
showLineNumberGutter: this.showLineNumberGutter, showLineNumberGutter: this.showLineNumberGutter,
showFoldGutter: this.showFoldGutter, showFoldGutter: this.showFoldGutter,
keymap: this.keymap, keymap: this.keymap,
keyBindings: this.keyBindings.map((kb) => toRaw(kb)),
emacsMetaKey: window.heynote.platform.isMac ? this.metaKey : "alt", emacsMetaKey: window.heynote.platform.isMac ? this.metaKey : "alt",
allowBetaVersions: this.allowBetaVersions, allowBetaVersions: this.allowBetaVersions,
enableGlobalHotkey: this.enableGlobalHotkey, enableGlobalHotkey: this.enableGlobalHotkey,
@ -86,13 +117,19 @@
alwaysOnTop: this.alwaysOnTop, alwaysOnTop: this.alwaysOnTop,
autoUpdate: this.autoUpdate, autoUpdate: this.autoUpdate,
bracketClosing: this.bracketClosing, bracketClosing: this.bracketClosing,
tabSize: this.tabSize,
bufferPath: this.bufferPath, bufferPath: this.bufferPath,
fontFamily: this.fontFamily === defaultFontFamily ? undefined : this.fontFamily, fontFamily: this.fontFamily === defaultFontFamily ? undefined : this.fontFamily,
fontSize: this.fontSize === defaultFontSize ? undefined : this.fontSize, fontSize: this.fontSize === defaultFontSize ? undefined : this.fontSize,
defaultBlockLanguage: this.defaultBlockLanguage === "text" ? undefined : this.defaultBlockLanguage,
defaultBlockLanguageAutoDetect: this.defaultBlockLanguageAutoDetect === true ? undefined : this.defaultBlockLanguageAutoDetect,
}) })
if (!this.showInDock) { if (!this.showInDock) {
this.showInMenu = true this.showInMenu = true
} }
if (this.theme != this.themeSetting) {
this.$emit("setTheme", this.theme)
}
}, },
async selectBufferLocation() { async selectBufferLocation() {
@ -138,6 +175,12 @@
:activeTab="activeTab" :activeTab="activeTab"
@click="activeTab = 'appearance'" @click="activeTab = 'appearance'"
/> />
<TabListItem
name="Key Bindings"
tab="keyboard-bindings"
:activeTab="activeTab"
@click="activeTab = 'keyboard-bindings'"
/>
<TabListItem <TabListItem
:name="isWebApp ? 'Version' : 'Updates'" :name="isWebApp ? 'Version' : 'Updates'"
tab="updates" tab="updates"
@ -148,23 +191,6 @@
</nav> </nav>
<div class="settings-content"> <div class="settings-content">
<TabContent tab="general" :activeTab="activeTab"> <TabContent tab="general" :activeTab="activeTab">
<div class="row">
<div class="entry">
<h2>Keymap</h2>
<select ref="keymapSelector" v-model="keymap" @change="updateSettings" class="keymap">
<template v-for="km in keymaps" :key="km.value">
<option :selected="km.value === keymap" :value="km.value">{{ km.name }}</option>
</template>
</select>
</div>
<div class="entry" v-if="keymap === 'emacs' && isMac">
<h2>Meta Key</h2>
<select v-model="metaKey" @change="updateSettings" class="metaKey">
<option :selected="metaKey === 'meta'" value="meta">Command</option>
<option :selected="metaKey === 'alt'" value="alt">Option</option>
</select>
</div>
</div>
<div class="row" v-if="!isWebApp"> <div class="row" v-if="!isWebApp">
<div class="entry"> <div class="entry">
<h2>Global Keyboard Shortcut</h2> <h2>Global Keyboard Shortcut</h2>
@ -221,14 +247,14 @@
</div> </div>
<div class="row" v-if="!isWebApp"> <div class="row" v-if="!isWebApp">
<div class="entry buffer-location"> <div class="entry buffer-location">
<h2>Buffer File Path</h2> <h2>Buffer Files Path</h2>
<label class="keyboard-shortcut-label"> <label class="keyboard-shortcut-label">
<input <input
type="checkbox" type="checkbox"
v-model="customBufferLocation" v-model="customBufferLocation"
@change="onCustomBufferLocationChange" @change="onCustomBufferLocationChange"
/> />
Use custom buffer file location Use custom location for the buffer files
</label> </label>
<div class="file-path"> <div class="file-path">
<button <button
@ -255,9 +281,51 @@
</label> </label>
</div> </div>
</div> </div>
<div class="row">
<div class="entry">
<h2>Tab Size</h2>
<select v-model="tabSize" @change="updateSettings" class="tab-size">
<option
v-for="size in [1, 2, 3, 4, 5, 6, 7, 8]"
:key="size"
:selected="tabSize === size"
:value="size"
>{{ size }} {{ size === 1 ? 'space' : 'spaces' }}</option>
</select>
</div>
</div>
<div class="row">
<div class="entry">
<h2>Default Block Language</h2>
<select v-model="defaultBlockLanguage" @change="updateSettings" class="block-language">
<template v-for="lang in languageOptions" :key="lang.value">
<option :selected="lang.value === defaultBlockLanguage" :value="lang.value">{{ lang.name }}</option>
</template>
</select>
<label>
<input
type="checkbox"
v-model="defaultBlockLanguageAutoDetect"
@change="updateSettings"
class="language-auto-detect"
/>
Auto-detection (default: on)
</label>
</div>
</div>
</TabContent> </TabContent>
<TabContent tab="appearance" :activeTab="activeTab"> <TabContent tab="appearance" :activeTab="activeTab">
<div class="row">
<div class="entry">
<h2>Color Theme</h2>
<select v-model="theme" @change="updateSettings" class="theme">
<option :selected="theme === 'system'" value="system">System</option>
<option :selected="theme === 'light'" value="light">Light</option>
<option :selected="theme === 'dark'" value="dark">Dark</option>
</select>
</div>
</div>
<div class="row"> <div class="row">
<div class="entry"> <div class="entry">
<h2>Gutters</h2> <h2>Gutters</h2>
@ -304,6 +372,31 @@
</div> </div>
</TabContent> </TabContent>
<TabContent tab="keyboard-bindings" :activeTab="activeTab">
<div class="row">
<div class="entry">
<h2>Keymap</h2>
<select v-model="keymap" @change="updateSettings" class="keymap">
<template v-for="km in keymaps" :key="km.value">
<option :selected="km.value === keymap" :value="km.value">{{ km.name }}</option>
</template>
</select>
</div>
<div class="entry" v-if="keymap === 'emacs' && isMac">
<h2>Meta Key</h2>
<select v-model="metaKey" @change="updateSettings" class="metaKey">
<option :selected="metaKey === 'meta'" value="meta">Command</option>
<option :selected="metaKey === 'alt'" value="alt">Option</option>
</select>
</div>
</div>
<KeyboardBindings
:userKeys="keyBindings ? keyBindings : {}"
v-model="keyBindings"
@addKeyBindingDialogVisible="addKeyBindingDialogVisible = $event"
/>
</TabContent>
<TabContent tab="updates" :activeTab="activeTab"> <TabContent tab="updates" :activeTab="activeTab">
<div class="row"> <div class="row">
<div class="entry"> <div class="entry">
@ -342,7 +435,7 @@
</div> </div>
</div> </div>
<div class="bottom-bar"> <div class="bottom-bar" :inert="addKeyBindingDialogVisible">
<button <button
@click="$emit('closeSettings')" @click="$emit('closeSettings')"
class="close" class="close"
@ -371,14 +464,16 @@
background: rgba(0, 0, 0, 0.5) background: rgba(0, 0, 0, 0.5)
.dialog .dialog
--dialog-height: 600px
--bottom-bar-height: 48px
box-sizing: border-box box-sizing: border-box
z-index: 2 z-index: 2
position: absolute position: absolute
left: 50% left: 50%
top: 50% top: 50%
transform: translate(-50%, -50%) transform: translate(-50%, -50%)
width: 700px width: 820px
height: 560px height: var(--dialog-height)
max-width: 100% max-width: 100%
max-height: 100% max-height: 100%
display: flex display: flex
@ -398,6 +493,7 @@
.dialog-content .dialog-content
flex-grow: 1 flex-grow: 1
display: flex display: flex
height: calc(var(--dialog-height) - var(--bottom-bar-height))
.sidebar .sidebar
box-sizing: border-box box-sizing: border-box
width: 140px width: 140px
@ -415,8 +511,10 @@
flex-grow: 1 flex-grow: 1
padding: 40px padding: 40px
overflow-y: auto overflow-y: auto
position: relative
select select
height: 22px height: 22px
margin: 4px 0
.row .row
display: flex display: flex
.entry .entry
@ -470,6 +568,8 @@
background: #222 background: #222
color: #aaa color: #aaa
.bottom-bar .bottom-bar
box-sizing: border-box
height: var(--bottom-bar-height)
border-radius: 0 0 5px 5px border-radius: 0 0 5px 5px
background: #eee background: #eee
text-align: right text-align: right
@ -478,4 +578,5 @@
background: #222 background: #222
.close .close
height: 28px height: 28px
cursor: pointer
</style> </style>

View File

@ -18,6 +18,7 @@
li li
padding: 9px 20px padding: 9px 20px
font-size: 13px font-size: 13px
line-height: 1.3
user-select: none user-select: none
cursor: pointer cursor: pointer
&:hover &:hover
@ -25,10 +26,11 @@
+dark-mode +dark-mode
background: #292929 background: #292929
&.active &.active
background: #48b57e background: var(--highlight-color)
color: #fff color: #fff
cursor: default cursor: default
+dark-mode +dark-mode
background: #1b6540 // needed for specificity (to not be overridden by :hover in dark mode)
background: var(--highlight-color)
</style> </style>

View File

@ -1,3 +1,4 @@
@import "reset" @use "reset"
@import "font" @use "font"
@import "base" @use "base"
@use "autocomplete"

45
src/css/autocomplete.sass Normal file
View File

@ -0,0 +1,45 @@
@use "@/src/css/include.sass" as *
.p-component
--p-inputtext-background: #fff
--p-inputtext-border-color: #ccc
--p-inputtext-border-radius: 3px
--p-inputtext-padding-y: 4px
--p-inputtext-padding-x: 4px
--p-inputtext-focus-border-color: var(--p-inputtext-border-color)
--p-inputtext-hover-border-color: var(--p-inputtext-border-color)
--p-inputtext-active-border-color: var(--p-inputtext-border-color)
--p-autocomplete-dropdown-border-color: var(--p-inputtext-border-color)
--p-autocomplete-dropdown-border-radius: var(--p-inputtext-border-radius)
--p-autocomplete-dropdown-hover-border-color: var(--p-inputtext-hover-border-color)
--p-autocomplete-dropdown-active-border-color: var(--p-inputtext-active-border-color)
--p-autocomplete-dropdown-hover-background: #f1f1f1
--p-autocomplete-option-focus-background: var(--highlight-color)
--p-autocomplete-option-focus-color: #fff
+dark-mode
--p-inputtext-background: #202020
--p-inputtext-border-color: #444
--p-autocomplete-dropdown-hover-background: #2a2a2a
.p-inputtext.p-inputtext
font-size: 12px
.p-autocomplete-list-container
background: #fff
border: 1px solid #f1f1f1
padding: 6px 0
border-radius: 3px
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
+dark-mode
color: rgba(255,255,255, 0.8)
background: #333
border: 1px solid #2a2a2a
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3)
.p-autocomplete-list > li
padding: 6px 10px
b
font-weight: bold

View File

@ -1,10 +1,14 @@
@use "include" as *
:root[theme='light'] :root[theme='light']
--status-bar-background: #48b57e --status-bar-background: #48b57e
--status-bar-color: #fff --status-bar-color: #fff
--highlight-color: #48b57e
:root[theme='dark'] :root[theme='dark']
--status-bar-background: #0e1217 --status-bar-background: #0e1217
--status-bar-color: rgba(255, 255, 255, 0.75) --status-bar-color: rgba(255, 255, 255, 0.75)
--highlight-color: #1b6540
html html
margin: 0 margin: 0

View File

@ -4,7 +4,6 @@
font-weight: 400 font-weight: 400
font-style: normal font-style: normal
@font-face @font-face
font-family: 'Hack' font-family: 'Hack'
src: url('@/assets/font/hack/hack-bold.woff2') format('woff2'), url('@/assets/font/hack/hack-bold.woff') format('woff') src: url('@/assets/font/hack/hack-bold.woff2') format('woff2'), url('@/assets/font/hack/hack-bold.woff') format('woff')
@ -23,5 +22,3 @@
font-weight: 700 font-weight: 700
font-style: italic font-style: italic
@import "@/assets/font/open-sans/open-sans"

View File

@ -6,3 +6,13 @@ export const CURRENCIES_LOADED = "heynote-currencies-loaded"
export const SET_CONTENT = "heynote-set-content" export const SET_CONTENT = "heynote-set-content"
export const ADD_NEW_BLOCK = "heynote-add-new-block" export const ADD_NEW_BLOCK = "heynote-add-new-block"
export const MOVE_BLOCK = "heynote-move-block" export const MOVE_BLOCK = "heynote-move-block"
export const DELETE_BLOCK = "heynote-delete-block"
export const CURSOR_CHANGE = "heynote-cursor-change"
export const APPEND_BLOCK = "heynote-append-block"
export const SET_FONT = "heynote-set-font"
// This function checks if any of the transactions has the given Heynote annotation
export function transactionsHasAnnotation(transactions, annotation) {
return transactions.some(tr => tr.annotation(heynoteEvent) === annotation)
}

View File

@ -0,0 +1,126 @@
import { syntaxTree } from "@codemirror/language"
import { Note, Document, NoteDelimiter } from "../lang-heynote/parser.terms.js"
import { IterMode } from "@lezer/common";
// tracks the size of the first delimiter
export let firstBlockDelimiterSize
function startTimer() {
const timeStart = performance.now();
return function () {
return Math.round(performance.now() - timeStart);
};
}
/**
* Return a list of blocks in the document from the syntax tree.
* syntaxTreeAvailable() should have been called before this function to ensure the syntax tree is available.
*/
export function getBlocksFromSyntaxTree(state) {
//const timer = startTimer()
const blocks = [];
const tree = syntaxTree(state, state.doc.length)
if (tree) {
tree.iterate({
enter: (type) => {
if (type.type.id == Document || type.type.id == Note) {
return true
} else if (type.type.id === NoteDelimiter) {
const langNode = type.node.getChild("NoteLanguage")
const language = state.doc.sliceString(langNode.from, langNode.to)
const isAuto = !!type.node.getChild("Auto")
const contentNode = type.node.nextSibling
blocks.push({
language: {
name: language,
auto: isAuto,
},
content: {
from: contentNode.from,
to: contentNode.to,
},
delimiter: {
from: type.from,
to: type.to,
},
range: {
from: type.node.from,
to: contentNode.to,
},
})
return false;
}
return false;
},
mode: IterMode.IgnoreMounts,
});
firstBlockDelimiterSize = blocks[0]?.delimiter.to
}
//console.log("getBlocksSyntaxTree took", timer(), "ms")
return blocks
}
/**
* Parse blocks from document's string contents using String.indexOf()
*/
export function getBlocksFromString(state) {
//const timer = startTimer()
const blocks = []
const doc = state.doc
if (doc.length === 0) {
return [];
}
const content = doc.sliceString(0, doc.length)
const delim = "\n∞∞∞"
let pos = 0
while (pos < doc.length) {
const blockStart = content.indexOf(delim, pos);
if (blockStart != pos) {
console.error("Error parsing blocks, expected delimiter at", pos)
break;
}
const langStart = blockStart + delim.length;
const delimiterEnd = content.indexOf("\n", langStart)
if (delimiterEnd < 0) {
console.error("Error parsing blocks. Delimiter didn't end with newline")
break
}
const langFull = content.substring(langStart, delimiterEnd);
let auto = false;
let lang = langFull;
if (langFull.endsWith("-a")) {
auto = true;
lang = langFull.substring(0, langFull.length - 2);
}
const contentFrom = delimiterEnd + 1;
let blockEnd = content.indexOf(delim, contentFrom);
if (blockEnd < 0) {
blockEnd = doc.length;
}
const block = {
language: {
name: lang,
auto: auto,
},
content: {
from: contentFrom,
to: blockEnd,
},
delimiter: {
from: blockStart,
to: delimiterEnd + 1,
},
range: {
from: blockStart,
to: blockEnd,
},
};
blocks.push(block);
pos = blockEnd;
}
//console.log("getBlocksFromString() took", timer(), "ms")
return blocks;
}

View File

@ -1,63 +1,31 @@
import { ViewPlugin, EditorView, Decoration, WidgetType, lineNumbers } from "@codemirror/view" import { ViewPlugin, EditorView, Decoration, WidgetType, lineNumbers } from "@codemirror/view"
import { layer, RectangleMarker } from "@codemirror/view" import { layer, RectangleMarker } from "@codemirror/view"
import { EditorState, RangeSetBuilder, StateField, Facet , StateEffect, RangeSet} from "@codemirror/state"; import { EditorState, RangeSetBuilder, StateField, RangeSet, Transaction} from "@codemirror/state";
import { syntaxTree, ensureSyntaxTree } from "@codemirror/language" import { syntaxTreeAvailable } from "@codemirror/language"
import { Note, Document, NoteDelimiter } from "../lang-heynote/parser.terms.js" import { useHeynoteStore } from "../../stores/heynote-store.js"
import { IterMode } from "@lezer/common"; import { heynoteEvent, LANGUAGE_CHANGE, CURSOR_CHANGE } from "../annotation.js";
import { heynoteEvent, LANGUAGE_CHANGE } from "../annotation.js";
import { SelectionChangeEvent } from "../event.js"
import { mathBlock } from "./math.js" import { mathBlock } from "./math.js"
import { emptyBlockSelected } from "./select-all.js"; import { emptyBlockSelected } from "./select-all.js";
import { firstBlockDelimiterSize, getBlocksFromSyntaxTree, getBlocksFromString } from "./block-parsing.js";
// tracks the size of the first delimiter /**
let firstBlockDelimiterSize * Get the blocks from the document state.
* If the syntax tree is available, we'll extract the blocks from that. Otherwise
function getBlocks(state, timeout=50) { * the blocks are parsed from the string contents of the document, which is much faster
const blocks = []; * than waiting for the tree parsing to finish.
const tree = ensureSyntaxTree(state, state.doc.length, timeout) */
if (tree) { export function getBlocks(state) {
tree.iterate({ if (syntaxTreeAvailable(state, state.doc.length)) {
enter: (type) => { return getBlocksFromSyntaxTree(state)
if (type.type.id == Document || type.type.id == Note) { } else {
return true return getBlocksFromString(state)
} else if (type.type.id === NoteDelimiter) {
const langNode = type.node.getChild("NoteLanguage")
const language = state.doc.sliceString(langNode.from, langNode.to)
const isAuto = !!type.node.getChild("Auto")
const contentNode = type.node.nextSibling
blocks.push({
language: {
name: language,
auto: isAuto,
},
content: {
from: contentNode.from,
to: contentNode.to,
},
delimiter: {
from: type.from,
to: type.to,
},
range: {
from: type.node.from,
to: contentNode.to,
},
})
return false;
} }
return false;
},
mode: IterMode.IgnoreMounts,
});
firstBlockDelimiterSize = blocks[0]?.delimiter.to
}
return blocks
} }
export const blockState = StateField.define({ export const blockState = StateField.define({
create(state) { create(state) {
return getBlocks(state, 1000); return getBlocks(state);
}, },
update(blocks, transaction) { update(blocks, transaction) {
// if blocks are empty it likely means we didn't get a parsed syntax tree, and then we want to update // if blocks are empty it likely means we didn't get a parsed syntax tree, and then we want to update
@ -201,8 +169,9 @@ const blockLayer = layer({
idx++; idx++;
return return
} }
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from)).top // view.coordsAtPos returns null if the editor is not visible
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to)).bottom const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom
if (idx === blocks.length - 1) { if (idx === blocks.length - 1) {
// Calculate how much extra height we need to add to the last block // Calculate how much extra height we need to add to the last block
let extraHeight = view.viewState.editorHeight - ( let extraHeight = view.viewState.editorHeight - (
@ -313,13 +282,24 @@ function getSelectionSize(state, sel) {
return count return count
} }
const emitCursorChange = (editor) => ViewPlugin.fromClass( 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 { class {
update(update) { update(update) {
// if the selection changed or the language changed (can happen without selection change), // if the selection changed or the language changed (can happen without selection change),
// emit a selection change event // emit a selection change event
const langChange = update.transactions.some(tr => tr.annotations.some(a => a.value == LANGUAGE_CHANGE)) const shouldUpdate = update.transactions.some(tr => tr.annotations.some(a => a.value == LANGUAGE_CHANGE || a.value == CURSOR_CHANGE))
if (update.selectionSet || langChange) { if (update.selectionSet || shouldUpdate) {
const cursorLine = getBlockLineFromPos(update.state, update.state.selection.main.head) const cursorLine = getBlockLineFromPos(update.state, update.state.selection.main.head)
const selectionSize = update.state.selection.ranges.map( const selectionSize = update.state.selection.ranges.map(
@ -328,17 +308,17 @@ const emitCursorChange = (editor) => ViewPlugin.fromClass(
const block = getActiveNoteBlock(update.state) const block = getActiveNoteBlock(update.state)
if (block && cursorLine) { if (block && cursorLine) {
editor.element.dispatchEvent(new SelectionChangeEvent({ heynoteStore.currentCursorLine = cursorLine
cursorLine, heynoteStore.currentSelectionSize = selectionSize
selectionSize, heynoteStore.currentLanguage = block.language.name
language: block.language.name, heynoteStore.currentLanguageAuto = block.language.auto
languageAuto: block.language.auto, heynoteStore.currentBufferName = editor.name
}))
} }
} }
} }
} }
) )
}
export const noteBlockExtension = (editor) => { export const noteBlockExtension = (editor) => {
return [ return [

View File

@ -1,5 +1,6 @@
import { EditorSelection } from "@codemirror/state" import { EditorSelection, Transaction } from "@codemirror/state"
import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK, MOVE_BLOCK } from "../annotation.js";
import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK, MOVE_BLOCK, DELETE_BLOCK } from "../annotation.js";
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./block" import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./block"
import { moveLineDown, moveLineUp } from "./move-lines.js"; import { moveLineDown, moveLineUp } from "./move-lines.js";
import { selectAll } from "./select-all.js"; import { selectAll } from "./select-all.js";
@ -7,7 +8,11 @@ import { selectAll } from "./select-all.js";
export { moveLineDown, moveLineUp, selectAll } export { moveLineDown, moveLineUp, selectAll }
export const insertNewBlockAtCursor = ({ state, dispatch }) => { export function getBlockDelimiter(defaultToken, autoDetect) {
return `\n∞∞∞${autoDetect ? defaultToken + '-a' : defaultToken}\n`
}
export const insertNewBlockAtCursor = (editor) => ({ state, dispatch }) => {
if (state.readOnly) if (state.readOnly)
return false return false
@ -16,7 +21,7 @@ export const insertNewBlockAtCursor = ({ state, dispatch }) => {
if (currentBlock) { if (currentBlock) {
delimText = `\n∞∞∞${currentBlock.language.name}${currentBlock.language.auto ? "-a" : ""}\n` delimText = `\n∞∞∞${currentBlock.language.name}${currentBlock.language.auto ? "-a" : ""}\n`
} else { } else {
delimText = "\n∞∞∞text-a\n" delimText = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect)
} }
dispatch(state.replaceSelection(delimText), dispatch(state.replaceSelection(delimText),
{ {
@ -28,13 +33,12 @@ export const insertNewBlockAtCursor = ({ state, dispatch }) => {
return true; return true;
} }
export const addNewBlockBeforeCurrent = ({ state, dispatch }) => { export const addNewBlockBeforeCurrent = (editor) => ({ state, dispatch }) => {
console.log("addNewBlockBeforeCurrent")
if (state.readOnly) if (state.readOnly)
return false return false
const block = getActiveNoteBlock(state) const block = getActiveNoteBlock(state)
const delimText = "\n∞∞∞text-a\n" const delimText = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect)
dispatch(state.update({ dispatch(state.update({
changes: { changes: {
@ -50,12 +54,12 @@ export const addNewBlockBeforeCurrent = ({ state, dispatch }) => {
return true; return true;
} }
export const addNewBlockAfterCurrent = ({ state, dispatch }) => { export const addNewBlockAfterCurrent = (editor) => ({ state, dispatch }) => {
if (state.readOnly) if (state.readOnly)
return false return false
const block = getActiveNoteBlock(state) const block = getActiveNoteBlock(state)
const delimText = "\n∞∞∞text-a\n" const delimText = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect)
dispatch(state.update({ dispatch(state.update({
changes: { changes: {
@ -70,12 +74,12 @@ export const addNewBlockAfterCurrent = ({ state, dispatch }) => {
return true; return true;
} }
export const addNewBlockBeforeFirst = ({ state, dispatch }) => { export const addNewBlockBeforeFirst = (editor) => ({ state, dispatch }) => {
if (state.readOnly) if (state.readOnly)
return false return false
const block = getFirstNoteBlock(state) const block = getFirstNoteBlock(state)
const delimText = "\n∞∞∞text-a\n" const delimText = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect)
dispatch(state.update({ dispatch(state.update({
changes: { changes: {
@ -91,11 +95,11 @@ export const addNewBlockBeforeFirst = ({ state, dispatch }) => {
return true; return true;
} }
export const addNewBlockAfterLast = ({ state, dispatch }) => { export const addNewBlockAfterLast = (editor) => ({ state, dispatch }) => {
if (state.readOnly) if (state.readOnly)
return false return false
const block = getLastNoteBlock(state) const block = getLastNoteBlock(state)
const delimText = "\n∞∞∞text-a\n" const delimText = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect)
dispatch(state.update({ dispatch(state.update({
changes: { changes: {
@ -131,6 +135,10 @@ export function changeLanguageTo(state, dispatch, block, language, auto) {
export function changeCurrentBlockLanguage(state, dispatch, language, auto) { export function changeCurrentBlockLanguage(state, dispatch, language, auto) {
const block = getActiveNoteBlock(state) const block = getActiveNoteBlock(state)
// if language is null, we only want to change the auto-detect flag
if (language === null) {
language = block.language.name
}
changeLanguageTo(state, dispatch, block, language, auto) changeLanguageTo(state, dispatch, block, language, auto)
} }
@ -310,7 +318,7 @@ export function triggerCurrenciesLoaded(state, dispatch) {
// This will make Math blocks re-render so that currency conversions are applied // This will make Math blocks re-render so that currency conversions are applied
dispatch(state.update({ dispatch(state.update({
changes:{from: 0, to: 0, insert:""}, changes:{from: 0, to: 0, insert:""},
annotations: [heynoteEvent.of(CURRENCIES_LOADED)], annotations: [heynoteEvent.of(CURRENCIES_LOADED), Transaction.addToHistory.of(false)],
})) }))
} }
@ -369,3 +377,65 @@ function moveCurrentBlock(state, dispatch, up) {
})) }))
return true return true
} }
export const deleteBlock = (editor) => ({state, dispatch}) => {
const range = state.selection.asSingle().ranges[0]
const blocks = state.facet(blockState)
let block
let nextBlock
for (let i = 0; i < blocks.length; i++) {
block = blocks[i]
if (block.range.from <= range.head && block.range.to >= range.head) {
if (i < blocks.length - 1) {
nextBlock = blocks[i + 1]
}
break
}
}
let replace = ""
let newSelection
if (blocks.length == 1) {
replace = getBlockDelimiter(editor.defaultBlockToken, editor.defaultBlockAutoDetect)
newSelection = replace.length
} else if (!nextBlock) {
// if it's the last block, the cursor should go at the en of the previous block
newSelection = block.delimiter.from
} else {
// if there is a next block, we want the cursor to be at the beginning of that block
newSelection = block.delimiter.from + (nextBlock.delimiter.to - nextBlock.delimiter.from)
}
dispatch(state.update({
changes: {
from: block.range.from,
to: block.range.to,
insert: replace,
},
selection: EditorSelection.cursor(newSelection),
annotations: [heynoteEvent.of(DELETE_BLOCK)],
}))
return true
}
export const deleteBlockSetCursorPreviousBlock = (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)],
}))
return true
}

View File

@ -1,5 +1,5 @@
import { EditorSelection } from "@codemirror/state" import { EditorSelection } from "@codemirror/state"
import { getActiveNoteBlock } from "./block" import { getNoteBlockFromPos } from "./block"
function updateSel(sel, by) { function updateSel(sel, by) {
return EditorSelection.create(sel.ranges.map(by), sel.mainIndex); return EditorSelection.create(sel.ranges.map(by), sel.mainIndex);
@ -28,10 +28,10 @@ export const deleteLine = (view) => {
const { state } = view const { state } = view
const block = getActiveNoteBlock(view.state)
const selectedLines = selectedLineBlocks(state) const selectedLines = selectedLineBlocks(state)
const changes = state.changes(selectedLines.map(({ from, to }) => { const changes = state.changes(selectedLines.map(({ from, to }) => {
const block = getNoteBlockFromPos(state, from)
if(from !== block.content.from || to !== block.content.to) { if(from !== block.content.from || to !== block.content.to) {
if (from > 0) from-- if (from > 0) from--
else if (to < state.doc.length) to++ else if (to < state.doc.length) to++

View File

@ -4,7 +4,7 @@ import { RangeSetBuilder } from "@codemirror/state"
import { WidgetType } from "@codemirror/view" import { WidgetType } from "@codemirror/view"
import { getNoteBlockFromPos } from "./block" import { getNoteBlockFromPos } from "./block"
import { CURRENCIES_LOADED } from "../annotation" import { transactionsHasAnnotation, CURRENCIES_LOADED } from "../annotation"
class MathResult extends WidgetType { class MathResult extends WidgetType {
@ -107,12 +107,6 @@ function mathDeco(view) {
return builder.finish() return builder.finish()
} }
// This function checks if any of the transactions has the given annotation
const transactionsHasAnnotation = (transactions, annotation) => {
return transactions.some(tr => tr.annotations.some(a => a.value === annotation))
}
export const mathBlock = ViewPlugin.fromClass(class { export const mathBlock = ViewPlugin.fromClass(class {
decorations decorations

View File

@ -0,0 +1,30 @@
import { EditorSelection, findClusterBreak} from "@codemirror/state";
import { getNoteBlockFromPos } from "./block"
/**
Flip the characters before and after the cursor(s).
*/
export const transposeChars = ({ state, dispatch }) => {
if (state.readOnly)
return false;
let changes = state.changeByRange(range => {
// prevent transposing characters if we're at the start or end of a block, since it'll break the block syntax
const block = getNoteBlockFromPos(state, range.from)
if (range.from === block.content.from || range.from === block.content.to) {
return { range }
}
if (!range.empty || range.from == 0 || range.from == state.doc.length)
return { range };
let pos = range.from, line = state.doc.lineAt(pos);
let from = pos == line.from ? pos - 1 : findClusterBreak(line.text, pos - line.from, false) + line.from;
let to = pos == line.to ? pos + 1 : findClusterBreak(line.text, pos - line.from, true) + line.from;
return { changes: { from, to, insert: state.doc.slice(pos, to).append(state.doc.slice(from, pos)) },
range: EditorSelection.cursor(to) };
});
if (changes.changes.empty)
return false;
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "move.character" }));
return true;
};

View File

@ -0,0 +1,10 @@
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
import { Prec } from "@codemirror/state"
import { keymap } from "@codemirror/view"
export function getCloseBracketsExtensions() {
return [
closeBrackets(),
Prec.highest(keymap.of(closeBracketsKeymap)),
]
}

167
src/editor/commands.js Normal file
View File

@ -0,0 +1,167 @@
import * as codeMirrorCommands from "@codemirror/commands"
import {
undo, redo,
indentMore, indentLess,
deleteCharBackward, deleteCharForward,
deleteGroupBackward, deleteGroupForward,
deleteLineBoundaryBackward, deleteLineBoundaryForward,
deleteToLineEnd, deleteToLineStart,
simplifySelection,
splitLine,
insertNewlineAndIndent,
} from "@codemirror/commands"
import { foldCode, unfoldCode } from "@codemirror/language"
import { selectNextOccurrence } from "@codemirror/search"
import { insertNewlineContinueMarkup } from "@codemirror/lang-markdown"
import {
addNewBlockAfterCurrent, addNewBlockBeforeCurrent, addNewBlockAfterLast, addNewBlockBeforeFirst, insertNewBlockAtCursor,
gotoPreviousBlock, gotoNextBlock, selectNextBlock, selectPreviousBlock,
gotoPreviousParagraph, gotoNextParagraph, selectNextParagraph, selectPreviousParagraph,
moveLineUp, moveLineDown,
selectAll,
deleteBlock, deleteBlockSetCursorPreviousBlock,
newCursorBelow, newCursorAbove,
} from "./block/commands.js"
import { deleteLine } from "./block/delete-line.js"
import { formatBlockContent } from "./block/format-code.js"
import { transposeChars } from "./block/transpose-chars.js"
import { cutCommand, copyCommand, pasteCommand } from "./copy-paste.js"
import { markModeMoveCommand, toggleSelectionMarkMode, selectionMarkModeCancel } from "./mark-mode.js"
const cursorPreviousBlock = markModeMoveCommand(gotoPreviousBlock, selectPreviousBlock)
const cursorNextBlock = markModeMoveCommand(gotoNextBlock, selectNextBlock)
const cursorPreviousParagraph = markModeMoveCommand(gotoPreviousParagraph, selectPreviousParagraph)
const cursorNextParagraph = markModeMoveCommand(gotoNextParagraph, selectNextParagraph)
const openLanguageSelector = (editor) => () => {
editor.openLanguageSelector()
return true
}
const openBufferSelector = (editor) => () => {
editor.openBufferSelector()
return true
}
const openCommandPalette = (editor) => () => {
editor.openCommandPalette()
return true
}
const openMoveToBuffer = (editor) => () => {
editor.openMoveToBufferSelector()
return true
}
const openCreateNewBuffer = (editor) => () => {
editor.openCreateBuffer("new")
return true
}
const nothing = (view) => {
return true
}
const cmd = (f, category, description) => ({
run: f,
name: f.name,
description: description,
category: category,
})
const cmdLessContext = (f, category, description) => ({
run: (editor) => f,
name: f.name,
description: description,
category: category,
})
const HEYNOTE_COMMANDS = {
addNewBlockAfterCurrent: cmd(addNewBlockAfterCurrent, "Block", "Add new block after current block"),
addNewBlockBeforeCurrent: cmd(addNewBlockBeforeCurrent, "Block", "Add new block before current block"),
addNewBlockAfterLast: cmd(addNewBlockAfterLast, "Block", "Add new block after last block"),
addNewBlockBeforeFirst: cmd(addNewBlockBeforeFirst, "Block", "Add new block before first block"),
insertNewBlockAtCursor: cmd(insertNewBlockAtCursor, "Block", "Insert new block at cursor"),
deleteBlock: cmd(deleteBlock, "Block", "Delete block"),
deleteBlockSetCursorPreviousBlock: cmd(deleteBlockSetCursorPreviousBlock, "Block", "Delete block and set cursor to previous block"),
cursorPreviousBlock: cmd(cursorPreviousBlock, "Cursor", "Move cursor to previous block"),
cursorNextBlock: cmd(cursorNextBlock, "Cursor", "Move cursor to next block"),
cursorPreviousParagraph: cmd(cursorPreviousParagraph, "Cursor", "Move cursor to previous paragraph"),
cursorNextParagraph: cmd(cursorNextParagraph, "Cursor", "Move cursor to next paragraph"),
toggleSelectionMarkMode: cmd(toggleSelectionMarkMode, "Cursor", "Toggle selection mark mode"),
selectionMarkModeCancel: cmd(selectionMarkModeCancel, "Cursor", "Cancel selection mark mode"),
openLanguageSelector: cmd(openLanguageSelector, "Block", "Select block language"),
openBufferSelector: cmd(openBufferSelector, "Buffer", "Buffer selector"),
openCommandPalette: cmd(openCommandPalette, "Editor", "Open command palette"),
openMoveToBuffer: cmd(openMoveToBuffer, "Block", "Move block to another buffer"),
openCreateNewBuffer: cmd(openCreateNewBuffer, "Buffer", "Create new buffer"),
cut: cmd(cutCommand, "Clipboard", "Cut selection"),
copy: cmd(copyCommand, "Clipboard", "Copy selection"),
// commands without editor context
paste: cmdLessContext(pasteCommand, "Clipboard", "Paste from clipboard"),
selectAll: cmdLessContext(selectAll, "Selection", "Select all"),
moveLineUp: cmdLessContext(moveLineUp, "Edit", "Move line up"),
moveLineDown: cmdLessContext(moveLineDown, "Edit", "Move line down"),
deleteLine: cmdLessContext(deleteLine, "Edit", "Delete line"),
formatBlockContent: cmdLessContext(formatBlockContent, "Block", "Format block content"),
newCursorAbove: cmdLessContext(newCursorAbove, "Cursor", "Add cursor above"),
newCursorBelow: cmdLessContext(newCursorBelow, "Cursor", "Add cursor below"),
selectPreviousParagraph: cmdLessContext(selectPreviousParagraph, "Selection", "Select to previous paragraph"),
selectNextParagraph: cmdLessContext(selectNextParagraph, "Selection", "Select to next paragraph"),
selectPreviousBlock: cmdLessContext(selectPreviousBlock, "Selection", "Select to previous block"),
selectNextBlock: cmdLessContext(selectNextBlock, "Selection", "Select to next block"),
nothing: cmdLessContext(nothing, "Misc", "Do nothing"),
// directly from CodeMirror
undo: cmdLessContext(undo, "Edit", "Undo"),
redo: cmdLessContext(redo, "Edit", "Redo"),
indentMore: cmdLessContext(indentMore, "Edit", "Indent more"),
indentLess: cmdLessContext(indentLess, "Edit", "Indent less"),
foldCode: cmdLessContext(foldCode, "Edit", "Fold code"),
unfoldCode: cmdLessContext(unfoldCode, "Edit", "Unfold code"),
selectNextOccurrence: cmdLessContext(selectNextOccurrence, "Cursor", "Select next occurrence"),
deleteCharBackward: cmdLessContext(deleteCharBackward, "Edit", "Delete character backward"),
deleteCharForward: cmdLessContext(deleteCharForward, "Edit", "Delete character forward"),
deleteGroupBackward: cmdLessContext(deleteGroupBackward, "Edit", "Delete group backward"),
deleteGroupForward: cmdLessContext(deleteGroupForward, "Edit", "Delete group forward"),
deleteLineBoundaryBackward: cmdLessContext(deleteLineBoundaryBackward, "Edit", "Delete from start of wrapped line"),
deleteLineBoundaryForward: cmdLessContext(deleteLineBoundaryForward, "Edit", "Delete to end of wrapped line"),
deleteToLineEnd: cmdLessContext(deleteToLineEnd, "Edit", "Delete to end of line"),
deleteToLineStart: cmdLessContext(deleteToLineStart, "Edit", "Delete from start of line"),
simplifySelection: cmdLessContext(simplifySelection, "Cursor", "Simplify selection"),
splitLine: cmdLessContext(splitLine, "Edit", "Split line"),
transposeChars: cmdLessContext(transposeChars, "Edit", "Transpose characters"),
insertNewlineAndIndent: cmdLessContext(insertNewlineAndIndent, "Edit", "Insert newline and indent"),
insertNewlineContinueMarkup: cmdLessContext(insertNewlineContinueMarkup, "Markdown", "Insert newline and continue todo lists/block quotes"),
}
// selection mark-mode:ify all cursor/select commands from CodeMirror
for (let commandSuffix of [
"CharLeft", "CharRight",
"CharBackward", "CharForward",
"LineUp", "LineDown",
"LineStart", "LineEnd",
"GroupLeft", "GroupRight",
"GroupForward", "GroupBackward",
"PageUp", "PageDown",
"SyntaxLeft", "SyntaxRight",
"SubwordBackward", "SubwordForward",
"LineBoundaryBackward", "LineBoundaryForward",
]) {
HEYNOTE_COMMANDS[`cursor${commandSuffix}`] = {
run: markModeMoveCommand(codeMirrorCommands[`cursor${commandSuffix}`], codeMirrorCommands[`select${commandSuffix}`]),
name: `cursor${commandSuffix}`,
description: `cursor${commandSuffix}`,
category: "Cursor",
}
HEYNOTE_COMMANDS[`select${commandSuffix}`] = {
run: (editor) => codeMirrorCommands[`select${commandSuffix}`],
name: `select${commandSuffix}`,
description: `select${commandSuffix}`,
category: "Cursor",
}
}
export { HEYNOTE_COMMANDS }

View File

@ -2,7 +2,6 @@ import { EditorState, EditorSelection } from "@codemirror/state"
import { EditorView } from "@codemirror/view" import { EditorView } from "@codemirror/view"
import { LANGUAGES } from './languages.js'; import { LANGUAGES } from './languages.js';
import { setEmacsMarkMode } from "./emacs.js"
const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|") const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|")
@ -61,7 +60,7 @@ export const heynoteCopyCut = (editor) => {
} }
// if we're in Emacs mode, we want to exit mark mode in case we're in it // if we're in Emacs mode, we want to exit mark mode in case we're in it
setEmacsMarkMode(false) editor.selectionMarkMode = false
// if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text // if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text
if (editor.deselectOnCopy && event.type == "copy") { if (editor.deselectOnCopy && event.type == "copy") {
@ -95,7 +94,7 @@ const copyCut = (view, cut, editor) => {
} }
// if we're in Emacs mode, we want to exit mark mode in case we're in it // if we're in Emacs mode, we want to exit mark mode in case we're in it
setEmacsMarkMode(false) editor.selectionMarkMode = false
// if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text // if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text
if (editor.deselectOnCopy && !cut) { if (editor.deselectOnCopy && !cut) {
@ -107,6 +106,7 @@ const copyCut = (view, cut, editor) => {
selection: newSelection, selection: newSelection,
})) }))
} }
return true
} }

View File

@ -1,8 +1,9 @@
import { Annotation, EditorState, Compartment } from "@codemirror/state" import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction, Prec } from "@codemirror/state"
import { EditorView, keymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view" import { EditorView, keymap as cmKeymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view"
import { indentUnit, forceParsing, foldGutter } from "@codemirror/language" import { indentUnit, forceParsing, foldGutter, ensureSyntaxTree } from "@codemirror/language"
import { markdown } from "@codemirror/lang-markdown" import { markdown, markdownKeymap } from "@codemirror/lang-markdown"
import { closeBrackets } from "@codemirror/autocomplete"; import { closeBrackets } from "@codemirror/autocomplete";
import { undo, redo } from "@codemirror/commands"
import { heynoteLight } from "./theme/light.js" import { heynoteLight } from "./theme/light.js"
import { heynoteDark } from "./theme/dark.js" import { heynoteDark } from "./theme/dark.js"
@ -10,36 +11,31 @@ import { heynoteBase } from "./theme/base.js"
import { getFontTheme } from "./theme/font-theme.js"; import { getFontTheme } from "./theme/font-theme.js";
import { customSetup } from "./setup.js" import { customSetup } from "./setup.js"
import { heynoteLang } from "./lang-heynote/heynote.js" import { heynoteLang } from "./lang-heynote/heynote.js"
import { noteBlockExtension, blockLineNumbers, blockState } from "./block/block.js" import { getCloseBracketsExtensions } from "./close-brackets.js"
import { heynoteEvent, SET_CONTENT } from "./annotation.js"; import { noteBlockExtension, blockLineNumbers, blockState, getActiveNoteBlock, triggerCursorChange } from "./block/block.js"
import { changeCurrentBlockLanguage, triggerCurrenciesLoaded } from "./block/commands.js" import { heynoteEvent, SET_CONTENT, DELETE_BLOCK, APPEND_BLOCK, SET_FONT } from "./annotation.js";
import { changeCurrentBlockLanguage, triggerCurrenciesLoaded, getBlockDelimiter, deleteBlock, selectAll } from "./block/commands.js"
import { formatBlockContent } from "./block/format-code.js" import { formatBlockContent } from "./block/format-code.js"
import { heynoteKeymap } from "./keymap.js" import { getKeymapExtensions } from "./keymap.js"
import { emacsKeymap } from "./emacs.js"
import { heynoteCopyCut } from "./copy-paste" import { heynoteCopyCut } from "./copy-paste"
import { languageDetection } from "./language-detection/autodetect.js" import { languageDetection } from "./language-detection/autodetect.js"
import { autoSaveContent } from "./save.js" import { autoSaveContent } from "./save.js"
import { todoCheckboxPlugin} from "./todo-checkbox.ts" import { todoCheckboxPlugin} from "./todo-checkbox.ts"
import { links } from "./links.js" import { links } from "./links.js"
import { HEYNOTE_COMMANDS } from "./commands.js";
export const LANGUAGE_SELECTOR_EVENT = "openLanguageSelector" import { NoteFormat } from "../common/note-format.js"
import { AUTO_SAVE_INTERVAL } from "../common/constants.js"
function getKeymapExtensions(editor, keymap) { import { useHeynoteStore } from "../stores/heynote-store.js";
if (keymap === "emacs") { import { useErrorStore } from "../stores/error-store.js";
return emacsKeymap(editor)
} else {
return heynoteKeymap(editor)
}
}
export class HeynoteEditor { export class HeynoteEditor {
constructor({ constructor({
element, element,
path,
content, content,
focus=true, focus=true,
theme="light", theme="light",
saveFunction=null,
keymap="default", keymap="default",
emacsMetaKey, emacsMetaKey,
showLineNumberGutter=true, showLineNumberGutter=true,
@ -47,8 +43,13 @@ export class HeynoteEditor {
bracketClosing=false, bracketClosing=false,
fontFamily, fontFamily,
fontSize, fontSize,
tabSize=4,
defaultBlockToken,
defaultBlockAutoDetect,
keyBindings,
}) { }) {
this.element = element this.element = element
this.path = path
this.themeCompartment = new Compartment this.themeCompartment = new Compartment
this.keymapCompartment = new Compartment this.keymapCompartment = new Compartment
this.lineNumberCompartmentPre = new Compartment this.lineNumberCompartmentPre = new Compartment
@ -56,35 +57,42 @@ export class HeynoteEditor {
this.foldGutterCompartment = new Compartment this.foldGutterCompartment = new Compartment
this.readOnlyCompartment = new Compartment this.readOnlyCompartment = new Compartment
this.closeBracketsCompartment = new Compartment this.closeBracketsCompartment = new Compartment
this.indentUnitCompartment = new Compartment
this.deselectOnCopy = keymap === "emacs" this.deselectOnCopy = keymap === "emacs"
this.emacsMetaKey = emacsMetaKey this.emacsMetaKey = emacsMetaKey
this.fontTheme = new Compartment this.fontTheme = new Compartment
this.setDefaultBlockLanguage(defaultBlockToken, defaultBlockAutoDetect)
this.contentLoaded = false
this.notesStore = useHeynoteStore()
this.errorStore = useErrorStore()
this.name = ""
this.selectionMarkMode = false
const state = EditorState.create({ const state = EditorState.create({
doc: content || "", doc: "",
extensions: [ extensions: [
this.keymapCompartment.of(getKeymapExtensions(this, keymap)), this.keymapCompartment.of(getKeymapExtensions(this, keymap, keyBindings)),
heynoteCopyCut(this), heynoteCopyCut(this),
//minimalSetup, //minimalSetup,
this.lineNumberCompartment.of(showLineNumberGutter ? [lineNumbers(), blockLineNumbers] : []), this.lineNumberCompartment.of(showLineNumberGutter ? [lineNumbers(), blockLineNumbers] : []),
customSetup, customSetup,
this.foldGutterCompartment.of(showFoldGutter ? [foldGutter()] : []), this.foldGutterCompartment.of(showFoldGutter ? [foldGutter()] : []),
this.closeBracketsCompartment.of(bracketClosing ? [getCloseBracketsExtensions()] : []),
this.closeBracketsCompartment.of(bracketClosing ? [closeBrackets()] : []),
this.readOnlyCompartment.of([]), this.readOnlyCompartment.of([]),
this.themeCompartment.of(theme === "dark" ? heynoteDark : heynoteLight), this.themeCompartment.of(theme === "dark" ? heynoteDark : heynoteLight),
heynoteBase, heynoteBase,
this.fontTheme.of(getFontTheme(fontFamily, fontSize)), this.fontTheme.of(getFontTheme(fontFamily, fontSize)),
indentUnit.of(" "), this.indentUnitCompartment.of(indentUnit.of(" ".repeat(tabSize))),
EditorView.scrollMargins.of(f => { EditorView.scrollMargins.of(f => {
return {top: 80, bottom: 80} return {top: 80, bottom: 80}
}), }),
heynoteLang(), heynoteLang(),
noteBlockExtension(this), noteBlockExtension(this),
languageDetection(() => this.view), languageDetection(path, () => this),
// set cursor blink rate to 1 second // set cursor blink rate to 1 second
drawSelection({cursorBlinkRate:1000}), drawSelection({cursorBlinkRate:1000}),
@ -94,53 +102,137 @@ export class HeynoteEditor {
return {class: view.state.facet(EditorView.darkTheme) ? "dark-theme" : "light-theme"} return {class: view.state.facet(EditorView.darkTheme) ? "dark-theme" : "light-theme"}
}), }),
saveFunction ? autoSaveContent(saveFunction, 2000) : [], autoSaveContent(this, AUTO_SAVE_INTERVAL),
// Markdown extensions, we need to add markdownKeymap manually with the highest precedence
// so that it takes precedence over the default keymap
todoCheckboxPlugin, todoCheckboxPlugin,
markdown(), markdown({addKeymap: false}),
Prec.highest(cmKeymap.of(markdownKeymap)),
links, links,
], ],
}) })
// make sure saveFunction is called when page is unloaded // make sure saveFunction is called when page is unloaded
if (saveFunction) {
window.addEventListener("beforeunload", () => { window.addEventListener("beforeunload", () => {
saveFunction(this.getContent()) this.save()
}) })
}
this.view = new EditorView({ this.view = new EditorView({
state: state, state: state,
parent: element, parent: element,
}) })
if (focus) { //this.setContent(content)
this.view.dispatch({ this.setReadOnly(true)
selection: {anchor: this.view.state.doc.length, head: this.view.state.doc.length}, this.contentLoadedPromise = this.loadContent();
scrollIntoView: true, this.contentLoadedPromise.then(() => {
this.setReadOnly(false)
}) })
if (focus) {
this.view.focus() this.view.focus()
} }
// trigger setFont once the fonts has loaded
document.fonts.ready.then(() => {
this.setFont(fontFamily, fontSize)
})
}
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() { 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
// set up content change listener
this.onChange = (content) => {
this.diskContent = content
this.setContent(content)
}
window.heynote.buffer.addOnChangeCallback(this.path, this.onChange)
await this.setContent(content)
} }
setContent(content) { setContent(content) {
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({ this.view.dispatch({
changes: { changes: {
from: 0, from: 0,
to: this.view.state.doc.length, to: this.view.state.doc.length,
insert: content, insert: this.note.content,
}, },
annotations: [heynoteEvent.of(SET_CONTENT)], annotations: [heynoteEvent.of(SET_CONTENT), Transaction.addToHistory.of(false)],
}) })
// Ensure we have a parsed syntax tree when buffer is loaded. This prevents errors for large buffers
// 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({ this.view.dispatch({
selection: {anchor: this.view.state.doc.length, head: this.view.state.doc.length}, selection: {anchor: this.view.state.doc.length, head: this.view.state.doc.length},
scrollIntoView: true, scrollIntoView: true,
}) })
} }
resolve()
})
})
}
setName(name) {
this.note.metadata.name = name
this.name = name
triggerCursorChange(this.view)
}
getBlocks() { getBlocks() {
return this.view.state.facet(blockState) return this.view.state.facet(blockState)
@ -150,6 +242,13 @@ export class HeynoteEditor {
return this.view.state.selection.main.head return this.view.state.selection.main.head
} }
setCursorPosition(position) {
this.view.dispatch({
selection: {anchor: position, head: position},
scrollIntoView: true,
})
}
focus() { focus() {
this.view.focus() this.view.focus()
} }
@ -163,6 +262,7 @@ export class HeynoteEditor {
setFont(fontFamily, fontSize) { setFont(fontFamily, fontSize) {
this.view.dispatch({ this.view.dispatch({
effects: this.fontTheme.reconfigure(getFontTheme(fontFamily, fontSize)), effects: this.fontTheme.reconfigure(getFontTheme(fontFamily, fontSize)),
annotations: [heynoteEvent.of(SET_FONT), Transaction.addToHistory.of(false)],
}) })
} }
@ -172,16 +272,88 @@ export class HeynoteEditor {
}) })
} }
setKeymap(keymap, emacsMetaKey) { setKeymap(keymap, emacsMetaKey, keyBindings) {
this.deselectOnCopy = keymap === "emacs" this.deselectOnCopy = keymap === "emacs"
this.emacsMetaKey = emacsMetaKey this.emacsMetaKey = emacsMetaKey
this.view.dispatch({ this.view.dispatch({
effects: this.keymapCompartment.reconfigure(getKeymapExtensions(this, keymap)), effects: this.keymapCompartment.reconfigure(getKeymapExtensions(this, keymap, keyBindings)),
}) })
} }
openLanguageSelector() { openLanguageSelector() {
this.element.dispatchEvent(new Event(LANGUAGE_SELECTOR_EVENT)) this.notesStore.openLanguageSelector()
}
openBufferSelector() {
this.notesStore.openBufferSelector()
}
openCommandPalette() {
this.notesStore.openCommandPalette()
}
openCreateBuffer(createMode) {
this.notesStore.openCreateBuffer(createMode)
}
openMoveToBufferSelector() {
this.notesStore.openMoveToBufferSelector()
}
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)
//})
// add new buffer to recent list so that it shows up at the top of the buffer selector
this.notesStore.addRecentBuffer(path)
this.notesStore.addRecentBuffer(this.notesStore.currentBufferPath)
}
getActiveBlockContent() {
const block = getActiveNoteBlock(this.view.state)
if (!block) {
return
}
return this.view.state.sliceDoc(block.range.from, block.range.to)
}
deleteActiveBlock() {
deleteBlock(this)(this.view)
}
appendBlockContent(content) {
this.view.dispatch({
changes: {
from: this.view.state.doc.length,
to: this.view.state.doc.length,
insert: content,
},
annotations: [heynoteEvent.of(APPEND_BLOCK)],
})
} }
setCurrentLanguage(lang, auto=false) { setCurrentLanguage(lang, auto=false) {
@ -202,10 +374,15 @@ export class HeynoteEditor {
setBracketClosing(value) { setBracketClosing(value) {
this.view.dispatch({ this.view.dispatch({
effects: this.closeBracketsCompartment.reconfigure(value ? [closeBrackets()] : []), effects: this.closeBracketsCompartment.reconfigure(value ? [getCloseBracketsExtensions()] : []),
}) })
} }
setDefaultBlockLanguage(token, autoDetect) {
this.defaultBlockToken = token || "text"
this.defaultBlockAutoDetect = autoDetect === undefined ? true : autoDetect
}
formatCurrentBlock() { formatCurrentBlock() {
formatBlockContent({ formatBlockContent({
state: this.view.state, state: this.view.state,
@ -216,6 +393,54 @@ export class HeynoteEditor {
currenciesLoaded() { currenciesLoaded() {
triggerCurrenciesLoaded(this.view.state, this.view.dispatch) 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)
}
undo() {
undo(this.view)
}
redo() {
redo(this.view)
}
selectAll() {
selectAll(this.view)
}
setTabSize(tabSize) {
this.view.dispatch({
effects: this.indentUnitCompartment.reconfigure(indentUnit.of(" ".repeat(tabSize)))
})
}
executeCommand(command) {
const cmd = HEYNOTE_COMMANDS[command]
if (!cmd) {
console.error(`Command not found: ${command}`)
return
}
cmd.run(this)(this.view)
}
} }

View File

@ -1,121 +0,0 @@
import { Direction} from "@codemirror/view"
import { EditorSelection, EditorState, Prec } from "@codemirror/state"
import {
undo, redo,
cursorGroupLeft, cursorGroupRight, selectGroupLeft, selectGroupRight,
simplifySelection,
deleteCharForward, deleteCharBackward, deleteToLineEnd,
splitLine,
transposeChars,
cursorPageDown,
cursorCharLeft, selectCharLeft,
cursorCharRight, selectCharRight,
cursorLineUp, selectLineUp,
cursorLineDown, selectLineDown,
cursorLineStart, selectLineStart,
cursorLineEnd, selectLineEnd,
} from "@codemirror/commands"
import { heynoteKeymap, keymapFromSpec } from "./keymap.js"
import {
gotoPreviousBlock, gotoNextBlock,
selectNextBlock, selectPreviousBlock,
gotoPreviousParagraph, gotoNextParagraph,
selectNextParagraph, selectPreviousParagraph,
selectAll,
} from "./block/commands.js"
import { pasteCommand, copyCommand, cutCommand } from "./copy-paste.js"
// if set to true, all keybindings for moving around is changed to their corresponding select commands
let emacsMarkMode = false
export function setEmacsMarkMode(value) {
emacsMarkMode = value
}
/**
* Return a command that will conditionally execute either the default command or the mark mode command
*
* @param defaultCmd Default command to execute
* @param {*} markModeCmd Command to execute if mark mode is active
*/
function emacsMoveCommand(defaultCmd, markModeCmd) {
return (view) => emacsMarkMode ? markModeCmd(view) : defaultCmd(view)
}
/**
* C-g command that exits mark mode and simplifies selection
*/
function emacsCancel(view) {
simplifySelection(view)
setEmacsMarkMode(false)
}
/**
* Exit mark mode before executing selectAll command
*/
function emacsSelectAll(view) {
setEmacsMarkMode(false)
return selectAll(view)
}
function emacsMetaKeyCommand(key, editor, command) {
const handler = (view, event) => {
if (editor.emacsMetaKey === "meta" && event.metaKey || editor.emacsMetaKey === "alt" && event.altKey) {
event.preventDefault()
return command(view)
} else {
return false
}
}
return [
{key, run:handler, preventDefault:false},
{key:key.replace("Meta", "Alt"), run:handler, preventDefault:false},
]
}
export function emacsKeymap(editor) {
return [
heynoteKeymap(editor),
Prec.highest(keymapFromSpec([
["Ctrl-Shift--", undo],
["Ctrl-.", redo],
["Ctrl-g", emacsCancel],
["ArrowLeft", emacsMoveCommand(cursorCharLeft, selectCharLeft)],
["ArrowRight", emacsMoveCommand(cursorCharRight, selectCharRight)],
["ArrowUp", emacsMoveCommand(cursorLineUp, selectLineUp)],
["ArrowDown", emacsMoveCommand(cursorLineDown, selectLineDown)],
{key: "Ctrl-ArrowLeft", run: emacsMoveCommand(cursorGroupLeft, selectGroupLeft), shift: selectGroupLeft},
{key: "Ctrl-ArrowRight", run: emacsMoveCommand(cursorGroupRight, selectGroupRight), shift: selectGroupRight},
["Ctrl-d", deleteCharForward],
["Ctrl-h", deleteCharBackward],
["Ctrl-k", deleteToLineEnd],
["Ctrl-o", splitLine],
["Ctrl-t", transposeChars],
["Ctrl-v", cursorPageDown],
["Ctrl-y", pasteCommand],
["Ctrl-w", cutCommand(editor)],
...emacsMetaKeyCommand("Meta-w", editor, copyCommand(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 },
])),
Prec.highest(keymapFromSpec([
["Ctrl-Space", (view) => { emacsMarkMode = !emacsMarkMode }],
["Mod-a", emacsSelectAll],
{key:"Mod-ArrowUp", run:emacsMoveCommand(gotoPreviousBlock, selectPreviousBlock), shift:selectPreviousBlock},
{key:"Mod-ArrowDown", run:emacsMoveCommand(gotoNextBlock, selectNextBlock), shift:selectNextBlock},
{key:"Ctrl-ArrowUp", run:emacsMoveCommand(gotoPreviousParagraph, selectPreviousParagraph), shift:selectPreviousParagraph},
{key:"Ctrl-ArrowDown", run:emacsMoveCommand(gotoNextParagraph, selectNextParagraph), shift:selectNextParagraph},
])),
]
}

View File

@ -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
}
}

View File

@ -1,72 +1,165 @@
import { keymap } from "@codemirror/view" import { keymap } from "@codemirror/view"
//import { EditorSelection, EditorState } from "@codemirror/state" import { Prec } from "@codemirror/state"
import {
indentLess, indentMore,
} from "@codemirror/commands"
import { import { HEYNOTE_COMMANDS } from "./commands.js"
insertNewBlockAtCursor,
addNewBlockBeforeCurrent, addNewBlockAfterCurrent,
addNewBlockBeforeFirst, addNewBlockAfterLast,
moveLineUp, moveLineDown,
selectAll,
gotoPreviousBlock, gotoNextBlock,
selectNextBlock, selectPreviousBlock,
gotoPreviousParagraph, gotoNextParagraph,
selectNextParagraph, selectPreviousParagraph,
newCursorBelow, newCursorAbove,
moveCurrentBlockUp, moveCurrentBlockDown,
} from "./block/commands.js"
import { pasteCommand, copyCommand, cutCommand } from "./copy-paste.js"
import { formatBlockContent } from "./block/format-code.js" const cmd = (key, command) => ({key, command})
import { deleteLine } from "./block/delete-line.js" const cmdShift = (key, command, shiftCommand) => {
return [
cmd(key, command),
cmd(`Shift-${key}`, shiftCommand),
]
}
const isMac = window.heynote.platform.isMac
const isLinux = window.heynote.platform.isLinux
const isWindows = window.heynote.platform.isWindows
export const DEFAULT_KEYMAP = [
cmd("Enter", "insertNewlineAndIndent"),
cmd("Mod-a", "selectAll"),
cmd("Mod-Enter", "addNewBlockAfterCurrent"),
cmd("Mod-Shift-Enter", "addNewBlockAfterLast"),
cmd("Alt-Enter", "addNewBlockBeforeCurrent"),
cmd("Alt-Shift-Enter", "addNewBlockBeforeFirst"),
cmd("Mod-Alt-Enter", "insertNewBlockAtCursor"),
...cmdShift("ArrowLeft", "cursorCharLeft", "selectCharLeft"),
...cmdShift("ArrowRight", "cursorCharRight", "selectCharRight"),
...cmdShift("ArrowUp", "cursorLineUp", "selectLineUp"),
...cmdShift("ArrowDown", "cursorLineDown", "selectLineDown"),
...cmdShift("Ctrl-ArrowLeft", "cursorGroupLeft", "selectGroupLeft"),
...cmdShift("Ctrl-ArrowRight", "cursorGroupRight", "selectGroupRight"),
...cmdShift("Alt-ArrowLeft", "cursorGroupLeft", "selectGroupLeft"),
...cmdShift("Alt-ArrowRight", "cursorGroupRight", "selectGroupRight"),
...cmdShift("Mod-ArrowUp", "cursorPreviousBlock", "selectPreviousBlock"),
...cmdShift("Mod-ArrowDown", "cursorNextBlock", "selectNextBlock"),
...cmdShift("Ctrl-ArrowUp", "cursorPreviousParagraph", "selectPreviousParagraph"),
...cmdShift("Ctrl-ArrowDown", "cursorNextParagraph", "selectNextParagraph"),
...cmdShift("PageUp", "cursorPageUp", "selectPageUp"),
...cmdShift("PageDown", "cursorPageDown", "selectPageDown"),
...cmdShift("Home", "cursorLineBoundaryBackward", "selectLineBoundaryBackward"),
...cmdShift("End", "cursorLineBoundaryForward", "selectLineBoundaryForward"),
cmd("Backspace", "deleteCharBackward"),
cmd("Delete", "deleteCharForward"),
cmd("Escape", "simplifySelection"),
cmd("Ctrl-Backspace", "deleteGroupBackward"),
cmd("Ctrl-Delete", "deleteGroupForward"),
...(isMac ? [
cmd("Alt-Backspace", "deleteGroupBackward"),
cmd("Alt-Delete", "deleteGroupForward"),
cmd("Mod-Backspace", "deleteLineBoundaryBackward"),
cmd("Mod-Delete", "deleteLineBoundaryForward"),
] : []),
cmd("Alt-ArrowUp", "moveLineUp"),
cmd("Alt-ArrowDown", "moveLineDown"),
cmd("Mod-Shift-k", "deleteLine"),
cmd("Mod-Alt-ArrowDown", "newCursorBelow"),
cmd("Mod-Alt-ArrowUp", "newCursorAbove"),
cmd("Mod-Shift-d", "deleteBlock"),
cmd("Mod-d", "selectNextOccurrence"),
cmd(isMac ? "Cmd-Alt-[" : "Ctrl-Shift-[", "foldCode"),
cmd(isMac ? "Cmd-Alt-]" : "Ctrl-Shift-]", "unfoldCode"),
cmd("Mod-c", "copy"),
cmd("Mod-v", "paste"),
cmd("Mod-x", "cut"),
cmd("Mod-z", "undo"),
cmd("Mod-Shift-z", "redo"),
...(isWindows || isLinux ? [
cmd("Mod-y", "redo"),
] : []),
cmd("Tab", "indentMore"),
cmd("Shift-Tab", "indentLess"),
//cmd("Alt-ArrowLeft", "cursorSubwordBackward"),
//cmd("Alt-ArrowRight", "cursorSubwordForward"),
cmd("Mod-l", "openLanguageSelector"),
cmd("Mod-p", "openBufferSelector"),
cmd("Mod-Shift-p", "openCommandPalette"),
cmd("Mod-s", "openMoveToBuffer"),
cmd("Mod-n", "openCreateNewBuffer"),
cmd("Alt-Shift-f", "formatBlockContent"),
// search
//cmd("Mod-f", "openSearchPanel"),
//cmd("F3", "findNext"),
//cmd("Mod-g", "findNext"),
//cmd("Shift-F3", "findPrevious"),
//cmd("Shift-Mod-g", "findPrevious"),
//cmd("Mod-Alt-g", "gotoLine"),
//cmd("Mod-d", "selectNextOccurrence"),
/*
- Mod-f: [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSearchPanel)
- F3, Mod-g: [`findNext`](https://codemirror.net/6/docs/ref/#search.findNext)
- Shift-F3, Shift-Mod-g: [`findPrevious`](https://codemirror.net/6/docs/ref/#search.findPrevious)
- Mod-Alt-g: [`gotoLine`](https://codemirror.net/6/docs/ref/#search.gotoLine)
- Mod-d: [`selectNextOccurrence`](https://codemirror.net/6/docs/ref/#search.selectNextOccurrence)
*/
]
export const EMACS_KEYMAP = [
cmd("Ctrl-w", "cut"),
cmd("Ctrl-y", "paste"),
cmd("EmacsMeta-w", "copy"),
cmd("Ctrl-Space", "toggleSelectionMarkMode"),
cmd("Ctrl-g", "selectionMarkModeCancel"),
cmd("Escape", "selectionMarkModeCancel"),
cmd("Ctrl-o", "splitLine"),
cmd("Ctrl-d", "deleteCharForward"),
cmd("Ctrl-h", "deleteCharBackward"),
cmd("Ctrl-k", "deleteToLineEnd"),
cmd("Ctrl-t", "transposeChars"),
cmd("Ctrl-Shift--", "undo"),
cmd("Ctrl-.", "redo"),
...cmdShift("Ctrl-v", "cursorPageDown", "selectPageDown"),
...cmdShift("Ctrl-b", "cursorCharLeft", "selectCharLeft"),
...cmdShift("Ctrl-f", "cursorCharRight", "selectCharRight"),
...cmdShift("Ctrl-a", "cursorLineStart", "selectLineStart"),
...cmdShift("Ctrl-e", "cursorLineEnd", "selectLineEnd"),
]
export function keymapFromSpec(specs) { function keymapFromSpec(specs, editor) {
return keymap.of(specs.map((spec) => { return keymap.of(specs.map((spec) => {
if (spec.run) { let key = spec.key
if ("preventDefault" in spec) { if (key.indexOf("EmacsMeta") != -1) {
return spec key = key.replace("EmacsMeta", editor.emacsMetaKey === "alt" ? "Alt" : "Meta")
} else {
return {...spec, preventDefault: true}
} }
} else {
const [key, run] = spec
return { return {
key, key: key,
run, //preventDefault: true,
preventDefault: true, preventDefault: false,
run: (view) => {
//console.log("run()", spec.key, spec.command)
const command = HEYNOTE_COMMANDS[spec.command]
if (!command) {
console.error(`Command not found: ${spec.command} (${spec.key})`)
return false
} }
return command.run(editor)(view)
},
} }
})) }))
} }
export function heynoteKeymap(editor) {
return keymapFromSpec([ export function heynoteKeymap(editor, keymap, userKeymap) {
["Mod-c", copyCommand(editor)], return [
["Mod-v", pasteCommand], keymapFromSpec([
["Mod-x", cutCommand(editor)], ...userKeymap,
["Tab", indentMore], ...keymap,
["Shift-Tab", indentLess], ], editor),
["Alt-Shift-Enter", addNewBlockBeforeFirst], ]
["Mod-Shift-Enter", addNewBlockAfterLast], }
["Alt-Enter", addNewBlockBeforeCurrent],
["Mod-Enter", addNewBlockAfterCurrent], export function getKeymapExtensions(editor, keymap, keyBindings) {
["Mod-Alt-Enter", insertNewBlockAtCursor], return heynoteKeymap(
["Mod-a", selectAll], editor,
["Alt-ArrowUp", moveLineUp], keymap === "emacs" ? EMACS_KEYMAP.concat(DEFAULT_KEYMAP) : DEFAULT_KEYMAP,
["Alt-ArrowDown", moveLineDown], keyBindings || [],
["Mod-l", () => editor.openLanguageSelector()], )
["Alt-Shift-f", formatBlockContent],
["Mod-Alt-ArrowDown", newCursorBelow],
["Mod-Alt-ArrowUp", newCursorAbove],
["Mod-Shift-k", deleteLine],
{key:"Mod-ArrowUp", run:gotoPreviousBlock, shift:selectPreviousBlock},
{key:"Mod-ArrowDown", run:gotoNextBlock, shift:selectNextBlock},
{key:"Ctrl-ArrowUp", run:gotoPreviousParagraph, shift:selectPreviousParagraph},
{key:"Ctrl-ArrowDown", run:gotoNextParagraph, shift:selectNextParagraph},
["Mod-Shift-Alt-ArrowUp", moveCurrentBlockUp],
["Mod-Shift-Alt-ArrowDown", moveCurrentBlockDown],
])
} }

View File

@ -11,7 +11,7 @@ NoteDelimiter {
@tokens { @tokens {
noteDelimiterMark { "∞∞∞" } noteDelimiterMark { "∞∞∞" }
NoteLanguage { "text" | "math" | "javascript" | "typescript" | "jsx" | "tsx" | "json" | "python" | "html" | "sql" | "markdown" | "java" | "php" | "css" | "xml" | "cpp" | "rust" | "csharp" | "ruby" | "shell" | "yaml" | "golang" | "clojure" | "erlang" | "lezer" | "toml" | "swift" | "kotlin" | "groovy" | "diff" | "powershell" } NoteLanguage { "text" | "math" | "javascript" | "typescript" | "jsx" | "tsx" | "json" | "python" | "html" | "sql" | "markdown" | "java" | "php" | "css" | "xml" | "cpp" | "rust" | "csharp" | "ruby" | "shell" | "yaml" | "golang" | "clojure" | "elixir" | "erlang" | "lezer" | "toml" | "swift" | "kotlin" | "groovy" | "diff" | "powershell" | "vue" | "dart" | "scala" }
Auto { "-a" } Auto { "-a" }
noteDelimiterEnter { "\n" } noteDelimiterEnter { "\n" }
//NoteContent { String } //NoteContent { String }

View File

@ -9,6 +9,7 @@ import { markdownLanguage } from "@codemirror/lang-markdown"
import { javaLanguage } from "@codemirror/lang-java" import { javaLanguage } from "@codemirror/lang-java"
import { lezerLanguage } from "@codemirror/lang-lezer" import { lezerLanguage } from "@codemirror/lang-lezer"
import { phpLanguage } from "@codemirror/lang-php" import { phpLanguage } from "@codemirror/lang-php"
import { elixirLanguage } from "codemirror-lang-elixir"
import { NoteContent, NoteLanguage } from "./parser.terms.js" import { NoteContent, NoteLanguage } from "./parser.terms.js"
import { LANGUAGES } from "../languages.js" import { LANGUAGES } from "../languages.js"
@ -22,7 +23,13 @@ export function configureNesting() {
if (id == NoteContent) { if (id == NoteContent) {
let noteLang = node.node.parent.firstChild.getChildren(NoteLanguage)[0] let noteLang = node.node.parent.firstChild.getChildren(NoteLanguage)[0]
let langName = input.read(noteLang?.from, noteLang?.to) let langName = input.read(noteLang?.from, noteLang?.to)
//console.log("langName:", langName)
// if the NoteContent is empty, we don't want to return a parser, since that seems to cause an
// error for StreamLanguage parsers when the buffer size is large (e.g >300 kb)
if (node.node.from == node.node.to) {
return null
}
if (langName in languageMapping && languageMapping[langName] !== null) { if (langName in languageMapping && languageMapping[langName] !== null) {
//console.log("found parser for language:", langName) //console.log("found parser for language:", langName)
return { return {

View File

@ -10,7 +10,7 @@ export const parser = LRParser.deserialize({
maxTerm: 10, maxTerm: 10,
skippedNodes: [0], skippedNodes: [0],
repeatNodeCount: 1, repeatNodeCount: 1,
tokenData: "-x~RbYZ!Z}!O!`#V#W!k#W#X$X#X#Y$k#Z#[%Z#[#]%|#^#_&`#_#`'|#`#a(f#a#b)O#d#e)}#f#g+i#g#h+x#h#i,b#l#m&S#m#n-a%&x%&y-g~!`OX~~!cP#T#U!f~!kOU~~!nR#`#a!w#d#e#l#g#h#r~!zP#c#d!}~#QP#^#_#T~#WP#i#j#Z~#^P#f#g#a~#dP#X#Y#g~#lOT~~#oP#d#e#g~#uQ#[#]#{#g#h#g~$OP#T#U$R~$UP#f#g#l~$[P#]#^$_~$bP#Y#Z$e~$hP#Y#Z#g~$nP#f#g$q~$tP#`#a$w~$zP#T#U$}~%QP#b#c%T~%WP#Z#[#g~%^Q#c#d$q#f#g%d~%gP#c#d%j~%mP#c#d%p~%sP#j#k%v~%yP#m#n#g~&PP#h#i&S~&VP#a#b&Y~&]P#`#a#g~&cQ#T#U&i#g#h'm~&lP#j#k&o~&rP#T#U&u~&zPT~#g#h&}~'QP#V#W'T~'WP#f#g'Z~'^P#]#^'a~'dP#d#e'g~'jP#h#i#g~'pQ#c#d'v#l#m#g~'yP#b#c#g~(PP#c#d(S~(VP#h#i(Y~(]P#`#a(`~(cP#]#^'v~(iP#X#Y(l~(oP#n#o(r~(uP#X#Y(x~({P#f#g#g~)RP#T#U)U~)XQ#f#g)_#h#i)w~)bP#_#`)e~)hP#W#X)k~)nP#c#d)q~)tP#k#l'v~)zP#[#]#g~*QR#[#]#l#c#d*Z#m#n+V~*^P#k#l*a~*dP#X#Y*g~*jP#f#g*m~*pP#g#h*s~*vP#[#]*y~*|P#X#Y+P~+SP#`#a&Y~+YP#h#i+]~+`P#[#]+c~+fP#c#d'v~+lP#i#j+o~+rQ#U#V%v#g#h'g~+{R#[#]*y#e#f&Y#k#l,U~,XP#]#^,[~,_P#Y#Z'g~,eS#X#Y,q#c#d&S#g#h,w#m#n,}~,tP#l#m'g~,zP#l#m#g~-QP#d#e-T~-WP#X#Y-Z~-^P#g#h&}~-dP#T#U&S~-jP%&x%&y-m~-pP%&x%&y-s~-xOY~", tokenData: "/W~RcYZ!^}!O!c#V#W!n#W#X$[#X#Y$}#Z#[&Y#[#]&{#^#_'_#_#`(u#`#a)_#a#b)q#d#e*p#f#g,[#g#h,k#h#i-j#j#k.i#l#m'R#m#n.o%&x%&y.u~!cOX~~!fP#T#U!i~!nOU~~!qR#`#a!z#d#e#o#g#h#u~!}P#c#d#Q~#TP#^#_#W~#ZP#i#j#^~#aP#f#g#d~#gP#X#Y#j~#oOT~~#rP#d#e#j~#xQ#[#]$O#g#h#j~$RP#T#U$U~$XP#f#g#o~$_Q#T#U$e#]#^$q~$hP#f#g$k~$nP#h#i#j~$tP#Y#Z$w~$zP#Y#Z#j~%QQ#`#a%W#f#g%p~%ZP#]#^%^~%aP#l#m%d~%gP#]#^%j~%mP#f#g#j~%sP#`#a%v~%yP#T#U%|~&PP#b#c&S~&VP#Z#[#j~&]Q#c#d%p#f#g&c~&fP#c#d&i~&lP#c#d&o~&rP#j#k&u~&xP#m#n#j~'OP#h#i'R~'UP#a#b'X~'[P#`#a#j~'bQ#T#U'h#g#h(f~'kP#j#k'n~'qP#T#U't~'yPT~#g#h'|~(PP#V#W(S~(VP#f#g(Y~(]P#]#^(`~(cP#d#e$k~(iQ#c#d(o#l#m#j~(rP#b#c#j~(xP#c#d({~)OP#h#i)R~)UP#`#a)X~)[P#]#^(o~)bP#X#Y)e~)hP#n#o)k~)nP#X#Y%j~)tP#T#U)w~)zQ#f#g*Q#h#i*j~*TP#_#`*W~*ZP#W#X*^~*aP#c#d*d~*gP#k#l(o~*mP#[#]#j~*sR#[#]#o#c#d*|#m#n+x~+PP#k#l+S~+VP#X#Y+Y~+]P#f#g+`~+cP#g#h+f~+iP#[#]+l~+oP#X#Y+r~+uP#`#a'X~+{P#h#i,O~,RP#[#],U~,XP#c#d(o~,_P#i#j,b~,eQ#U#V&u#g#h$k~,nS#V#W,z#[#]+l#e#f'X#k#l-^~,}P#T#U-Q~-TP#`#a-W~-ZP#T#U#j~-aP#]#^-d~-gP#Y#Z$k~-mS#X#Y-y#c#d'R#g#h.P#m#n.V~-|P#l#m$k~.SP#l#m#j~.YP#d#e.]~.`P#X#Y.c~.fP#g#h'|~.lP#i#j#d~.rP#T#U'R~.xP%&x%&y.{~/OP%&x%&y/R~/WOY~",
tokenizers: [0, noteContent], tokenizers: [0, noteContent],
topRules: {"Document":[0,2]}, topRules: {"Document":[0,2]},
tokenPrec: 0 tokenPrec: 0

View File

@ -1,5 +1,5 @@
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view"; import { EditorView, ViewPlugin } from "@codemirror/view";
import { redoDepth } from "@codemirror/commands"; import { redoDepth } from "@codemirror/commands";
import { getActiveNoteBlock, blockState } from "../block/block"; import { getActiveNoteBlock, blockState } from "../block/block";
import { levenshtein_distance } from "./levenshtein"; import { levenshtein_distance } from "./levenshtein";
@ -25,17 +25,18 @@ function cancelIdleCallbackCompat(id) {
} }
} }
export function languageDetection(getView) { // we'll use a shared global web worker for the language detection, for multiple Editor instances
const previousBlockContent = {} const editorInstances = {}
let idleCallbackId = null
const detectionWorker = new Worker('langdetect-worker.js?worker'); const detectionWorker = new Worker('langdetect-worker.js?worker');
detectionWorker.onmessage = (event) => { detectionWorker.onmessage = (event) => {
//console.log("event:", event.data) //console.log("event:", event.data)
if (!event.data.guesslang.language) { if (!event.data.guesslang.language) {
return return
} }
const view = getView()
const editor = editorInstances[event.data.path]
//const editor = getEditor()
const view = editor.view
const state = view.state const state = view.state
const block = getActiveNoteBlock(state) const block = getActiveNoteBlock(state)
const newLang = GUESSLANG_TO_TOKEN[event.data.guesslang.language] const newLang = GUESSLANG_TO_TOKEN[event.data.guesslang.language]
@ -57,7 +58,16 @@ export function languageDetection(getView) {
} }
} }
const plugin = EditorView.updateListener.of(update => { export function languageDetection(path, getEditor) {
const previousBlockContent = {}
let idleCallbackId = null
const editor = getEditor()
editorInstances[path] = editor
//const plugin = EditorView.updateListener.of(update => {
const plugin = ViewPlugin.fromClass(
class {
update(update) {
if (update.docChanged) { if (update.docChanged) {
if (idleCallbackId !== null) { if (idleCallbackId !== null) {
cancelIdleCallbackCompat(idleCallbackId) cancelIdleCallbackCompat(idleCallbackId)
@ -88,11 +98,12 @@ export function languageDetection(getView) {
const content = update.state.doc.sliceString(block.content.from, block.content.to) const content = update.state.doc.sliceString(block.content.from, block.content.to)
if (content === "" && redoDepth(update.state) === 0) { if (content === "" && redoDepth(update.state) === 0) {
// if content is cleared, set language to plaintext // if content is cleared, set language to default
const view = getView() //const editor = getEditor()
const view = editor.view
const block = getActiveNoteBlock(view.state) const block = getActiveNoteBlock(view.state)
if (block.language.name !== "text") { if (block.language.name !== editor.defaultBlockToken) {
changeLanguageTo(view.state, view.dispatch, block, "text", true) changeLanguageTo(view.state, view.dispatch, block, editor.defaultBlockToken, true)
} }
delete previousBlockContent[idx] delete previousBlockContent[idx]
} }
@ -106,12 +117,20 @@ export function languageDetection(getView) {
detectionWorker.postMessage({ detectionWorker.postMessage({
content: content, content: content,
idx: idx, idx: idx,
path: path,
}) })
previousBlockContent[idx] = content previousBlockContent[idx] = content
} }
}) })
} }
}) }
destroy() {
console.log("Removing editorInstance for:", path)
delete editorInstances[path]
}
}
)
return plugin return plugin
} }

View File

@ -12,6 +12,7 @@ import { cppLanguage } from "@codemirror/lang-cpp"
import { xmlLanguage } from "@codemirror/lang-xml" import { xmlLanguage } from "@codemirror/lang-xml"
import { rustLanguage } from "@codemirror/lang-rust" import { rustLanguage } from "@codemirror/lang-rust"
import { csharpLanguage } from "@replit/codemirror-lang-csharp" import { csharpLanguage } from "@replit/codemirror-lang-csharp"
import { vueLanguage } from "@codemirror/lang-vue";
import { StreamLanguage } from "@codemirror/language" import { StreamLanguage } from "@codemirror/language"
import { ruby } from "@codemirror/legacy-modes/mode/ruby" import { ruby } from "@codemirror/legacy-modes/mode/ruby"
@ -19,21 +20,22 @@ import { shell } from "@codemirror/legacy-modes/mode/shell"
import { yaml } from "@codemirror/legacy-modes/mode/yaml" import { yaml } from "@codemirror/legacy-modes/mode/yaml"
import { go } from "@codemirror/legacy-modes/mode/go" import { go } from "@codemirror/legacy-modes/mode/go"
import { clojure } from "@codemirror/legacy-modes/mode/clojure" import { clojure } from "@codemirror/legacy-modes/mode/clojure"
import { elixirLanguage } from "codemirror-lang-elixir"
import { erlang } from "@codemirror/legacy-modes/mode/erlang" import { erlang } from "@codemirror/legacy-modes/mode/erlang"
import { toml } from "@codemirror/legacy-modes/mode/toml" import { toml } from "@codemirror/legacy-modes/mode/toml"
import { swift } from "@codemirror/legacy-modes/mode/swift" import { swift } from "@codemirror/legacy-modes/mode/swift"
import { kotlin } from "@codemirror/legacy-modes/mode/clike" import { kotlin, dart, scala } from "@codemirror/legacy-modes/mode/clike"
import { groovy } from "@codemirror/legacy-modes/mode/groovy" import { groovy } from "@codemirror/legacy-modes/mode/groovy"
import { diff } from "@codemirror/legacy-modes/mode/diff"; import { diff } from "@codemirror/legacy-modes/mode/diff";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell"; import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import typescriptPlugin from "prettier/plugins/typescript.mjs" import typescriptPlugin from "prettier/plugins/typescript"
import babelPrettierPlugin from "prettier/plugins/babel.mjs" import babelPrettierPlugin from "prettier/plugins/babel"
import htmlPrettierPlugin from "prettier/esm/parser-html.mjs" import htmlPrettierPlugin from "prettier/plugins/html"
import cssPrettierPlugin from "prettier/esm/parser-postcss.mjs" import cssPrettierPlugin from "prettier/plugins/postcss"
import markdownPrettierPlugin from "prettier/esm/parser-markdown.mjs" import markdownPrettierPlugin from "prettier/plugins/markdown"
import yamlPrettierPlugin from "prettier/plugins/yaml.mjs" import yamlPrettierPlugin from "prettier/plugins/yaml"
import * as prettierPluginEstree from "prettier/plugins/estree.mjs"; import * as prettierPluginEstree from "prettier/plugins/estree";
class Language { class Language {
@ -118,7 +120,7 @@ export const LANGUAGES = [
new Language({ new Language({
token: "php", token: "php",
name: "PHP", name: "PHP",
parser: phpLanguage.parser, parser: phpLanguage.configure({top:"Program"}).parser,
guesslang: "php", guesslang: "php",
}), }),
new Language({ new Language({
@ -189,6 +191,12 @@ export const LANGUAGES = [
parser: StreamLanguage.define(clojure).parser, parser: StreamLanguage.define(clojure).parser,
guesslang: "clj", guesslang: "clj",
}), }),
new Language({
token: "elixir",
name: "Elixir",
parser: elixirLanguage.parser,
guesslang: "ex",
}),
new Language({ new Language({
token: "erlang", token: "erlang",
name: "Erlang", name: "Erlang",
@ -253,6 +261,24 @@ export const LANGUAGES = [
parser: StreamLanguage.define(powerShell).parser, parser: StreamLanguage.define(powerShell).parser,
guesslang: "ps1", guesslang: "ps1",
}), }),
new Language({
token: "vue",
name: "Vue",
parser: vueLanguage.parser,
guesslang: null,
}),
new Language({
token: "dart",
name: "Dart",
parser: StreamLanguage.define(dart).parser,
guesslang: "dart",
}),
new Language({
token: "scala",
name: "Scala",
parser: StreamLanguage.define(scala).parser,
guesslang: "scala",
}),
] ]

39
src/editor/mark-mode.js Normal file
View File

@ -0,0 +1,39 @@
import {
simplifySelection,
} from "@codemirror/commands"
/**
* Takes a command that moves the cursor and a command that marks the selection, and returns a new command that
* will run the mark command if we're in Emacs mark mode, or the move command otherwise.
*/
export function markModeMoveCommand(defaultCmd, markModeCmd) {
return (editor) => {
if (editor.selectionMarkMode) {
return (view) => {
markModeCmd(view)
// we need to return true here instead of returning what the default command returns, since the default
// codemirror select commands will return false if the selection doesn't change, which in turn will
// make the default *move* command run which will kill the selection if we're in mark mode
return true
}
} else {
return (view) => defaultCmd(view)
}
}
}
export function toggleSelectionMarkMode(editor) {
return (view) => {
editor.selectionMarkMode = !editor.selectionMarkMode
return true
}
}
export function selectionMarkModeCancel(editor) {
return (view) => {
simplifySelection(view)
editor.selectionMarkMode = false
return true
}
}

View File

@ -1,18 +1,22 @@
import { ViewPlugin } from "@codemirror/view" import { ViewPlugin } from "@codemirror/view"
import { debounce } from "debounce" import { debounce } from "debounce"
import { SET_CONTENT }  from "./annotation"
export const autoSaveContent = (saveFunction, interval) => { export const autoSaveContent = (editor, interval) => {
const save = debounce((view) => { const save = debounce(() => {
//console.log("saving buffer") //console.log("saving buffer")
saveFunction(view.state.sliceDoc()) editor.save()
}, interval); }, interval);
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class { class {
update(update) { update(update) {
if (update.docChanged) { if (update.docChanged) {
save(update.view) const initialSetContent = update.transactions.flatMap(t => t.annotations).some(a => a.value === SET_CONTENT)
if (!initialSetContent) {
save()
}
} }
} }
} }

View File

@ -68,11 +68,12 @@ const customSetup = /*@__PURE__*/(() => [
EditorView.lineWrapping, EditorView.lineWrapping,
scrollPastEnd(), scrollPastEnd(),
keymap.of([ keymap.of([
...closeBracketsKeymap, //...closeBracketsKeymap,
...defaultKeymap, //...defaultKeymap,
...searchKeymap, ...searchKeymap,
...historyKeymap, //...historyKeymap,
...foldKeymap, //...foldKeymap,
//...completionKeymap, //...completionKeymap,
//...lintKeymap //...lintKeymap
]) ])

View File

@ -1,11 +1,39 @@
import { EditorView } from "@codemirror/view" import { EditorView } from "@codemirror/view"
import { Facet } from "@codemirror/state"
/**
* Check if the given font family is monospace by drawing test characters on a canvas
*/
function isMonospace(fontFamily) {
const testCharacters = ['i', 'W', 'm', ' ']
const testSize = '72px'
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
context.font = `${testSize} ${fontFamily}`
const widths = testCharacters.map(char => context.measureText(char).width)
return widths.every(width => width === widths[0])
}
export const isMonospaceFont = Facet.define({
combine: values => values.length ? values[0] : true,
})
export function getFontTheme(fontFamily, fontSize) { export function getFontTheme(fontFamily, fontSize) {
fontSize = fontSize || window.heynote.defaultFontSize fontSize = fontSize || window.heynote.defaultFontSize
return EditorView.theme({ const computedFontFamily = fontFamily || window.heynote.defaultFontFamily
return [
EditorView.theme({
'.cm-scroller': { '.cm-scroller': {
fontFamily: fontFamily || window.heynote.defaultFontFamily, fontFamily: computedFontFamily,
fontSize: (fontSize) + "px", fontSize: (fontSize) + "px",
}, },
}) }),
// in order to avoid a short flicker when the program is loaded with the default font (Hack),
// we hardcode Hack to be monospace
isMonospaceFont.of(computedFontFamily === "Hack" ? true : isMonospace(computedFontFamily)),
]
} }

View File

@ -3,25 +3,45 @@ import { syntaxTree, ensureSyntaxTree } from "@codemirror/language"
import { WidgetType } from "@codemirror/view" import { WidgetType } from "@codemirror/view"
import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view" import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view"
import { isMonospaceFont } from "./theme/font-theme"
import { transactionsHasAnnotation, SET_FONT } from "./annotation"
class CheckboxWidget extends WidgetType { class CheckboxWidget extends WidgetType {
constructor(readonly checked: boolean) { super() } constructor(readonly checked: boolean, readonly monospace: boolean) { super() }
eq(other: CheckboxWidget) { return other.checked == this.checked } eq(other: CheckboxWidget) { return other.checked == this.checked && other.monospace == this.monospace }
toDOM() { toDOM() {
let wrap = document.createElement("span") let wrap = document.createElement("span")
wrap.setAttribute("aria-hidden", "true") wrap.setAttribute("aria-hidden", "true")
wrap.className = "cm-taskmarker-toggle" wrap.className = "cm-taskmarker-toggle"
wrap.style.position = "relative"
// Three spaces since it's the same width as [ ] and [x] let box = document.createElement("input")
wrap.appendChild(document.createTextNode(" "))
let box = wrap.appendChild(document.createElement("input"))
box.type = "checkbox" box.type = "checkbox"
box.checked = this.checked box.checked = this.checked
box.tabIndex = -1
box.style.margin = "0"
box.style.padding = "0"
if (this.monospace) {
// if the font is monospaced, we'll set the content of the wrapper to " " and the
// position of the checkbox to absolute, since three spaces will be the same width
// as "[ ]" and "[x]" so that characters on different lines will line up
wrap.appendChild(document.createTextNode(" "))
wrap.style.position = "relative"
box.style.position = "absolute" box.style.position = "absolute"
box.style.top = "-3px" box.style.top = "0"
box.style.left = "0" box.style.left = "0.25em"
box.style.width = "1.1em"
box.style.height = "1.1em"
} else {
// if the font isn't monospaced, we'll let the checkbox take up as much space as needed
box.style.position = "relative"
box.style.top = "0.1em"
box.style.marginRight = "0.5em"
}
wrap.appendChild(box)
return wrap return wrap
} }
@ -52,7 +72,7 @@ function checkboxes(view: EditorView) {
if (view.state.doc.sliceString(nodeRef.to, nodeRef.to+1) === " ") { if (view.state.doc.sliceString(nodeRef.to, nodeRef.to+1) === " ") {
let isChecked = view.state.doc.sliceString(nodeRef.from, nodeRef.to).toLowerCase() === "[x]" let isChecked = view.state.doc.sliceString(nodeRef.from, nodeRef.to).toLowerCase() === "[x]"
let deco = Decoration.replace({ let deco = Decoration.replace({
widget: new CheckboxWidget(isChecked), widget: new CheckboxWidget(isChecked, view.state.facet(isMonospaceFont)),
inclusive: false, inclusive: false,
}) })
widgets.push(deco.range(nodeRef.from, nodeRef.to)) widgets.push(deco.range(nodeRef.from, nodeRef.to))
@ -92,9 +112,10 @@ export const todoCheckboxPlugin = [
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) if (update.docChanged || update.viewportChanged || transactionsHasAnnotation(update.transactions, SET_FONT)) {
this.decorations = checkboxes(update.view) this.decorations = checkboxes(update.view)
} }
}
}, { }, {
decorations: v => v.decorations, decorations: v => v.decorations,

View File

@ -1,17 +1,33 @@
import './css/application.sass' import './css/application.sass'
import '../assets/font/open-sans/open-sans.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config';
import App from './components/App.vue' import App from './components/App.vue'
import { loadCurrencies } from './currency' import { loadCurrencies } from './currency'
import { useErrorStore } from './stores/error-store'
import { useHeynoteStore, initHeynoteStore } from './stores/heynote-store'
const pinia = createPinia()
const app = createApp(App) const app = createApp(App)
app.use(pinia)
app.use(PrimeVue)
app.mount('#app').$nextTick(() => { app.mount('#app').$nextTick(() => {
// hide loading screen // hide loading screen
postMessage({ payload: 'removeLoading' }, '*') postMessage({ payload: 'removeLoading' }, '*')
}) })
const errorStore = useErrorStore()
//errorStore.addError("test error")
window.heynote.getInitErrors().then((errors) => {
errors.forEach((e) => errorStore.addError(e))
})
initHeynoteStore()
@ -19,3 +35,4 @@ app.mount('#app').$nextTick(() => {
loadCurrencies() loadCurrencies()
setInterval(loadCurrencies, 1000 * 3600 * 4) setInterval(loadCurrencies, 1000 * 3600 * 4)
window.heynote.init()

198
src/stores/editor-cache.js Normal file
View File

@ -0,0 +1,198 @@
import { toRaw, watch } from 'vue';
import { defineStore } from "pinia"
import { NoteFormat } from "../common/note-format"
import { useSettingsStore } from './settings-store'
import { useErrorStore } from './error-store'
import { useHeynoteStore } from './heynote-store'
import { HeynoteEditor } from '../editor/editor'
const NUM_EDITOR_INSTANCES = 5
export const useEditorCacheStore = defineStore("editorCache", {
state: () => ({
editorCache: {
lru: [],
cache: {},
watchHandler: null,
themeWatchHandler: null,
containerElement: null,
},
}),
actions: {
createEditor(path) {
const settingsStore = useSettingsStore()
const errorStore = useErrorStore()
try {
return new HeynoteEditor({
element: this.containerElement,
path: path,
theme: settingsStore.theme,
keymap: settingsStore.settings.keymap,
emacsMetaKey: settingsStore.settings.emacsMetaKey,
showLineNumberGutter: settingsStore.settings.showLineNumberGutter,
showFoldGutter: settingsStore.settings.showFoldGutter,
bracketClosing: settingsStore.settings.bracketClosing,
fontFamily: settingsStore.settings.fontFamily,
fontSize: settingsStore.settings.fontSize,
tabSize: settingsStore.settings.tabSize,
defaultBlockToken: settingsStore.settings.defaultBlockLanguage,
defaultBlockAutoDetect: settingsStore.settings.defaultBlockLanguageAutoDetect,
keyBindings: settingsStore.settings.keyBindings,
})
} catch (e) {
errorStore.addError("Error! " + e.message)
throw e
}
},
getOrCreateEditor(path, updateLru) {
if (updateLru) {
// 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]
} else {
const editor = this.createEditor(path)
this.addEditor(path, editor)
if (!updateLru) {
// if need to add the editor to the LRU, but at the top so that it is the first to be removed
this.editorCache.lru.unshift(path)
}
return editor
}
},
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 = []
},
onCurrenciesLoaded() {
this.eachEditor((editor) => {
editor.currenciesLoaded()
})
},
setUp(containerElement) {
this.containerElement = containerElement
const settingsStore = useSettingsStore()
this.watchHandler = watch(() => settingsStore.settings, (newSettings, oldSettings) => {
//console.log("Settings changed (watch)", newSettings, oldSettings)
const changedKeys = Object.keys(newSettings).filter(key => newSettings[key] !== oldSettings[key])
for (const key of changedKeys) {
this.eachEditor((editor) => {
switch (key) {
case "keymap":
case "emacsMetaKey":
case "keyBindings":
editor.setKeymap(newSettings.keymap, newSettings.emacsMetaKey, newSettings.keyBindings)
break
case "showLineNumberGutter":
editor.setLineNumberGutter(newSettings.showLineNumberGutter)
break
case "showFoldGutter":
editor.setFoldGutter(newSettings.showFoldGutter)
break
case "bracketClosing":
editor.setBracketClosing(newSettings.bracketClosing)
break
case "fontFamily":
case "fontSize":
editor.setFont(newSettings.fontFamily, newSettings.fontSize)
break
case "tabSize":
editor.setTabSize(newSettings.tabSize)
break
case "defaultBlockLanguage":
case "defaultBlockLanguageAutoDetect":
editor.setDefaultBlockLanguage(newSettings.defaultBlockLanguage, newSettings.defaultBlockLanguageAutoDetect)
break
}
})
}
})
this.themeWatchHandler = watch(() => settingsStore.theme, (theme) => {
this.eachEditor((editor) => {
editor.setTheme(theme)
})
})
window.document.addEventListener("currenciesLoaded", this.onCurrenciesLoaded)
},
tearDown() {
if (this.watchHandler) {
this.watchHandler()
}
if (this.themeWatchHandler) {
this.themeWatchHandler()
}
window.document.removeEventListener("currenciesLoaded", this.onCurrenciesLoaded)
this.editorCache.lru = []
this.editorCache.cache = {}
},
moveCurrentBlockToOtherEditor(targetPath) {
const heynoteStore = useHeynoteStore()
const editor = toRaw(this.getEditor(heynoteStore.currentBufferPath))
let otherEditor = toRaw(this.getOrCreateEditor(targetPath, false))
otherEditor.hide()
const content = editor.getActiveBlockContent()
otherEditor.contentLoadedPromise.then(() => {
otherEditor.appendBlockContent(content)
editor.deleteActiveBlock()
// add the target buffer to recent buffers so that it shows up at the top of the buffer selector
heynoteStore.addRecentBuffer(targetPath)
heynoteStore.addRecentBuffer(heynoteStore.currentBufferPath)
})
//console.log("LRU", this.editorCache.lru)
}
},
})

21
src/stores/error-store.js Normal file
View File

@ -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)
},
},
})

197
src/stores/heynote-store.js Normal file
View File

@ -0,0 +1,197 @@
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,
showMoveToBufferSelector: false,
showCommandPalette: false,
}),
actions: {
async updateBuffers() {
this.setBuffers(await window.heynote.buffer.getList())
},
setBuffers(buffers) {
this.buffers = buffers
},
openBuffer(path) {
this.closeDialog()
this.currentBufferPath = path
this.addRecentBuffer(path)
},
addRecentBuffer(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
},
openCommandPalette() {
this.closeDialog()
this.showCommandPalette = true
},
openMoveToBufferSelector() {
this.closeDialog()
this.showMoveToBufferSelector = 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
this.showMoveToBufferSelector = false
this.showCommandPalette = false
},
closeBufferSelector() {
this.showBufferSelector = false
this.showCommandPalette = false
},
closeMoveToBufferSelector() {
this.showMoveToBufferSelector = false
},
editBufferMetadata(path) {
if (this.currentBufferPath !== path) {
this.openBuffer(path)
}
this.closeDialog()
this.showEditBuffer = true
},
executeCommand(command) {
if (this.currentEditor) {
toRaw(this.currentEditor).executeCommand(command)
}
},
/**
* 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()
}

View File

@ -0,0 +1,49 @@
import { defineStore } from "pinia"
import { SETTINGS_CHANGE_EVENT } from '@/src/common/constants'
export const useSettingsStore = defineStore("settings", {
state: () => {
return {
settings: window.heynote.settings,
themeSetting: "system",
theme: window.heynote.themeMode.initial,
}
},
actions: {
onSettingsChange(settings) {
this.settings = settings
},
setTheme(theme) {
window.heynote.themeMode.set(theme)
this.themeSetting = theme
},
setUp() {
window.heynote.mainProcess.on(SETTINGS_CHANGE_EVENT, (event, settings) => {
this.onSettingsChange(settings)
})
window.heynote.themeMode.get().then((mode) => {
this.theme = mode.computed
this.themeSetting = mode.theme
})
const onThemeChange = (theme) => {
this.theme = theme
if (theme === "system") {
document.documentElement.setAttribute("theme", window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
} else {
document.documentElement.setAttribute("theme", theme)
}
}
onThemeChange(window.heynote.themeMode.initial)
window.heynote.themeMode.onChange(onThemeChange)
},
tearDown() {
window.heynote.themeMode.removeListener()
}
},
})

View File

@ -8,7 +8,7 @@ test.beforeEach(async ({page}) => {
await heynotePage.goto() await heynotePage.goto()
expect((await heynotePage.getBlocks()).length).toBe(1) expect((await heynotePage.getBlocks()).length).toBe(1)
heynotePage.setContent(` await heynotePage.setContent(`
text text
Block A Block A
text text
@ -114,3 +114,32 @@ const runTest = async (page, key, expectedBlocks) => {
await expect(await page.locator("css=.heynote-block-start.first")).toHaveCount(1) await expect(await page.locator("css=.heynote-block-start.first")).toHaveCount(1)
} }
test("test custom default block language", async ({ page, browserName }) => {
heynotePage.setContent(`
text
Text block`)
await page.locator("css=.status-block.settings").click()
await page.locator("css=li.tab-editing").click()
await page.locator("css=select.block-language").selectOption("Rust")
await page.locator("body").press("Escape")
await page.locator("body").press((heynotePage.isMac ? "Meta" : "Control") + "+Enter")
expect(await heynotePage.getContent()).toBe(`
text
Text block
rust-a
`)
await page.locator("css=.status-block.settings").click()
await page.locator("css=li.tab-editing").click()
await page.locator("css=input.language-auto-detect").click()
await page.locator("body").press("Escape")
await page.locator("body").press((heynotePage.isMac ? "Meta" : "Control") + "+Enter")
expect(await heynotePage.getContent()).toBe(`
text
Text block
rust-a
rust
`)
})

View File

@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test"
import { EditorState } from "@codemirror/state"
import { heynoteLang } from "../src/editor/lang-heynote/heynote.js"
import { getBlocksFromSyntaxTree, getBlocksFromString } from "../src/editor/block/block-parsing.js"
test("parse blocks from both syntax tree and string contents", async ({page}) => {
const contents = `
text
Text Block A
text-a
Text Block B
json-a
{
"key": "value"
}
python
print("Hello, World!")
`
const state = EditorState.create({
doc: contents,
extensions: heynoteLang(),
})
const treeBlocks = getBlocksFromSyntaxTree(state)
const stringBlocks = getBlocksFromString(state)
expect(treeBlocks).toEqual(stringBlocks)
})

View File

@ -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"
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").press("ArrowUp")
await page.locator("body").press("Enter")
await page.waitForTimeout(50)
await page.locator("body").pressSequentially("My New Buffer")
await page.locator("body").press("Enter")
await page.waitForTimeout(150)
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`)
})
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
`)
})

View File

@ -22,7 +22,7 @@ test("test default font is Hack", async ({ page }) => {
})).toBeLessThan(20) })).toBeLessThan(20)
}) })
test("test custom font", async ({ page, browserName }) => { test("test custom font", async ({ page }) => {
// monkey patch window.queryLocalFonts because it's not available in Playwright // monkey patch window.queryLocalFonts because it's not available in Playwright
await page.evaluate(() => { await page.evaluate(() => {
window.queryLocalFonts = async () => { window.queryLocalFonts = async () => {
@ -64,3 +64,55 @@ test("test custom font", async ({ page, browserName }) => {
return el.clientHeight return el.clientHeight
})).toBeGreaterThan(20) })).toBeGreaterThan(20)
}) })
test("markdown todo checkbox position with monospaced font", async ({ page }) => {
await heynotePage.setContent(`
markdown
- [ ] Test
- [x] Test 2
`)
await page.locator("css=.cm-taskmarker-toggle input[type=checkbox]").first().waitFor()
await expect(await page.locator("css=.cm-taskmarker-toggle input[type=checkbox]")).toHaveCount(2)
await expect(await page.locator("css=.cm-taskmarker-toggle input[type=checkbox]").first()).toHaveCSS("position", "absolute")
})
test("markdown todo checkbox position with variable width font", async ({ page }) => {
await page.evaluate(() => {
window.queryLocalFonts = async () => {
return [
{
family: "Arial",
style: "Regular",
},
{
family: "Hack",
fullName: "Hack Regular",
style: "Regular",
postscriptName: "Hack-Regular",
},
{
family: "Hack",
fullName: "Hack Italic",
style: "Italic",
postscriptName: "Hack-Italic",
},
]
}
})
await page.locator("css=.status-block.settings").click()
await page.locator("css=li.tab-appearance").click()
await page.locator("css=select.font-family").selectOption("Arial")
await page.locator("css=select.font-size").selectOption("20")
await page.locator("body").press("Escape")
await heynotePage.setContent(`
markdown
- [ ] Test
- [x] Test 2
`)
await page.locator("css=.cm-taskmarker-toggle input[type=checkbox]").first().waitFor()
await expect(await page.locator("css=.cm-taskmarker-toggle input[type=checkbox]")).toHaveCount(2)
await expect(await page.locator("css=.cm-taskmarker-toggle input[type=checkbox]").first()).toHaveCSS("position", "relative")
})

View File

@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import { HeynotePage } from "./test-utils.js";
let heynotePage
test.beforeEach(async ({page}) => {
heynotePage = new HeynotePage(page)
await heynotePage.goto()
await heynotePage.setContent(`
text
`)
})
test("add custom key binding", async ({page}) => {
await page.locator("css=.status-block.settings").click()
await page.locator("css=.overlay .settings .dialog .sidebar li.tab-keyboard-bindings").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings")).toBeVisible()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-keybinding").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog")).toBeVisible()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog input.keys")).toBeFocused()
await page.locator("body").press("Control+Shift+H")
await page.locator("body").press("Enter")
await page.locator("body").pressSequentially("language")
await page.locator(".p-autocomplete-list li.p-autocomplete-option.p-focus").click()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .save").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings table tr.keybind-user")).toHaveCount(1)
expect((await heynotePage.getSettings()).keyBindings).toEqual([{key:"Control-Shift-h", command:"openLanguageSelector"}])
await page.locator("css=.overlay .settings .dialog .bottom-bar .close").click()
await page.locator("body").press("Control+Shift+H")
await expect(page.locator("css=.language-selector .items > li.selected")).toBeVisible()
})
test("delete custom key binding", async ({page}) => {
await page.locator("css=.status-block.settings").click()
await page.locator("css=.overlay .settings .dialog .sidebar li.tab-keyboard-bindings").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings")).toBeVisible()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-keybinding").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog")).toBeVisible()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog input.keys")).toBeFocused()
await page.locator("body").press("Control+Shift+H")
await page.locator("body").press("Enter")
await page.locator("body").pressSequentially("language")
await page.locator(".p-autocomplete-list li.p-autocomplete-option.p-focus").click()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings .add-key-binding-dialog .save").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings table tr.keybind-user")).toHaveCount(1)
expect((await heynotePage.getSettings()).keyBindings).toEqual([{key:"Control-Shift-h", command:"openLanguageSelector"}])
await page.locator("css=.overlay .settings .dialog .bottom-bar .close").click()
await page.locator("body").press("Control+Shift+H")
await expect(page.locator("css=.language-selector .items > li.selected")).toBeVisible()
await page.locator("css=.status-block.settings").click()
await page.locator("css=.overlay .settings .dialog .sidebar li.tab-keyboard-bindings").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings")).toBeVisible()
await page.locator("css=.settings .tab-content.tab-keyboard-bindings table tr.keybind-user .delete").click()
await expect(page.locator("css=.settings .tab-content.tab-keyboard-bindings table tr.keybind-user")).toHaveCount(0)
await page.locator("css=.overlay .settings .dialog .bottom-bar .close").click()
await page.locator("body").press("Control+Shift+H")
await expect(page.locator("css=.language-selector .items > li.selected")).toHaveCount(0)
})
test("disable default key binding", async ({page}) => {
const langKey = heynotePage.isMac ? "Meta+L" : "Control+L"
await page.locator("body").press(langKey)
await expect(page.locator("css=.language-selector .items > li.selected")).toBeVisible()
await page.locator("body").press("Escape")
await expect(page.locator("css=.language-selector .items > li.selected")).toHaveCount(0)
const settings = await heynotePage.getSettings()
settings.keyBindings = [{key:"Mod-L", command:"nothing"}]
await heynotePage.setSettings(settings)
await page.locator("body").press(langKey)
await expect(page.locator("css=.language-selector .items > li.selected")).toHaveCount(0)
})

View File

@ -0,0 +1,44 @@
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"
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
markdown
Block B
text
Block C`)
await page.waitForTimeout(100)
})
test("delete first block", async ({page}) => {
await heynotePage.setCursorPosition(10)
await page.locator("body").press(heynotePage.agnosticKey("Mod+Shift+D"))
await page.waitForTimeout(50)
expect(await heynotePage.getCursorPosition()).toBe(13)
})
test("delete middle block", async ({page}) => {
await heynotePage.setCursorPosition(32)
await page.locator("body").press(heynotePage.agnosticKey("Mod+Shift+D"))
await page.waitForTimeout(50)
expect(await heynotePage.getCursorPosition()).toBe(25)
})
test("delete last block", async ({page}) => {
await heynotePage.setCursorPosition(52)
await page.locator("body").press(heynotePage.agnosticKey("Mod+Shift+D"))
await page.waitForTimeout(50)
expect(await heynotePage.getCursorPosition()).toBe(36)
})

View File

@ -13,6 +13,7 @@ test.beforeEach(async ({ page, browserName }) => {
test.skip() test.skip()
} }
await page.locator("css=.status-block.settings").click() await page.locator("css=.status-block.settings").click()
await page.locator("css=li.tab-keyboard-bindings").click()
//await page.locator("css=li.tab-editing").click() //await page.locator("css=li.tab-editing").click()
await page.locator("css=select.keymap").selectOption("emacs") await page.locator("css=select.keymap").selectOption("emacs")
if (heynotePage.isMac) { if (heynotePage.isMac) {

View File

@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => {
test("JSON formatting", async ({ page }) => { test("JSON formatting", async ({ page }) => {
heynotePage.setContent(` await heynotePage.setContent(`
json json
{"test": 1, "key2": "hey!"} {"test": 1, "key2": "hey!"}
`) `)
@ -25,7 +25,7 @@ test("JSON formatting", async ({ page }) => {
}) })
test("JSON formatting (cursor at start)", async ({ page }) => { test("JSON formatting (cursor at start)", async ({ page }) => {
heynotePage.setContent(` await heynotePage.setContent(`
json json
{"test": 1, "key2": "hey!"} {"test": 1, "key2": "hey!"}
`) `)

View File

@ -0,0 +1,16 @@
import { test, expect } from "@playwright/test";
import { HeynotePage } from "./test-utils.js";
let heynotePage
test.beforeEach(async ({ page }) => {
heynotePage = new HeynotePage(page)
await heynotePage.goto()
})
test("test language selector search by file ending", async ({ page }) => {
await page.locator("body").press(heynotePage.agnosticKey("Mod+L"))
await page.locator("body").pressSequentially("cpp")
await expect(page.locator("css=.language-selector .items > li.selected")).toHaveText("C++")
})

View File

@ -0,0 +1,125 @@
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"
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)
// create secondary buffer
await heynotePage.saveBuffer("other.txt", `
text-a
First block
math
Second block`)
});
test("move block to other buffer", async ({page}) => {
await page.locator("body").press(heynotePage.agnosticKey("Mod+S"))
await page.waitForTimeout(50)
await page.locator("body").press("Enter")
await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50);
const buffers = Object.keys(await heynotePage.getStoredBufferList())
expect(buffers).toContain("other.txt")
const otherBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("other.txt"))
expect(await heynotePage.getContent()).toBe(`
text
Block A
text
Block B`)
expect(otherBuffer.content).toBe(`
text-a
First block
math
Second block
text
Block C`)
})
test("move block to other open/cached buffer", async ({page}) => {
await page.locator("body").press(heynotePage.agnosticKey("Mod+P"))
await page.locator("body").press("Enter")
await page.waitForTimeout(50)
await page.locator("body").press(heynotePage.agnosticKey("Mod+P"))
await page.locator("body").press("Enter")
await page.waitForTimeout(50)
await page.locator("body").press(heynotePage.agnosticKey("Mod+S"))
await page.waitForTimeout(50)
await page.locator("body").press("Enter")
await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50);
const buffers = Object.keys(await heynotePage.getStoredBufferList())
expect(buffers).toContain("other.txt")
const otherBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("other.txt"))
expect(await heynotePage.getContent()).toBe(`
text
Block A
text
Block B`)
expect(otherBuffer.content).toBe(`
text-a
First block
math
Second block
text
Block C`)
})
test("cursor position after moving first block", async ({page}) => {
await heynotePage.setCursorPosition(10)
expect(await heynotePage.getCursorPosition()).toBe(10)
await page.locator("body").press(heynotePage.agnosticKey("Mod+S"))
await page.waitForTimeout(50)
await page.locator("body").press("Enter")
await page.waitForTimeout(50)
expect(await heynotePage.getCursorPosition()).toBe(9)
})
test("cursor position after moving middle block", async ({page}) => {
await heynotePage.setCursorPosition(28)
await page.locator("body").press(heynotePage.agnosticKey("Mod+S"))
await page.waitForTimeout(50)
await page.locator("body").press("Enter")
await page.waitForTimeout(50)
expect(await heynotePage.getCursorPosition()).toBe(25)
})
test("cursor position after moving last block", async ({page}) => {
await heynotePage.setCursorPosition(48)
await page.locator("body").press(heynotePage.agnosticKey("Mod+S"))
await page.waitForTimeout(50)
await page.locator("body").press("Enter")
await page.waitForTimeout(50)
expect(await heynotePage.getCursorPosition()).toBe(32)
})

View File

@ -15,7 +15,6 @@ Block A
Block B Block B
text text
Block C`) Block C`)
// check that blocks are created // check that blocks are created
expect((await heynotePage.getBlocks()).length).toBe(3) expect((await heynotePage.getBlocks()).length).toBe(3)

62
tests/note-format.spec.js Normal file
View File

@ -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])
})

View File

@ -0,0 +1,23 @@
import { test, expect } from "@playwright/test";
import { HeynotePage } from "./test-utils.js";
let heynotePage
test.beforeEach(async ({ page }) => {
heynotePage = new HeynotePage(page)
await heynotePage.goto()
});
test("test default tab size", async ({ page }) => {
await page.locator("body").press("Tab")
expect(await heynotePage.getBlockContent(0)).toBe(" ")
})
test("test custom tab size", async ({ page }) => {
await page.locator("css=.status-block.settings").click()
await page.locator("css=li.tab-editing").click()
await page.locator("css=select.tab-size").selectOption("2")
await page.locator("body").press("Escape")
await page.locator("body").press("Tab")
expect(await heynotePage.getBlockContent(0)).toBe(" ")
})

View File

@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { NoteFormat } from '../src/common/note-format.js';
export function pageErrorGetter(page) { export function pageErrorGetter(page) {
let messages = []; let messages = [];
@ -19,6 +20,7 @@ export class HeynotePage {
async goto() { async goto() {
await this.page.goto("/") await this.page.goto("/")
await expect(this.page).toHaveTitle(/Heynote/) await expect(this.page).toHaveTitle(/Heynote/)
await expect(this.page.locator(".cm-editor")).toBeVisible()
expect(this.getErrors()).toStrictEqual([]) expect(this.getErrors()).toStrictEqual([])
} }
@ -26,19 +28,31 @@ export class HeynotePage {
return await this.page.evaluate(() => window._heynote_editor.getBlocks()) return await this.page.evaluate(() => window._heynote_editor.getBlocks())
} }
async getContent() { async getBufferData() {
return await this.page.evaluate(() => window._heynote_editor.getContent()) return await this.page.evaluate(() => window._heynote_editor.getContent())
} }
async getContent() {
const note = NoteFormat.load(await this.getBufferData())
return note.content
}
async setContent(content) { async setContent(content) {
await expect(this.page.locator("css=.cm-editor")).toBeVisible() await expect(this.page.locator("css=.cm-editor")).toBeVisible()
await this.page.evaluate((content) => window._heynote_editor.setContent(content), content) await this.page.evaluate(async (content) => {
await window._heynote_editor.setContent(content)
await window._heynote_editor.save()
}, content)
} }
async getCursorPosition() { async getCursorPosition() {
return await this.page.evaluate(() => window._heynote_editor.getCursorPosition()) return await this.page.evaluate(() => window._heynote_editor.getCursorPosition())
} }
async setCursorPosition(position) {
await this.page.evaluate((position) => window._heynote_editor.setCursorPosition(position), position)
}
async getBlockContent(blockIndex) { async getBlockContent(blockIndex) {
const blocks = await this.getBlocks() const blocks = await this.getBlocks()
const content = await this.getContent() const content = await this.getContent()
@ -50,4 +64,32 @@ export class HeynotePage {
async getStoredSettings() { async getStoredSettings() {
return await this.page.evaluate(() => JSON.parse(window.localStorage.getItem("settings"))) 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)
}
async saveBuffer(path, content) {
const format = new NoteFormat()
format.content = content
await this.page.evaluate(({path, content}) => window.heynote.buffer.save(path, content), {path, content:format.serialize()})
}
async getSettings() {
return await this.page.evaluate(() => {
return JSON.parse(window.localStorage.getItem("settings") || "{}")
})
}
async setSettings(settings) {
await this.page.evaluate((settings) => window.heynote.setSettings(settings), settings)
}
agnosticKey(key) {
return key.replace("Mod", this.isMac ? "Meta" : "Control")
}
} }

View File

@ -12,7 +12,10 @@
"lib": ["ESNext", "DOM"], "lib": ["ESNext", "DOM"],
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"allowJs": true "allowJs": true,
"paths": {
"@/*": ["./*"],
}
}, },
"include": ["src"," shared-utils"], "include": ["src"," shared-utils"],
"references": [ "references": [

View File

@ -17,10 +17,8 @@ rmSync('dist-electron', { recursive: true, force: true })
const isDevelopment = process.env.NODE_ENV === "development" || !!process.env.VSCODE_DEBUG const isDevelopment = process.env.NODE_ENV === "development" || !!process.env.VSCODE_DEBUG
const isProduction = process.env.NODE_ENV === "production" const isProduction = process.env.NODE_ENV === "production"
const updateReadmeKeybinds = async () => { const injectKeybindsInDocs = async () => {
const readmePath = path.resolve(__dirname, 'README.md') const keybindsRegex = /^(<!-- keyboard_shortcuts -->\s*).*?^(```\s+#)/gms
let readme = fs.readFileSync(readmePath, 'utf-8')
const keybindsRegex = /^(### What are the default keyboard shortcuts\?\s*).*?^(```\s+#)/gms
const shortcuts = `$1**On Mac** const shortcuts = `$1**On Mac**
\`\`\` \`\`\`
@ -32,8 +30,10 @@ ${keyHelpStr('darwin')}
\`\`\` \`\`\`
${keyHelpStr('win32')} ${keyHelpStr('win32')}
$2` $2`
readme = readme.replace(keybindsRegex, shortcuts) const docsPath = path.resolve(__dirname, 'docs', 'index.md')
fs.writeFileSync(readmePath, readme) let docs = fs.readFileSync(docsPath, 'utf-8')
docs = docs.replace(keybindsRegex, shortcuts)
fs.writeFileSync(docsPath, docs)
} }
const updateGuesslangLanguagesInWebWorker = async () => { const updateGuesslangLanguagesInWebWorker = async () => {
@ -46,17 +46,19 @@ const updateGuesslangLanguagesInWebWorker = async () => {
} }
// https://vitejs.dev/config/ const resolveConfig = {
export default defineConfig({
resolve: {
alias: { alias: {
'@': path.resolve(__dirname), '@': path.resolve(__dirname),
}, },
}, }
// https://vitejs.dev/config/
export default defineConfig({
resolve: resolveConfig,
plugins: [ plugins: [
vue(), vue(),
updateReadmeKeybinds(), injectKeybindsInDocs(),
updateGuesslangLanguagesInWebWorker(), updateGuesslangLanguagesInWebWorker(),
electron([ electron([
{ {
@ -78,10 +80,11 @@ export default defineConfig({
external: Object.keys("dependencies" in pkg ? pkg.dependencies : {}), external: Object.keys("dependencies" in pkg ? pkg.dependencies : {}),
}, },
}, },
resolve: resolveConfig,
}, },
}, },
{ {
entry: 'electron/preload/index.ts', entry: 'electron/preload/index.js',
onstart(options) { onstart(options) {
// Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete,
// instead of restarting the entire Electron App. // instead of restarting the entire Electron App.
@ -96,6 +99,7 @@ export default defineConfig({
external: Object.keys("dependencies" in pkg ? pkg.dependencies : {}), external: Object.keys("dependencies" in pkg ? pkg.dependencies : {}),
}, },
}, },
resolve: resolveConfig,
}, },
}, },
{ {
@ -132,8 +136,9 @@ export default defineConfig({
css: { css: {
preprocessorOptions: { preprocessorOptions: {
sass: { sass: {
api: "modern-compiler",
additionalData: ` additionalData: `
@import "./src/css/include.sass" @use "@/src/css/include.sass" as *
` `
} }
} }

View File

@ -1,4 +1,8 @@
import { SETTINGS_CHANGE_EVENT, OPEN_SETTINGS_EVENT } from "../electron/constants"; import { Exception } from "sass";
import { SETTINGS_CHANGE_EVENT, OPEN_SETTINGS_EVENT } from "@/src/common/constants";
import { NoteFormat } from "../src/common/note-format";
const NOTE_KEY_PREFIX = "heynote-library__"
const mediaMatch = window.matchMedia('(prefers-color-scheme: dark)') const mediaMatch = window.matchMedia('(prefers-color-scheme: dark)')
let themeCallback = null let themeCallback = null
@ -14,6 +18,16 @@ let autoUpdateCallbacks = null
let currencyData = null let currencyData = null
let platform let platform
// In the latest version of Playwright, the window.navigator.userAgentData.platform is not reported correctly on Mac,
// wo we'll fallback to deprecated window.navigator.platform which still works
if (__TESTS__ && window.navigator.platform.indexOf("Mac") !== -1) {
platform = {
isMac: true,
isWindows: false,
isLinux: false,
}
} else {
const uaPlatform = window.navigator?.userAgentData?.platform || window.navigator.platform const uaPlatform = window.navigator?.userAgentData?.platform || window.navigator.platform
if (uaPlatform.indexOf("Win") !== -1) { if (uaPlatform.indexOf("Win") !== -1) {
platform = { platform = {
@ -34,6 +48,7 @@ if (uaPlatform.indexOf("Win") !== -1) {
isLinux: false, isLinux: false,
} }
} }
}
platform.isWebApp = true platform.isWebApp = true
@ -49,6 +64,12 @@ class IpcRenderer {
this.callbacks[event].push(callback) this.callbacks[event].push(callback)
} }
off(event, callback) {
if (this.callbacks[event]) {
this.callbacks[event] = this.callbacks[event].filter(cb => cb !== callback)
}
}
send(event, ...args) { send(event, ...args) {
if (this.callbacks[event]) { if (this.callbacks[event]) {
for (const callback of this.callbacks[event]) { for (const callback of this.callbacks[event]) {
@ -68,11 +89,55 @@ let initialSettings = {
showLineNumberGutter: true, showLineNumberGutter: true,
showFoldGutter: true, showFoldGutter: true,
bracketClosing: false, bracketClosing: false,
keyBindings: [],
} }
if (settingsData !== null) { if (settingsData !== null) {
initialSettings = Object.assign(initialSettings, JSON.parse(settingsData)) 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 = { const Heynote = {
platform: platform, platform: platform,
@ -80,38 +145,99 @@ const Heynote = {
defaultFontSize: isMobileDevice ? 16 : 12, defaultFontSize: isMobileDevice ? 16 : 12,
buffer: { buffer: {
async load() { async load(path) {
const content = localStorage.getItem("buffer") //console.log("loading", path)
return content === null ? "\n∞∞∞text-a\n" : content const content = localStorage.getItem(noteKey(path))
return content === null ? '{"formatVersion":"1.0.0","name":"Scratch"}\n∞∞∞text-a\n' : content
}, },
async save(content) { async save(path, content) {
localStorage.setItem("buffer", content) //console.log("saving", path, content)
localStorage.setItem(noteKey(path), content)
}, },
async saveAndQuit(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) {
}, },
onChangeCallback(callback) { async exists(path) {
return localStorage.getItem(noteKey(path)) !== null
},
}, },
onWindowClose(callback) { async getList() {
//ipcRenderer.on(WINDOW_CLOSE_EVENT, callback) //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 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 close(path) {
},
_onChangeCallbacks: {},
addOnChangeCallback(path, callback) {
},
removeOnChangeCallback(path, callback) {
},
pathSeparator: "/",
},
mainProcess: {
on(event, callback) {
ipcRenderer.on(event, callback)
},
off(event, callback) {
ipcRenderer.off(event, callback)
},
invoke(event, ...args) {
}
}, },
settings: initialSettings, settings: initialSettings,
onOpenSettings(callback) {
ipcRenderer.on(OPEN_SETTINGS_EVENT, callback)
},
onSettingsChange(callback) {
ipcRenderer.on(SETTINGS_CHANGE_EVENT, (event, settings) => callback(settings))
},
setSettings(settings) { setSettings(settings) {
localStorage.setItem("settings", JSON.stringify(settings)) localStorage.setItem("settings", JSON.stringify(settings))
ipcRenderer.send(SETTINGS_CHANGE_EVENT, settings) ipcRenderer.send(SETTINGS_CHANGE_EVENT, settings)
@ -121,7 +247,7 @@ const Heynote = {
set: (mode) => { set: (mode) => {
localStorage.setItem("theme", mode) localStorage.setItem("theme", mode)
themeCallback(mode) themeCallback(mode)
console.log("set theme to", mode) //console.log("set theme to", mode)
}, },
get: async () => { get: async () => {
const theme = localStorage.getItem("theme") || "system" const theme = localStorage.getItem("theme") || "system"
@ -152,6 +278,14 @@ const Heynote = {
async getVersion() { async getVersion() {
return __APP_VERSION__ + " (" + __GIT_HASH__ + ")" return __APP_VERSION__ + " (" + __GIT_HASH__ + ")"
}, },
async getInitErrors() {
},
setWindowTitle(title) {
document.title = title + " - Heynote"
},
} }
export { Heynote, ipcRenderer} export { Heynote, ipcRenderer}

Some files were not shown because too many files have changed in this diff Show More