WIP: Implement support for multiple notes

Refactor Vue <-> Editor <-> CodeMirror code.
Introduce Pinia store to keep global state, in order to get rid of a lot of event juggling between Editor class/child components and the root App component.
This commit is contained in:
Jonatan Heyman 2024-07-24 13:52:44 +02:00
parent f156320601
commit f11f360496
20 changed files with 1179 additions and 295 deletions

View File

@ -16,15 +16,15 @@ const untildify = (pathWithTilde) => {
: pathWithTilde;
}
export function constructBufferFilePath(directoryPath) {
return join(untildify(directoryPath), isDev ? "buffer-dev.txt" : "buffer.txt")
export function constructBufferFilePath(directoryPath, path) {
return join(untildify(directoryPath), path)
}
export function getBufferFilePath() {
export function getFullBufferFilePath(path) {
let defaultPath = app.getPath("userData")
let configPath = CONFIG.get("settings.bufferPath")
let bufferPath = configPath.length ? configPath : defaultPath
let bufferFilePath = constructBufferFilePath(bufferPath)
let bufferFilePath = constructBufferFilePath(bufferPath, path)
try {
// use realpathSync to resolve a potential symlink
return fs.realpathSync(bufferFilePath)
@ -103,39 +103,45 @@ export class Buffer {
// Buffer
let buffer
export function loadBuffer() {
if (buffer) {
buffer.close()
let buffers = {}
export function loadBuffer(path) {
if (buffers[path]) {
buffers[path].close()
}
buffer = new Buffer({
filePath: getBufferFilePath(),
buffers[path] = new Buffer({
filePath: getFullBufferFilePath(path),
onChange: (content) => {
win?.webContents.send("buffer-content:change", content)
console.log("Old buffer.js onChange")
win?.webContents.send("buffer-content:change", path, content)
},
})
return buffer
return buffers[path]
}
ipcMain.handle('buffer-content:load', async () => {
if (buffer.exists() && !(eraseInitialContent && isDev)) {
return await buffer.load()
ipcMain.handle('buffer-content:load', async (event, path) => {
if (!buffers[path]) {
loadBuffer(path)
}
if (buffers[path].exists() && !(eraseInitialContent && isDev)) {
return await buffers[path].load()
} else {
return isDev ? initialDevContent : initialContent
}
});
async function save(content) {
return await buffer.save(content)
async function save(path, content) {
return await buffers[path].save(content)
}
ipcMain.handle('buffer-content:save', async (event, content) => {
return await save(content)
ipcMain.handle('buffer-content:save', async (event, path, content) => {
return await save(path, content)
});
export let contentSaved = false
ipcMain.handle('buffer-content:saveAndQuit', async (event, content) => {
await save(content)
ipcMain.handle('buffer-content:saveAndQuit', async (event, contents) => {
for (const [path, content] of contents) {
await save(path, content)
}
contentSaved = true
app.quit()
})

View File

@ -0,0 +1,210 @@
import fs from "fs"
import os from "node:os"
import { join, dirname, basename } from "path"
import * as jetpack from "fs-jetpack";
import { app, ipcMain, dialog } from "electron"
const untildify = (pathWithTilde) => {
const homeDir = os.homedir()
return homeDir ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDir) : pathWithTilde
}
async function readNoteMetadata(filePath) {
const chunks = []
for await (let chunk of fs.createReadStream(filePath, { start: 0, end:4000 })) {
chunks.push(chunk)
}
const headContent = Buffer.concat(chunks).toString("utf8")
const firstSeparator = headContent.indexOf("\n∞∞∞")
if (firstSeparator === -1) {
return null
}
try {
const metadata = JSON.parse(headContent.slice(0, firstSeparator).trim())
return {"name": metadata.name, "tags": metadata.tags}
} catch (e) {
return {}
}
}
export class FileLibrary {
constructor(basePath) {
basePath = untildify(basePath)
if (jetpack.exists(basePath) !== "dir") {
throw new Error(`Path directory does not exist: ${basePath}`)
}
this.basePath = fs.realpathSync(basePath)
this.jetpack = jetpack.cwd(this.basePath)
this.files = {};
this.watcher = null;
this.contentSaved = false
this.onChangeCallback = null
}
async exists(path) {
return this.jetpack.exists(path) === "file"
}
async load(path) {
if (this.files[path]) {
return this.files[path].read()
}
const fullPath = fs.realpathSync(join(this.basePath, path))
this.files[path] = new NoteBuffer({fullPath, library:this})
return await this.files[path].read()
}
async save(path, content) {
if (!this.files[path]) {
throw new Error(`File not loaded: ${path}`)
}
return await this.files[path].save(content)
}
async getList() {
console.log("Loading notes")
const notes = {}
const files = await this.jetpack.findAsync(this.basePath, {
matching: "*.txt",
recursive: true,
})
const promises = []
for (const file of files) {
promises.push(readNoteMetadata(join(this.basePath, file)))
}
const metadataList = await Promise.all(promises)
metadataList.forEach((metadata, i) => {
const path = files[i]
notes[path] = metadata
})
return notes
}
setupWatcher(win) {
if (!this.watcher) {
this.watcher = fs.watch(
this.basePath,
{
persistent: true,
recursive: true,
encoding: "utf8",
},
async (eventType, changedPath) => {
console.log("File changed", eventType, changedPath)
//if (changedPath.toLowerCase().endsWith(".txt")) {
// console.log("txt", this.notes)
// if (await this.exists(changedPath)) {
// console.log("file exists!")
// const newMetadata = await readNoteMetadata(join(this.basePath, changedPath))
// if (!(changedPath in this.notes) || newMetadata.name !== this.notes[changedPath].name) {
// this.notes[changedPath] = newMetadata
// win.webContents.send("buffer:noteMetadataChanged", changedPath, newMetadata)
// console.log("metadata changed")
// } else {
// console.log("no metadata change")
// }
// } else if (changedPath in this.notes) {
// console.log("note removed", changedPath)
// delete this.notes[changedPath]
// win.webContents.send("buffer:noteRemoved", changedPath)
// }
//}
for (const [path, buffer] of Object.entries(this.files)) {
if (changedPath === basename(path)) {
const content = await buffer.read()
if (buffer._lastSavedContent !== content) {
win.webContents.send("buffer:change", path, content)
}
}
}
}
)
}
}
closeFile(path) {
if (this.files[path]) {
delete this.files[path]
}
}
close() {
for (const buffer of Object.values(this.files)) {
this.closeFile(buffer.filePath)
}
this.stopWatcher()
}
stopWatcher() {
if (this.watcher) {
this.watcher.close()
this.watcher = null
}
}
}
export class NoteBuffer {
constructor({fullPath, library}) {
this.fullPath = fullPath
this._lastSavedContent = null
this.library = library
}
async read() {
return await this.library.jetpack.read(this.fullPath, 'utf8')
}
async save(content) {
this._lastSavedContent = content
const saveResult = await this.library.jetpack.write(this.fullPath, content, {
atomic: true,
mode: '600',
})
return saveResult
}
exists() {
return jetpack.exists(this.fullPath) === "file"
}
}
export function setupFileLibraryEventHandlers(library, win) {
ipcMain.handle('buffer:load', async (event, path) => {
console.log("buffer:load", path)
return await library.load(path)
});
ipcMain.handle('buffer:save', async (event, path, content) => {
return await library.save(path, content)
});
ipcMain.handle('buffer:getList', async (event) => {
return await library.getList()
});
ipcMain.handle('buffer:exists', async (event, path) => {
return await library.exists(path)
});
ipcMain.handle('buffer:close', async (event, path) => {
return await library.closeFile(path)
});
ipcMain.handle('buffer:saveAndQuit', async (event, contents) => {
library.stopWatcher()
for (const [path, content] of contents) {
await library.save(path, content)
}
library.contentSaved = true
app.quit()
})
library.setupWatcher(win)
}

View File

@ -10,6 +10,7 @@ import { isDev, isLinux, isMac, isWindows } from '../detect-platform';
import { initializeAutoUpdate, checkForUpdates } from './auto-update';
import { fixElectronCors } from './cors';
import { loadBuffer, contentSaved } from './buffer';
import { FileLibrary, setupFileLibraryEventHandlers } from './file-library';
// The built directory structure
@ -49,6 +50,7 @@ Menu.setApplicationMenu(menu)
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
export let win: BrowserWindow | null = null
let fileLibrary: FileLibrary | null = null
let tray: Tray | null = null;
let initErrors: string[] = []
// Here, you can also use other preload
@ -139,7 +141,7 @@ async function createWindow() {
}
// Prevent the window from closing, and send a message to the renderer which will in turn
// send a message to the main process to save the current buffer and close the window.
if (!contentSaved) {
if (!!fileLibrary && !fileLibrary.contentSaved) {
event.preventDefault()
win?.webContents.send(WINDOW_CLOSE_EVENT)
} else {
@ -303,6 +305,7 @@ function registerAlwaysOnTop() {
}
app.whenReady().then(createWindow).then(async () => {
setupFileLibraryEventHandlers(fileLibrary, win)
initializeAutoUpdate(win)
registerGlobalHotkey()
registerShowInDock()
@ -344,8 +347,16 @@ ipcMain.handle('dark-mode:set', (event, mode) => {
ipcMain.handle('dark-mode:get', () => nativeTheme.themeSource)
// load buffer on app start
loadBuffer()
// Initialize note/file library
const customLibraryPath = CONFIG.get("settings.bufferPath")
const libraryPath = customLibraryPath ? customLibraryPath : join(app.getPath("userData"), "notes")
console.log("libraryPath", libraryPath)
try {
fileLibrary = new FileLibrary(libraryPath)
} catch (error) {
initErrors.push(`Error: ${error.message}`)
}
ipcMain.handle("getInitErrors", () => {
return initErrors
})

View File

@ -1,6 +1,6 @@
const { contextBridge } = require('electron')
import themeMode from "./theme-mode"
import { isMac, isWindows, isLinux } from "../detect-platform"
import { isMac, isWindows, isLinux, isDev } from "../detect-platform"
import { ipcRenderer } from "electron"
import {
WINDOW_CLOSE_EVENT,
@ -30,8 +30,19 @@ contextBridge.exposeInMainWorld("heynote", {
isWebApp: false,
},
isDev: isDev,
themeMode: themeMode,
init() {
ipcRenderer.on("buffer:change", (event, path, content) => {
// called on all changes to open buffer files
// go through all registered callbacks for this path and call them
if (this.buffer._onChangeCallbacks[path]) {
this.buffer._onChangeCallbacks[path].forEach(callback => callback(content))
}
})
},
quit() {
console.log("quitting")
//ipcRenderer.invoke("app_quit")
@ -46,25 +57,52 @@ contextBridge.exposeInMainWorld("heynote", {
},
buffer: {
async load() {
return await ipcRenderer.invoke("buffer-content:load")
async exists(path) {
return await ipcRenderer.invoke("buffer:exists", path)
},
async save(content) {
return await ipcRenderer.invoke("buffer-content:save", content)
async getList() {
return await ipcRenderer.invoke("buffer:getList")
},
async saveAndQuit(content) {
return await ipcRenderer.invoke("buffer-content:saveAndQuit", content)
async load(path) {
return await ipcRenderer.invoke("buffer:load", path)
},
onChangeCallback(callback) {
ipcRenderer.on("buffer-content:change", callback)
async save(path, content) {
return await ipcRenderer.invoke("buffer:save", path, content)
},
async saveAndQuit(contents) {
return await ipcRenderer.invoke("buffer:saveAndQuit", contents)
},
async close(path) {
return await ipcRenderer.invoke("buffer:close", path)
},
_onChangeCallbacks: {},
addOnChangeCallback(path, callback) {
// register a callback to be called when the buffer content changes for a specific file
if (!this._onChangeCallbacks[path]) {
this._onChangeCallbacks[path] = []
}
this._onChangeCallbacks[path].push(callback)
},
removeOnChangeCallback(path, callback) {
if (this._onChangeCallbacks[path]) {
this._onChangeCallbacks[path] = this._onChangeCallbacks[path].filter(cb => cb !== callback)
}
},
async selectLocation() {
return await ipcRenderer.invoke("buffer-content:selectLocation")
}
},
callbacks(callbacks) {
ipcRenderer.on("buffer:noteMetadataChanged", (event, path, info) => callbacks?.noteMetadataChanged(path, info))
ipcRenderer.on("buffer:noteRemoved", (event, path) => callbacks?.noteRemoved(path))
},
},
settings: CONFIG.get("settings"),

View File

@ -28,6 +28,7 @@ onmessage = (event) => {
},
content: content,
idx: event.data.idx,
path: event.data.path,
})
return
}
@ -53,6 +54,7 @@ onmessage = (event) => {
},
content: content,
idx: event.data.idx,
path: event.data.path,
})
return
}
@ -66,6 +68,7 @@ onmessage = (event) => {
},
content: content,
idx: event.data.idx,
path: event.data.path,
})
return
}

View File

@ -1,8 +1,17 @@
<script>
import { mapState, mapActions } from 'pinia'
import { mapWritableState } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
import { useErrorStore } from "../stores/error-store"
import StatusBar from './StatusBar.vue'
import Editor from './Editor.vue'
import LanguageSelector from './LanguageSelector.vue'
import NoteSelector from './NoteSelector.vue'
import Settings from './settings/Settings.vue'
import ErrorMessages from './ErrorMessages.vue'
import NewNote from './NewNote.vue'
export default {
components: {
@ -10,20 +19,17 @@
StatusBar,
LanguageSelector,
Settings,
NoteSelector,
ErrorMessages,
NewNote,
},
data() {
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,
showLanguageSelector: false,
showSettings: false,
settings: window.heynote.settings,
}
@ -56,13 +62,61 @@
window.heynote.themeMode.removeListener()
},
watch: {
// when a dialog is closed, we want to focus the editor
showLanguageSelector(value) { this.dialogWatcher(value) },
showNoteSelector(value) { this.dialogWatcher(value) },
showCreateNote(value) { this.dialogWatcher(value) },
currentNotePath() {
this.focusEditor()
},
},
computed: {
...mapState(useNotesStore, [
"currentNotePath",
"showLanguageSelector",
"showNoteSelector",
"showCreateNote",
]),
editorInert() {
return this.showCreateNote || this.showSettings
},
},
methods: {
...mapActions(useNotesStore, [
"openLanguageSelector",
"openNoteSelector",
"openCreateNote",
"closeDialog",
"openNote",
]),
// 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() {
this.showSettings = true
},
closeSettings() {
this.showSettings = false
this.$refs.editor.focus()
this.focusEditor()
},
toggleTheme() {
@ -78,25 +132,8 @@
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) {
this.showLanguageSelector = false
this.closeDialog()
this.$refs.editor.setLanguage(language)
},
@ -111,7 +148,6 @@
<template>
<div class="container">
<Editor
@cursorChange="onCursorChange"
:theme="theme"
:development="development"
:debugSyntaxTree="false"
@ -124,37 +160,44 @@
:fontSize="settings.fontSize"
:defaultBlockLanguage="settings.defaultBlockLanguage || 'text'"
:defaultBlockLanguageAutoDetect="settings.defaultBlockLanguageAutoDetect === undefined ? true : settings.defaultBlockLanguageAutoDetect"
:inert="editorInert"
class="editor"
ref="editor"
@openLanguageSelector="openLanguageSelector"
/>
<StatusBar
:line="line"
:column="column"
:selectionSize="selectionSize"
:language="language"
:languageAuto="languageAuto"
:theme="theme"
:themeSetting="themeSetting"
:autoUpdate="settings.autoUpdate"
:allowBetaVersions="settings.allowBetaVersions"
@toggleTheme="toggleTheme"
@openNoteSelector="openNoteSelector"
@openLanguageSelector="openLanguageSelector"
@formatCurrentBlock="formatCurrentBlock"
@openSettings="showSettings = true"
@click="() => {$refs.editor.focus()}"
class="status"
/>
<div class="overlay">
<LanguageSelector
v-if="showLanguageSelector"
@selectLanguage="onSelectLanguage"
@close="closeLanguageSelector"
@close="closeDialog"
/>
<NoteSelector
v-if="showNoteSelector"
@openNote="openNote"
@close="closeDialog"
/>
<Settings
v-if="showSettings"
:initialSettings="settings"
@closeSettings="closeSettings"
/>
<NewNote
v-if="showCreateNote"
@close="closeDialog"
/>
<ErrorMessages />
</div>
</div>
</template>

View File

@ -1,6 +1,9 @@
<script>
import { HeynoteEditor, LANGUAGE_SELECTOR_EVENT } from '../editor/editor.js'
import { HeynoteEditor } from '../editor/editor.js'
import { syntaxTree } from "@codemirror/language"
import { toRaw } from 'vue';
import { mapState } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
export default {
props: {
@ -38,66 +41,23 @@
data() {
return {
syntaxTreeDebugContent: null,
bufferFilePath: null,
editor: null,
}
},
mounted() {
this.$refs.editor.addEventListener("selectionChange", (e) => {
//console.log("selectionChange:", e)
this.$emit("cursorChange", {
cursorLine: e.cursorLine,
selectionSize: e.selectionSize,
language: e.language,
languageAuto: e.languageAuto,
})
})
this.loadBuffer(this.currentNotePath)
this.$refs.editor.addEventListener(LANGUAGE_SELECTOR_EVENT, (e) => {
this.$emit("openLanguageSelector")
})
// load buffer content and create editor
window.heynote.buffer.load().then((content) => {
try {
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)
this.editor.setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect)
// set up buffer change listener
window.heynote.buffer.onChangeCallback((event, content) => {
diskContent = content
this.editor.setContent(content)
})
} catch (e) {
alert("Error! " + e.message)
throw e
}
})
// set up window close handler that will save the buffer and quit
window.heynote.onWindowClose(() => {
window.heynote.buffer.saveAndQuit(this.editor.getContent())
window.heynote.buffer.saveAndQuit([
[this.editor.path, this.editor.getContent()],
])
})
window.document.addEventListener("currenciesLoaded", this.onCurrenciesLoaded)
// if debugSyntaxTree prop is set, display syntax tree for debugging
if (this.debugSyntaxTree) {
setInterval(() => {
@ -123,65 +83,108 @@
},
watch: {
currentNotePath(path) {
//console.log("currentNotePath changed to", path)
this.loadBuffer(path)
},
theme(newTheme) {
this.editor.setTheme(newTheme)
toRaw(this.editor).setTheme(newTheme)
},
keymap() {
this.editor.setKeymap(this.keymap, this.emacsMetaKey)
toRaw(this.editor).setKeymap(this.keymap, this.emacsMetaKey)
},
emacsMetaKey() {
this.editor.setKeymap(this.keymap, this.emacsMetaKey)
toRaw(this.editor).setKeymap(this.keymap, this.emacsMetaKey)
},
showLineNumberGutter(show) {
this.editor.setLineNumberGutter(show)
toRaw(this.editor).setLineNumberGutter(show)
},
showFoldGutter(show) {
this.editor.setFoldGutter(show)
toRaw(this.editor).setFoldGutter(show)
},
bracketClosing(value) {
this.editor.setBracketClosing(value)
toRaw(this.editor).setBracketClosing(value)
},
fontFamily() {
this.editor.setFont(this.fontFamily, this.fontSize)
toRaw(this.editor).setFont(this.fontFamily, this.fontSize)
},
fontSize() {
this.editor.setFont(this.fontFamily, this.fontSize)
toRaw(this.editor).setFont(this.fontFamily, this.fontSize)
},
defaultBlockLanguage() {
this.editor.setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect)
toRaw(this.editor).setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect)
},
defaultBlockLanguageAutoDetect() {
this.editor.setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect)
toRaw(this.editor).setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect)
},
},
computed: {
...mapState(useNotesStore, [
"currentNotePath",
]),
},
methods: {
setLanguage(language) {
if (language === "auto") {
this.editor.setCurrentLanguage(null, true)
} else {
this.editor.setCurrentLanguage(language, false)
loadBuffer(path) {
if (this.editor) {
this.editor.destroy()
}
this.editor.focus()
// load buffer content and create editor
this.bufferFilePath = path
try {
this.editor = new HeynoteEditor({
element: this.$refs.editor,
path: this.bufferFilePath,
theme: this.theme,
keymap: this.keymap,
emacsMetaKey: this.emacsMetaKey,
showLineNumberGutter: this.showLineNumberGutter,
showFoldGutter: this.showFoldGutter,
bracketClosing: this.bracketClosing,
fontFamily: this.fontFamily,
fontSize: this.fontSize,
defaultBlockToken: this.defaultBlockLanguage,
defaultBlockAutoDetect: this.defaultBlockLanguageAutoDetect,
})
window._heynote_editor = toRaw(this.editor)
} catch (e) {
alert("Error! " + e.message)
throw e
}
},
setLanguage(language) {
const editor = toRaw(this.editor)
if (language === "auto") {
editor.setCurrentLanguage(null, true)
} else {
editor.setCurrentLanguage(language, false)
}
editor.focus()
},
formatCurrentBlock() {
this.editor.formatCurrentBlock()
this.editor.focus()
const editor = toRaw(this.editor)
editor.formatCurrentBlock()
editor.focus()
},
onCurrenciesLoaded() {
this.editor.currenciesLoaded()
if (this.editor) {
toRaw(this.editor).currenciesLoaded()
}
},
focus() {
this.editor.focus()
toRaw(this.editor).focus()
},
},
}

158
src/components/NewNote.vue Normal file
View File

@ -0,0 +1,158 @@
<script>
import { mapState, mapActions } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
import FolderSelect from './form/FolderSelect.vue'
export default {
data() {
return {
name: "",
filename: "",
tags: [],
}
},
components: {
FolderSelect
},
async mounted() {
await this.updateNotes()
this.$refs.nameInput.focus()
},
computed: {
...mapState(useNotesStore, [
"notes",
]),
},
methods: {
...mapActions(useNotesStore, [
"updateNotes",
]),
onKeydown(event) {
if (event.key === "Escape") {
this.$emit("close")
event.preventDefault()
}
},
onSubmit(event) {
event.preventDefault()
console.log("Creating note", this.name)
this.$emit("close")
//this.$emit("create", this.$refs.input.value)
},
}
}
</script>
<template>
<div class="scroller" @keydown="onKeydown" tabindex="-1">
<form class="new-note" tabindex="-1" @focusout="onFocusOut" ref="container" @submit="onSubmit">
<div class="container">
<h1>New Note from Block</h1>
<input
placeholder="Name"
type="text"
v-model="name"
class="name-input"
ref="nameInput"
/>
<label for="folder-select">Create in</label>
<FolderSelect id="folder-select" />
</div>
<div class="bottom-bar">
<button type="submit">Create Note</button>
</div>
</form>
</div>
</template>
<style scoped lang="sass">
.scroller
overflow: auto
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
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)
&:focus
outline: none
+dark-mode
background: #333
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
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: 400px
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
+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
display: flex
justify-content: flex-end
+dark-mode
background: #222
button
font-size: 12px
height: 28px
border: 1px solid #c5c5c5
border-radius: 3px
padding-left: 10px
padding-right: 10px
&:focus
outline-color: #48b57e
</style>

View File

@ -0,0 +1,186 @@
<script>
import { mapState, mapActions } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
export default {
data() {
return {
selected: 0,
filter: "",
items: [],
}
},
async mounted() {
await this.updateNotes()
this.$refs.container.focus()
this.$refs.input.focus()
this.items = Object.entries(this.notes).map(([path, metadata]) => {
return {
"path": path,
"name": metadata?.name || path,
}
})
},
computed: {
...mapState(useNotesStore, [
"notes",
]),
filteredItems() {
return this.items.filter((lang) => {
return lang.name.toLowerCase().indexOf(this.filter.toLowerCase()) !== -1
})
},
},
methods: {
...mapActions(useNotesStore, [
"updateNotes",
]),
onKeydown(event) {
if (event.key === "ArrowDown") {
this.selected = Math.min(this.selected + 1, this.filteredItems.length - 1)
event.preventDefault()
if (this.selected === this.filteredItems.length - 1) {
this.$refs.container.scrollIntoView({block: "end"})
} else {
this.$refs.item[this.selected].scrollIntoView({block: "nearest"})
}
} else if (event.key === "ArrowUp") {
this.selected = Math.max(this.selected - 1, 0)
event.preventDefault()
if (this.selected === 0) {
this.$refs.container.scrollIntoView({block: "start"})
} else {
this.$refs.item[this.selected].scrollIntoView({block: "nearest"})
}
} else if (event.key === "Enter") {
this.selectItem(this.filteredItems[this.selected].path)
event.preventDefault()
} else if (event.key === "Escape") {
this.$emit("close")
event.preventDefault()
}
},
selectItem(path) {
this.$emit("openNote", path)
},
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")
}
},
}
}
</script>
<template>
<div class="scroller">
<form class="note-selector" tabindex="-1" @focusout="onFocusOut" ref="container">
<input
type="text"
ref="input"
@keydown="onKeydown"
@input="onInput"
v-model="filter"
/>
<ul class="items">
<li
v-for="item, idx in filteredItems"
:key="item.path"
:class="idx === selected ? 'selected' : ''"
@click="selectItem(item.path)"
ref="item"
>
<span class="name">{{ item.name }}</span>
<span class="path">{{ item.path }}</span>
</li>
</ul>
</form>
</div>
</template>
<style scoped lang="sass">
.scroller
overflow: auto
position: fixed
top: 0
left: 0
bottom: 0
right: 0
.note-selector
font-size: 13px
padding: 10px
//background: #48b57e
background: #efefef
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)
+dark-mode
background: #151516
box-shadow: 0 0 10px rgba(0,0,0,0.5)
+webapp-mobile
max-width: calc(100% - 80px)
input
background: #fff
padding: 4px 5px
border: 1px solid #ccc
box-sizing: border-box
border-radius: 2px
width: 400px
margin-bottom: 10px
&: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%
.items
> li
border-radius: 3px
padding: 5px 12px
cursor: pointer
display: flex
align-items: center
&:hover
background: #e2e2e2
&.selected
background: #48b57e
color: #fff
+dark-mode
color: rgba(255,255,255, 0.53)
&:hover
background: #29292a
&.selected
background: #1b6540
color: rgba(255,255,255, 0.87)
.name
margin-right: 12px
.path
opacity: 0.6
font-size: 12px
</style>

View File

@ -1,17 +1,14 @@
<script>
import { mapState } from 'pinia'
import UpdateStatusItem from './UpdateStatusItem.vue'
import { LANGUAGES } from '../editor/languages.js'
import { useNotesStore } from "../stores/notes-store"
const LANGUAGE_MAP = Object.fromEntries(LANGUAGES.map(l => [l.token, l]))
const LANGUAGE_NAMES = Object.fromEntries(LANGUAGES.map(l => [l.token, l.name]))
export default {
props: [
"line",
"column",
"selectionSize",
"language",
"languageAuto",
"theme",
"themeSetting",
"autoUpdate",
@ -33,8 +30,17 @@
},
computed: {
...mapState(useNotesStore, [
"currentNoteName",
"currentCursorLine",
"currentLanguage",
"currentSelectionSize",
"currentLanguage",
"currentLanguageAuto",
]),
languageName() {
return LANGUAGE_NAMES[this.language] || this.language
return LANGUAGE_NAMES[this.currentLanguage] || this.currentLanguage
},
className() {
@ -42,7 +48,7 @@
},
supportsFormat() {
const lang = LANGUAGE_MAP[this.language]
const lang = LANGUAGE_MAP[this.currentLanguage]
return !!lang ? lang.supportsFormat : false
},
@ -54,6 +60,10 @@
return `Format Block Content (Alt + Shift + F)`
},
changeNoteTitle() {
return `Change Note (${this.cmdKey} + P)`
},
changeLanguageTitle() {
return `Change language for current block (${this.cmdKey} + L)`
},
@ -68,24 +78,31 @@
<template>
<div :class="className">
<div class="status-block line-number">
Ln <span class="num">{{ line }}</span>
Col <span class="num">{{ column }}</span>
<template v-if="selectionSize > 0">
Sel <span class="num">{{ selectionSize }}</span>
Ln <span class="num">{{ currentCursorLine?.line }}</span>
Col <span class="num">{{ currentCursorLine?.col }}</span>
<template v-if="currentSelectionSize > 0">
Sel <span class="num">{{ currentSelectionSize }}</span>
</template>
</div>
<div class="spacer"></div>
<div
@click="$emit('openLanguageSelector')"
@click.stop="$emit('openNoteSelector')"
class="status-block note clickable"
:title="changeNoteTitle"
>
{{ currentNoteName }}
</div>
<div
@click.stop="$emit('openLanguageSelector')"
class="status-block lang clickable"
:title="changeLanguageTitle"
>
{{ languageName }}
<span v-if="languageAuto" class="auto">(auto)</span>
<span v-if="currentLanguageAuto" class="auto">(auto)</span>
</div>
<div
v-if="supportsFormat"
@click="$emit('formatCurrentBlock')"
@click.stop="$emit('formatCurrentBlock')"
class="status-block format clickable"
:title="formatBlockTitle"
>
@ -100,7 +117,7 @@
<span :class="'icon ' + themeSetting"></span>
</div>
<div
@click="$emit('openSettings')"
@click.stop="$emit('openSettings')"
class="status-block settings clickable"
title="Settings"
>

View File

@ -0,0 +1,53 @@
<script>
export default {
props: {
tree: Array,
},
methods: {
onKeyDown(event) {
console.log("Keydown", event.key)
if (event.key === "Enter") {
event.preventDefault()
this.$emit("click")
}
},
}
}
</script>
<template>
<button
class="folder-select-container"
@keydown="onKeyDown"
@click.stop.prevent="()=>{}"
>
<div class="folder root selected">
Heynote Root
</div>
<div class="folder indent">New Folder&hellip;</div>
</button>
</template>
<style lang="sass" scoped>
.folder-select-container
width: 100%
background: #fff
border: 1px solid #ccc
border-radius: 2px
padding: 5px 5px
text-align: left
&:focus
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
.folder
padding: 3px 6px
font-size: 13px
&.selected
background: #48b57e
color: #fff
&.indent
padding-left: 16px
</style>

View File

@ -4,8 +4,8 @@ import { EditorState, RangeSetBuilder, StateField, Facet , StateEffect, RangeSet
import { syntaxTree, ensureSyntaxTree, syntaxTreeAvailable } from "@codemirror/language"
import { Note, Document, NoteDelimiter } from "../lang-heynote/parser.terms.js"
import { IterMode } from "@lezer/common";
import { useNotesStore } from "../../stores/notes-store.js"
import { heynoteEvent, LANGUAGE_CHANGE } from "../annotation.js";
import { SelectionChangeEvent } from "../event.js"
import { mathBlock } from "./math.js"
import { emptyBlockSelected } from "./select-all.js";
@ -404,7 +404,9 @@ function getSelectionSize(state, sel) {
return count
}
const emitCursorChange = (editor) => ViewPlugin.fromClass(
const emitCursorChange = (editor) => {
const notesStore = useNotesStore()
return ViewPlugin.fromClass(
class {
update(update) {
// if the selection changed or the language changed (can happen without selection change),
@ -419,17 +421,16 @@ const emitCursorChange = (editor) => ViewPlugin.fromClass(
const block = getActiveNoteBlock(update.state)
if (block && cursorLine) {
editor.element.dispatchEvent(new SelectionChangeEvent({
cursorLine,
selectionSize,
language: block.language.name,
languageAuto: block.language.auto,
}))
notesStore.currentCursorLine = cursorLine
notesStore.currentSelectionSize = selectionSize
notesStore.currentLanguage = block.language.name
notesStore.currentLanguageAuto = block.language.auto
}
}
}
}
)
}
export const noteBlockExtension = (editor) => {
return [

View File

@ -1,4 +1,4 @@
import { Annotation, EditorState, Compartment, Facet, EditorSelection } from "@codemirror/state"
import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction } from "@codemirror/state"
import { EditorView, keymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view"
import { indentUnit, forceParsing, foldGutter, ensureSyntaxTree } from "@codemirror/language"
import { markdown } from "@codemirror/lang-markdown"
@ -22,8 +22,8 @@ import { autoSaveContent } from "./save.js"
import { todoCheckboxPlugin} from "./todo-checkbox.ts"
import { links } from "./links.js"
import { NoteFormat } from "./note-format.js"
import { useNotesStore } from "../stores/notes-store.js";
export const LANGUAGE_SELECTOR_EVENT = "openLanguageSelector"
function getKeymapExtensions(editor, keymap) {
if (keymap === "emacs") {
@ -37,10 +37,10 @@ function getKeymapExtensions(editor, keymap) {
export class HeynoteEditor {
constructor({
element,
path,
content,
focus=true,
theme="light",
saveFunction=null,
keymap="default",
emacsMetaKey,
showLineNumberGutter=true,
@ -48,8 +48,11 @@ export class HeynoteEditor {
bracketClosing=false,
fontFamily,
fontSize,
defaultBlockToken,
defaultBlockAutoDetect,
}) {
this.element = element
this.path = path
this.themeCompartment = new Compartment
this.keymapCompartment = new Compartment
this.lineNumberCompartmentPre = new Compartment
@ -60,9 +63,10 @@ export class HeynoteEditor {
this.deselectOnCopy = keymap === "emacs"
this.emacsMetaKey = emacsMetaKey
this.fontTheme = new Compartment
this.defaultBlockToken = "text"
this.defaultBlockAutoDetect = true
this.saveFunction = saveFunction
this.setDefaultBlockLanguage(defaultBlockToken, defaultBlockAutoDetect)
this.contentLoaded = false
this.notesStore = useNotesStore()
const state = EditorState.create({
doc: "",
@ -88,7 +92,7 @@ export class HeynoteEditor {
}),
heynoteLang(),
noteBlockExtension(this),
languageDetection(() => this),
languageDetection(path, () => this),
// set cursor blink rate to 1 second
drawSelection({cursorBlinkRate:1000}),
@ -98,7 +102,7 @@ export class HeynoteEditor {
return {class: view.state.facet(EditorView.darkTheme) ? "dark-theme" : "light-theme"}
}),
this.saveFunction ? autoSaveContent(this, 2000) : [],
autoSaveContent(this, 2000),
todoCheckboxPlugin,
markdown(),
@ -107,34 +111,66 @@ export class HeynoteEditor {
})
// make sure saveFunction is called when page is unloaded
if (saveFunction) {
window.addEventListener("beforeunload", () => {
this.save()
})
}
this.view = new EditorView({
state: state,
parent: element,
})
this.setContent(content)
//this.setContent(content)
this.setReadOnly(true)
this.loadContent().then(() => {
this.setReadOnly(false)
})
if (focus) {
this.view.focus()
}
}
save() {
this.saveFunction(this.getContent())
async save() {
if (!this.contentLoaded) {
return
}
const content = this.getContent()
if (content === this.diskContent) {
return
}
console.log("saving:", this.path)
this.diskContent = content
await window.heynote.buffer.save(this.path, content)
}
getContent() {
this.note.content = this.view.state.sliceDoc()
this.note.cursors = this.view.state.selection.toJSON()
const ranges = this.note.cursors.ranges
if (ranges.length == 1 && ranges[0].anchor == 0 && ranges[0].head == 0) {
console.log("DEBUG!! Cursor is at 0,0")
console.trace()
}
return this.note.serialize()
}
async loadContent() {
console.log("loading content", this.path)
const content = await window.heynote.buffer.load(this.path)
this.diskContent = content
this.contentLoaded = true
this.setContent(content)
// set up content change listener
this.onChange = (content) => {
this.diskContent = content
this.setContent(content)
}
window.heynote.buffer.addOnChangeCallback(this.path, this.onChange)
}
setContent(content) {
try {
this.note = NoteFormat.load(content)
@ -143,6 +179,7 @@ export class HeynoteEditor {
this.setReadOnly(true)
throw e
}
this.notesStore.currentNoteName = this.note.metadata?.name || this.path
return new Promise((resolve) => {
// set buffer content
this.view.dispatch({
@ -151,7 +188,7 @@ export class HeynoteEditor {
to: this.view.state.doc.length,
insert: this.note.content,
},
annotations: [heynoteEvent.of(SET_CONTENT)],
annotations: [heynoteEvent.of(SET_CONTENT), Transaction.addToHistory.of(false)],
})
// Ensure we have a parsed syntax tree when buffer is loaded. This prevents errors for large buffers
@ -217,7 +254,15 @@ export class HeynoteEditor {
}
openLanguageSelector() {
this.element.dispatchEvent(new Event(LANGUAGE_SELECTOR_EVENT))
this.notesStore.openLanguageSelector()
}
openNoteSelector() {
this.notesStore.openNoteSelector()
}
openCreateNote() {
this.notesStore.openCreateNote()
}
setCurrentLanguage(lang, auto=false) {
@ -257,6 +302,15 @@ export class HeynoteEditor {
currenciesLoaded() {
triggerCurrenciesLoaded(this.view.state, this.view.dispatch)
}
destroy() {
if (this.onChange) {
window.heynote.buffer.removeOnChangeCallback(this.path, this.onChange)
}
this.save()
this.view.destroy()
window.heynote.buffer.close(this.path)
}
}

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

@ -57,6 +57,8 @@ export function heynoteKeymap(editor) {
["Alt-ArrowUp", moveLineUp],
["Alt-ArrowDown", moveLineDown],
["Mod-l", () => editor.openLanguageSelector()],
["Mod-p", () => editor.openNoteSelector()],
["Mod-s", () => editor.openCreateNote()],
["Alt-Shift-f", formatBlockContent],
["Mod-Alt-ArrowDown", newCursorBelow],
["Mod-Alt-ArrowUp", newCursorAbove],

View File

@ -1,5 +1,5 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { redoDepth } from "@codemirror/commands";
import { getActiveNoteBlock, blockState } from "../block/block";
import { levenshtein_distance } from "./levenshtein";
@ -25,17 +25,17 @@ function cancelIdleCallbackCompat(id) {
}
}
export function languageDetection(getEditor) {
const previousBlockContent = {}
let idleCallbackId = null
// we'll use a shared global web worker for the language detection, for multiple Editor instances
const editorInstances = {}
const detectionWorker = new Worker('langdetect-worker.js?worker');
detectionWorker.onmessage = (event) => {
//console.log("event:", event.data)
if (!event.data.guesslang.language) {
return
}
const editor = getEditor()
const editor = editorInstances[event.data.path]
//const editor = getEditor()
const view = editor.view
const state = view.state
const block = getActiveNoteBlock(state)
@ -58,7 +58,16 @@ export function languageDetection(getEditor) {
}
}
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 (idleCallbackId !== null) {
cancelIdleCallbackCompat(idleCallbackId)
@ -90,7 +99,7 @@ export function languageDetection(getEditor) {
const content = update.state.doc.sliceString(block.content.from, block.content.to)
if (content === "" && redoDepth(update.state) === 0) {
// if content is cleared, set language to default
const editor = getEditor()
//const editor = getEditor()
const view = editor.view
const block = getActiveNoteBlock(view.state)
if (block.language.name !== editor.defaultBlockToken) {
@ -108,12 +117,20 @@ export function languageDetection(getEditor) {
detectionWorker.postMessage({
content: content,
idx: idx,
path: path,
})
previousBlockContent[idx] = content
}
})
}
})
}
destroy() {
console.log("Removing editorInstance for:", path)
delete editorInstances[path]
}
}
)
return plugin
}

View File

@ -1,5 +1,6 @@
import { ViewPlugin } from "@codemirror/view"
import { debounce } from "debounce"
import { SET_CONTENT }  from "./annotation"
export const autoSaveContent = (editor, interval) => {
@ -12,9 +13,12 @@ export const autoSaveContent = (editor, interval) => {
class {
update(update) {
if (update.docChanged) {
const initialSetContent = update.transactions.flatMap(t => t.annotations).some(a => a.value === SET_CONTENT)
if (!initialSetContent) {
save()
}
}
}
}
)
}

View File

@ -6,6 +6,7 @@ import { createPinia } from 'pinia'
import App from './components/App.vue'
import { loadCurrencies } from './currency'
import { useErrorStore } from './stores/error-store'
import { useNotesStore, initNotesStore } from './stores/notes-store'
const pinia = createPinia()
@ -18,10 +19,12 @@ app.mount('#app').$nextTick(() => {
})
const errorStore = useErrorStore()
//errorStore.addError("test error")
window.heynote.getInitErrors().then((errors) => {
errors.forEach((e) => errorStore.addError(e))
})
initNotesStore()
@ -29,3 +32,4 @@ window.heynote.getInitErrors().then((errors) => {
loadCurrencies()
setInterval(loadCurrencies, 1000 * 3600 * 4)
window.heynote.init()

65
src/stores/notes-store.js Normal file
View File

@ -0,0 +1,65 @@
import { defineStore } from "pinia"
export const useNotesStore = defineStore("notes", {
state: () => ({
notes: {},
currentNotePath: window.heynote.isDev ? "buffer-dev.txt" : "buffer.txt",
currentNoteName: null,
currentLanguage: null,
currentLanguageAuto: null,
currentCursorLine: null,
currentSelectionSize: null,
showNoteSelector: false,
showLanguageSelector: false,
showCreateNote: false,
}),
actions: {
async updateNotes() {
this.setNotes(await window.heynote.buffer.getList())
},
setNotes(notes) {
this.notes = notes
},
createNewNote(path, content) {
//window.heynote.buffer.save(path, content)
this.updateNotes()
},
openNote(path) {
this.showNoteSelector = false
this.showLanguageSelector = false
this.showCreateNote = false
this.currentNotePath = path
},
openLanguageSelector() {
this.showLanguageSelector = true
this.showNoteSelector = false
this.showCreateNote = false
},
openNoteSelector() {
this.showNoteSelector = true
this.showLanguageSelector = false
this.showCreateNote = false
},
openCreateNote() {
this.showCreateNote = true
this.showNoteSelector = false
this.showLanguageSelector = false
},
closeDialog() {
this.showCreateNote = false
this.showNoteSelector = false
this.showLanguageSelector = false
},
},
})
export async function initNotesStore() {
const notesStore = useNotesStore()
await notesStore.updateNotes()
}

View File

@ -80,20 +80,38 @@ const Heynote = {
defaultFontSize: isMobileDevice ? 16 : 12,
buffer: {
async load() {
const content = localStorage.getItem("buffer")
async load(path) {
const content = localStorage.getItem(path)
return content === null ? "\n∞∞∞text-a\n" : content
},
async save(content) {
localStorage.setItem("buffer", content)
async save(path, content) {
console.log("saving", path, content)
localStorage.setItem(path, content)
},
async saveAndQuit(content) {
async saveAndQuit(contents) {
},
onChangeCallback(callback) {
async exists(path) {
return true
},
async getList(path) {
return [{"path":"buffer.txt", "metadata":{}}]
},
async close(path) {
},
_onChangeCallbacks: {},
addOnChangeCallback(path, callback) {
},
removeOnChangeCallback(path, callback) {
},
},