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 c14c700791
commit d01c19fd72
20 changed files with 1179 additions and 295 deletions

View File

@ -16,15 +16,15 @@ const untildify = (pathWithTilde) => {
: pathWithTilde; : pathWithTilde;
} }
export function constructBufferFilePath(directoryPath) { export function constructBufferFilePath(directoryPath, path) {
return join(untildify(directoryPath), isDev ? "buffer-dev.txt" : "buffer.txt") return join(untildify(directoryPath), path)
} }
export function getBufferFilePath() { export function getFullBufferFilePath(path) {
let defaultPath = app.getPath("userData") let defaultPath = app.getPath("userData")
let configPath = CONFIG.get("settings.bufferPath") let configPath = CONFIG.get("settings.bufferPath")
let bufferPath = configPath.length ? configPath : defaultPath let bufferPath = configPath.length ? configPath : defaultPath
let bufferFilePath = constructBufferFilePath(bufferPath) let bufferFilePath = constructBufferFilePath(bufferPath, path)
try { try {
// use realpathSync to resolve a potential symlink // use realpathSync to resolve a potential symlink
return fs.realpathSync(bufferFilePath) return fs.realpathSync(bufferFilePath)
@ -103,39 +103,45 @@ export class Buffer {
// Buffer // Buffer
let buffer let buffers = {}
export function loadBuffer() { export function loadBuffer(path) {
if (buffer) { if (buffers[path]) {
buffer.close() buffers[path].close()
} }
buffer = new Buffer({ buffers[path] = new Buffer({
filePath: getBufferFilePath(), filePath: getFullBufferFilePath(path),
onChange: (content) => { 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 () => { ipcMain.handle('buffer-content:load', async (event, path) => {
if (buffer.exists() && !(eraseInitialContent && isDev)) { if (!buffers[path]) {
return await buffer.load() loadBuffer(path)
}
if (buffers[path].exists() && !(eraseInitialContent && isDev)) {
return await buffers[path].load()
} else { } else {
return isDev ? initialDevContent : initialContent return isDev ? initialDevContent : initialContent
} }
}); });
async function save(content) { async function save(path, content) {
return await buffer.save(content) return await buffers[path].save(content)
} }
ipcMain.handle('buffer-content:save', async (event, content) => { ipcMain.handle('buffer-content:save', async (event, path, content) => {
return await save(content) return await save(path, content)
}); });
export let contentSaved = false export let contentSaved = false
ipcMain.handle('buffer-content:saveAndQuit', async (event, content) => { ipcMain.handle('buffer-content:saveAndQuit', async (event, contents) => {
await save(content) for (const [path, content] of contents) {
await save(path, content)
}
contentSaved = true contentSaved = true
app.quit() 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 { initializeAutoUpdate, checkForUpdates } from './auto-update';
import { fixElectronCors } from './cors'; import { fixElectronCors } from './cors';
import { loadBuffer, contentSaved } from './buffer'; import { loadBuffer, contentSaved } from './buffer';
import { FileLibrary, setupFileLibraryEventHandlers } from './file-library';
// The built directory structure // The built directory structure
@ -49,6 +50,7 @@ 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[] = [] let initErrors: string[] = []
// Here, you can also use other preload // 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 // 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 {
@ -308,6 +310,7 @@ function registerAlwaysOnTop() {
} }
app.whenReady().then(createWindow).then(async () => { app.whenReady().then(createWindow).then(async () => {
setupFileLibraryEventHandlers(fileLibrary, win)
initializeAutoUpdate(win) initializeAutoUpdate(win)
registerGlobalHotkey() registerGlobalHotkey()
registerShowInDock() registerShowInDock()
@ -349,8 +352,16 @@ 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 // Initialize note/file library
loadBuffer() 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", () => { ipcMain.handle("getInitErrors", () => {
return initErrors return initErrors
}) })

View File

@ -1,6 +1,6 @@
const { contextBridge } = require('electron') const { contextBridge } = require('electron')
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, WINDOW_CLOSE_EVENT,
@ -30,8 +30,19 @@ contextBridge.exposeInMainWorld("heynote", {
isWebApp: false, isWebApp: false,
}, },
isDev: isDev,
themeMode: themeMode, 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() { quit() {
console.log("quitting") console.log("quitting")
//ipcRenderer.invoke("app_quit") //ipcRenderer.invoke("app_quit")
@ -46,25 +57,52 @@ contextBridge.exposeInMainWorld("heynote", {
}, },
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 load(path) {
return await ipcRenderer.invoke("buffer-content:saveAndQuit", content) return await ipcRenderer.invoke("buffer:load", path)
}, },
onChangeCallback(callback) { async save(path, content) {
ipcRenderer.on("buffer-content:change", callback) 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() { async selectLocation() {
return await ipcRenderer.invoke("buffer-content: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"), settings: CONFIG.get("settings"),

View File

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

View File

@ -1,8 +1,17 @@
<script> <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 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 NoteSelector from './NoteSelector.vue'
import Settings from './settings/Settings.vue' import Settings from './settings/Settings.vue'
import ErrorMessages from './ErrorMessages.vue'
import NewNote from './NewNote.vue'
export default { export default {
components: { components: {
@ -10,20 +19,17 @@
StatusBar, StatusBar,
LanguageSelector, LanguageSelector,
Settings, Settings,
NoteSelector,
ErrorMessages,
NewNote,
}, },
data() { data() {
return { return {
line: 1,
column: 1,
selectionSize: 0,
language: "plaintext",
languageAuto: true,
theme: window.heynote.themeMode.initial, theme: window.heynote.themeMode.initial,
initialTheme: window.heynote.themeMode.initial, initialTheme: window.heynote.themeMode.initial,
themeSetting: 'system', 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,
} }
@ -56,13 +62,61 @@
window.heynote.themeMode.removeListener() 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: { 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() { openSettings() {
this.showSettings = true this.showSettings = true
}, },
closeSettings() { closeSettings() {
this.showSettings = false this.showSettings = false
this.$refs.editor.focus() this.focusEditor()
}, },
toggleTheme() { toggleTheme() {
@ -78,25 +132,8 @@
this.$refs.editor.focus() 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)
}, },
@ -111,7 +148,6 @@
<template> <template>
<div class="container"> <div class="container">
<Editor <Editor
@cursorChange="onCursorChange"
:theme="theme" :theme="theme"
:development="development" :development="development"
:debugSyntaxTree="false" :debugSyntaxTree="false"
@ -124,37 +160,44 @@
:fontSize="settings.fontSize" :fontSize="settings.fontSize"
:defaultBlockLanguage="settings.defaultBlockLanguage || 'text'" :defaultBlockLanguage="settings.defaultBlockLanguage || 'text'"
:defaultBlockLanguageAutoDetect="settings.defaultBlockLanguageAutoDetect === undefined ? true : settings.defaultBlockLanguageAutoDetect" :defaultBlockLanguageAutoDetect="settings.defaultBlockLanguageAutoDetect === undefined ? true : settings.defaultBlockLanguageAutoDetect"
:inert="editorInert"
class="editor" class="editor"
ref="editor" ref="editor"
@openLanguageSelector="openLanguageSelector"
/> />
<StatusBar <StatusBar
:line="line"
:column="column"
:selectionSize="selectionSize"
:language="language"
:languageAuto="languageAuto"
:theme="theme" :theme="theme"
:themeSetting="themeSetting" :themeSetting="themeSetting"
:autoUpdate="settings.autoUpdate" :autoUpdate="settings.autoUpdate"
:allowBetaVersions="settings.allowBetaVersions" :allowBetaVersions="settings.allowBetaVersions"
@toggleTheme="toggleTheme" @toggleTheme="toggleTheme"
@openNoteSelector="openNoteSelector"
@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"
/>
<NoteSelector
v-if="showNoteSelector"
@openNote="openNote"
@close="closeDialog"
/> />
<Settings <Settings
v-if="showSettings" v-if="showSettings"
:initialSettings="settings" :initialSettings="settings"
@closeSettings="closeSettings" @closeSettings="closeSettings"
/> />
<NewNote
v-if="showCreateNote"
@close="closeDialog"
/>
<ErrorMessages />
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,6 +1,9 @@
<script> <script>
import { HeynoteEditor, LANGUAGE_SELECTOR_EVENT } from '../editor/editor.js' import { HeynoteEditor } from '../editor/editor.js'
import { syntaxTree } from "@codemirror/language" import { syntaxTree } from "@codemirror/language"
import { toRaw } from 'vue';
import { mapState } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
export default { export default {
props: { props: {
@ -38,66 +41,23 @@
data() { data() {
return { return {
syntaxTreeDebugContent: null, syntaxTreeDebugContent: null,
bufferFilePath: null,
editor: null,
} }
}, },
mounted() { mounted() {
this.$refs.editor.addEventListener("selectionChange", (e) => { this.loadBuffer(this.currentNotePath)
//console.log("selectionChange:", e)
this.$emit("cursorChange", {
cursorLine: e.cursorLine,
selectionSize: e.selectionSize,
language: e.language,
languageAuto: e.languageAuto,
})
})
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 // set up window close handler that will save the buffer and quit
window.heynote.onWindowClose(() => { 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 debugSyntaxTree prop is set, display syntax tree for debugging
if (this.debugSyntaxTree) { if (this.debugSyntaxTree) {
setInterval(() => { setInterval(() => {
@ -123,65 +83,108 @@
}, },
watch: { watch: {
currentNotePath(path) {
//console.log("currentNotePath changed to", path)
this.loadBuffer(path)
},
theme(newTheme) { theme(newTheme) {
this.editor.setTheme(newTheme) toRaw(this.editor).setTheme(newTheme)
}, },
keymap() { keymap() {
this.editor.setKeymap(this.keymap, this.emacsMetaKey) toRaw(this.editor).setKeymap(this.keymap, this.emacsMetaKey)
}, },
emacsMetaKey() { emacsMetaKey() {
this.editor.setKeymap(this.keymap, this.emacsMetaKey) toRaw(this.editor).setKeymap(this.keymap, this.emacsMetaKey)
}, },
showLineNumberGutter(show) { showLineNumberGutter(show) {
this.editor.setLineNumberGutter(show) toRaw(this.editor).setLineNumberGutter(show)
}, },
showFoldGutter(show) { showFoldGutter(show) {
this.editor.setFoldGutter(show) toRaw(this.editor).setFoldGutter(show)
}, },
bracketClosing(value) { bracketClosing(value) {
this.editor.setBracketClosing(value) toRaw(this.editor).setBracketClosing(value)
}, },
fontFamily() { fontFamily() {
this.editor.setFont(this.fontFamily, this.fontSize) toRaw(this.editor).setFont(this.fontFamily, this.fontSize)
}, },
fontSize() { fontSize() {
this.editor.setFont(this.fontFamily, this.fontSize) toRaw(this.editor).setFont(this.fontFamily, this.fontSize)
}, },
defaultBlockLanguage() { defaultBlockLanguage() {
this.editor.setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect) toRaw(this.editor).setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect)
}, },
defaultBlockLanguageAutoDetect() { defaultBlockLanguageAutoDetect() {
this.editor.setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect) toRaw(this.editor).setDefaultBlockLanguage(this.defaultBlockLanguage, this.defaultBlockLanguageAutoDetect)
}, },
}, },
computed: {
...mapState(useNotesStore, [
"currentNotePath",
]),
},
methods: { methods: {
setLanguage(language) { loadBuffer(path) {
if (language === "auto") { if (this.editor) {
this.editor.setCurrentLanguage(null, true) this.editor.destroy()
} else {
this.editor.setCurrentLanguage(language, false)
} }
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() { formatCurrentBlock() {
this.editor.formatCurrentBlock() const editor = toRaw(this.editor)
this.editor.focus() editor.formatCurrentBlock()
editor.focus()
}, },
onCurrenciesLoaded() { onCurrenciesLoaded() {
this.editor.currenciesLoaded() if (this.editor) {
toRaw(this.editor).currenciesLoaded()
}
}, },
focus() { 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> <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 { useNotesStore } from "../stores/notes-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", "theme",
"themeSetting", "themeSetting",
"autoUpdate", "autoUpdate",
@ -33,8 +30,17 @@
}, },
computed: { computed: {
...mapState(useNotesStore, [
"currentNoteName",
"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 +48,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 +60,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 +78,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('openNoteSelector')"
class="status-block note clickable"
:title="changeNoteTitle"
>
{{ currentNoteName }}
</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"
> >
@ -100,7 +117,7 @@
<span :class="'icon ' + themeSetting"></span> <span :class="'icon ' + themeSetting"></span>
</div> </div>
<div <div
@click="$emit('openSettings')" @click.stop="$emit('openSettings')"
class="status-block settings clickable" class="status-block settings clickable"
title="Settings" 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 { syntaxTree, ensureSyntaxTree, syntaxTreeAvailable } from "@codemirror/language"
import { Note, Document, NoteDelimiter } from "../lang-heynote/parser.terms.js" import { Note, Document, NoteDelimiter } from "../lang-heynote/parser.terms.js"
import { IterMode } from "@lezer/common"; import { IterMode } from "@lezer/common";
import { useNotesStore } from "../../stores/notes-store.js"
import { heynoteEvent, LANGUAGE_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";
@ -404,7 +404,9 @@ function getSelectionSize(state, sel) {
return count return count
} }
const emitCursorChange = (editor) => ViewPlugin.fromClass( const emitCursorChange = (editor) => {
const notesStore = useNotesStore()
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),
@ -419,17 +421,16 @@ 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({ notesStore.currentCursorLine = cursorLine
cursorLine, notesStore.currentSelectionSize = selectionSize
selectionSize, notesStore.currentLanguage = block.language.name
language: block.language.name, notesStore.currentLanguageAuto = block.language.auto
languageAuto: block.language.auto,
}))
} }
} }
} }
} }
) )
}
export const noteBlockExtension = (editor) => { export const noteBlockExtension = (editor) => {
return [ 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 { EditorView, keymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view"
import { indentUnit, forceParsing, foldGutter, ensureSyntaxTree } from "@codemirror/language" import { indentUnit, forceParsing, foldGutter, ensureSyntaxTree } from "@codemirror/language"
import { markdown } from "@codemirror/lang-markdown" import { markdown } from "@codemirror/lang-markdown"
@ -22,8 +22,8 @@ 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 { NoteFormat } from "./note-format.js" import { NoteFormat } from "./note-format.js"
import { useNotesStore } from "../stores/notes-store.js";
export const LANGUAGE_SELECTOR_EVENT = "openLanguageSelector"
function getKeymapExtensions(editor, keymap) { function getKeymapExtensions(editor, keymap) {
if (keymap === "emacs") { if (keymap === "emacs") {
@ -37,10 +37,10 @@ function getKeymapExtensions(editor, keymap) {
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,
@ -48,8 +48,11 @@ export class HeynoteEditor {
bracketClosing=false, bracketClosing=false,
fontFamily, fontFamily,
fontSize, fontSize,
defaultBlockToken,
defaultBlockAutoDetect,
}) { }) {
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
@ -60,9 +63,10 @@ export class HeynoteEditor {
this.deselectOnCopy = keymap === "emacs" this.deselectOnCopy = keymap === "emacs"
this.emacsMetaKey = emacsMetaKey this.emacsMetaKey = emacsMetaKey
this.fontTheme = new Compartment this.fontTheme = new Compartment
this.defaultBlockToken = "text" this.setDefaultBlockLanguage(defaultBlockToken, defaultBlockAutoDetect)
this.defaultBlockAutoDetect = true this.contentLoaded = false
this.saveFunction = saveFunction this.notesStore = useNotesStore()
const state = EditorState.create({ const state = EditorState.create({
doc: "", doc: "",
@ -88,7 +92,7 @@ export class HeynoteEditor {
}), }),
heynoteLang(), heynoteLang(),
noteBlockExtension(this), noteBlockExtension(this),
languageDetection(() => this), languageDetection(path, () => this),
// set cursor blink rate to 1 second // set cursor blink rate to 1 second
drawSelection({cursorBlinkRate:1000}), drawSelection({cursorBlinkRate:1000}),
@ -98,7 +102,7 @@ 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"}
}), }),
this.saveFunction ? autoSaveContent(this, 2000) : [], autoSaveContent(this, 2000),
todoCheckboxPlugin, todoCheckboxPlugin,
markdown(), markdown(),
@ -107,34 +111,66 @@ export class HeynoteEditor {
}) })
// 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", () => {
this.save() this.save()
}) })
}
this.view = new EditorView({ this.view = new EditorView({
state: state, state: state,
parent: element, parent: element,
}) })
this.setContent(content) //this.setContent(content)
this.setReadOnly(true)
this.loadContent().then(() => {
this.setReadOnly(false)
})
if (focus) { if (focus) {
this.view.focus() this.view.focus()
} }
} }
save() { async save() {
this.saveFunction(this.getContent()) 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() {
this.note.content = this.view.state.sliceDoc() this.note.content = this.view.state.sliceDoc()
this.note.cursors = this.view.state.selection.toJSON() 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() 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) { setContent(content) {
try { try {
this.note = NoteFormat.load(content) this.note = NoteFormat.load(content)
@ -143,6 +179,7 @@ export class HeynoteEditor {
this.setReadOnly(true) this.setReadOnly(true)
throw e throw e
} }
this.notesStore.currentNoteName = this.note.metadata?.name || this.path
return new Promise((resolve) => { return new Promise((resolve) => {
// set buffer content // set buffer content
this.view.dispatch({ this.view.dispatch({
@ -151,7 +188,7 @@ export class HeynoteEditor {
to: this.view.state.doc.length, to: this.view.state.doc.length,
insert: this.note.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 // 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() { openLanguageSelector() {
this.element.dispatchEvent(new Event(LANGUAGE_SELECTOR_EVENT)) this.notesStore.openLanguageSelector()
}
openNoteSelector() {
this.notesStore.openNoteSelector()
}
openCreateNote() {
this.notesStore.openCreateNote()
} }
setCurrentLanguage(lang, auto=false) { setCurrentLanguage(lang, auto=false) {
@ -257,6 +302,15 @@ export class HeynoteEditor {
currenciesLoaded() { currenciesLoaded() {
triggerCurrenciesLoaded(this.view.state, this.view.dispatch) 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-ArrowUp", moveLineUp],
["Alt-ArrowDown", moveLineDown], ["Alt-ArrowDown", moveLineDown],
["Mod-l", () => editor.openLanguageSelector()], ["Mod-l", () => editor.openLanguageSelector()],
["Mod-p", () => editor.openNoteSelector()],
["Mod-s", () => editor.openCreateNote()],
["Alt-Shift-f", formatBlockContent], ["Alt-Shift-f", formatBlockContent],
["Mod-Alt-ArrowDown", newCursorBelow], ["Mod-Alt-ArrowDown", newCursorBelow],
["Mod-Alt-ArrowUp", newCursorAbove], ["Mod-Alt-ArrowUp", newCursorAbove],

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,17 @@ function cancelIdleCallbackCompat(id) {
} }
} }
export function languageDetection(getEditor) { // 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');
detectionWorker.onmessage = (event) => {
const detectionWorker = new Worker('langdetect-worker.js?worker');
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 editor = getEditor()
const editor = editorInstances[event.data.path]
//const editor = getEditor()
const view = editor.view const view = editor.view
const state = view.state const state = view.state
const block = getActiveNoteBlock(state) const block = getActiveNoteBlock(state)
@ -56,9 +56,18 @@ export function languageDetection(getEditor) {
console.log("Content has changed significantly, not setting new language") console.log("Content has changed significantly, not setting new language")
} }
} }
} }
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)
@ -90,7 +99,7 @@ export function languageDetection(getEditor) {
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 default // if content is cleared, set language to default
const editor = getEditor() //const editor = getEditor()
const view = editor.view const view = editor.view
const block = getActiveNoteBlock(view.state) const block = getActiveNoteBlock(view.state)
if (block.language.name !== editor.defaultBlockToken) { if (block.language.name !== editor.defaultBlockToken) {
@ -108,12 +117,20 @@ export function languageDetection(getEditor) {
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

@ -1,5 +1,6 @@
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 = (editor, interval) => { export const autoSaveContent = (editor, interval) => {
@ -12,9 +13,12 @@ export const autoSaveContent = (editor, interval) => {
class { class {
update(update) { update(update) {
if (update.docChanged) { if (update.docChanged) {
const initialSetContent = update.transactions.flatMap(t => t.annotations).some(a => a.value === SET_CONTENT)
if (!initialSetContent) {
save() save()
} }
} }
} }
}
) )
} }

View File

@ -6,6 +6,7 @@ import { createPinia } from 'pinia'
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 { useErrorStore } from './stores/error-store'
import { useNotesStore, initNotesStore } from './stores/notes-store'
const pinia = createPinia() const pinia = createPinia()
@ -18,10 +19,12 @@ app.mount('#app').$nextTick(() => {
}) })
const errorStore = useErrorStore() const errorStore = useErrorStore()
//errorStore.addError("test error")
window.heynote.getInitErrors().then((errors) => { window.heynote.getInitErrors().then((errors) => {
errors.forEach((e) => errorStore.addError(e)) errors.forEach((e) => errorStore.addError(e))
}) })
initNotesStore()
@ -29,3 +32,4 @@ window.heynote.getInitErrors().then((errors) => {
loadCurrencies() loadCurrencies()
setInterval(loadCurrencies, 1000 * 3600 * 4) 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, defaultFontSize: isMobileDevice ? 16 : 12,
buffer: { buffer: {
async load() { async load(path) {
const content = localStorage.getItem("buffer") const content = localStorage.getItem(path)
return content === null ? "\n∞∞∞text-a\n" : content return content === null ? "\n∞∞∞text-a\n" : content
}, },
async save(content) { async save(path, content) {
localStorage.setItem("buffer", 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) {
}, },
}, },