Thought cabinet

This commit is contained in:
Dakedres 2024-12-11 19:21:57 -07:00
parent 50acba5cdb
commit bb6662d7b1
41 changed files with 32477 additions and 2338 deletions

8
.gitignore vendored
View File

@ -1,11 +1,7 @@
.temp
.tmp
node_modules/
/index.html
/bin
/dist
/build
/src/lib
/lib
yarn-error.log
neutralinojs.log
yarn-error.log

View File

@ -1 +1 @@
# Qwe
# Thought Cabinet

BIN
assets/bottom-dither.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

BIN
assets/colors.css Normal file

Binary file not shown.

BIN
assets/top-dither.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

View File

@ -1,63 +0,0 @@
// Usage: node build <platform> <[dev]/prod>
import Path from 'path'
import { copyFile } from 'fs/promises'
let [ platform, environment = 'dev' ] = process.argv.slice(2)
let projectRoot = Path.dirname(new URL(import.meta.url).pathname)
const indexTemplate = scriptSources => {
let scripts = scriptSources.map(src => "<script async src=" + JSON.stringify(src) + "></script>")
// if(platform == 'neutralino')
// scripts.unshift('<script src="build/neutralino.js"></script>')
return `\
<html>
<head>
<!-- <link rel="icon" href=".temp/editorapp/resources/icons/appIcon.png"> -->
${scripts.join('\n')}
<script async type="module" src="./editor.js"></script>
<style id="stylesheet">
body { display: flex; flex-direction: column; margin: 0; height: 100vh; max-height: 100vh; font-size: 14px; }
nav { background: #f5f5f5; color: #6c6c6c; margin: 2px 5px }
nav input { all: unset; text-decoration: underline; font-family: sans-serif; }
.cm-editor { flex-grow: 1; outline: 1px solid #ddd; overflow-y: auto; }
</style>
</head>
</html>
`
}
let out
switch(environment) {
case 'dev':
let scripts = [
Path.join('platforms', platform + '.js')
]
out = indexTemplate(scripts)
break
case 'prod':
await copyFile(
Path.join(projectRoot, 'platforms', platform + '.js'),
Path.join(projectRoot, 'build', 'platform.js')
)
out = indexTemplate([
'platform.js',
'config.js',
'editor.js',
'libs.js'
])
break
}
process.stdout.write(out)

43
debug.js Normal file
View File

@ -0,0 +1,43 @@
/*
I'm imagining a notes app
It is styled like an actual notepad, with a dogear in the bottom right. Clicking on the left side of the dog ear goes to the most recent day, clicking right goes through previous days.
At the top right is a little hamburger button and a pound symbol button.
- The hamburger lists all days in an empty note
- The pound symbol lists all tags in an empty note
Middle clicking on a date lists all days in an empty note
Middle clicking on a tag lists all tags in an empty note
An empty note is a note that is not saved
The current note is stored in window.location.hash
Other than opening to the current day on launch, the application actually has no conceptualization of dates or time. It merely alphabetically sorts the files available in the working directory
*/
/*
Handles are a public, constant object that merely needs to be passable into device methods to represent a file
device.Open must return a File
*/
const modules = [
'date',
'editor',
'fs',
'list',
'note',
'tags',
'view'
]
;(async () => {
for(let module of modules) {
window[module] = await import(`/src/${module}.js`)
}
window.device = await import('device').then(m => m.default)
window.cm = await import('/lib/codemirror.js').then(m => m.default)
})()

48
device/browser.js Normal file
View File

@ -0,0 +1,48 @@
import * as store from './store.js'
const noWorkingDirWarning = "Please open a directory to allow file saving."
export async function Init() {
await store.Init()
window.cwd = await store.Get('directory')
}
export async function cd() {
window.cwd = await window.showDirectoryPicker()
await store.Set('directory', window.cwd)
}
export async function Entries() {
let pool = new Map()
for await (let [ , e ] of window.cwd.entries()) {
if(e instanceof FileSystemFileHandle) {
pool.set(e.name, e)
}
}
return pool
}
export async function Create(sPath) {
return window.cwd.getFileHandle(sPath, { create: true })
}
export async function Open(hFile) {
if(!window.cwd) {
return new Blob([ noWorkingDirWarning ], { type: "text/plain" })
}
return hFile.getFile()
}
export async function Write(hFile, sContent) {
if(!window.cwd) {
throw new Error("Unable to save file: no working directory")
}
let s = await hFile.createWritable()
.catch(console.error)
await s.write(sContent)
await s.close()
}

68
device/nw.js Normal file
View File

@ -0,0 +1,68 @@
const FS = require('fs/promises')
const Path = require('path')
const themePath = process.env.HOME ? Path.join(process.env.HOME, '.config/base16.css') : 'base16.css'
function handle(sPath) {
return {
name: Path.basename(sPath),
path: absolute(sPath)
}
}
function absolute(sPath) {
return Path.isAbsolute(sPath) ? sPath : Path.join(window.cwd, sPath)
}
export async function Init() {
window.cwd = nw.App.startPath
await loadTheme()
watchTheme()
}
async function watchTheme() {
let watcher = await FS.watch(themePath)
for await (const event of watcher) {
await loadTheme()
}
}
async function loadTheme() {
let f = await Open(handle(themePath))
loadStylesheet(f)
}
function loadStylesheet(fStylesheet) {
let l = document.createElement('link')
l.rel = "stylesheet"
l.href = URL.createObjectURL(fStylesheet)
document.head.appendChild(l)
}
export async function Entries() {
let pool = new Map()
for(let d of await FS.readdir(window.cwd, { withFileTypes: true })) {
if(d.isFile()) {
let h = handle(d.name)
pool.set(d.name, h)
}
}
return pool
}
export async function Create(sPath) {
return handle(sPath)
}
export async function Open(hFile) {
let b = await FS.readFile(hFile.path)
return new File([ b ], hFile.name)
}
export async function Write(hFile, sContent) {
return await FS.writeFile(hFile.path, sContent, { encoding: 'utf-8' })
}

46
device/store.js Normal file
View File

@ -0,0 +1,46 @@
const app = window.app
const dbName = 'notes'
const objectStoreName = 'files'
export function transact() {
return window.db
.transaction(objectStoreName, "readwrite")
.objectStore(objectStoreName)
}
export function promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = (event) => {
resolve(request.result)
}
request.onerror = (event) => {
reject(request.error)
}
})
}
export async function Init() {
const request = indexedDB.open(dbName, 2)
request.onupgradeneeded = (event) => {
const db = event.target.result
const objectStore = db.createObjectStore(objectStoreName)
objectStore.createIndex('value', 'value', { unique: false })
}
window.db = await promisify(request)
}
export function Get(key) {
return promisify(
transact().get(key)
)
}
export function Set(key, value) {
return promisify(
transact().put(value, key)
)
}

205
index.html Normal file
View File

@ -0,0 +1,205 @@
<html>
<head>
<!-- start importmap -->
<script type="importmap">
{
"imports": {
"device": "./device/nw.js"
}
}
</script>
<!-- end importmap -->
<script>
window.app = {}
</script>
<script src="/src/cabinet.js" type="module"></script>
<script src="/debug.js" type="module"></script>
<style>
:root {
--iiv: #e8e4cf;
--iv: #999580;
--v: #6684e1;
--vi: #292824;
--vii: #20201d;
--a: #ae9513;
}
:root {
--iiv: var(--base00);
--iv: var(--base01);
--v: var(--base0C);
--vi: var(--base07);
--vii: var(--base06);
--a: var(--base0A)
}
body {
margin: 0;
caret-color: auto !important
}
main {
height: 100vh;
flex-direction: column;
box-sizing: border-box;
overflow-y: auto;
padding: 4px 0;
}
body, .cm-editor .cm-scroller {
font-size: 14px;
font-family: monospace;
}
#next-note, #prev-note {
position: absolute;
left: 0;
bottom: 0;
height: 24px;
width: 24px;
border: 0;
outline: none;
z-index: 1;
}
#next-note {
clip-path: polygon(100% 0, 0 0, 100% 100%);
z-index: 2;
}
nav {
top: 0;
right: 0;
z-index: 2;
display: flex;
flex-direction: row;
justify-content: end;
align-content: center;
}
nav, footer, header {
position: absolute;
width: 100%;
height: 24px;
}
footer, header {
position: absolute;
pointer-events: none;
user-select: none;
content: " ";
mask-mode: luminance;
pointer-events: none;
z-index: 1;
}
header {
top: 0;
mask-image: url(/assets/top-dither.png);
}
footer {
bottom: 0;
mask-image: url(/assets/bottom-dither.png);
}
aside {
display: inline-block;
margin: 0;
height: 24px;
position: relative;
z-index: 2;
font-weight: bold;
}
a {
text-decoration: none;
color: inherit;
}
a svg {
height: 100%;
}
.cm-editor {
padding-bottom: 40vh;
}
.cm-editor .cm-line, nav, aside {
padding: 0 2px 0 6px;
}
nav {
padding-right: 6px;
}
.cm-editor, .cm-editor.cm-focused {
flex-grow: 1;
outline: none;
overflow-y: auto
}
.cm-gutters {
user-select: none;
cursor: vertical-text;
}
::-webkit-scrollbar {
background: transparent;
width: 6px;
height: 6px;
}
</style>
<link rel="stylesheet" href="assets/colors.css">
</head>
<body>
<main id="main">
<aside inert>
<span id="streak"></span>
<span id="title"></span>
</aside>
<header></header>
<footer></footer>
<nav id="menubar">
<a tabindex="-1" href="#&notes">
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <path d="M4 6h16v2H4V6zm0 5h16v2H4v-2zm16 5H4v2h16v-2z" fill="currentColor"/> </svg>
</a>
<a tabindex="-1" href="#&tags">
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h4v4H7V7zm6 0h4v4h-4V7zm-6 6h4v4H7v-4zm6 0h4v4h-4v-4z" fill="currentColor"/> </svg>
</svg>
</a>
</nav>
<button tabindex="-1" id="next-note" onclick="note.Next()"></button>
<button tabindex="-1" id="prev-note" onclick="note.Prev()"></button>
</main>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import * as m from '@codemirror/lang-javascript'
export default m

View File

@ -1,2 +0,0 @@
import * as m from '@codemirror/lang-markdown'
export default m

View File

@ -1,22 +0,0 @@
function Platform() {
this.save = async (path, data) => {
localStorage.setItem(path, data)
}
this.open = (path) => {
return localStorage.getItem(path)
}
this.readdir = (path) => {
return Object.keys(localStorage)
.filter(key => key.startsWith(path))
}
this.createWindow = url => {
return window.open(url ?? 'about:blank', '_blank')
}
this.createInstance = url => {
return window.open(url ?? this.location)
}
}

View File

@ -1,125 +0,0 @@
{
let userHome
const handleNeutralinoError = err => {
throw new Error(err.code + ': ' + err.message)
}
const mapDirent = ({ entry, type }) => ({
name: entry,
type
})
window.Platform = {
//
// Paths
//
dirname(path) {
let normalPath = this.normalize(path)
let index = normalPath.lastIndexOf('/')
return index === -1 ? '' : normalPath.slice(0, index)
},
filename(path) {
let index = path.lastIndexOf('/')
return index === -1 ? path : path.slice(index + 1)
},
ext(path) {
let filename = this.filename(path)
let index = filename.lastIndexOf('.')
return index === -1 ? '' : filename.slice(index + 1)
},
join(...args) {
let parts = []
for(let arg of args) {
parts = parts.concat(arg.split('/'))
}
return this.normalizePathFromParts(parts)
},
// TODO: support non-posix
absolute(path) {
return path.charAt(0) === '/'
},
normalize(path) {
return this.normalizePathFromParts(path.split('/'))
},
normalizePathFromParts(parts) {
// let newPath = path !== '/' && path.endsWith('/') ?
// path.slice(0, -1) :
// path
let out = []
let skips = 0
for(let i = parts.length - 1; i >= 0; i--) {
let part = parts[i]
if(part.length == 0 || part === '.') {
continue
} else if(part == '..') {
skips++
continue
} else if(skips == 0) {
out.unshift(part)
} else {
skips--
}
}
return '/' + out.join('/')
},
//
// FS
//
async access(path) {
try {
await Neutralino.filesystem.getStats(path)
return
} catch(err) {
if(err.name = 'NE_FS_NOPATHE') {
return false
} else {
throw err
}
}
},
writeText(path, content) {
return Neutralino.filesystem.writeFile(path, content)
.catch(handleNeutralinoError)
},
readText(path) {
return Neutralino.filesystem.readFile(path)
.catch(handleNeutralinoError)
},
readdir(path) {
return Neutralino.filesystem.readDirectory(path)
.catch(handleNeutralinoError)
.then(dirents => dirents.map(mapDirent))
},
//
// OS
//
async getHome() {
return userHome ?? (userHome = await Neutralino.os.getEnv('HOME'))
},
}
}

View File

@ -1,48 +0,0 @@
import nodeResolve from "@rollup/plugin-node-resolve";
import Path, { format } from 'path'
import FS from 'fs'
let plugins = [
nodeResolve()
]
let targetDir = '../src/lib'
const dirname = Path.dirname(new URL(import.meta.url).pathname)
let langDir = Path.join(dirname, 'lang')
export default [
{
input: [
'./codemirror.js',
...FS.readdirSync(langDir, { withFileTypes: true })
.filter(dirent => dirent.isFile() && dirent.name.endsWith('.js'))
.map(dirent => Path.join(langDir, dirent.name))
],
output: {
dir: targetDir,
format: "es"
},
plugins
},
// ...FS.readdirSync(langDir, { withFileTypes: true })
// .filter(dirent => dirent.isFile() && dirent.name.endsWith('.js'))
// .map(dirent => ({
// input: Path.join(langDir, dirent.name),
// external: [
// '@codemirror/state',
// '@codemirror/commands',
// '@codemirror/view',
// '@codemirror/language',
// '@codemirror/search',
// '@codemirror/autocomplete',
// '@codemirror/lint'
// ],
// output: {
// file: Path.join(targetDir, dirent.name),
// format: "es",
// globals
// },
// plugins
// }))
]

View File

@ -1,47 +0,0 @@
{
"applicationId": "net.sys42.dakedres.editor",
"version": "1.0.0",
"defaultMode": "window",
"port": 0,
"documentRoot": "/",
"url": "/src/index.html",
"enableServer": true,
"enableNativeAPI": true,
"tokenSecurity": "one-time",
"logging": {
"enabled": true,
"writeToLogFile": true
},
"nativeAllowList": [
"app.*",
"os.*",
"filesystem.*",
"debug.log"
],
"globalVariables": {},
"modes": {
"window": {
"title": "waw",
"width": 800,
"height": 500,
"minWidth": 400,
"minHeight": 200,
"fullScreen": false,
"alwaysOnTop": false,
"icon": "/resources/icon.png",
"enableInspector": true,
"borderless": false,
"maximize": false,
"hidden": false,
"resizable": true,
"exitProcessOnClose": true
}
},
"cli": {
"binaryName": "qwe",
"resourcesPath": "/build/",
"clientLibrary": "/build/neutralino.js",
"binaryVersion": "4.14.1",
"clientVersion": "3.12.0"
}
}

View File

@ -1,40 +0,0 @@
# Deps
Needed to
```sudo apt install libsoup-2.4-dev libwebkit2gtk-4.0-dev libjavascriptcoregtk-4.0-dev```
On void:
```
sudo xbps-install webkit2gtk-devel
```
---
# Functionality
## View settings
View settings contain
- monospace or proportional
- line gutters enabled
- linting enabled
- user-selected language stylization
- etc.
These are associated with the individual file, or the workspace if one is set.
They are only saved once a file is saved to the filesystem.
## Workspace
Your workspace is indicated on the right-side of the status bar, and represents the directory that view settings are being saved to
It can be edited by clicking on the workspace entry, or pressing Ctrl-W. The prompt will expand to fit the whole status bar, and will default to the workspace of the current file
Directory view will show the workspace if one is defined
## Directory view
Directory view is opened with ctrl-l
It will show a tree of the current directory, with subdirectories folded. The cursor will by default be at the position of the last file opened.

View File

@ -1,22 +1,17 @@
{
"name": "editor",
"name": "tc",
"version": "1.0.0",
"main": "index.js",
"main": "index.html",
"license": "MIT",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-markdown": "^6.2.3",
"@neutralinojs/neu": "^10.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"codemirror": "^6.0.1",
"rollup": "^4.9.1"
"@codemirror/lang-markdown": "^6.2.3",
"rollup": "^4.9.1",
"@rollup/plugin-node-resolve": "^15.2.3"
},
"scripts": {
"build-deps": "cd lib && rollup -c",
"dev": "neu run",
"build": "neu build",
"update": "neu update",
"install:neu": "rm src/lib/platform.js && ln lib/platforms/neutralino.js src/lib/platform.js && ln build/neutralino.js src/lib/neutralino.js"
"mode:browser": ""
},
"type": "module"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

53
rollup/codemirror.js Normal file
View File

@ -0,0 +1,53 @@
import { EditorState, Compartment, Transaction, Annotation, EditorSelection } from '@codemirror/state'
import { indentWithTab, undo, redo, history, defaultKeymap, historyKeymap, indentMore, indentLess } from '@codemirror/commands'
import { EditorView, ViewPlugin, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine, keymap } from '@codemirror/view'
import { foldGutter, indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language'
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
import { closeBrackets, autocompletion, closeBracketsKeymap, startCompletion, closeCompletion, acceptCompletion, moveCompletionSelection } from '@codemirror/autocomplete'
import { lintKeymap } from '@codemirror/lint'
import * as markdown from '@codemirror/lang-markdown'
export default {
EditorView,
Compartment,
EditorState,
keymap,
indentWithTab,
lineNumbers,
highlightActiveLineGutter,
highlightSpecialChars,
history,
foldGutter,
drawSelection,
dropCursor,
indentOnInput,
syntaxHighlighting,
bracketMatching,
closeBrackets,
autocompletion,
rectangularSelection,
crosshairCursor,
highlightActiveLine,
highlightSelectionMatches,
closeBracketsKeymap,
defaultKeymap,
searchKeymap,
historyKeymap,
foldKeymap,
// completionKeymap,
lintKeymap,
undo,
redo,
Transaction,
Annotation,
defaultHighlightStyle,
startCompletion,
closeCompletion,
moveCompletionSelection,
acceptCompletion,
indentMore,
indentLess,
EditorSelection,
ViewPlugin,
...markdown
}

23
rollup/rollup.config.mjs Normal file
View File

@ -0,0 +1,23 @@
import nodeResolve from "@rollup/plugin-node-resolve";
import Path, { format } from 'path'
import FS from 'fs'
let plugins = [
nodeResolve()
]
let targetDir = '../build'
const dirname = Path.dirname(new URL(import.meta.url).pathname)
let langDir = Path.join(dirname, 'lang')
export default [
{
input: [ './codemirror.js' ],
output: {
dir: targetDir,
format: "es"
},
plugins
},
]

17
src/cabinet.js Normal file
View File

@ -0,0 +1,17 @@
import * as device from 'device'
import * as editor from './editor.js'
import * as view from './view.js'
import * as date from './date.js'
import * as note from './note.js'
async function main() {
await device.Init()
await view.Init()
await note.Init()
editor.Init()
await note.CheckHash()
view.Streak()
}
window.addEventListener('load', main)

View File

@ -1,61 +0,0 @@
export default {
statusBarAtTop: false,
landingDocument: {
name: 'welcome.md',
render() {
return 'Welcome to qwe!'
}
},
binds: {
reload: 'Ctrl-Escape',
editPath: 'Ctrl-r',
save: 'Ctrl-s',
open: 'Ctrl-o',
openDirectory: 'Ctrl-l'
},
languages: [
{
exts: [ 'js', 'mjs', 'cjs', 'ejs' ],
name: 'js',
import: './lib/javascript.js',
createExtension({ javascript }) {
return javascript({
})
}
},
{
exts: [ 'md' ],
name: 'md',
import: './lib/markdown.js',
createExtension({ markdown }) {
return markdown()
}
}
],
plumb(selection) {
if(selection.length == 0)
return
let isDirectory = selection.endsWith('/')
editor.setDocPath(
Platform.absolute(selection) ?
selection :
Platform.join(Platform.dirname(editor.doc.getPath()), selection)
)
selection = editor.doc.getPath()
if(isDirectory) {
return editor.openDirectory(selection)
} else {
return editor.openDocument(selection)
}
}
}

13
src/date.js Normal file
View File

@ -0,0 +1,13 @@
export function Is(sKey) {
return /^([0-9]+[./-]){2}[0-9]+$/.test(sKey) && !Number.isNaN(Date.parse(sKey))
}
export function Current() {
let now = new Date()
return [
(now.getMonth() + 1).toString().padStart(2, 0),
now.getDate().toString().padStart(2, 0),
now.getFullYear().toString().slice(-2)
].join('.')
}

81
src/editor.js Normal file
View File

@ -0,0 +1,81 @@
const app = window.app
import cm from '/lib/codemirror.js'
import * as view from './view.js'
import * as note from './note.js'
export function Init() {
app.editor = new cm.EditorView({
state: createState(),
parent: app.main
// parent: view.editorContainer
})
app.editor.dom.tabindex = 0
}
function extensions() {
return [
cm.markdownLanguage.extension,
cm.syntaxHighlighting(cm.defaultHighlightStyle, { fallback: true }),
cm.history(),
cm.dropCursor(),
cm.EditorView.lineWrapping,
cm.ViewPlugin.fromClass(class {
update(event) {
if(event.focusChanged && !app.editor.hasFocus) {
app.editor.focus()
}
if(event.docChanged) {
SaveTimeout()
}
}
}),
cm.keymap.of(keymap())
]
}
function keymap() {
return [
...cm.closeBracketsKeymap,
...cm.defaultKeymap,
...cm.searchKeymap,
...cm.foldKeymap,
...cm.lintKeymap,
...cm.historyKeymap,
{ key: "Tab", run: cm.indentMore, shift: cm.indentLess },
{ key: "Ctrl-h", run: note.Prev },
{ key: "Ctrl-j", run: note.Prev },
{ key: "Ctrl-k", run: note.Next },
{ key: "Ctrl-l", run: note.Next },
]
}
function SaveTimeout() {
if(app.saveTimeout != null) {
return
}
app.saveTimeout = setTimeout(async () => {
await note.Save()
app.saveTimeout = null
}, 1000)
}
export function Load() {
app.editor.setState(createState())
app.editor.focus()
}
function createState() {
return cm.EditorState.create({
extensions: extensions(),
doc: app.disk
})
}
export function Content() {
return app.editor.state.doc.toString()
}

10
src/fs.js Normal file
View File

@ -0,0 +1,10 @@
import * as device from 'device'
export function Basename(sPath) {
return sPath.slice(sPath.lastIndexOf('/') + 1, sPath.lastIndexOf('.'))
}
export function Text(hFile) {
return hFile == null ? '' : device.Open(hFile)
.then(f => f.text())
}

View File

@ -1,23 +0,0 @@
<html>
<head>
<script async src="./lib/neutralino.js"></script>
<script async src="./lib/platform.js"></script>
<script async type="module" src="./qwe.js"></script>
<style id="stylesheet">
body { display: flex; flex-direction: column; margin: 0; height: 100vh; max-height: 100vh; font-size: 14px; }
nav { background: #f5f5f5; color: #6c6c6c; margin: 2px 5px }
nav input { all: unset; text-decoration: underline; font-family: sans-serif; }
nav #workspace { float: right; text-align: right; }
.cm-editor { flex-grow: 1; outline: 1px solid #ddd; overflow-y: auto; }
.cm-gutters { user-select: none; cursor: vertical-text; }
.cm-editor::-webkit-scrollbar, .cm-scroller::-webkit-scrollbar { background: #f5f5f5; width: 14px; height: 14px; }
.cm-editor::-webkit-scrollbar-thumb, .cm-scroller::-webkit-scrollbar-thumb { background: #ddd }
.cm-editor::-webkit-scrollbar-corner, .cm-scroller::-webkit-scrollbar-corner { background: #f5f5f5; }
</style>
</head>
</html>

19
src/list.js Normal file
View File

@ -0,0 +1,19 @@
export const pageHash = '&notes'
export async function All() {
let a = ''
for(let [ n, f ] of app.all.entries()) {
a += summarize(n, await fs.Text(f)) + '\n'
}
return a
}
function summarize(sName, sDoc) {
let s = ' ' + sName
let t = tags.List(sDoc)
if(t) s += ' - ' + t
return s
}

130
src/note.js Normal file
View File

@ -0,0 +1,130 @@
const app = window.app
import * as device from 'device'
import * as fs from './fs.js'
import * as date from './date.js'
import * as tags from './tags.js'
import * as list from './list.js'
import * as view from './view.js'
import * as editor from './editor.js'
export async function Init() {
await populate()
await Load()
window.addEventListener('hashchange', CheckHash)
}
export async function CheckHash() {
if(window.location.hash === '') {
Open(date.Current())
return
}
if(window.location.hash.slice(1) === app.name) {
return
}
await Load()
editor.Load()
}
export async function populate() {
let f = await device.Entries()
// TODO: refactor and check filetype
let n = [ ...f.entries() ]
.map(([ n, h ]) => [ fs.Basename(n), h ])
.filter(([ n ]) => date.Is(n) && Date.parse(n) < Date.now())
app.all = new Map(n)
order()
}
export function order() {
app.all = new Map(
[ ...app.all.entries() ]
.sort(([ a ], [ b ]) => {
return new Date(b) - new Date(a)
})
)
}
export function Open(sName) {
window.location.hash = sName
}
export function Next() {
let p
for(let c of app.all.keys()) {
if(c == app.name) {
break
}
p = c
}
Open(p ?? date.Current())
}
export function Prev() {
let p
for(let c of app.all.keys()) {
if(p == app.name) {
Open(c)
return
}
p = c
}
Open(app.all.keys().next().value)
}
export function getIndex() {
if(!this.handle) {
return app.list.length - 1
} else {
return app.list.indexOf(this.name)
}
}
// TODO: remove app.disk, change the flow so content is fed directly into new editor state
export async function Load() {
app.name = window.location.hash.substring(1)
switch(app.name) {
case tags.pageHash:
app.handle = null
app.disk = await tags.Rank()
break
case list.pageHash:
app.handle = null
app.disk = await list.All()
break
default:
app.handle = app.all.get(app.name)
await ReadDisk()
break
}
view.Title(app.name)
}
export async function ReadDisk() {
app.disk = await fs.Text(app.handle)
}
export async function Save() {
if(app.handle == null) {
if(date.Is(app.name)) {
app.handle = await device.Create(Filename())
app.all.set(app.name, app.handle)
order()
} else {
return
}
}
await device.Write(app.handle, editor.Content())
await ReadDisk()
}
export function Filename() {
return app.name + '.md'
}

View File

@ -1,419 +0,0 @@
import config from './config.js'
import cm from './lib/codemirror.js'
window.cm = cm
function Editor() {
this.init = async () => {
if(config.statusBarAtTop)
document.body.appendChild(statusbar.element)
console.log(cm, cm.EditorView)
this.workspace = await Platform.getHome()
statusbar.updateWorkspace()
this.view = new cm.EditorView({
state: await openLandingDocument()
.catch(console.error),
parent: document.body
})
if(!config.statusBarAtTop)
document.body.appendChild(statusbar.element)
this.doc.setView(this.view)
this.view.focus()
}
this.openDocument = async (path) => {
await Doc.open(path)
.then(doc => this.setDoc(doc))
.catch(err => console.error(`Failed to open "${path}": ${err}`))
}
this.saveDocument = () => {
let path = this.doc.getPath()
Platform.writeText(path, this.view.state.doc.toString())
.then(() => this.doc.boundPath = path)
// TODO: User interface for errors like this
.catch(err => 'Failed to save file:' + err)
}
this.setDocPath = path => {
this.doc.setPath(path)
statusbar.updateFilename()
}
this.setDoc = async (doc, bind) => {
this.doc = doc
statusbar.updateFilename()
this.doc.setView(this.view)
this.view.setState(await this.doc.createState())
}
this.openDirectory = async (path) => {
let fileList = (await Platform.readdir(path))
.filter(dirent => dirent.name != '.')
.map(dirent => dirent.type == 'FILE' ? dirent.name : dirent.name + '/')
.sort()
.join('\n')
let doc = new Doc(fileList, Platform.join(path, '~' + Platform.filename(path)))
this.setDoc(doc)
}
// this.updateFilename = () => {
// this.doc.path = Platform.dirname(this.doc.path) + '/' + statusbar.filename.value
// }
const openLandingDocument = async () => {
let doc = new Doc(config.landingDocument.render(), Platform.join(this.workspace, config.landingDocument.name))
this.doc = doc
statusbar.updateFilename()
return await doc.createState()
.catch(err => console.error("Could not open landing document", err))
}
this.doc = null
this.workspace = '/'
}
function Statusbar() {
this.selectFilename = () => {
// lastFilename = this.filename.value
let path = editor.doc.getPath()
if(path.startsWith(editor.workspace))
path = path.slice(editor.workspace.length + 1)
this.filename.value = path
this.filename.select()
this.filename.selectionStart = path.length - getPathName(path).length
}
this.updateFilename = () => {
this.filename.value = getPathName(editor.doc.getPath())
}
this.updateWorkspace = () => {
this.workspace.value = Platform.filename(editor.workspace)
}
const onPromptKeydown = (event) => {
switch(event.key) {
case 'Enter':
case 'Escape':
event.preventDefault()
editor.view.focus()
break
}
}
const onFilenameExit = () => {
if(this.filename.value == '') {
this.updateFilename()
} else {
let path = this.filename.value
editor.setDocPath(Platform.absolute(path) ? path : Platform.join(editor.workspace, path))
}
}
const getPathName = path => {
if(path.endsWith('/')) {
let index = path.lastIndexOf('/', path.length - 2) + 1
return path.slice(index)
} else {
return Platform.filename(path)
}
}
this.element = document.createElement('nav')
this.filename = document.createElement('input')
this.workspace = document.createElement('input')
// let lastFilename = ''
this.filename.id = "filename"
this.filename.addEventListener('click', this.selectFilename)
this.filename.addEventListener('keydown', onPromptKeydown)
this.filename.addEventListener('focusout', onFilenameExit)
this.workspace.id = "workspace"
this.element.appendChild(this.filename)
this.element.appendChild(this.workspace)
}
function Doc(content, initialPath) {
// if(path) {
// let workspaces = Platform.storage.get('workspaces')
// }
this.createState = async () => {
let ranges = []
let merge = true
let mouseDown = false
const addBlock = (block) => {
if(block.length > 0)
ranges.push(cm.EditorSelection.range(block.from, block.from + block.length))
}
let extensions = [
Doc.zen.of([
cm.lineNumbers({
domEventHandlers: {
contextmenu(v, b, e) {
event.preventDefault()
},
mousedown(view, block, event) {
// Prevent selection when dragging
event.preventDefault()
switch(event.button) {
// Right click collects each line as a selection
// Left click creates a single selection, by merging them
case 0:
case 2:
mouseDown = true
merge = event.button == 0
ranges = []
addBlock(block)
break
case 1:
config.plumb(
view.state.doc
.slice(block.from, block.from + block.length)
.toString()
.trim()
)
.then(() => view.focus())
}
},
mousemove(view, block, event) {
if(mouseDown)
addBlock(block)
// editor.view.state.selection.addRange(cm.SelectionRange.create(block.from, block.from + block.length))
},
mouseup(view, block, event) {
if(mouseDown && ranges.length > 0) {
// Join with previous selection if shift is being held down
if(event.shiftKey) {
ranges = ranges.concat(view.state.selection.ranges)
}
if(merge) {
let from = Infinity, to = 0
for(let range of ranges) {
if(range.from < from)
from = range.from
if(range.to > to)
to = range.to
}
ranges = [ cm.EditorSelection.range(from, to) ]
}
view.dispatch(editor.view.state.update({
selection: cm.EditorSelection.create(ranges)
}))
view.focus()
}
mouseDown = false
}
}
}),
cm.highlightActiveLine(),
cm.highlightActiveLineGutter(),
cm.highlightSpecialChars(),
]),
cm.foldGutter(),
cm.drawSelection(),
cm.dropCursor(),
cm.EditorState.allowMultipleSelections.of(true),
cm.indentOnInput(),
cm.syntaxHighlighting(cm.defaultHighlightStyle, { fallback: true }),
cm.bracketMatching(),
cm.closeBrackets(),
cm.autocompletion(),
cm.rectangularSelection(),
cm.crosshairCursor(),
cm.highlightSelectionMatches(),
cm.history(),
Doc.language.of(this.language ? await langManager.resolveExtension(this.language) : []),
Keymaps.default
]
return cm.EditorState.create({
extensions,
doc: content
})
}
this.setPath = newPath => {
path = newPath
this.setLanguage()
}
this.getPath = () =>
path
this.setView = (newView) => {
view = newView
this.setLanguage()
}
this.getView = () =>
view
this.setLanguage = (newLanguage = getLanguage()) => {
let oldLanguage = this.language
this.language = newLanguage
if(!view)
return
if(this.language == null) {
setViewLanguageExtension()
} else if(oldLanguage == null || oldLanguage.name !== this.language.name) {
langManager.resolveExtension(this.language)
.then(setViewLanguageExtension)
.catch(err => console.error('Failed to set language extension', err))
}
}
const getLanguage = () => {
let ext = Platform.ext(path)
if(ext) {
return langManager.exts[ext]
}
}
const setViewLanguageExtension = (extension = []) => {
view.dispatch({
effects: Doc.language.reconfigure(extension)
})
}
// This is the path in the statusbar
let path = initialPath ?? ''
let view = null
// This is where doc state info is saved
this.boundPath = initialPath
this.language = getLanguage()
}
Doc.open = async (path) => {
let content = await Platform.readText(path)
.catch(console.error)
return new Doc(content ?? '', path)
}
Doc.tabSize = new cm.Compartment
Doc.zen = new cm.Compartment
Doc.language = new cm.Compartment
const Keymaps = {
default: cm.keymap.of([
...cm.closeBracketsKeymap,
...cm.defaultKeymap,
...cm.searchKeymap,
...cm.foldKeymap,
...cm.lintKeymap,
...cm.historyKeymap,
// Alt completion keymap that merges with tab to indent
{ key: "Ctrl-Space", run: cm.startCompletion },
{ key: "Escape", run: cm.closeCompletion },
{ key: "ArrowDown", run: cm.moveCompletionSelection(true) },
{ key: "ArrowUp", run: cm.moveCompletionSelection(false) },
{ key: "PageDown", run: cm.moveCompletionSelection(true, "page") },
{ key: "PageUp", run: cm.moveCompletionSelection(false, "page") },
{ key: "Tab", run(view, event) { event.preventDefault(); cm.acceptCompletion(view) || cm.indentMore(view) }, shift: cm.indentLess },
{
key: config.binds.editPath,
run({ state }, event) {
event.preventDefault()
let mainSelection = state.selection.main
if(!mainSelection.empty)
statusbar.filename.value = state.sliceDoc(mainSelection.from, mainSelection.to)
statusbar.selectFilename()
}
},
{
key: config.binds.open,
run({ state }) {
let mainSelection = state.selection.main
if(!mainSelection.empty) {
let path = state.sliceDoc(mainSelection.from, mainSelection.to).trim()
config.plumb(path)
return
}
editor.openDirectory(editor.doc.getPath())
}
},
{
key: config.binds.save,
run() {
editor.saveDocument()
}
},
{
key: config.binds.openDirectory,
async run() {
let path = editor.doc.getPath()
if(Platform.filename(path).startsWith('~'))
path = Platform.join(path, '../../')
else
path = Platform.dirname(path)
editor.openDirectory(path)
}
},
{
key: config.binds.reload,
run() {
window.location.href += ''
}
}
])
}
function LanguageManager() {
this.resolveExtension = (language) =>
import(language.import)
.then(mod => language.createExtension(mod.default))
this.exts = {}
for(let language of config.languages) {
for(let ext of language.exts)
this.exts[ext] = language
}
}
window.addEventListener('load', () => {
Neutralino.init()
window.langManager = new LanguageManager()
window.editor = new Editor()
window.statusbar = new Statusbar()
editor.init()
})

53
src/tags.js Normal file
View File

@ -0,0 +1,53 @@
export const pageHash = '&tags'
export function Scan(sDoc) {
let t = []
let r = /(\s|^)\#([a-z-]+)/gmi
let m
while((m = r.exec(sDoc)) !== null) {
if(m.index === r.lastIndex) {
r.lastIndex++
}
t.push(m[2])
}
return t
}
export function List(sDoc) {
let t = Scan(sDoc)
return t.map(t => '#' + t).join(', ')
}
// Map<string, []<string, Handle>>
export async function All() {
let a = new Map()
for(let [ name, handle ] of app.all.entries()) {
let ts = Scan(await fs.Text(handle))
let n = { name, handle }
for(let t of ts) {
a.set(t, a.has(t) ? a.get(t).concat(n) : [ n ])
}
}
return a
}
export async function Rank() {
let a = await All()
return [ ...a.entries() ]
.sort(([ , ans ], [ , bns ]) => ans.length - bns.length)
.reverse()
.map(formatRank)
.join('\n')
}
function formatRank([ sTag, sOccrs ]) {
return sOccrs.length.toString().padStart(3, ' ') + ' #' + sTag
}

81
src/view.js Normal file
View File

@ -0,0 +1,81 @@
import * as tags from './tags.js'
import * as note from './note.js'
import * as list from './list.js'
const app = window.app
export function Init() {
app.main = document.getElementById('main')
app.nextNote = document.getElementById('next-note')
app.prevNote = document.getElementById('prev-note')
app.streak = document.getElementById('streak')
app.title = document.getElementById('title')
}
export function Title(sTitle) {
let t = sTitle
switch(sTitle) {
case list.pageHash:
t = 'All Notes'
break
case tags.pageHash:
t = 'All Tags'
break
}
document.title = t
app.title.innerText = t
}
export function Streak() {
let consDays = 0
let pd
for(let n of app.all.keys()) {
let d = new Date(n)
if(pd && pd - d > (24 * 60 * 60 * 1000)) {
break
}
consDays++
pd = d
}
app.streak.innerText = intToRoman(consDays)
}
const numerals = [
[ 'M', 1000 ],
[ 'CM', 900 ],
[ 'D', 500 ],
[ 'CD', 400 ],
[ 'C', 100 ],
[ 'XC', 90 ],
[ 'L', 50 ],
[ 'XL', 40 ],
[ 'X', 10 ],
[ 'IX', 9 ],
[ 'V', 5 ],
[ 'IV', 4 ],
[ 'I', 1 ]
]
/**
* @param {number} num
* @return {string}
*/
const intToRoman = function(num) {
let roman = ''
while(num !== 0) {
for(let [ numeral, value ] of numerals) {
if((num - value) >= 0) {
roman += numeral
num -= value
break
}
}
}
return roman
}

4
targets/browser Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
util/setup-build "browser"
cp device/store.js build/device

5
targets/browser.json Executable file
View File

@ -0,0 +1,5 @@
{
"imports": {
"device": "./device/browser.js"
}
}

13
targets/nw Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
util/setup-build "nw"
wget -c "https://dl.nwjs.io/v0.93.0/nwjs-v0.93.0-linux-x64.tar.gz" -O - \
| tar -xz -C build/ --strip-components=1
printf %s\\n '#!/bin/sh
tc_path=$(readlink -f "$0")
tc_dir=$(dirname "$tc_path")
"${tc_dir}/nw" "${tc_dir}"
' > build/tc
chmod +x build/tc

5
targets/nw.json Executable file
View File

@ -0,0 +1,5 @@
{
"imports": {
"device": "./device/nw.js"
}
}

28
task/inject-importmap Executable file
View File

@ -0,0 +1,28 @@
#!/bin/sh
importmap="$1"
tmp="$(mktemp)"
awk '
BEGIN {
s="<!-- start importmap -->"
e="<!-- end importmap -->"
}
FILENAME != "index.html" {
d=d $0 "\n"
next
}
$0 == s || $0 == e { print }
$0 == s,$0 == e {
if(d) {
print "<script type=\"importmap\">"
printf d;
d = ""
print "</script>"
}
next
}
{ print }
' "$importmap" index.html > "$tmp" \
&& mv "$tmp" index.html

15
task/setup-build Executable file
View File

@ -0,0 +1,15 @@
#!/bin/sh
name="$1"
task/inject-importmap "targets/${name}.json"
rm -rf build/*
cp -r src/ build/
cp -r assets/ build/
cp -r lib/ build/
cp index.html package.json debug.js build/
mkdir -p build/device
cp "device/${name}.js" build/device

1459
yarn.lock

File diff suppressed because it is too large Load Diff