├── .github ├── workers │ └── github-login │ │ ├── github-oauth.js │ │ └── wrangler.toml └── workflows │ ├── deploy.yml │ └── workers.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── _config.js ├── bundle.ts ├── deno.json ├── deno.lock ├── lib ├── action │ ├── commands.ts │ ├── commands_test.ts │ ├── keybinds.ts │ ├── keybinds_test.ts │ ├── menus.ts │ ├── menus_test.ts │ └── mod.ts ├── backend │ ├── browser.ts │ ├── github.ts │ └── mod.ts ├── com │ ├── checkbox.tsx │ ├── clock.tsx │ ├── codeblock.tsx │ ├── description.tsx │ ├── document.tsx │ ├── iframe.tsx │ ├── smartnode.tsx │ ├── tag.tsx │ ├── template.tsx │ └── textfield.tsx ├── mod.ts ├── model │ ├── components.ts │ ├── hooks.ts │ ├── mod.ts │ └── module │ │ ├── bus.ts │ │ ├── mod.ts │ │ ├── mod_test.ts │ │ └── node.ts ├── ui │ ├── app.tsx │ ├── drawer.tsx │ ├── menu.tsx │ ├── mod.ts │ ├── node │ │ ├── editor.tsx │ │ └── new.tsx │ ├── notices.tsx │ ├── outline.tsx │ ├── palette.tsx │ ├── panel.tsx │ ├── picker.tsx │ ├── quickadd.tsx │ ├── reference.tsx │ ├── search.tsx │ └── settings.tsx ├── view │ ├── cards.tsx │ ├── document.tsx │ ├── empty.tsx │ ├── list.tsx │ ├── table.tsx │ ├── tabs.tsx │ └── views.ts └── workbench │ ├── mod.ts │ ├── path.ts │ ├── path_test.ts │ ├── util.js │ ├── workbench.ts │ └── workspace.ts └── web ├── _components ├── latest.njk ├── nav.tsx └── toc.njk ├── _includes └── layouts │ ├── blog.tsx │ ├── default.tsx │ └── docs.tsx ├── _vendor ├── codemirror │ ├── Makefile │ ├── codemirror.ts │ └── package.json ├── minisearch │ └── Makefile ├── mithril │ └── Makefile └── octokit │ ├── Makefile │ └── octokit.js ├── blog ├── index.tsx ├── influences.md ├── v0-1-0.md ├── v0-2-0.md ├── v0-3-0.md ├── v0-4-0.md ├── v0-5-0.md ├── v0-6-0.md ├── v0-7-0.md └── welcome.md ├── demo └── index.njk ├── docs ├── dev │ ├── 1-overview.md │ ├── 2-data-model.md │ ├── 3-user-actions.md │ ├── 4-workbench-ui.md │ ├── 5-backend-adapters.md │ ├── 6-api-reference.md │ └── index.tsx ├── index.tsx ├── project │ ├── 1-contributing.md │ ├── 2-roadmap.md │ └── index.tsx ├── quickstart │ ├── 1-using.md │ ├── 2-building.md │ ├── 3-customizing.md │ └── index.tsx └── user │ ├── 01-what-is-treehouse.md │ ├── 02-data-storage.md │ ├── 03-nodes.md │ ├── 04-fields.md │ ├── 05-smart-nodes.md │ ├── 06-tags.md │ ├── 07-templates.md │ ├── 07.1-views.md │ ├── 08-calendar.md │ ├── 09-quick-add.md │ ├── 10-command-palette.md │ ├── 11-keyboard-shortcuts.md │ ├── 12-css-theming.md │ ├── 13-backend-extensions.md │ └── index.tsx ├── index.tsx └── static ├── CNAME ├── analytics.js ├── app ├── main.css ├── main.js └── main.webmanifest ├── blog ├── v0.1.0 │ └── index.html ├── v0.2.0 │ └── index.html ├── v0.3.0 │ └── index.html └── v0.4.0 │ └── index.html ├── halftone_green.png ├── halftone_white.png ├── halftone_white_15.png ├── halftone_white_50.png ├── icon.png ├── lib ├── 0.2.0 │ └── treehouse.min.js ├── 0.3.0 │ ├── treehouse.min.js │ └── treehouse.min.js.map ├── 0.4.0 │ ├── treehouse.min.js │ └── treehouse.min.js.map ├── 0.5.0 │ ├── treehouse.min.js │ └── treehouse.min.js.map ├── 0.6.0 │ ├── treehouse.min.js │ └── treehouse.min.js.map └── 0.7.0 │ ├── treehouse.min.js │ └── treehouse.min.js.map ├── logo.svg ├── photos ├── blog │ ├── nls.jpeg │ ├── notion.jpeg │ ├── obsidian.png │ ├── tana.webp │ ├── tiddlywiki.png │ └── workflowy.png ├── hero-image.png ├── live-search.png ├── outline-image.png ├── quickadd-image.png ├── screenshot-small.png └── search-image.png ├── style.css ├── style ├── design.css ├── normalize.css ├── site.css └── themes │ ├── darkmode.css │ ├── sepia.css │ └── sublime.css └── vnd ├── codemirror-6.0.1.min.js ├── minisearch-6.0.1.min.js ├── mithril-2.0.3.min.js └── octokit-18.12.0.min.js /.github/workers/github-login/github-oauth.js: -------------------------------------------------------------------------------- 1 | addEventListener("fetch", (event) => { 2 | event.respondWith(handle(event.request)); 3 | }); 4 | 5 | // use secrets 6 | const client_id = CLIENT_ID; 7 | const client_secret = CLIENT_SECRET; 8 | 9 | async function handle(request) { 10 | // handle CORS pre-flight request 11 | if (request.method === "OPTIONS") { 12 | return new Response(null, { 13 | headers: { 14 | "Access-Control-Allow-Origin": "*", 15 | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 16 | "Access-Control-Allow-Headers": "Content-Type", 17 | }, 18 | }); 19 | } 20 | 21 | // redirect GET requests to the OAuth login page on github.com 22 | if (request.method === "GET") { 23 | const scope = new URL(request.url).searchParams.get("scope"); 24 | return Response.redirect( 25 | `https://github.com/login/oauth/authorize?client_id=${client_id}&scope=${scope}`, 26 | 302 27 | ); 28 | } 29 | 30 | try { 31 | const { code } = await request.json(); 32 | 33 | const response = await fetch( 34 | "https://github.com/login/oauth/access_token", 35 | { 36 | method: "POST", 37 | headers: { 38 | "content-type": "application/json", 39 | accept: "application/json", 40 | }, 41 | body: JSON.stringify({ client_id, client_secret, code }), 42 | } 43 | ); 44 | const result = await response.json(); 45 | const headers = { 46 | "Access-Control-Allow-Origin": "*", 47 | }; 48 | 49 | if (result.error) { 50 | return new Response(JSON.stringify(result), { status: 401, headers }); 51 | } 52 | 53 | return new Response(JSON.stringify({ token: result.access_token }), { 54 | status: 201, 55 | headers, 56 | }); 57 | } catch (error) { 58 | console.error(error); 59 | return new Response(error.message, { 60 | status: 500, 61 | }); 62 | } 63 | } -------------------------------------------------------------------------------- /.github/workers/github-login/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "treehouse-demo-login" 2 | workers_dev = true 3 | compatibility_date = "2023-02-08" 4 | main = "github-oauth.js" -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Website Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | build-and-deploy: 10 | concurrency: ci-${{ github.ref }} 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup Deno 17 | uses: denoland/setup-deno@v1 18 | with: 19 | deno-version: v1.x 20 | 21 | - name: Build 22 | run: | 23 | deno task bundle 24 | deno task build 25 | env: 26 | BACKEND: github 27 | BACKEND_URL: https://treehouse-demo-login.proteco.workers.dev 28 | 29 | - name: Deploy 30 | uses: JamesIves/github-pages-deploy-action@v4 31 | with: 32 | folder: web/_out -------------------------------------------------------------------------------- /.github/workflows/workers.yml: -------------------------------------------------------------------------------- 1 | name: Worker Deploys 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - '.github/workers/**' 8 | - '.github/workflows/workers.yml' 9 | jobs: 10 | github-login: 11 | concurrency: ci-${{ github.ref }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Publish 18 | uses: cloudflare/wrangler-action@2.0.0 19 | with: 20 | command: publish 21 | apiToken: ${{ secrets.CF_API_TOKEN }} 22 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 23 | workingDirectory: '.github/workers/github-login' 24 | secrets: | 25 | CLIENT_ID 26 | CLIENT_SECRET 27 | env: 28 | CLIENT_ID: ${{ secrets.DEMO_CLIENT_ID }} 29 | CLIENT_SECRET: ${{ secrets.DEMO_CLIENT_SECRET }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /web/_out 2 | /web/static/lib/treehouse.js 3 | /web/static/lib/treehouse.js.map 4 | /web/static/lib/treehouse.min.js 5 | /web/static/lib/treehouse.min.js.map 6 | /web/_vendor/**/node_modules 7 | /web/_vendor/**/package-lock.json 8 | /web/_vendor/octokit/package.json 9 | /local 10 | .DS_Store 11 | /NOTES -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jeff Lindsay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # treehouse 2 | 3 | Bring your own backend Tana-style frontend. Actively being developed. 4 | 5 | ![Screenshot](https://treehouse.sh/photos/screenshot-small.png) 6 | 7 | * Outline editor and repurposable workbench shell. 8 | * Built with [Deno](https://deno.land/) toolchain. Zero Node.js utilization. 9 | * Minimal dependencies, mainly [Mithril.js](https://mithril.js.org/). 10 | 11 | ## Demo 12 | 13 | This project is meant as a frontend toolkit for your own note-taking app, 14 | but we're maintaining a usable [live demo](https://treehouse.sh/demo/) 15 | based on the current state of the main branch. 16 | 17 | ## Development 18 | 19 | Contribute by [installing Deno](https://deno.land/manual@v1.30.0/getting_started/installation), 20 | cloning the repo, and running: 21 | 22 | ``` 23 | deno task serve 24 | ``` 25 | 26 | This will run a development server for the project site that includes the 27 | demo, which you can use for development. 28 | 29 | ## Community 30 | 31 | * [Devstream](https://www.twitch.tv/progrium) - Majority of development is streamed live on Twitch 32 | * [Devlog](https://github.com/treehousedev/treehouse/discussions/categories/devlog) - Devlog updates about development 33 | * [Forums](https://github.com/treehousedev/treehouse/discussions) - Basic discussion on GitHub 34 | 35 | ## License 36 | 37 | MIT 38 | -------------------------------------------------------------------------------- /_config.js: -------------------------------------------------------------------------------- 1 | import lume from "lume/mod.ts"; 2 | import attrs from "npm:markdown-it-attrs"; 3 | import jsx from "lume/plugins/jsx_preact.ts"; 4 | import nav from "lume/plugins/nav.ts"; 5 | import feed from "lume/plugins/feed.ts"; 6 | import codeHighlight from "lume/plugins/code_highlight.ts"; 7 | import lang_javascript from "https://unpkg.com/@highlightjs/cdn-assets@11.6.0/es/languages/javascript.min.js"; 8 | import lang_bash from "https://unpkg.com/@highlightjs/cdn-assets@11.6.0/es/languages/bash.min.js"; 9 | import toc from "https://deno.land/x/lume_markdown_plugins@v0.5.1/toc.ts"; 10 | 11 | import * as esbuild from "https://deno.land/x/esbuild@v0.17.2/mod.js"; 12 | 13 | let lastBuild = 0; 14 | 15 | const site = lume({ 16 | location: new URL("https://treehouse.sh"), 17 | src: "./web", 18 | dest: "./web/_out", 19 | server: { 20 | middlewares: [ 21 | async (request, next) => { 22 | const url = new URL(request.url); 23 | if (url.pathname === "/lib/treehouse.min.js") { 24 | if (lastBuild < Date.now()-1000) { 25 | await esbuild.build({ 26 | entryPoints: ["lib/mod.ts"], 27 | bundle: true, 28 | outfile: "web/_out/lib/treehouse.js", 29 | jsxFactory: "m", 30 | sourcemap: true, 31 | format: "esm", 32 | keepNames: true, 33 | // minify: true 34 | }); 35 | lastBuild = Date.now(); 36 | } 37 | Object.defineProperty(request, "url", {value: request.url.replace(".min", "")}); 38 | } 39 | return await next(request); 40 | } 41 | ] 42 | } 43 | }, { 44 | markdown: { 45 | plugins: [attrs], 46 | keepDefaultPlugins: true, 47 | } 48 | }); 49 | site.copy("static", "."); 50 | 51 | site.use(feed({ 52 | output: ["/blog/feed.rss", "/blog/feed.json"], 53 | query: "layout=layouts/blog.tsx", 54 | info: { 55 | title: "=site.title", 56 | description: "=site.description", 57 | }, 58 | items: { 59 | title: "=title", 60 | }, 61 | })); 62 | site.use(toc({ 63 | level: 2, 64 | anchor: false 65 | })); 66 | site.use(nav()); 67 | site.use(jsx()); 68 | site.use(codeHighlight({ 69 | languages: { 70 | javascript: lang_javascript, 71 | bash: lang_bash, 72 | }, 73 | })); 74 | 75 | site.filter("formatDate", (value) => new Date(value).toLocaleDateString('en-us', { year:"numeric", month:"short", day:"numeric"})); 76 | 77 | site.data("site", { 78 | title: "Treehouse", 79 | description: "An open source note-taking frontend to extend and customize." 80 | }); 81 | site.data("backend", JSON.stringify({ 82 | name: Deno.env.get("BACKEND") || "browser", 83 | url: Deno.env.get("BACKEND_URL") 84 | })); 85 | 86 | export default site; 87 | -------------------------------------------------------------------------------- /bundle.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as esbuild from "https://deno.land/x/esbuild@v0.17.2/mod.js"; 3 | 4 | var outfile = "web/static/lib/treehouse.min.js"; 5 | 6 | console.log(`Creating bundle at ${outfile} ...`); 7 | await esbuild.build({ 8 | entryPoints: ["lib/mod.ts"], 9 | bundle: true, 10 | outfile: outfile, 11 | jsxFactory: "m", 12 | sourcemap: true, 13 | format: "esm", 14 | keepNames: true, 15 | minify: true 16 | }); 17 | esbuild.stop(); 18 | console.log("Finished!"); 19 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "jsxImportSource": "npm:preact" 5 | }, 6 | "tasks": { 7 | "bundle": "deno run --unstable -A ./bundle.ts", 8 | "lume": "echo \"import 'lume/cli.ts'\" | deno run --unstable -A -", 9 | "build": "deno task lume", 10 | "serve": "deno task lume -s" 11 | }, 12 | "imports": { 13 | "lume/": "https://deno.land/x/lume@v1.17.5/", 14 | "https://deno.land/std/": "https://deno.land/std@0.177.0/" 15 | } 16 | } -------------------------------------------------------------------------------- /lib/action/commands.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface Command { 3 | id: string; 4 | title?: string; 5 | category?: string; 6 | icon?: string; 7 | hidden?: boolean; 8 | action: Function; 9 | when?: Function; 10 | } 11 | 12 | export class CommandRegistry { 13 | commands: {[index: string]: Command} 14 | 15 | constructor() { 16 | this.commands = {}; 17 | } 18 | 19 | registerCommand(cmd: Command) { 20 | this.commands[cmd.id] = cmd; 21 | } 22 | 23 | canExecuteCommand(id: string, ...rest: any): boolean { 24 | if (this.commands[id]) { 25 | if (this.commands[id].when && !this.commands[id].when(...rest)) { 26 | return false; 27 | } 28 | return true; 29 | } 30 | return false; 31 | } 32 | 33 | executeCommand(id: string, ...rest: any): Promise { 34 | return new Promise((resolve) => { 35 | const ret = this.commands[id].action(...rest); 36 | resolve(ret); 37 | }); 38 | } 39 | } -------------------------------------------------------------------------------- /lib/action/commands_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { assertEquals } from "https://deno.land/std@0.173.0/testing/asserts.ts"; 3 | import { CommandRegistry } from "./commands.ts"; 4 | 5 | Deno.test("command registration and execution", async () => { 6 | const r = new CommandRegistry(); 7 | r.registerCommand({ 8 | id: "test", 9 | action: async (msg: string) => { 10 | return msg 11 | } 12 | }) 13 | const ret = await r.executeCommand("test", "Hello world"); 14 | 15 | assertEquals(ret, "Hello world"); 16 | }); -------------------------------------------------------------------------------- /lib/action/keybinds.ts: -------------------------------------------------------------------------------- 1 | 2 | const isMac = (navigator.userAgent.toLowerCase().indexOf("mac") !== -1); 3 | 4 | export function bindingSymbols(key?: string): string[] { 5 | if (!key) return []; 6 | const symbols = { 7 | "backspace": "⌫", 8 | "shift": "⇧", 9 | "meta": "⌘", 10 | "tab": "↹", 11 | "ctrl": "⌃", 12 | "arrowup": "↑", 13 | "arrowdown": "↓", 14 | "arrowleft": "←", 15 | "arrowright": "→", 16 | "enter": "⏎" 17 | }; 18 | const keys = key.toLowerCase().split("+"); 19 | return keys.map(filterKeyForNonMacMeta).map(k => (Object.keys(symbols).includes(k)) ? symbols[k] : k); 20 | } 21 | 22 | // if key is meta and not on a mac, change it to ctrl, 23 | // otherwise return the key as is 24 | function filterKeyForNonMacMeta(key: string): string { 25 | return (!isMac && key === "meta") ? "ctrl": key; 26 | } 27 | 28 | export interface Binding { 29 | command: string; 30 | key: string; 31 | //when 32 | //args 33 | } 34 | 35 | export class KeyBindings { 36 | bindings: Binding[]; 37 | 38 | constructor() { 39 | this.bindings = []; 40 | } 41 | 42 | registerBinding(binding: Binding) { 43 | this.bindings.push(binding); 44 | } 45 | 46 | getBinding(commandId: string): Binding|null { 47 | for (const b of this.bindings) { 48 | if (b.command === commandId) { 49 | return b; 50 | } 51 | } 52 | return null; 53 | } 54 | 55 | evaluateEvent(event: KeyboardEvent): Binding|null { 56 | bindings: for (const b of this.bindings) { 57 | let modifiers = b.key.toLowerCase().split("+"); 58 | let key = modifiers.pop(); 59 | if (key !== event.key.toLowerCase()) { 60 | continue; 61 | } 62 | for (const checkMod of ["shift", "ctrl", "alt", "meta"]) { 63 | let hasMod = modifiers.includes(checkMod); 64 | if (!isMac) { 65 | if (checkMod === "meta") continue; 66 | if (checkMod === "ctrl") { 67 | hasMod = modifiers.includes("meta") || modifiers.includes("ctrl"); 68 | } 69 | } 70 | // @ts-ignore 71 | const modState = event[`${filterKeyForNonMacMeta(checkMod)}Key`]; 72 | if (!modState && hasMod) { 73 | continue bindings; 74 | } 75 | if (modState && !hasMod) { 76 | continue bindings; 77 | } 78 | } 79 | return b; 80 | } 81 | return null; 82 | } 83 | } -------------------------------------------------------------------------------- /lib/action/keybinds_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { assertEquals } from "https://deno.land/std@0.173.0/testing/asserts.ts"; 3 | import { KeyBindings } from "./keybinds.ts"; 4 | 5 | Deno.test("binding registration", async () => { 6 | const bindings = new KeyBindings(); 7 | bindings.registerBinding({ 8 | command: "test", 9 | key: "shift+a" 10 | }) 11 | const ret = bindings.getBinding("test"); 12 | 13 | assertEquals(ret?.key, "shift+a"); 14 | }); -------------------------------------------------------------------------------- /lib/action/menus.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface MenuItem { 3 | command?: string; 4 | //alt?: string; 5 | when?: Function; 6 | title?: Function; 7 | onclick?: Function; 8 | disabled?: boolean; 9 | //group 10 | //submenu 11 | } 12 | 13 | export class MenuRegistry { 14 | menus: {[index: string]: MenuItem[]}; 15 | 16 | constructor() { 17 | this.menus = {}; 18 | } 19 | 20 | registerMenu(id: string, items: MenuItem[]) { 21 | this.menus[id] = items; 22 | } 23 | } -------------------------------------------------------------------------------- /lib/action/menus_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { assertEquals } from "https://deno.land/std@0.173.0/testing/asserts.ts"; 3 | import { MenuRegistry } from "./menus.ts"; 4 | 5 | Deno.test("menu registration", async () => { 6 | const menus = new MenuRegistry(); 7 | menus.registerMenu("test/test", [{ 8 | command: "test" 9 | }]); 10 | const ret = menus.menus["test/test"]; 11 | 12 | assertEquals(ret[0].command, "test"); 13 | }); -------------------------------------------------------------------------------- /lib/action/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A module for modeling user actions around commands. 3 | * 4 | * Primarily providing a command registry, this module also 5 | * provides a registry and utilities for implementing keybindings 6 | * and menus. 7 | * 8 | * @module 9 | */ -------------------------------------------------------------------------------- /lib/backend/browser.ts: -------------------------------------------------------------------------------- 1 | import {RawNode} from "../model/mod.ts"; 2 | import { SearchIndex, FileStore, ChangeNotifier } from "./mod.ts"; 3 | 4 | export class BrowserBackend { 5 | auth: null; 6 | index: SearchIndex; 7 | files: FileStore; 8 | changes?: ChangeNotifier; 9 | 10 | constructor() { 11 | this.auth = null; 12 | this.files = new LocalStorageFileStore(); 13 | if (window.MiniSearch) { 14 | this.index = new SearchIndex_MiniSearch(); 15 | } else { 16 | this.index = new SearchIndex_Dumb(); 17 | } 18 | this.changes = { 19 | registerNotifier(cb: (nodeIDs: string[]) => void) { 20 | window.reloadNodes = cb; 21 | } 22 | }; 23 | } 24 | } 25 | 26 | export class SearchIndex_MiniSearch { 27 | indexer: any; // MiniSearch 28 | 29 | constructor() { 30 | this.indexer = new MiniSearch({ 31 | idField: "ID", 32 | fields: ['ID', 'Name', 'Value', 'Value.markdown'], // fields to index for full-text search 33 | storeFields: ['ID'], // fields to return with search results 34 | extractField: (document, fieldName) => { 35 | return fieldName.split('.').reduce((doc, key) => doc && doc[key], document); 36 | } 37 | }); 38 | } 39 | 40 | index(node: RawNode) { 41 | if (this.indexer.has(node.ID)) { 42 | this.indexer.replace(node); 43 | } else { 44 | this.indexer.add(node); 45 | } 46 | } 47 | 48 | remove(id: string) { 49 | try { 50 | this.indexer.discard(id); 51 | } catch {} 52 | } 53 | 54 | search(query: string): string[] { 55 | const suggested = this.indexer.autoSuggest(query); 56 | if (suggested.length === 0) return []; 57 | return this.indexer.search(suggested[0].suggestion, { 58 | prefix: true, 59 | combineWith: 'AND', 60 | }).map(doc => doc.ID); 61 | } 62 | } 63 | 64 | 65 | export class SearchIndex_Dumb { 66 | nodes: Record; 67 | 68 | constructor() { 69 | this.nodes = {}; 70 | } 71 | 72 | index(node: RawNode) { 73 | this.nodes[node.ID] = node.Name; 74 | } 75 | 76 | remove(id: string) { 77 | delete this.nodes[id]; 78 | } 79 | 80 | search(query: string): string[] { 81 | const results: string[] = []; 82 | for (const id in this.nodes) { 83 | if (this.nodes[id].includes(query)) { 84 | results.push(id); 85 | } 86 | } 87 | return results; 88 | } 89 | } 90 | 91 | 92 | 93 | export class LocalStorageFileStore { 94 | async readFile(path: string): Promise { 95 | return localStorage.getItem(`treehouse:${path}`); 96 | } 97 | 98 | async writeFile(path: string, contents: string) { 99 | localStorage.setItem(`treehouse:${path}`, contents); 100 | } 101 | } -------------------------------------------------------------------------------- /lib/backend/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Backend provides the APIs to implement a backend adapter as well 3 | * as several built-in backend adapters. These are instantiated and 4 | * passed to `setup` for a working SPA. 5 | * 6 | * @module 7 | */ 8 | import {RawNode} from "../model/mod.ts"; 9 | 10 | /** 11 | * Backend is the adapter object API to be implemented for a working backend. 12 | * Typically these fields are set to `this` and the different APIs are 13 | * implemented on the same object. 14 | */ 15 | export interface Backend { 16 | auth: Authenticator|null; 17 | index: SearchIndex; 18 | files: FileStore; 19 | changes?: ChangeNotifier; 20 | } 21 | 22 | 23 | export interface Authenticator { 24 | login(); 25 | logout(); 26 | currentUser(): User|null; 27 | } 28 | 29 | export interface User { 30 | userID(): string; 31 | displayName(): string; 32 | avatarURL(): string; 33 | } 34 | 35 | export interface SearchIndex { 36 | index(node: RawNode); 37 | remove(id: string); 38 | search(query: string): string[]; 39 | } 40 | 41 | export interface FileStore { 42 | readFile(path: string): Promise; 43 | writeFile(path: string, contents: string): Promise; 44 | } 45 | 46 | export interface ChangeNotifier { 47 | registerNotifier(cb: (nodeIDs: string[]) => void); 48 | } 49 | -------------------------------------------------------------------------------- /lib/com/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Node } from "../model/mod.ts"; 3 | 4 | @component 5 | export class Checkbox { 6 | checked: boolean; 7 | 8 | constructor() { 9 | this.checked = false; 10 | } 11 | 12 | beforeEditor() { 13 | return CheckboxEditor; 14 | } 15 | } 16 | 17 | const CheckboxEditor = { 18 | view({attrs: {node}}) { 19 | const toggleCheckbox = (e) => { 20 | const checkbox = node.getComponent(Checkbox); 21 | checkbox.checked = !checkbox.checked; 22 | node.changed(); 23 | } 24 | return 25 | } 26 | } -------------------------------------------------------------------------------- /lib/com/codeblock.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Workbench, Context } from "../workbench/mod.ts"; 3 | export interface CodeExecutor { 4 | // executes the source and returns an output string. 5 | // exceptions in execution should be caught and returned as a string. 6 | execute(source: string, options: ExecuteOptions): Promise; 7 | 8 | canExecute(options: ExecuteOptions): boolean; 9 | } 10 | 11 | export interface ExecuteOptions { 12 | language: string; 13 | } 14 | 15 | // defaultExecutor can be replaced with an external service, etc 16 | export let defaultExecutor: CodeExecutor = { 17 | async execute( 18 | source: string, 19 | options: ExecuteOptions 20 | ): Promise { 21 | if (options.language !== "javascript") { 22 | return `Unsupported language: ${options.language}`; 23 | } 24 | let output = window.eval(source); 25 | //return JSON.stringify(output); 26 | return output.toString(); 27 | }, 28 | 29 | canExecute(options: ExecuteOptions): boolean { 30 | if (options.language === "javascript") { 31 | return true; 32 | } 33 | return false; 34 | }, 35 | }; 36 | 37 | @component 38 | export class CodeBlock { 39 | code: string; 40 | language: string; 41 | detectLanguage: boolean; 42 | 43 | constructor(language?: string) { 44 | this.code = ""; 45 | this.language = ""; 46 | this.detectLanguage = true; 47 | 48 | if (language) { 49 | this.language = language; 50 | this.detectLanguage = false; 51 | } 52 | } 53 | 54 | childrenView() { 55 | return CodeEditorWithOutput; 56 | } 57 | 58 | handleIcon(collapsed: boolean = false): any { 59 | return ( 60 | 72 | 73 | 74 | 75 | ); 76 | } 77 | 78 | static initialize(workbench: Workbench) { 79 | workbench.commands.registerCommand({ 80 | id: "make-code-block", 81 | title: "Make Code Block", 82 | when: (ctx: Context) => { 83 | if (!ctx.node) return false; 84 | if (ctx.node.raw.Rel === "Fields") return false; 85 | if (ctx.node.parent && ctx.node.parent.hasComponent(Document)) 86 | return false; 87 | return true; 88 | }, 89 | action: (ctx: Context, language?: string) => { 90 | const com = new CodeBlock(language); 91 | if (ctx?.node) { 92 | ctx.node.addComponent(com); 93 | ctx.node.changed(); 94 | workbench.workspace.setExpanded( 95 | ctx.path.head, 96 | ctx.path.node, 97 | true 98 | ); 99 | } 100 | }, 101 | }); 102 | } 103 | } 104 | 105 | const CodeEditor = { 106 | oncreate(vnode) { 107 | const { 108 | dom, 109 | attrs: { path }, 110 | } = vnode; 111 | const snippet = path.node.getComponent(CodeBlock); 112 | 113 | //@ts-ignore 114 | dom.jarEditor = new window.CodeJar(dom, (editor) => { 115 | // highlight.js does not trim old tags, 116 | // let's do it by this hack. 117 | editor.textContent = editor.textContent; 118 | //@ts-ignore 119 | window.hljs.highlightBlock(editor); 120 | 121 | if (snippet.detectLanguage) { 122 | //@ts-ignore 123 | snippet.language = window.hljs.highlightAuto(editor.textContent).language || ""; 124 | } 125 | 126 | }); 127 | dom.jarEditor.updateCode(snippet.code); 128 | dom.jarEditor.onUpdate((code) => { 129 | snippet.code = code; 130 | path.node.changed(); 131 | }); 132 | }, 133 | 134 | view({ attrs: { workbench, path } }) { 135 | // this cancels the keydown on the outline node 136 | // so you can use arrow keys normally 137 | const onkeydown = (e) => e.stopPropagation(); 138 | 139 | return
; 140 | }, 141 | }; 142 | 143 | const Output = { 144 | view({ dom, state, attrs: { path } }) { 145 | const snippet = path.node.getComponent(CodeBlock); 146 | 147 | let handleClick = async () => { 148 | state.output = "Running..."; 149 | try { 150 | const res = await defaultExecutor.execute(snippet.code, { 151 | language: snippet.language, 152 | }); 153 | 154 | // Update output using m.prop to ensure it's persistent across re-renders 155 | state.output = res; // Call m.prop with the new value 156 | } catch (error) { 157 | state.output = error.toString(); 158 | } 159 | }; 160 | return ( 161 |
162 |

{state.output ? "Output: " + state.output : ""}

163 | 166 |
167 | ); 168 | }, 169 | }; 170 | 171 | class CodeEditorWithOutput { 172 | view(vnode) { 173 | return [m(CodeEditor, vnode.attrs), m(Output, vnode.attrs)]; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/com/description.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Node } from "../model/mod.ts"; 3 | import { objectManaged } from "../model/hooks.ts"; 4 | 5 | @component 6 | export class Description { 7 | text: string; 8 | 9 | constructor() { 10 | this.text = ""; 11 | } 12 | 13 | editor() { 14 | return DescriptionEditor; 15 | } 16 | 17 | static initialize(workbench: Workbench) { 18 | workbench.commands.registerCommand({ 19 | id: "add-description", 20 | title: "Add Description", 21 | when: (ctx: Context) => { 22 | if (!ctx.node) return false; 23 | if (ctx.path.previous && objectManaged(ctx.path.previous)) return false; 24 | if (ctx.node.hasComponent(Description)) return false; 25 | return true; 26 | }, 27 | action: (ctx: Context, name: string) => { 28 | const desc = new Description(); 29 | ctx.node.addComponent(desc); 30 | ctx.node.changed(); 31 | } 32 | }); 33 | workbench.commands.registerCommand({ 34 | id: "remove-description", 35 | title: "Remove Description", 36 | when: (ctx: Context) => { 37 | if (!ctx.node) return false; 38 | if (ctx.path.previous && objectManaged(ctx.path.previous)) return false; 39 | if (ctx.node.hasComponent(Description)) return true; 40 | return false; 41 | }, 42 | action: (ctx: Context, name: string) => { 43 | ctx.node.removeComponent(Description); 44 | ctx.node.changed(); 45 | } 46 | }); 47 | } 48 | } 49 | 50 | const DescriptionEditor = { 51 | view({attrs: {node}}) { 52 | const oninput = (e) => { 53 | const desc = node.getComponent(Description); 54 | desc.text = e.target.value; 55 | node.changed(); 56 | } 57 | const onblur = (e) => { 58 | const desc = node.getComponent(Description); 59 | if (desc.text === "") { 60 | node.removeComponent(Description); 61 | } 62 | node.changed(); 63 | } 64 | 65 | return 66 | } 67 | } -------------------------------------------------------------------------------- /lib/com/document.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Node } from "../model/mod.ts"; 3 | 4 | @component 5 | export class Document { 6 | object?: Node; 7 | 8 | constructor() { 9 | } 10 | 11 | onAttach(node: Node) { 12 | this.object = node.parent; 13 | this.object.setAttr("view", "document"); 14 | } 15 | 16 | handleIcon(collapsed: boolean = false): any { 17 | return ( 18 | 19 | {/* {collapsed?:null} */} 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | toJSON(key: string): any { 30 | return {}; 31 | } 32 | 33 | static initialize(workbench: Workbench) { 34 | workbench.commands.registerCommand({ 35 | id: "make-document", 36 | title: "Make Document", 37 | action: (ctx: Context) => { 38 | if (!ctx.node) return; 39 | const doc = new Document(); 40 | ctx.node.addComponent(doc); 41 | ctx.node.changed(); 42 | workbench.executeCommand("zoom", ctx); 43 | } 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/com/iframe.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Node } from "../model/mod.ts"; 3 | 4 | @component 5 | export class InlineFrame { 6 | url: string; 7 | 8 | constructor() { 9 | this.url = "https://example.com"; 10 | } 11 | 12 | childrenView() { 13 | return InlineFrameView; 14 | } 15 | handleIcon(collapsed: boolean = false): any { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | 26 | static initialize(workbench: Workbench) { 27 | workbench.commands.registerCommand({ 28 | id: "make-iframe", 29 | title: "Make Inline Frame", 30 | when: (ctx: Context) => { 31 | if (!ctx.node) return false; 32 | if (ctx.node.raw.Rel === "Fields") return false; 33 | if (ctx.node.parent && ctx.node.parent.hasComponent(Document)) return false; 34 | return true; 35 | }, 36 | action: (ctx: Context) => { 37 | const frame = new InlineFrame(); 38 | if (ctx.node.name.startsWith("http://") || 39 | ctx.node.name.startsWith("https://")) { 40 | frame.url = ctx.node.name; 41 | ctx.node.addComponent(frame); 42 | workbench.defocus(); 43 | ctx.node.name = ctx.node.name.replaceAll("https://", "").replaceAll("http://"); 44 | workbench.workspace.setExpanded(ctx.path.head, ctx.node, true); 45 | workbench.focus(ctx.path); 46 | } 47 | 48 | } 49 | }); 50 | } 51 | 52 | } 53 | 54 | const InlineFrameView = { 55 | view({attrs: {path}}) { 56 | const iframe = path.node.getComponent(InlineFrame); 57 | return ( 58 |
59 | 60 |
61 | ) 62 | } 63 | } -------------------------------------------------------------------------------- /lib/com/smartnode.tsx: -------------------------------------------------------------------------------- 1 | import { Workbench } from "../workbench/mod.ts"; 2 | import { component } from "../model/components.ts"; 3 | import { Node } from "../model/mod.ts"; 4 | 5 | function debounce(func, timeout = 1000){ 6 | let timer; 7 | return (...args) => { 8 | clearTimeout(timer); 9 | timer = setTimeout(() => { func.apply(this, args); }, timeout); 10 | }; 11 | } 12 | 13 | @component 14 | export class SmartNode { 15 | workbench: Workbench; 16 | component?: Node; 17 | object?: Node; 18 | results?: Node[]; 19 | query: string; 20 | 21 | lastQuery?: string; 22 | lastResultCount?: number; 23 | initialSearch: boolean; 24 | 25 | constructor() { 26 | this.workbench = window.workbench; 27 | this.searchDebounce = debounce(this.search.bind(this)); 28 | this.query = ""; 29 | this.initialSearch = false; 30 | } 31 | 32 | handleIcon(collapsed: boolean = false): any { 33 | return ( 34 | 35 | {collapsed?:null} 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | belowEditor() { 45 | return SmartFilter; 46 | } 47 | 48 | onAttach(node: Node) { 49 | this.component = node; 50 | this.object = node.parent; 51 | node.bus.observe((n: Node) => { 52 | if (!node.isDestroyed) { 53 | this.searchDebounce(); 54 | } 55 | }); 56 | } 57 | 58 | search() { 59 | if (!this.object) return; 60 | if (!this.query) { 61 | this.lastQuery = ""; 62 | this.results = []; 63 | return; 64 | } 65 | this.initialSearch = true; 66 | 67 | const results = this.workbench.search(this.query) 68 | .filter(n => n.id !== this.object.id && n.id !== this.component.id); 69 | 70 | if (results.length !== this.lastResultCount || this.query !== this.lastQuery) { 71 | if (this.results) { 72 | // clean up old results 73 | this.results.forEach((n) => n.destroy()); 74 | } 75 | this.results = results.map(n => { 76 | const ref = this.object.bus.make(""); 77 | ref.raw.Parent = "@tmp"; // cleaned up next import 78 | ref.refTo = n; 79 | return ref; 80 | }); 81 | this.lastQuery = this.query; 82 | this.lastResultCount = results.length; 83 | } 84 | } 85 | 86 | objectChildren(node: Node, children: Node[]): Node[] { 87 | if (!this.results && this.query && !this.initialSearch) { 88 | this.search(); 89 | } 90 | return this.results || []; 91 | } 92 | 93 | toJSON(key: string): any { 94 | return { 95 | query: this.query 96 | }; 97 | } 98 | 99 | fromJSON(obj: any) { 100 | this.query = obj.query || ""; 101 | } 102 | 103 | static initialize(workbench: Workbench) { 104 | workbench.commands.registerCommand({ 105 | id: "make-smart-node", 106 | title: "Make Smart Node", 107 | when: (ctx: Context) => { 108 | if (!ctx.node) return false; 109 | if (ctx.node.raw.Rel === "Fields") return false; 110 | if (ctx.node.childCount > 0) return false; 111 | if (ctx.node.parent && ctx.node.parent.hasComponent(Document)) return false; 112 | return true; 113 | }, 114 | action: (ctx: Context) => { 115 | workbench.defocus(); 116 | const search = new SmartNode(); 117 | ctx.node.addComponent(search); 118 | workbench.workspace.setExpanded(ctx.path.head, ctx.node, true); 119 | if (ctx.node.name === "") { 120 | setTimeout(() => { 121 | // defocusing will overwrite this from buffer 122 | // without a delay 123 | ctx.node.name = "Unnamed Smart Node"; 124 | m.redraw(); 125 | document.querySelector(`#node-${ctx.path.id}-${ctx.node.id} input`).focus(); 126 | }, 10); 127 | } 128 | } 129 | }); 130 | } 131 | } 132 | 133 | 134 | const SmartFilter = { 135 | view({attrs: {node, component, expanded}}) { 136 | if (!expanded) return; 137 | 138 | const oninput = (e) => { 139 | component.query = e.target.value; 140 | component.search(); 141 | node.changed(); 142 | } 143 | return ( 144 |
145 |
146 | 157 |
158 | ) 159 | } 160 | } -------------------------------------------------------------------------------- /lib/com/tag.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Node } from "../model/mod.ts"; 3 | import { Workbench, Workspace } from "../workbench/mod.ts"; 4 | import { Path } from "../workbench/path.ts"; 5 | import { Template } from "./template.tsx"; 6 | import { Picker } from "../ui/picker.tsx"; 7 | 8 | @component 9 | export class Tag { 10 | name: string; 11 | 12 | constructor(name: string) { 13 | this.name = name; 14 | } 15 | 16 | afterEditor() { 17 | return TagBadge; 18 | } 19 | 20 | static initialize(workbench: Workbench) { 21 | workbench.commands.registerCommand({ 22 | id: "add-tag", 23 | title: "Add tag", 24 | hidden: true, 25 | action: (ctx: Context, name: string) => { 26 | if (!ctx.node) return; 27 | const tag = new Tag(name); 28 | ctx.node.addComponent(tag); 29 | const tmpl = Template.findNode(workbench.workspace, name); 30 | if (tmpl) { 31 | tmpl.fields.map(f => f.duplicate()).forEach(f => { 32 | ctx.node.addLinked("Fields", f); 33 | f.raw.Parent = ctx.node.raw.ID; 34 | }); 35 | tmpl.children.map(c => c.duplicate()).forEach(c => { 36 | ctx.node.addChild(c); 37 | c.raw.Parent = ctx.node.raw.ID; 38 | }); 39 | } 40 | ctx.node.changed(); 41 | } 42 | }); 43 | } 44 | 45 | static findAll(ws: Workspace): string[] { 46 | const tags = new Set(); 47 | ws.mainNode().walk((n) => { 48 | if (n.value instanceof Tag) { 49 | tags.add(n.value.name); 50 | } 51 | return false; 52 | }, {includeComponents: true}); 53 | return [...tags]; 54 | } 55 | 56 | static findTagged(ws: Workspace, name: string): Node[] { 57 | const nodes = []; 58 | ws.mainNode().walk((n) => { 59 | if (n.value instanceof Tag && n.value.name === name) { 60 | nodes.push(n.parent); 61 | } 62 | return false; 63 | }, {includeComponents: true}); 64 | return nodes; 65 | } 66 | 67 | static showPopover(bench: Workbench, path: Path, node: Node, inputview: Function, closer: Function) { 68 | const tags = Tag.findAll(bench.workspace); 69 | const trigger = bench.getInput(path); 70 | const rect = trigger.getBoundingClientRect(); 71 | let x = document.body.scrollLeft + rect.x + (trigger.selectionStart * 10) + 20; 72 | let y = document.body.scrollTop + rect.y + rect.height; 73 | bench.showPopover(() => ( 74 | { 76 | closer(); 77 | bench.getInput(path).blur(); 78 | node.name = node.name.replace(/\s*#\w*/, ""); 79 | bench.executeCommand("add-tag", {node, path}, item.name); 80 | }} 81 | onchange={(state) => { 82 | if (node.name.includes("#")) { 83 | state.input = node.name.split("#")[1]; 84 | } else { 85 | state.input = ""; 86 | } 87 | const filtered = [...tags] 88 | .filter(t => t.toLowerCase().startsWith(state.input.toLowerCase())) 89 | .map(t => {return {name: t}}); 90 | if ((filtered[0] && filtered[0].name != state.input && state.input != "") || filtered.length === 0) { 91 | filtered.unshift({name: state.input, prefix: "Create tag: "}); 92 | } 93 | state.items = filtered; 94 | }} 95 | inputview={inputview} 96 | itemview={(item) => 97 |
98 |
{item.prefix||""}{item.name}
99 |
100 | } 101 | /> 102 | ), {top: `${y}px`, left: `${x}px`}); 103 | } 104 | } 105 | 106 | const TagBadge = { 107 | view({attrs: {node, component}}) { 108 | const onkeydown = (e) => { 109 | if (e.key === "Backspace") { 110 | node.removeComponent(component); 111 | node.changed(); 112 | } 113 | }; 114 | return ( 115 |
116 | 117 |
{component.name}
118 |
119 | ) 120 | } 121 | } -------------------------------------------------------------------------------- /lib/com/template.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Node } from "../model/mod.ts"; 3 | 4 | @component 5 | export class Template { 6 | object?: Node; 7 | 8 | constructor() { 9 | } 10 | 11 | onAttach(node: Node) { 12 | this.object = node.parent; 13 | } 14 | 15 | handleIcon(collapsed: boolean = false): any { 16 | return ( 17 | 18 | {collapsed?:null} 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | toJSON(key: string): any { 26 | return {}; 27 | } 28 | 29 | static initialize(workbench: Workbench) { 30 | workbench.commands.registerCommand({ 31 | id: "make-template", 32 | title: "Make Template", 33 | when: (ctx: Context) => { 34 | if (!ctx.node) return false; 35 | if (ctx.node.raw.Rel === "Fields") return false; 36 | if (ctx.node.parent && ctx.node.parent.hasComponent(Document)) return false; 37 | return true; 38 | }, 39 | action: (ctx: Context) => { 40 | const tmpl = new Template(); 41 | ctx.node.addComponent(tmpl); 42 | ctx.node.changed(); 43 | } 44 | }); 45 | } 46 | 47 | static findNode(ws: Workspace, name: string): Node|null { 48 | let node = null; 49 | ws.mainNode().walk((n) => { 50 | if (n.value instanceof Template && n.value.object.name === name) { 51 | node = n.value.object; 52 | return true; 53 | } 54 | return false; 55 | }, {includeComponents: true}); 56 | return node; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/com/textfield.tsx: -------------------------------------------------------------------------------- 1 | import { component } from "../model/components.ts"; 2 | import { Node } from "../model/mod.ts"; 3 | 4 | @component 5 | export class TextField { 6 | constructor() { 7 | 8 | } 9 | 10 | handleIcon(): any { 11 | return 12 | } 13 | } -------------------------------------------------------------------------------- /lib/model/components.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Components are classes that can be used for component values in component nodes. 3 | * These classes need to be registered so they can be properly "hydrated" from 4 | * marshaled form (usually JSON) back into class instances. 5 | * 6 | * @module 7 | */ 8 | 9 | const registry: Record = {}; 10 | 11 | export function component(target: any) { 12 | registry[componentName(target)] = target; 13 | } 14 | 15 | export function componentName(target: any): string { 16 | if (target.prototype === undefined) { 17 | target = target.constructor; 18 | } 19 | return `treehouse.${target.name}`; 20 | } 21 | 22 | export function getComponent(com: any): any { 23 | if (typeof com === "string") { 24 | return registry[com]; 25 | } 26 | return registry[componentName(com)]; 27 | } 28 | 29 | export function inflateToComponent(com: any, obj: any): any { 30 | const o = new (getComponent(com)); 31 | if (o["fromJSON"] instanceof Function) { 32 | o.fromJSON(obj); 33 | } else { 34 | Object.defineProperties(o, Object.getOwnPropertyDescriptors(obj)); 35 | } 36 | return o; 37 | } 38 | 39 | export function duplicate(obj: any): any { 40 | if (obj === undefined) { 41 | return undefined; 42 | } 43 | const com = getComponent(obj); 44 | if (!com) { 45 | return structuredClone(obj); 46 | } 47 | const src = JSON.parse(JSON.stringify(obj)||""); 48 | const dst = new obj.constructor(); 49 | if (dst["fromJSON"] instanceof Function) { 50 | dst.fromJSON(src); 51 | } else { 52 | Object.defineProperties(dst, Object.getOwnPropertyDescriptors(src)); 53 | } 54 | return dst; 55 | } -------------------------------------------------------------------------------- /lib/model/hooks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Hooks are single method interfaces implemented by component values. There are 3 | * some system hook interfaces defined here as well as utilities for working with 4 | * system and app hooks. 5 | * 6 | * @module 7 | */ 8 | import { Node } from "./mod.ts"; 9 | 10 | // triggered on parent set or import (if has parent), or addcomponent 11 | export interface AttachListener { 12 | onAttach(node: Node): void; 13 | } 14 | 15 | // called on accessing children 16 | export interface ChildProvider { 17 | objectChildren(node: Node, children: Node[]): Node[]; 18 | } 19 | 20 | export function hasHook(node: Node, hook: string): boolean { 21 | return node.value && node.value[hook] instanceof Function; 22 | } 23 | 24 | export function triggerHook(node: Node, hook: string, ...args: any[]): any { 25 | if (hasHook(node, hook)) { 26 | return node.value[hook].apply(node.value, args); 27 | } 28 | } 29 | 30 | export function objectHas(obj: Node, hook: string): boolean { 31 | for (const com of obj.components) { 32 | if (hasHook(com, hook)) return true; 33 | } 34 | return false; 35 | } 36 | 37 | export function objectCall(obj: Node, hook: string, ...args: any[]): any { 38 | for (const com of obj.components) { 39 | if (hasHook(com, hook)) { 40 | return com.value[hook].apply(com.value, args); 41 | } 42 | } 43 | } 44 | 45 | export function componentsWith(obj: Node, hook: string, ...args: any[]): any[] { 46 | const ret = []; 47 | for (const com of obj.components) { 48 | if (hasHook(com, hook)) { 49 | ret.push(com.value); 50 | } 51 | } 52 | return ret; 53 | } 54 | 55 | 56 | 57 | // shorthand for nodes that have child provider hook. 58 | // use this to determine if some commands should be 59 | // prevented since visible children will not be impacted. 60 | export function objectManaged(obj: Node): boolean { 61 | return objectHas(obj, "objectChildren"); 62 | } -------------------------------------------------------------------------------- /lib/model/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The Treehouse model is an extensible node tree system. It is an implementation of the 3 | * Manifold system which is in active development, so expect changes here. 4 | * 5 | * @module 6 | */ 7 | 8 | export type WalkFunc = (node: Node) => boolean; 9 | export type ObserverFunc = (node: Node) => void; 10 | 11 | export interface WalkOptions { 12 | followRefs: boolean; 13 | includeComponents: boolean; 14 | } 15 | 16 | export interface RawNode { 17 | ID: string; 18 | Name: string; 19 | Value?: any; 20 | Parent?: string; 21 | Linked: Record; // Rel => IDs 22 | Attrs: Record; 23 | 24 | Rel?: string; // Parent Rel hint kludge (Components, Fields) 25 | } 26 | 27 | 28 | 29 | export interface Node { 30 | readonly id: string; 31 | readonly bus: Bus; 32 | readonly raw: RawNode; 33 | 34 | name: string; 35 | value: any; 36 | parent: Node|null; 37 | refTo: Node|null; 38 | siblingIndex: number; 39 | 40 | readonly prevSibling: Node|null; 41 | readonly nextSibling: Node|null; 42 | readonly ancestors: Node[]; 43 | readonly isDestroyed: boolean; 44 | readonly path: string; 45 | 46 | readonly children: Node[]; 47 | readonly childCount: number; 48 | addChild(node: Node): void; 49 | removeChild(node: Node): void; 50 | 51 | readonly components: Node[]; 52 | readonly componentCount: number; 53 | addComponent(obj: any): void; 54 | removeComponent(type: any): void; 55 | hasComponent(type: any): boolean; 56 | getComponent(type: any): any|null; 57 | // getComponentsInChildren 58 | // getComponentsInParents 59 | 60 | getLinked(rel: string): Node[]; 61 | addLinked(rel: string, node: Node): void; 62 | removeLinked(rel: string, node: Node): void; 63 | moveLinked(rel: string, node: Node, idx: number): void; 64 | 65 | componentField(name: string): any|null; 66 | 67 | getAttr(name: string): string; 68 | setAttr(name: string, value: string): void; 69 | 70 | find(path: string): Node|null; 71 | walk(fn: WalkFunc, opts?: WalkOptions): boolean; 72 | destroy(): void; 73 | duplicate(): Node; 74 | changed(): void; 75 | 76 | 77 | } 78 | 79 | 80 | export interface Bus { 81 | import(nodes: RawNode[]): void; 82 | export(): RawNode[]; 83 | make(name: string, value?: any): Node; 84 | destroy(node: Node): void; 85 | roots(): Node[]; 86 | root(name?: string): Node|null; 87 | find(path:string): Node|null; 88 | walk(fn: WalkFunc, opts?: WalkOptions): void; 89 | observe(fn: ObserverFunc): void; 90 | } 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /lib/model/module/bus.ts: -------------------------------------------------------------------------------- 1 | import { RawNode, Node as INode, Bus as IBus, WalkFunc, ObserverFunc, WalkOptions } from "../mod.ts"; 2 | import { componentName, getComponent, inflateToComponent } from "../components.ts"; 3 | import { triggerHook, hasHook } from "../hooks.ts"; 4 | import { Node } from "./mod.ts"; 5 | 6 | export class Bus { 7 | nodes: Record; 8 | observers: ObserverFunc[]; 9 | 10 | constructor() { 11 | this.nodes = {"@root": { 12 | ID: "@root", 13 | Name: "@root", 14 | Linked: {Children: [], Components: []}, 15 | Attrs: {} 16 | }}; 17 | this.observers = []; 18 | } 19 | 20 | changed(n: INode) { 21 | this.observers.forEach(cb => cb(n)); 22 | } 23 | 24 | /* Bus interface */ 25 | 26 | import(nodes: RawNode[]) { 27 | for (const n of nodes) { 28 | if (n.Value && getComponent(n.Name)) { 29 | n.Value = inflateToComponent(n.Name, n.Value); 30 | n.Rel = "Components"; // kludge 31 | } 32 | this.nodes[n.ID] = n; 33 | } 34 | for (const n of nodes) { 35 | // clear stored tmp nodes 36 | if (n.Parent === "@tmp") { 37 | delete this.nodes[n.ID]; 38 | continue; 39 | } 40 | // clear nodes with no parent that aren't system 41 | if (!n.ID.startsWith("@") && n.Parent === undefined) { 42 | delete this.nodes[n.ID]; 43 | continue; 44 | } 45 | // clear nodes with non-existant parent 46 | if (n.Parent && !this.nodes[n.Parent]) { 47 | delete this.nodes[n.ID]; 48 | continue; 49 | } 50 | const node = this.find(n.ID); 51 | if (node) { 52 | // check orphan 53 | if (node.parent && !node.parent.raw) { 54 | delete this.nodes[n.ID]; 55 | continue; 56 | } 57 | // trigger attach 58 | triggerHook(node, "onAttach", node); 59 | } 60 | } 61 | } 62 | 63 | export(): RawNode[] { 64 | const nodes: RawNode[] = []; 65 | for (const n of Object.values(this.nodes)) { 66 | nodes.push(n); 67 | } 68 | return nodes; 69 | } 70 | 71 | make(name: string, value?: any): INode { 72 | let parent: INode|null = null; 73 | if (name.includes("/")) { 74 | const parts = name.split("/"); 75 | parent = this.find(parts[0]); 76 | for (let i = 1; i < parts.length-1; i++) { 77 | if (parent === null) { 78 | throw "unable to get root"; 79 | } 80 | 81 | let child = parent.find(parts[i]); 82 | if (!child) { 83 | child = this.make(parts.slice(0, i+1).join("/")); 84 | } 85 | parent = child; 86 | } 87 | name = parts[parts.length-1]; 88 | } 89 | const id = (name.startsWith("@"))?name:uniqueId(); 90 | this.nodes[id] = { 91 | ID: id, 92 | Name: name, 93 | Value: value, 94 | Linked: {Children: [], Components: []}, 95 | Attrs: {} 96 | }; 97 | const node = new Node(this, id); 98 | if (parent) { 99 | node.parent = parent; 100 | } 101 | return node; 102 | } 103 | 104 | // destroys node but not linked nodes 105 | destroy(n: INode) { 106 | const p = n.parent; 107 | if (p !== null && !p.isDestroyed) { 108 | let rel = n.raw.Rel || "Children"; 109 | if (p.raw.Linked[rel].includes(n.id)) { 110 | p.raw.Linked[rel].splice(n.siblingIndex, 1); 111 | } 112 | } 113 | delete this.nodes[n.id]; 114 | if (p) { 115 | this.changed(p); 116 | } 117 | } 118 | 119 | roots(): INode[] { 120 | return Object.values(this.nodes).filter(n => n.Parent === undefined).map(n => new Node(this, n.ID)); 121 | } 122 | 123 | root(name?: string): INode|null { 124 | name = name || "@root" 125 | const node = this.roots().find(root => root.name === name); 126 | if (node === undefined) return null; 127 | return node; 128 | } 129 | 130 | find(path:string): INode|null { 131 | const byId = this.nodes[path]; 132 | if (byId) return new Node(this, byId.ID); 133 | const parts = path.split("/"); 134 | if (parts.length === 1 && parts[0].startsWith("@")) { 135 | // did not find @id by ID so return null 136 | return null; 137 | } 138 | let cur = this.root(parts[0]); 139 | if (!cur && this.nodes[parts[0]]) { 140 | cur = new Node(this, this.nodes[parts[0]].ID); 141 | } 142 | if (cur) { 143 | parts.shift(); 144 | } else { 145 | cur = this.root("@root"); 146 | } 147 | if (!cur) { 148 | return null; 149 | } 150 | const findChild = (n: INode, name: string): INode|undefined => { 151 | if (n.refTo) { 152 | n = n.refTo; 153 | } 154 | return n.children.find(child => child.name === name); 155 | } 156 | for (const name of parts) { 157 | const child = findChild(cur, name); 158 | if (!child) return null; 159 | cur = child; 160 | } 161 | return cur; 162 | } 163 | 164 | walk(fn: WalkFunc, opts?: WalkOptions) { 165 | for (const root of this.roots()) { 166 | if (root.walk(fn, opts)) return; 167 | } 168 | } 169 | 170 | observe(fn: ObserverFunc) { 171 | this.observers.push(fn); 172 | } 173 | } 174 | 175 | const uniqueId = () => { 176 | const dateString = Date.now().toString(36); 177 | const randomness = Math.random().toString(36).substring(2); 178 | return dateString + randomness; 179 | }; 180 | -------------------------------------------------------------------------------- /lib/model/module/mod.ts: -------------------------------------------------------------------------------- 1 | export { Bus } from "./bus.ts"; 2 | export { Node } from "./node.ts"; -------------------------------------------------------------------------------- /lib/model/module/mod_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { assertEquals, assert, assertExists } from "https://deno.land/std@0.173.0/testing/asserts.ts"; 3 | import * as module from "./mod.ts"; 4 | import { Node } from "../mod.ts"; 5 | import { component } from "../components.ts"; 6 | 7 | Deno.test("node children", () => { 8 | const bus = new module.Bus(); 9 | const nodeA = bus.make("@root/NodeA"); 10 | const nodeB = bus.make("@root/NodeB"); 11 | const nodeC = bus.make("@root/NodeC"); 12 | 13 | const root = bus.root(); 14 | assertExists(root); 15 | assertEquals(root.childCount, 3); 16 | 17 | assertEquals(nodeA.siblingIndex, 0); 18 | assertEquals(nodeB.siblingIndex, 1); 19 | nodeB.siblingIndex = 0; 20 | assertEquals(nodeA.siblingIndex, 1); 21 | assertEquals(nodeB.siblingIndex, 0); 22 | 23 | assertEquals(nodeA.parent?.name, root.name); 24 | 25 | }); 26 | 27 | @component 28 | class Foobar { 29 | state: string; 30 | nodes: Node[]; 31 | 32 | constructor() { 33 | this.state = ""; 34 | this.nodes = []; 35 | } 36 | 37 | onAttach(node: Node) { 38 | if (!this.nodes.length) { 39 | const b = node.bus; 40 | this.nodes.push(b.make("Foo1")); 41 | this.nodes.push(b.make("Foo2")); 42 | this.nodes.push(b.make("Foo3")); 43 | } 44 | } 45 | 46 | objectChildren(node: Node, children: Node[]): Node[] { 47 | return this.nodes; 48 | } 49 | } 50 | 51 | Deno.test("components", () => { 52 | const b = new module.Bus(); 53 | const root = b.root(); 54 | assertExists(root); 55 | 56 | const f = new Foobar(); 57 | f.state = "hello"; 58 | root.addComponent(f); 59 | 60 | assertEquals(root.hasComponent(Foobar), true); 61 | 62 | const ff = root.getComponent(Foobar); 63 | assertEquals(f, ff); 64 | 65 | root.removeComponent(Foobar); 66 | assertEquals(root.hasComponent(Foobar), false); 67 | 68 | }); 69 | 70 | Deno.test("references", () => { 71 | const bus = new module.Bus(); 72 | const a = bus.make("A"); 73 | const b = bus.make("A/B"); 74 | const c = bus.make("A/B/C"); 75 | 76 | const x = bus.make("X"); 77 | const r = bus.make(""); 78 | r.refTo = b; 79 | r.parent = x; 80 | r.value = "value"; 81 | 82 | assertEquals(r.name, b.name); 83 | assertEquals(r.value, b.value); 84 | 85 | assertEquals(r.path, "X/B"); 86 | assertEquals(r.refTo.path, "A/B"); 87 | 88 | assertEquals(b.children[0].id, r.refTo.children[0].id); 89 | 90 | const names = ["A", "B", "C"]; 91 | a.walk((n: Node): boolean => { 92 | assertEquals(n.name, names.shift()); 93 | return false; 94 | }); 95 | 96 | }); -------------------------------------------------------------------------------- /lib/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | export const Drawer = { 2 | view({ attrs, children }) { 3 | const open = attrs.open; 4 | return ( 5 |
6 | {children} 7 |
8 | ) 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /lib/ui/menu.tsx: -------------------------------------------------------------------------------- 1 | import { bindingSymbols } from "../action/keybinds.ts"; 2 | 3 | function isDisabled(workbench, item, cmd, ctx) { 4 | if (cmd) { 5 | return item.disabled || !workbench.canExecuteCommand(cmd.id, ctx); 6 | } 7 | return item.disabled; 8 | } 9 | 10 | export const Menu: m.Component = { 11 | view({attrs: {workbench, x, y, items, align, commands, ctx}}) { 12 | const onclick = (item, cmd) => (e) => { 13 | e.stopPropagation(); 14 | if (isDisabled(workbench, item, cmd, ctx)) { 15 | return; 16 | } 17 | workbench.closeMenu(); 18 | if (item.onclick) { 19 | item.onclick(); 20 | } 21 | if (cmd) { 22 | workbench.executeCommand(cmd.id, ctx); 23 | } 24 | }; 25 | return ( 26 | 52 | ) 53 | } 54 | }; 55 | 56 | /*
  • Indent
    shift+A
  • 57 |
  • Open in new panel
    shift+meta+Backspace
  • 58 |
    59 |
  • Show list view
  • 60 |
  • Move
  • 61 |
  • Delete node
  • 62 |
    */ 63 | -------------------------------------------------------------------------------- /lib/ui/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UI module contains the React-style view components that make up the UI. 3 | * They implement the Mithril.js component API and use JSX. 4 | * 5 | * @module 6 | */ -------------------------------------------------------------------------------- /lib/ui/node/editor.tsx: -------------------------------------------------------------------------------- 1 | import { objectCall, objectHas } from "../../model/hooks.ts"; 2 | import { Document } from "../../com/document.tsx"; 3 | import { Description } from "../../com/description.tsx"; 4 | 5 | export const NodeEditor: m.Component = { 6 | view ({attrs: {workbench, path, onkeydown, oninput, disallowEmpty, editValue, placeholder}, state}) { 7 | const node = path.node; 8 | let prop = (editValue) ? "value" : "name"; 9 | 10 | const display = () => { 11 | if (prop === "name") { 12 | return objectHas(node, "displayName") ? objectCall(node, "displayName", node) : node.name; 13 | } 14 | return node[prop] || ""; 15 | } 16 | const onfocus = () => { 17 | state.initialValue = node[prop]; 18 | workbench.context.node = node; 19 | workbench.context.path = path; 20 | } 21 | const getter = () => { 22 | return node[prop]; 23 | } 24 | const setter = (v, finished) => { 25 | if (!node.isDestroyed) { 26 | if (disallowEmpty && v.length === 0) { 27 | node[prop] = state.initialValue; 28 | } else { 29 | node[prop] = v; 30 | } 31 | } 32 | if (finished) { 33 | workbench.context.node = null; 34 | } 35 | } 36 | 37 | if (node.raw.Rel === "Fields") { 38 | placeholder = (editValue) ? "Value" : "Field"; 39 | } 40 | 41 | let id = `input-${path.id}-${node.id}`; 42 | if (prop === "value") { 43 | id = id+"-value"; 44 | } 45 | let editor = TextAreaEditor; 46 | if (node.parent && node.parent.hasComponent(Document) && window.Editor) { 47 | editor = CodeMirrorEditor; 48 | } 49 | let desc = undefined; 50 | if (node.hasComponent(Description)) { 51 | desc = node.getComponent(Description); 52 | } 53 | return ( 54 |
    55 | {m(editor, {id, getter, setter, display, onkeydown, onfocus, oninput, placeholder, workbench, path})} 56 | {(desc) ? m(desc.editor(), {node}) :null} 57 |
    58 | ) 59 | } 60 | } 61 | 62 | 63 | interface Attrs { 64 | id?: string; 65 | onkeydown?: Function; 66 | onfocus?: Function; 67 | onblur?: Function; 68 | onmount?: Function; 69 | getter: Function; 70 | setter: Function; 71 | display?: Function; 72 | } 73 | 74 | interface State { 75 | editing: boolean; 76 | buffer: string; 77 | } 78 | 79 | export const CodeMirrorEditor: m.Component = { 80 | oncreate({dom,state,attrs: {id, onkeydown, onfocus, onblur, oninput, getter, setter, display, placeholder}}) { 81 | const value = (state.editing) 82 | ? state.buffer 83 | : (display) ? display() : getter(); 84 | 85 | const defaultKeydown = (e) => { 86 | if (e.key === "Enter") { 87 | e.preventDefault(); 88 | e.stopPropagation(); 89 | } 90 | } 91 | const startEdit = (e) => { 92 | if (onfocus) onfocus(e); 93 | state.editing = true; 94 | state.buffer = getter(); 95 | } 96 | const finishEdit = (e) => { 97 | // safari can trigger blur more than once 98 | // for a given element, namely when clicking 99 | // into devtools. this prevents the second 100 | // blur setting node name to undefined/empty. 101 | if (state.editing) { 102 | state.editing = false; 103 | setter(state.buffer, true); 104 | state.buffer = undefined; 105 | } 106 | if (onblur) onblur(e); 107 | } 108 | const edit = (e) => { 109 | state.buffer = e.target.value; 110 | setter(state.buffer, false); 111 | if (oninput) { 112 | oninput(e); 113 | } 114 | } 115 | 116 | state.editor = new window.Editor(dom, value, placeholder); 117 | state.editor.onblur = finishEdit; 118 | state.editor.onfocus = startEdit; 119 | state.editor.oninput = edit; 120 | state.editor.onkeydown = onkeydown||defaultKeydown; 121 | dom.editor = state.editor; 122 | dom.id = id; 123 | }, 124 | onupdate({dom,state,attrs: {getter, display}}) { 125 | state.editor.value = (state.editing) 126 | ? state.buffer 127 | : (display) ? display() : getter(); 128 | }, 129 | view () { 130 | return ( 131 |
    132 | ) 133 | } 134 | } 135 | 136 | export const TextAreaEditor: m.Component = { 137 | oncreate({dom,attrs}) { 138 | const textarea = dom.querySelector("textarea"); 139 | const initialHeight = textarea.offsetHeight; 140 | const span = dom.querySelector("span"); 141 | this.updateHeight = () => { 142 | span.style.width = `${Math.max(textarea.offsetWidth, 100)}px`; 143 | span.innerHTML = textarea.value.replace("\n", "
    "); 144 | let height = span.offsetHeight; 145 | if (height === 0 && initialHeight > 0) { 146 | height = initialHeight; 147 | } 148 | textarea.style.height = (height > 0) ? `${height}px` : `var(--body-line-height)`; 149 | } 150 | textarea.addEventListener("input", () => this.updateHeight()); 151 | textarea.addEventListener("blur", () => span.innerHTML = ""); 152 | setTimeout(() => this.updateHeight(), 50); 153 | if (attrs.onmount) attrs.onmount(textarea); 154 | }, 155 | onupdate() { 156 | this.updateHeight(); 157 | }, 158 | view ({attrs: {id, onkeydown, onfocus, onblur, oninput, getter, setter, display, placeholder, path, workbench}, state}) { 159 | const value = (state.editing) 160 | ? state.buffer 161 | : (display) ? display() : getter(); 162 | 163 | const defaultKeydown = (e) => { 164 | if (e.key === "Enter") { 165 | e.preventDefault(); 166 | e.stopPropagation(); 167 | } 168 | } 169 | const startEdit = (e) => { 170 | if (onfocus) onfocus(e); 171 | state.editing = true; 172 | state.buffer = getter(); 173 | } 174 | const finishEdit = (e) => { 175 | // safari can trigger blur more than once 176 | // for a given element, namely when clicking 177 | // into devtools. this prevents the second 178 | // blur setting node name to undefined/empty. 179 | if (state.editing) { 180 | state.editing = false; 181 | setter(state.buffer, true); 182 | state.buffer = undefined; 183 | } 184 | if (onblur) onblur(e); 185 | } 186 | const edit = (e) => { 187 | state.buffer = e.target.value; 188 | setter(state.buffer, false); 189 | if (oninput) { 190 | oninput(e); 191 | } 192 | } 193 | const handlePaste = (e) => { 194 | const textData = e.clipboardData.getData('Text'); 195 | if (textData.length > 0) { 196 | e.preventDefault(); 197 | e.stopPropagation(); 198 | 199 | const lines = textData.split('\n').map(line => line.trim()).filter(line => line.length > 0); 200 | state.buffer = lines.shift(); 201 | setter(state.buffer, true); 202 | 203 | let node = path.node; 204 | for (const line of lines) { 205 | const newNode = workbench.workspace.new(line); 206 | newNode.parent = node.parent; 207 | newNode.siblingIndex = node.siblingIndex + 1; 208 | m.redraw.sync(); 209 | const p = path.clone(); 210 | p.pop(); 211 | workbench.focus(p.append(newNode)); 212 | node = newNode; 213 | } 214 | } 215 | } 216 | 217 | return ( 218 |
    219 | 229 | 230 |
    231 | ) 232 | } 233 | } -------------------------------------------------------------------------------- /lib/ui/node/new.tsx: -------------------------------------------------------------------------------- 1 | 2 | export const NewNode = { 3 | view({attrs: {workbench, path}}) { 4 | const keydown = (e) => { 5 | if (e.key === "Tab") { 6 | e.stopPropagation(); 7 | e.preventDefault(); 8 | if (node.childCount > 0) { 9 | const lastchild = path.node.children[path.node.childCount-1]; 10 | workbench.executeCommand("insert-child", {node: lastchild, path}); 11 | } 12 | } else { 13 | workbench.executeCommand("insert-child", {node: path.node, path}, e.target.value); 14 | } 15 | } 16 | return ( 17 |
    18 | 19 | 20 | 21 | 22 |
    23 | 28 |
    29 |
    30 | ) 31 | } 32 | } -------------------------------------------------------------------------------- /lib/ui/notices.tsx: -------------------------------------------------------------------------------- 1 | 2 | export const LockStolenMessage = { 3 | view() { 4 | return ( 5 |
    6 |

    Refresh to view latest updates

    7 |

    8 | Your notes were updated in another browser session. Refresh the page to view the latest version. 9 |

    10 |
    11 | 14 | 15 |
    16 |
    17 | ) 18 | } 19 | } 20 | 21 | export const FirstTimeMessage = { 22 | view({attrs: {workbench}}) { 23 | return ( 24 |
    25 |

    Treehouse is under active development

    26 |

    This is a preview based on our main branch, which is actively being developed.

    27 |

    If you find a bug, please report it via 28 |   29 |  > Submit Issue. 30 |

    31 |

    32 | Data is stored using localstorage, which you can reset via 33 |   34 |  > Reset Demo. 35 |

    36 |
    37 | 41 | 42 |
    43 |
    44 | ) 45 | } 46 | } 47 | 48 | export const GitHubMessage = { 49 | view({attrs: {workbench, finished}}) { 50 | return ( 51 |
    52 |

    Login with GitHub

    53 |

    The GitHub backend is experimental so use at your own risk!

    54 |

    To store your workbench we will create a public repository called

    <username>.treehouse.sh
    if it doesn't already exist. You can manually make this repository private via GitHub if you want.

    55 |

    You can Logout via the 56 |   57 |   58 | menu in the top right to return to the localstorage backend. 59 |

    60 |
    61 | 64 | 69 |
    70 |
    71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/ui/palette.tsx: -------------------------------------------------------------------------------- 1 | import { bindingSymbols } from "../action/keybinds.ts"; 2 | import { Picker } from "./picker.tsx"; 3 | 4 | export const CommandPalette: m.Component = { 5 | 6 | view({ attrs: { workbench, ctx } }) { 7 | const getTitle = (cmd) => { 8 | const title = cmd.title || cmd.id; 9 | return title.replace('-', ' ').replace(/(^|\s)\S/g, t => t.toUpperCase()); 10 | } 11 | const sort = (a, b) => { 12 | return getTitle(a).localeCompare(getTitle(b)); 13 | } 14 | const onpick = (cmd) => { 15 | workbench.closeDialog(); 16 | workbench.commands.executeCommand(cmd.id, ctx); 17 | } 18 | const onchange = (state) => { 19 | state.items = cmds.filter(cmd => { 20 | const value = cmd.title || cmd.id; 21 | return value.toLowerCase().includes(state.input.toLowerCase()); 22 | }) 23 | } 24 | const getBindingSymbols = (cmd) => { 25 | const binding = workbench.keybindings.getBinding(cmd.id); 26 | return binding ? bindingSymbols(binding.key).join(" ").toUpperCase() : ""; 27 | } 28 | 29 | const cmds = Object.values(workbench.commands.commands) 30 | .filter(cmd => !cmd.hidden) 31 | .filter(cmd => workbench.canExecuteCommand(cmd.id, ctx)) 32 | .sort(sort); 33 | 34 | return ( 35 |
    36 | 38 |
    39 | 40 |
    41 | } 42 | itemview={(cmd) => 43 |
    44 |
    {getTitle(cmd)}
    45 |
    {getBindingSymbols(cmd)}
    46 |
    47 | } /> 48 |
    49 | ) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /lib/ui/panel.tsx: -------------------------------------------------------------------------------- 1 | import { OutlineEditor } from "./outline.tsx"; 2 | import { NodeEditor } from "./node/editor.tsx"; 3 | 4 | export const Panel = { 5 | view({ attrs }) { 6 | const path = attrs.path; 7 | const workbench = attrs.workbench; 8 | const node = path.node; 9 | 10 | const close = (e) => { 11 | workbench.executeCommand("close-panel", {}, path); 12 | } 13 | const goBack = (e) => { 14 | let node = path.pop(); 15 | // if there was a duplicate id in the path, 16 | // pop again 17 | if (node === path.node) { 18 | path.pop(); 19 | } 20 | } 21 | const maximize = (e) => { 22 | // todo: should be a command 23 | workbench.panels = [path]; 24 | workbench.context.path = path; 25 | } 26 | function calcHeight(value = "") { 27 | let numberOfLineBreaks = (value.match(/\n/g) || []).length; 28 | // min-height + lines x line-height + padding + border 29 | let newHeight = 20 + numberOfLineBreaks * 20; 30 | return newHeight; 31 | } 32 | let viewClass = ""; 33 | if (node.getAttr("view")) { 34 | viewClass = `${node.getAttr("view")}-panel` 35 | } 36 | return
    37 |
    38 | {(path.length > 1) ? 39 |
    40 | 41 | 42 | 43 |
    44 | : null} 45 | 46 |
    47 | {(node.parent && node.parent.id !== "@root") ? workbench.open(node.parent)}>{node.parent.name} :  } 48 |
    49 | 50 | {(workbench.panels.length > 1) ? 51 |
    52 | 53 | 54 |
    55 | : null} 56 |
    57 | 58 |
    59 |
    workbench.showMenu(e, { node, path })} data-menu="node"> 60 | 61 |
    62 | 63 |
    64 |
    65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /lib/ui/picker.tsx: -------------------------------------------------------------------------------- 1 | 2 | export interface Attrs { 3 | input: string; 4 | inputview: (onkeydown: Function, oninput: Function) => any; 5 | itemview: (item: any, idx: number) => any; 6 | onpick: (item: any) => void; 7 | onchange: (State) => void; 8 | } 9 | 10 | export interface State { 11 | selected: number; 12 | input: string; 13 | items: any[]; 14 | } 15 | 16 | export const Picker: m.Component = { 17 | onupdate({ state, dom }) { 18 | const items = dom.querySelector(".items").children; 19 | if (state.selected !== undefined && items.length > 0) { 20 | items[state.selected].scrollIntoView({ block: "nearest" }); 21 | } 22 | }, 23 | 24 | oncreate({ attrs, state, dom }) { 25 | if (attrs.inputview) { 26 | dom.querySelector("input")?.focus(); 27 | } 28 | if (state.selected === undefined) { 29 | state.selected = 0; 30 | } 31 | }, 32 | 33 | view({ attrs, state }) { 34 | 35 | state.selected = (state.selected === undefined) ? 0 : state.selected; 36 | state.input = (state.input === undefined) ? (attrs.input || "") : state.input; 37 | if (state.items === undefined) { 38 | state.items = []; 39 | attrs.onchange(state); 40 | } 41 | 42 | const onkeydown = (e) => { 43 | const mod = (a, b) => ((a % b) + b) % b; 44 | if (e.key === "ArrowDown") { 45 | if (state.selected === undefined) { 46 | state.selected = 0; 47 | return false; 48 | } 49 | state.selected = mod(state.selected + 1, state.items.length); 50 | return false; 51 | } 52 | if (e.key === "ArrowUp") { 53 | if (state.selected === undefined) { 54 | state.selected = 0; 55 | } 56 | state.selected = mod(state.selected - 1, state.items.length); 57 | return false; 58 | } 59 | if (e.key === "Enter") { 60 | if (state.selected !== undefined) { 61 | attrs.onpick(state.items[state.selected]); 62 | } 63 | return false; 64 | } 65 | } 66 | const oninput = (e) => { 67 | state.input = e.target.value; 68 | state.selected = 0; 69 | attrs.onchange(state); 70 | } 71 | return ( 72 |
    73 | {attrs.inputview(onkeydown, oninput, state.input)} 74 |
    75 | {state.items.map((item, idx) => ( 76 |
    attrs.onpick(item)} 78 | onmouseover={() => state.selected = idx}> 79 | {attrs.itemview(item, idx)} 80 |
    81 | ))} 82 |
    83 |
    84 | ) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/ui/quickadd.tsx: -------------------------------------------------------------------------------- 1 | import { OutlineEditor } from "./outline.tsx"; 2 | import { Path } from "../workbench/mod.ts"; 3 | 4 | export const QuickAdd = { 5 | view({attrs: {workbench, node}}) { 6 | const path = new Path(node, "quickadd"); 7 | return ( 8 |
    9 |

    Quick Add

    10 | 11 |
    12 | 16 | 17 |
    18 |
    19 | ) 20 | } 21 | } -------------------------------------------------------------------------------- /lib/ui/reference.tsx: -------------------------------------------------------------------------------- 1 | import { bindingSymbols } from "../action/keybinds.ts"; 2 | 3 | export const KeyboardReference = { 4 | view({ attrs }) { 5 | const workbench = attrs.workbench; 6 | const shortcuts = { 7 | "": [ 8 | "pick-command", 9 | ], 10 | "Edit": [ 11 | "cut", 12 | "copy", 13 | "copy-reference", 14 | "paste", 15 | "mark-done", 16 | "insert", 17 | "delete", 18 | ], 19 | "Navigate": [ 20 | "expand", 21 | "collapse", 22 | "indent", 23 | "outdent", 24 | "move-up", 25 | "move-down", 26 | "prev", 27 | "next", 28 | ], 29 | }; 30 | 31 | const getBindingSymbols = (cmd) => { 32 | const binding = workbench.keybindings.getBinding(cmd.id); 33 | return binding ? bindingSymbols(binding.key).join(" ").toUpperCase() : ""; 34 | }; 35 | 36 | return ( 37 |
    38 |

    Keyboard Shortcuts

    39 | 40 | {Object.entries(shortcuts).map(([header, ids]) => { 41 | return ( 42 |
    43 | {(header.length !== 0) &&

    {header}

    } 44 |
    45 | {ids.map(id => workbench.commands.commands[id]).map(cmd => ( 46 |
    47 |
    {getBindingSymbols(cmd)}
    48 |
    {cmd.title}
    49 |
    50 | ))} 51 |
    52 |
    53 | ); 54 | })} 55 |
    56 | ) 57 | } 58 | } -------------------------------------------------------------------------------- /lib/ui/search.tsx: -------------------------------------------------------------------------------- 1 | import { Picker } from "./picker.tsx"; 2 | 3 | export const Search: m.Component = { 4 | 5 | view({ attrs: { input, workbench } }) { 6 | 7 | const onpick = (node) => { 8 | workbench.closeDialog(); 9 | workbench.open(node); 10 | } 11 | const onchange = (state) => { 12 | if (state.input) { 13 | state.items = workbench.search(state.input); 14 | } else { 15 | state.items = []; 16 | } 17 | } 18 | 19 | return ( 20 | 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/ui/settings.tsx: -------------------------------------------------------------------------------- 1 | 2 | export const Settings = { 3 | view({attrs: {workbench}, state}) { 4 | const currentTheme = workbench.workspace.settings.theme; 5 | state.selectedTheme = (state.selectedTheme === undefined) ? currentTheme : state.selectedTheme; 6 | const oninput = (e) => { 7 | state.selectedTheme = e.target.value; 8 | } 9 | return ( 10 |
    11 |

    Settings

    12 |
    13 |
    Theme
    14 |
    15 | 21 |
    22 |
    23 |
    24 | 27 | 36 |
    37 |
    38 | ) 39 | } 40 | } -------------------------------------------------------------------------------- /lib/view/cards.tsx: -------------------------------------------------------------------------------- 1 | import { Node } from "../model/mod.ts"; 2 | import { InlineFrame } from "../com/iframe.tsx"; 3 | 4 | function tryFields(n: Node, fields: string[]): any|null { 5 | for (const field of fields) { 6 | const value = n.componentField(field); 7 | if (value) { 8 | return value; 9 | } 10 | } 11 | return null; 12 | } 13 | 14 | export default { 15 | view({attrs: {workbench, path}}) { 16 | const node = path.node; 17 | return ( 18 |
    19 | {node.children.map(n => { 20 | const linkURL = tryFields(n, ["linkURL"]); 21 | const dateTime = tryFields(n, ["updatedAt", "createdAt"]); 22 | const userName = tryFields(n, ["updatedBy", "createdBy", "username"]); 23 | const thumbnailURL = tryFields(n, ["thumbnailURL", "coverURL"]); 24 | const frame = n.getComponent(InlineFrame); 25 | 26 | let thumbnail = ; 32 | if (frame) { 33 | thumbnail = ( 34 |
    41 | 46 |
    47 | ) 48 | } 49 | return ( 50 |
    51 |
    52 | {(linkURL) 53 | ? {thumbnail} 54 | : thumbnail 55 | } 56 |
    57 |
    58 | {(linkURL) 59 | ? {n.name} 60 | : n.name} 61 |
    62 | {userName &&
    63 | {userName} 64 |
    } 65 | {dateTime &&
    66 | {timeAgo(dateTime)} 67 |
    } 68 |
    69 | )})} 70 |
    71 | ) 72 | } 73 | } 74 | 75 | function timeAgo(date) { 76 | if (!(date instanceof Date)) { 77 | throw new Error("Input must be a valid Date object."); 78 | } 79 | 80 | const now = new Date(); 81 | const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 82 | 83 | const intervals = { 84 | year: 31536000, 85 | month: 2592000, 86 | week: 604800, 87 | day: 86400, 88 | hour: 3600, 89 | minute: 60, 90 | second: 1 91 | }; 92 | 93 | for (const [unit, secondsInUnit] of Object.entries(intervals)) { 94 | const count = Math.floor(seconds / secondsInUnit); 95 | if (count > 0) { 96 | return `${count} ${unit}${count > 1 ? 's' : ''} ago`; 97 | } 98 | } 99 | 100 | return 'just now'; 101 | } -------------------------------------------------------------------------------- /lib/view/document.tsx: -------------------------------------------------------------------------------- 1 | import { NewNode } from "../ui/node/new.tsx"; 2 | import { OutlineNode } from "../ui/outline.tsx"; 3 | 4 | export default { 5 | view({attrs: {workbench, path, alwaysShowNew}}) { 6 | let node = path.node; 7 | if (path.node.refTo) { 8 | node = path.node.refTo; 9 | } 10 | let showNew = false; 11 | if ((node.childCount === 0 && node.getLinked("Fields").length === 0) || alwaysShowNew) { 12 | showNew = true; 13 | } 14 | return ( 15 |
    16 |
    17 | {(node.getLinked("Fields").length > 0) && 18 | node.getLinked("Fields").map(n => ) 19 | } 20 |
    21 |
    22 | {(node.childCount > 0) && node.children.map(n => )} 23 | {showNew && } 24 |
    25 |
    26 | ) 27 | } 28 | } -------------------------------------------------------------------------------- /lib/view/empty.tsx: -------------------------------------------------------------------------------- 1 | import { NewNode } from "../ui/node/new.tsx"; 2 | import { OutlineNode } from "../ui/outline.tsx"; 3 | 4 | export default { 5 | view({attrs: {workbench, path}}) { 6 | return ( 7 |
    8 |
    9 | ) 10 | } 11 | } -------------------------------------------------------------------------------- /lib/view/list.tsx: -------------------------------------------------------------------------------- 1 | import { NewNode } from "../ui/node/new.tsx"; 2 | import { OutlineNode } from "../ui/outline.tsx"; 3 | import { SmartNode } from "../com/smartnode.tsx"; 4 | 5 | export default { 6 | view({attrs: {workbench, path, alwaysShowNew}}) { 7 | let node = path.node; 8 | if (path.node.refTo) { 9 | node = path.node.refTo; 10 | } 11 | let showNew = false; 12 | if ((node.childCount === 0 && node.getLinked("Fields").length === 0) || alwaysShowNew) { 13 | showNew = true; 14 | } 15 | // TODO: find some way to not hardcode this rule 16 | if (node.hasComponent(SmartNode)) { 17 | showNew = false; 18 | } 19 | return ( 20 |
    21 |
    22 | {(node.getLinked("Fields").length > 0) && 23 | node.getLinked("Fields").map(n => ) 24 | } 25 |
    26 |
    27 | {(node.childCount > 0) && node.children.map(n => )} 28 | {showNew && } 29 |
    30 |
    31 | ) 32 | } 33 | } -------------------------------------------------------------------------------- /lib/view/table.tsx: -------------------------------------------------------------------------------- 1 | import { NodeEditor, TextAreaEditor } from "../ui/node/editor.tsx"; 2 | import { OutlineNode } from "../ui/outline.tsx"; 3 | 4 | export default { 5 | view({attrs: {workbench, path}, state}) { 6 | const node = path.node; 7 | state.fields = (state.fields === undefined) ? new Set() : state.fields; 8 | node.children.forEach(n => { 9 | n.getLinked("Fields").forEach(f => state.fields.add(f.name)); 10 | }); 11 | const getFieldEditor = (node, field) => { 12 | const fields = node.getLinked("Fields").filter(f => f.name === field); 13 | if (fields.length === 0) return ""; 14 | return 15 | } 16 | return ( 17 | 18 | 19 | 20 | 21 | {[...state.fields].map(f => )} 22 | 23 | 24 | 25 | {node.children.map(n => ( 26 | 27 | 28 | {[...state.fields].map(f => )} 29 | 30 | ))} 31 | 32 |
    Title{f}
    {getFieldEditor(n, f)}
    33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /lib/view/tabs.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { getNodeView } from "../view/views.ts"; 3 | 4 | export default { 5 | view({ attrs: { workbench, path }, state }) { 6 | const node = path.node; 7 | state.tabs = (state.tabs === undefined) ? new Set() : state.tabs; 8 | state.selectedTab = (state.selectedTab === undefined) ? "" : state.selectedTab; 9 | node.children.forEach(n => { 10 | state.tabs.add(n.raw); 11 | if (state.selectedTab === "") state.selectedTab = n.raw.ID; 12 | }); 13 | const handleTabClick = (id) => { 14 | state.selectedTab = id; 15 | }; 16 | const selectedNode = node.children.find(n => n.id === state.selectedTab); 17 | return ( 18 |
    19 |
    20 | {[...state.tabs].map(n =>
    handleTabClick(n.ID)}>{n.Name}
    )} 21 |
    22 |
    23 |
    24 | {m(getNodeView(selectedNode), {workbench, path: path.append(selectedNode)})} 25 |
    26 |
    27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/view/views.ts: -------------------------------------------------------------------------------- 1 | import empty from "./empty.tsx"; 2 | import list from "./list.tsx"; 3 | import table from "./table.tsx"; 4 | import tabs from "./tabs.tsx"; 5 | import document from "./document.tsx"; 6 | import cards from "./cards.tsx"; 7 | 8 | export const views = { 9 | list, 10 | table, 11 | tabs, 12 | document, 13 | cards 14 | } 15 | 16 | // deprecated. use getNodeView 17 | export function getView(name) { 18 | return views[name] || empty; 19 | } 20 | 21 | export function getNodeView(node) { 22 | return views[node.getAttr("view") || "list"] || empty; 23 | } 24 | 25 | window.registerView = (name, view) => { 26 | views[name] = view; 27 | workbench.commands.registerCommand({ 28 | id: `view-${name}`, 29 | title: `View as ${toTitleCase(name)}`, 30 | action: (ctx: Context) => { 31 | if (!ctx.node) return; 32 | ctx.node.setAttr("view", name); 33 | } 34 | }); 35 | } 36 | 37 | function toTitleCase(str) { 38 | return str.replace( 39 | /\w\S*/g, 40 | function(txt) { 41 | return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); 42 | } 43 | ); 44 | } -------------------------------------------------------------------------------- /lib/workbench/mod.ts: -------------------------------------------------------------------------------- 1 | export { Workbench } from "./workbench.ts"; 2 | export { Path } from "./path.ts"; 3 | export { Workspace } from "./workspace.ts"; 4 | 5 | import { Node } from "../model/mod.ts"; 6 | 7 | /** 8 | * Context is a user context object interface. This is used to 9 | * track a global context for the user, mainly what node(s) are selected, 10 | * but is also used for local context in commands. 11 | */ 12 | export interface Context { 13 | path: Path; 14 | node: Node|null; 15 | nodes?: Node[]; 16 | event?: Event; 17 | 18 | } -------------------------------------------------------------------------------- /lib/workbench/path.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "../model/mod.ts"; 2 | import { SHA1 } from "./util.js"; 3 | 4 | /** 5 | * Path is a stack of nodes. It can be used as a history stack 6 | * so you can "zoom" into subnodes and return back to previous nodes. 7 | * It is also used to identify nodes in the UI more specifically than 8 | * the node ID since a node can be shown more than once (references, panels, etc). 9 | */ 10 | export class Path { 11 | name: string; 12 | nodes: Node[]; 13 | 14 | constructor(head?: Node, name?: string) { 15 | if (name) { 16 | this.name = name; 17 | } else { 18 | this.name = Math.random().toString(36).substring(2); 19 | } 20 | if (head) { 21 | this.nodes = [head]; 22 | } else { 23 | this.nodes = []; 24 | } 25 | } 26 | 27 | push(node: Node) { 28 | this.nodes.push(node); 29 | } 30 | 31 | pop(): Node|null { 32 | return this.nodes.pop() || null; 33 | } 34 | 35 | sub(): Path { 36 | return new Path(this.node, this.name); 37 | } 38 | 39 | clone(): Path { 40 | const p = new Path(); 41 | p.name = this.name; 42 | p.nodes = [...this.nodes]; 43 | return p; 44 | } 45 | 46 | append(node: Node): Path { 47 | const p = this.clone(); 48 | p.push(node); 49 | return p; 50 | } 51 | 52 | get length(): number { 53 | return this.nodes.length; 54 | } 55 | 56 | get id(): string { 57 | return SHA1([this.name, ...this.nodes.map(n => n.id)].join(":")); 58 | } 59 | 60 | get node(): Node { 61 | return this.nodes[this.nodes.length-1]; 62 | } 63 | 64 | get previous(): Node|null { 65 | if (this.nodes.length < 2) return null; 66 | return this.nodes[this.nodes.length-2]; 67 | } 68 | 69 | get head(): Node { 70 | return this.nodes[0]; 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /lib/workbench/path_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertNotEquals } from "https://deno.land/std@0.173.0/testing/asserts.ts"; 2 | import { Path } from "./path.ts"; 3 | import { Bus } from "../model/module/mod.ts"; 4 | 5 | Deno.test("path ids", async () => { 6 | const b = new Bus(); 7 | const n1 = b.make("Node1"); 8 | const n2 = b.make("Node2"); 9 | const n3 = b.make("Node3"); 10 | 11 | const p = new Path(n1); 12 | const d1 = p.id; 13 | p.push(n2); 14 | assertNotEquals(d1, p.id); 15 | 16 | const pA = p.append(n3); 17 | const pB = p.append(n3); 18 | assertEquals(pA.id, pB.id); 19 | 20 | pA.pop(); 21 | assertNotEquals(pA.id, pB.id); 22 | pB.pop(); 23 | assertEquals(pA.id, pB.id); 24 | 25 | assertEquals(p.head.name, "Node1"); 26 | assertEquals(p.node.name, "Node2"); 27 | }); -------------------------------------------------------------------------------- /lib/workbench/util.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Secure Hash Algorithm (SHA1) 4 | * http://www.webtoolkit.info/ 5 | **/ 6 | export function SHA1(msg) { 7 | function rotate_left(n,s) { 8 | var t4 = ( n<>>(32-s)); 9 | return t4; 10 | }; 11 | function lsb_hex(val) { 12 | var str=''; 13 | var i; 14 | var vh; 15 | var vl; 16 | for( i=0; i<=6; i+=2 ) { 17 | vh = (val>>>(i*4+4))&0x0f; 18 | vl = (val>>>(i*4))&0x0f; 19 | str += vh.toString(16) + vl.toString(16); 20 | } 21 | return str; 22 | }; 23 | function cvt_hex(val) { 24 | var str=''; 25 | var i; 26 | var v; 27 | for( i=7; i>=0; i-- ) { 28 | v = (val>>>(i*4))&0x0f; 29 | str += v.toString(16); 30 | } 31 | return str; 32 | }; 33 | function Utf8Encode(string) { 34 | string = string.replace(/\r\n/g,'\n'); 35 | var utftext = ''; 36 | for (var n = 0; n < string.length; n++) { 37 | var c = string.charCodeAt(n); 38 | if (c < 128) { 39 | utftext += String.fromCharCode(c); 40 | } 41 | else if((c > 127) && (c < 2048)) { 42 | utftext += String.fromCharCode((c >> 6) | 192); 43 | utftext += String.fromCharCode((c & 63) | 128); 44 | } 45 | else { 46 | utftext += String.fromCharCode((c >> 12) | 224); 47 | utftext += String.fromCharCode(((c >> 6) & 63) | 128); 48 | utftext += String.fromCharCode((c & 63) | 128); 49 | } 50 | } 51 | return utftext; 52 | }; 53 | var blockstart; 54 | var i, j; 55 | var W = new Array(80); 56 | var H0 = 0x67452301; 57 | var H1 = 0xEFCDAB89; 58 | var H2 = 0x98BADCFE; 59 | var H3 = 0x10325476; 60 | var H4 = 0xC3D2E1F0; 61 | var A, B, C, D, E; 62 | var temp; 63 | msg = Utf8Encode(msg); 64 | var msg_len = msg.length; 65 | var word_array = new Array(); 66 | for( i=0; i>>29 ); 88 | word_array.push( (msg_len<<3)&0x0ffffffff ); 89 | for ( blockstart=0; blockstart { 34 | try { 35 | await this.fs.writeFile(path, contents); 36 | console.log("Saved workspace."); 37 | } catch (e: Error) { 38 | console.error(e); 39 | document.dispatchEvent(new CustomEvent("BackendError")); 40 | } 41 | }); 42 | } 43 | 44 | get rawNodes(): RawNode[] { 45 | return this.bus.export(); 46 | } 47 | 48 | observe(fn: (n: Node) => void) { 49 | this.bus.observe(fn); 50 | } 51 | 52 | async save(immediate?: boolean) { 53 | const contents = JSON.stringify({ 54 | version: 1, 55 | lastopen: this.lastOpenedID, 56 | expanded: this.expanded, 57 | nodes: this.rawNodes, 58 | settings: this.settings 59 | }, null, 2); 60 | if (immediate) { 61 | await this.fs.writeFile("workspace.json", contents); 62 | } else { 63 | this.writeDebounce("workspace.json", contents); 64 | } 65 | } 66 | 67 | migrateRawNode(n: RawNode): RawNode { 68 | if (n.Name === "treehouse.SearchNode") { 69 | n.Name = "treehouse.SmartNode"; 70 | } 71 | return n; 72 | } 73 | 74 | async reload(nodeIDs: string[]) { 75 | let doc = JSON.parse(await this.fs.readFile("workspace.json") || "{}"); 76 | if (doc.nodes) { 77 | doc.nodes = doc.nodes.filter(n => nodeIDs.includes(n.ID)); 78 | doc.nodes = doc.nodes.map(this.migrateRawNode); 79 | this.bus.import(doc.nodes); 80 | m.redraw(); 81 | console.log(`Reloaded ${doc.nodes.length} nodes.`); 82 | } 83 | } 84 | 85 | async load() { 86 | let doc = JSON.parse(await this.fs.readFile("workspace.json") || "{}"); 87 | if (doc.nodes) { 88 | doc.nodes = doc.nodes.map(this.migrateRawNode); 89 | this.bus.import(doc.nodes); 90 | console.log(`Loaded ${doc.nodes.length} nodes.`); 91 | } 92 | if (doc.expanded) { 93 | // Only import the node keys that still exist 94 | // in the workspace. 95 | for (const n in doc.expanded) { 96 | for (const i in doc.expanded[n]) { 97 | if (this.bus.find(i)) { 98 | if (!this.expanded[n]) this.expanded[n] = {}; 99 | this.expanded[n][i] = doc.expanded[n][i]; 100 | } 101 | } 102 | } 103 | } 104 | if (doc.lastopen) { 105 | this.lastOpenedID = doc.lastopen; 106 | } 107 | if (doc.settings) { 108 | this.settings = Object.assign(this.settings, doc.settings); 109 | } 110 | } 111 | 112 | mainNode(): Node { 113 | let main = this.bus.find("@workspace"); 114 | if (!main) { 115 | console.info("Building missing workspace node.") 116 | const root = this.bus.find("@root"); 117 | const ws = this.bus.make("@workspace"); 118 | ws.name = "Workspace"; 119 | ws.parent = root; 120 | const cal = this.bus.make("@calendar"); 121 | cal.name = "Calendar"; 122 | cal.parent = ws; 123 | const home = this.bus.make("Home"); 124 | home.parent = ws; 125 | main = ws; 126 | } 127 | return main; 128 | } 129 | 130 | find(path: string): Node | null { 131 | return this.bus.find(path) 132 | } 133 | 134 | new(name: string, value?: any): Node { 135 | return this.bus.make(name, value); 136 | } 137 | 138 | // TODO: take single Path 139 | getExpanded(head: Node, n: Node): boolean { 140 | if (!this.expanded[head.id]) { 141 | this.expanded[head.id] = {}; 142 | } 143 | let expanded = this.expanded[head.id][n.id]; 144 | if (expanded === undefined) { 145 | expanded = false; 146 | } 147 | return expanded; 148 | } 149 | 150 | // TODO: take single Path 151 | setExpanded(head: Node, n: Node, b: boolean) { 152 | if (!this.expanded[head.id]) { 153 | this.expanded[head.id] = {}; 154 | } 155 | this.expanded[head.id][n.id] = b; 156 | this.save(); 157 | } 158 | 159 | findAbove(path: Path): Path | null { 160 | if (path.node.id === path.head.id) { 161 | return null; 162 | } 163 | const p = path.clone(); 164 | p.pop(); // pop to parent 165 | let prev = path.node.prevSibling; 166 | if (!prev) { 167 | // if not a field and parent has fields, return last field 168 | const fieldCount = path.previous.getLinked("Fields").length; 169 | if (path.node.raw.Rel !== "Fields" && fieldCount > 0) { 170 | return p.append(path.previous.getLinked("Fields")[fieldCount - 1]); 171 | } 172 | // if no prev sibling, and no fields, return parent 173 | return p; 174 | } 175 | const lastSubIfExpanded = (p: Path): Path => { 176 | const expanded = this.getExpanded(path.head, p.node); 177 | if (!expanded) { 178 | // if not expanded, return input path 179 | return p; 180 | } 181 | const fieldCount = p.node.getLinked("Fields").length; 182 | if (p.node.childCount === 0 && fieldCount > 0) { 183 | const lastField = p.node.getLinked("Fields")[fieldCount - 1]; 184 | // if expanded, no children, has fields, return last field or its last sub if expanded 185 | return lastSubIfExpanded(p.append(lastField)); 186 | } 187 | if (p.node.childCount === 0) { 188 | // expanded, no fields, no children 189 | return p; 190 | } 191 | const lastChild = p.node.children[p.node.childCount - 1]; 192 | // return last child or its last sub if expanded 193 | return lastSubIfExpanded(p.append(lastChild)); 194 | } 195 | // return prev sibling or its last child if expanded 196 | return lastSubIfExpanded(p.append(prev)); 197 | } 198 | 199 | findBelow(path: Path): Path | null { 200 | // TODO: find a way to indicate pseudo "new" node for expanded leaf nodes 201 | const p = path.clone(); 202 | if (this.getExpanded(path.head, path.node) && path.node.getLinked("Fields").length > 0) { 203 | // if expanded and fields, return first field 204 | return p.append(path.node.getLinked("Fields")[0]); 205 | } 206 | if (this.getExpanded(path.head, path.node) && path.node.childCount > 0) { 207 | // if expanded and children, return first child 208 | return p.append(path.node.children[0]); 209 | } 210 | const nextSiblingOrParentNextSibling = (p: Path): Path | null => { 211 | const next = p.node.nextSibling; 212 | if (next) { 213 | p.pop(); // pop to parent 214 | // if next sibling, return that 215 | return p.append(next); 216 | } 217 | const parent = p.previous; 218 | if (!parent) { 219 | // if no parent, return null 220 | return null; 221 | } 222 | if (p.node.raw.Rel === "Fields" && parent.childCount > 0) { 223 | p.pop(); // pop to parent 224 | // if field and parent has children, return first child 225 | return p.append(parent.children[0]); 226 | } 227 | p.pop(); // pop to parent 228 | // return parents next sibling or parents parents next sibling 229 | return nextSiblingOrParentNextSibling(p); 230 | } 231 | // return next sibling or parents next sibling 232 | return nextSiblingOrParentNextSibling(p); 233 | } 234 | 235 | } 236 | 237 | 238 | function debounce(func, timeout = 3000) { 239 | let timer; 240 | return (...args) => { 241 | clearTimeout(timer); 242 | timer = setTimeout(() => { func.apply(this, args); }, timeout); 243 | }; 244 | } 245 | -------------------------------------------------------------------------------- /web/_components/latest.njk: -------------------------------------------------------------------------------- 1 |
    2 | {% asyncEach item in nav.menu("/blog").children | sort(true, true, "data.date") %} 3 |
    4 | {{item.data.title}} 5 |
    {{item.data.date | formatDate }}
    6 |
    7 | {% endeach %} 8 |
    -------------------------------------------------------------------------------- /web/_components/nav.tsx: -------------------------------------------------------------------------------- 1 | export default ({ title, href, currentUrl, nav }) => { 2 | const menu = nav.menu(href); 3 | const showChildren = currentUrl.includes(href) && menu.children.length > 0; 4 | return ( 5 | <> 6 |
    {title}
    7 | {showChildren &&
    } 8 | 9 | ); 10 | } -------------------------------------------------------------------------------- /web/_components/toc.njk: -------------------------------------------------------------------------------- 1 | {% if toc.length %} 2 | 7 | {% endif %} -------------------------------------------------------------------------------- /web/_includes/layouts/blog.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Blog"; 2 | export const active = "blog"; 3 | export const heading = "Branching Out"; 4 | export const subheading = "Notes from the Treehouse"; 5 | export const layout = "layouts/default.tsx"; 6 | 7 | export default ({title, author, date, children, comp}, filters) => ( 8 |
    9 |
    10 |
    11 |

    {title}

    12 |
    {author}
    13 |
    {new Date(date).toLocaleDateString('en-us', { year:"numeric", month:"long", day:"numeric"})}
    14 | {children} 15 |
    16 | 26 |
    27 |
    28 | ); -------------------------------------------------------------------------------- /web/_includes/layouts/docs.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Docs"; 2 | export const active = "docs"; 3 | export const heading = "Documentation"; 4 | export const layout = "layouts/default.tsx"; 5 | export default ({url, nav, comp, children}) => { 6 | const header = () => { 7 | if (url.includes('/docs/quickstart')) return 'Quickstart'; 8 | if (url.includes('/docs/user')) return 'User Guide'; 9 | if (url.includes('/docs/dev')) return 'Developer Guide'; 10 | if (url.includes('/docs/project')) return 'Project Guide'; 11 | return 'Documentation'; 12 | }; 13 | return ( 14 |
    15 |
    16 | 22 |
    23 |

    {header()}

    24 | {children} 25 |
    26 |
    27 |
    28 | ) 29 | } -------------------------------------------------------------------------------- /web/_vendor/codemirror/Makefile: -------------------------------------------------------------------------------- 1 | 2 | bundle: 3 | npm install && esbuild codemirror.ts --bundle --outfile=../../static/vnd/codemirror-6.0.1.min.js --format=esm --minify -------------------------------------------------------------------------------- /web/_vendor/codemirror/codemirror.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state" 2 | import { placeholder as placeholderPlugin, EditorView, ViewUpdate } from "@codemirror/view" 3 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown" 4 | import { javascript } from "@codemirror/lang-javascript" 5 | import { defaultHighlightStyle, syntaxHighlighting} from "@codemirror/language" 6 | import { classHighlighter } from "@lezer/highlight" 7 | 8 | const proxyMerge = (...objects: any[]) => { 9 | const extra = objects.slice(1); 10 | 11 | return new Proxy(objects[0], { 12 | get: (target, name) => { 13 | const obj = extra.find(o => o[name]); 14 | return obj 15 | ? obj[name] 16 | : target[name]; 17 | }, 18 | }); 19 | }; 20 | 21 | export class Editor { 22 | view: EditorView; 23 | parent: HTMLElement; 24 | oninput?: Function; 25 | onblur?: Function; 26 | onfocus?: Function; 27 | onkeydown?: Function; 28 | 29 | constructor(parent: HTMLElement, value: string, placeholder?: string) { 30 | this.parent = parent; 31 | this.view = new EditorView({ 32 | parent: parent, 33 | state: EditorState.create({ 34 | doc: value, 35 | extensions: [ 36 | EditorView.lineWrapping, 37 | placeholderPlugin(placeholder), 38 | syntaxHighlighting(defaultHighlightStyle), 39 | syntaxHighlighting(classHighlighter), 40 | markdown({ 41 | defaultCodeLanguage: javascript(), 42 | base: markdownLanguage 43 | }), 44 | EditorView.updateListener.of((v: ViewUpdate) => { 45 | if (v.docChanged && this.oninput) { 46 | this.oninput(proxyMerge(new CustomEvent("UpdateEvent"), {target: this})); 47 | if (m) m.redraw(); 48 | } 49 | }), 50 | EditorView.domEventHandlers({ 51 | // input: (event, view) => { 52 | // if (this.oninput) { 53 | // this.oninput(proxyMerge(event, {target: this})); 54 | // if (m) m.redraw(); 55 | // } 56 | // }, 57 | blur: (event, view) => { 58 | if (this.onblur) { 59 | this.onblur(proxyMerge(event, {target: this})); 60 | if (m) m.redraw(); 61 | } 62 | }, 63 | focus: (event, view) => { 64 | if (this.onfocus) { 65 | this.onfocus(proxyMerge(event, {target: this})); 66 | if (m) m.redraw(); 67 | } 68 | }, 69 | keydown: (event, view) => { 70 | if (this.onkeydown) { 71 | let defaultPrevented = false; 72 | this.onkeydown(proxyMerge(event, { 73 | target: this, 74 | preventDefault: () => defaultPrevented = true, 75 | stopPropagation: () => null, 76 | })); 77 | if (m) m.redraw(); 78 | return defaultPrevented; 79 | } 80 | }, 81 | }), 82 | ] 83 | }) 84 | }); 85 | } 86 | 87 | get value(): string { 88 | return this.view.state.doc.toString(); 89 | } 90 | 91 | set value(v: string) { 92 | if (v !== this.value) { 93 | const update = this.view.state.update({changes: {from: 0, to: this.view.state.doc.length, insert: v}}); 94 | this.view.update([update]); 95 | } 96 | } 97 | 98 | get selectionStart(): number { 99 | return this.view.state.selection.main.anchor; 100 | } 101 | 102 | get selectionEnd(): number { 103 | return this.view.state.selection.main.head; 104 | } 105 | 106 | get coordsAtCursor(): any { 107 | return this.view.coordsAtPos(this.view.state.selection.main.head); 108 | } 109 | 110 | focus() { 111 | this.view.focus(); 112 | } 113 | 114 | blur() { 115 | this.view.contentDOM.blur(); 116 | } 117 | 118 | setSelectionRange(start: number, end: number) { 119 | this.view.dispatch({ 120 | selection: { 121 | anchor: start, 122 | head: end, 123 | }, 124 | }); 125 | } 126 | 127 | getBoundingClientRect(): any { 128 | return this.parent.getBoundingClientRect() 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /web/_vendor/codemirror/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@codemirror/lang-javascript": "^6.1.4", 4 | "@codemirror/lang-markdown": "^6.1.0", 5 | "@codemirror/language": "^6.6.0", 6 | "@codemirror/state": "^6.2.0", 7 | "@codemirror/view": "^6.9.2", 8 | "codemirror": "^6.0.1" 9 | } 10 | } -------------------------------------------------------------------------------- /web/_vendor/minisearch/Makefile: -------------------------------------------------------------------------------- 1 | VERSION := 6.0.1 2 | 3 | download: 4 | curl -s https://cdn.jsdelivr.net/npm/minisearch@$(VERSION)/dist/umd/index.min.js | grep -v "//# sourceMappingURL=" > ../../static/vnd/minisearch-$(VERSION).min.js -------------------------------------------------------------------------------- /web/_vendor/mithril/Makefile: -------------------------------------------------------------------------------- 1 | VERSION := 2.0.3 2 | 3 | download: 4 | curl -s https://cdnjs.cloudflare.com/ajax/libs/mithril/$(VERSION)/mithril.min.js > ../../static/vnd/mithril-$(VERSION).min.js -------------------------------------------------------------------------------- /web/_vendor/octokit/Makefile: -------------------------------------------------------------------------------- 1 | VERSION := 18.12.0 2 | 3 | bundle: 4 | npm install @octokit/rest@$(VERSION) && esbuild octokit.js --bundle --outfile=../../static/vnd/octokit-$(VERSION).min.js --format=esm --minify -------------------------------------------------------------------------------- /web/_vendor/octokit/octokit.js: -------------------------------------------------------------------------------- 1 | export { Octokit } from "@octokit/rest"; -------------------------------------------------------------------------------- /web/blog/index.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Treehouse"; 2 | export default (data) => ( 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /web/blog/influences.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Treehouse Influences" 4 | author: "Jeff Lindsay" 5 | date: "2023-04-20" 6 | --- 7 | 8 | Over the past few months, we've built a frontend framework for an elegant, quality outliner that's open source, extensible, and gives you control of your data. I’d like to share some of the design influences for the Treehouse frontend, which should give a sense of the unique direction Treehouse is going from here. 9 | 10 | The biggest influences for Treehouse are Tana, Notion, and Obsidian. These three represent the state of the art of personal and collaborative information management, sometimes simplified as note-taking tools. However, "note-taking tools" sells them short as they go beyond note-taking and information management. For lack of a better descriptor, many consider them [tools for thought](https://www.forthought.tools/). 11 | 12 | ## From Notes to Tools for Thought 13 | 14 | For most, note-taking brings to mind simple apps like Apple Notes and Google Keep, or even just a text file editor. These work well for people because they're already there and focus on quick and easy plain text capture. We could call this casual note-taking. 15 | 16 | Back in the 2000s, hosted and self-hosted wikis became popular for easy, collaborative web publishing and knowledge management. Like Wikipedia, they could be used to build out hyperlinked knowledge repositories. Many wiki-based tools focused on their use as personal notebooks, one of the most influential examples being [TiddlyWiki](https://tiddlywiki.com/). The simple versatility of the wiki laid the groundwork for what we call "tools for thought" today. 17 | 18 | ![TiddlyWiki screenshot](https://treehouse.sh/photos/blog/tiddlywiki.png) 19 | 20 | When [Notion](https://www.notion.so/) appeared in the mid-2010s, it built on the idea of the wiki and introduced structured data management with flexible views that *effectively gave you integrated, customizable versions of other productivity tools*. Notion, Airtable, and others helped bring in the age of no-code and low-code tools, allowing knowledge workers and entrepreneurs to build their own "apps" or solutions to problems without traditionally building software. Notion brought it all together in a simple, user-friendly experience based around the core idea of wiki-like information management. 21 | 22 | ![Notion screenshot](https://treehouse.sh/photos/blog/notion.jpeg) 23 | 24 | Meanwhile, a separate paradigm of note-taking tools emerged, focusing on the nested, tree-like structure of the outline. Perhaps inspired by tools like [OmniOutliner](https://www.omnigroup.com/omnioutliner/) and [Org Mode](https://orgmode.org/) for Emacs of the 2000s, [Workflowy](https://workflowy.com/) appeared in 2010 as a no-frills web-based outliner. 25 | 26 | ![Workflowy screenshot](https://treehouse.sh/photos/blog/workflowy.png) 27 | 28 | [Obsidian](https://obsidian.md/) arrived in 2020 and is a local app focusing on Markdown files stored on your filesystem. Obsidian has a large plugin ecosystem giving it a wide breadth of features, but it’s especially appealing to those that want to own their data. If you strip away the plugins, Obsidian is a pretty simple hyperlinked Markdown editor. 29 | 30 | ![Obsidian screenshot](https://treehouse.sh/photos/blog/obsidian.png) 31 | 32 | Most recently, a tool in early access called [Tana](https://tana.inc/) caught my attention. Their key innovation is taking the linked outline model of Workflowy and introducing schemas for nodes, making them into structured data. This gives Tana the embedded database functionality of Notion and Airtable, a step towards bringing the two paradigms of note-taking software together towards powerful, malleable tools for thought. 33 | 34 | ![Tana screenshot](https://treehouse.sh/photos/blog/tana.webp) 35 | 36 | ## How Treehouse Fits In 37 | 38 | By now there's no shortage of options in this space, both as SaaS and open source. Take a look at this growing [encyclopedia of note-taking tools](https://noteapps.info/). Like Notion and Tana, many of the apps listed are much more than note-taking tools. Some lean into the framing of "collaborative documents", and some are just categorized more generally as "productivity tools". Tana goes so far as to say "the everything OS". 39 | 40 | Note-taking is just the beginning. It's a tangible gateway for something more powerful inherent to computing. Ever since Engelbart's [mother of all demos](https://en.wikipedia.org/wiki/The_Mother_of_All_Demos), the computing revolution seems to start with powerful tools for thought, which are, at minimum, good note-taking tools. 41 | 42 | ![Engelbart using NLS](https://treehouse.sh/photos/blog/nls.jpeg) 43 | 44 | Treehouse is a frontend and starter kit for anybody else that wants to explore this space with us. We will release a standalone product based on it soon, but most of the user-facing development will be done in the open source Treehouse project. 45 | 46 | ![Treehouse screenshot](https://treehouse.sh/photos/hero-image.png) 47 | 48 | Today with Treehouse you can build your own Workflowy equivalent, but soon it will become more comparable to Tana and Notion with the open extensibility of Obsidian. That alone is pretty exciting to have in a minimal open source project, but I can't wait to show you what will come next. 49 | 50 | ## Coming Soon 51 | 52 | In the next post, I'll start getting technical and share details on the Treehouse project stack and architecture. If you can't wait, we do have [documentation](https://treehouse.sh/docs/dev/) for you to check out. 53 | 54 | Thanks for reading, and a big thanks to my [sponsors](https://github.com/sponsors/progrium) for supporting this kind of open source work. Share your thoughts and favorite note-taking tools in the [discussion thread](https://github.com/treehousedev/treehouse/discussions/95) for this post. -------------------------------------------------------------------------------- /web/blog/v0-1-0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Release 0.1.0" 4 | author: "Jeff Lindsay" 5 | date: "2023-03-03" 6 | --- 7 | ## It begins... 8 | 9 | This first [development release](https://github.com/treehousedev/treehouse/releases/tag/0.1.0) captures core functionality of the frontend to the point of being internally usable in the demo deployment. For developers, the project source layout, architecture, and backend API is defined in broad strokes in the right direction but is by no means stable. 10 | 11 | [![Watch Demo](http://i3.ytimg.com/vi/wtJCYlR2_ys/hqdefault.jpg)](https://www.youtube.com/watch?v=wtJCYlR2_ys) 12 | [Demo video](https://www.youtube.com/watch?v=wtJCYlR2_ys) 13 | 14 | ## Initial Functionality 15 | * Basic outliner 16 | * Commands, menus, keybindings 17 | * Workspace model 18 | * Navigation tree 19 | * Command palette 20 | * Multi-view panels 21 | * Calendar/today notes 22 | * Quick add notes 23 | * Full-text search using [Minisearch](https://github.com/lucaong/minisearch) 24 | * Localstorage backend 25 | * GitHub backend 26 | 27 | ## Enhancements 28 | * Implemented OS detection for registering different keybindings [#1](https://github.com/treehousedev/treehouse/issues/1) 29 | * Don't collapse new nested node [#3](https://github.com/treehousedev/treehouse/issues/3) 30 | * Hitting tab on plus sign should create an indented node [#4](https://github.com/treehousedev/treehouse/issues/4) 31 | * Improve cursor location when manually deleting a node [#5](https://github.com/treehousedev/treehouse/issues/5) 32 | * Improve backspace behavior when cursor is at the beginning of a node [#6](https://github.com/treehousedev/treehouse/issues/6) 33 | * Add keyboard shortcut to zoom [#8](https://github.com/treehousedev/treehouse/issues/8) 34 | * Allow editing title node [#9](https://github.com/treehousedev/treehouse/issues/9) 35 | * Hide root node [#10](https://github.com/treehousedev/treehouse/issues/10) 36 | * Clean up sidebar text [#11](https://github.com/treehousedev/treehouse/issues/11) 37 | * Remember node expansion state [#19](https://github.com/treehousedev/treehouse/issues/19) 38 | * Typography updates [#21](https://github.com/treehousedev/treehouse/issues/21) 39 | 40 | ## Bugfixes 41 | * Enable text wrapping for nodes [#2](https://github.com/treehousedev/treehouse/issues/2) 42 | * Highlight to delete for nested node gives a TypeError [#12](https://github.com/treehousedev/treehouse/issues/12) 43 | * Hitting return mid-node copies contents instead of moving [#17](https://github.com/treehousedev/treehouse/issues/17) 44 | * Quick Add formatting issue [#18](https://github.com/treehousedev/treehouse/issues/18) 45 | * Error logging in with GitHub [#22](https://github.com/treehousedev/treehouse/issues/22) 46 | * Node content disappears when it loses focus [#24](https://github.com/treehousedev/treehouse/issues/24) 47 | * Display issue w/ icons overlapping search bar [#26](https://github.com/treehousedev/treehouse/issues/26) -------------------------------------------------------------------------------- /web/blog/v0-2-0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Release 0.2.0" 4 | author: "Jeff Lindsay" 5 | date: "2023-03-27" 6 | --- 7 | ## New design system, GitHub session locking, and documentation 8 | 9 | [This release](https://github.com/treehousedev/treehouse/releases/tag/0.2.0) is a refinement of our initial release, fixing a number of bugs and adding interaction improvements. The look and feel of the UI was also updated with the start of a new CSS design system based on custom properties. Session locking was added for the live demo and GitHub backend so multiple devices/browsers/tabs don't clobber changes of each other. Documentation also got an upgrade with the start of a [full guide](https://treehouse.sh/docs/quickstart/) on the website. 10 | 11 | [![Watch Demo](http://i3.ytimg.com/vi/wj4uai9yUJ0/hqdefault.jpg)](https://www.youtube.com/watch?v=wj4uai9yUJ0) 12 | [Demo video](https://www.youtube.com/watch?v=wj4uai9yUJ0) 13 | 14 | ## Bugfixes 15 | * Autosave error when switching between devices [#32](https://github.com/treehousedev/treehouse/issues/32) 16 | * Deleting a node doesn't delete child nodes [#25](https://github.com/treehousedev/treehouse/issues/25) 17 | * Hitting return should produce a new node directly below the above node [#29](https://github.com/treehousedev/treehouse/issues/29) 18 | * TypeError exception when switching back from new panel [#65](https://github.com/treehousedev/treehouse/issues/65) 19 | * Support emojis [#52](https://github.com/treehousedev/treehouse/issues/52) 20 | 21 | ## Enhancements and Chores 22 | * Typography and layout improvements to application [#37](https://github.com/treehousedev/treehouse/issues/37) 23 | * Add keyboard shortcut to move nodes up or down [#28](https://github.com/treehousedev/treehouse/issues/28) 24 | * Prevent backspace to delete if there are child nodes [#15](https://github.com/treehousedev/treehouse/issues/15) 25 | * Allow renaming the workspace [#23](https://github.com/treehousedev/treehouse/issues/23) 26 | * Clicking outside of the search bar/command palette should close it [#48](https://github.com/treehousedev/treehouse/issues/48) 27 | * Workspace/workbench separation [#39](https://github.com/treehousedev/treehouse/issues/39) 28 | * Add API docs [#34](https://github.com/treehousedev/treehouse/issues/34) 29 | * Set up versioned library bundle [#41](https://github.com/treehousedev/treehouse/issues/41) 30 | * Allow backspace to delete an empty child node [#53](https://github.com/treehousedev/treehouse/issues/53) 31 | * Save last location on reloads [#54](https://github.com/treehousedev/treehouse/issues/54) 32 | * Hide buttons to move a panel up/down [#49](https://github.com/treehousedev/treehouse/issues/49) 33 | 34 | --- 35 | [*Discuss on GitHub*](https://github.com/treehousedev/treehouse/discussions/82) -------------------------------------------------------------------------------- /web/blog/v0-3-0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Release 0.3.0" 4 | author: "Jeff Lindsay" 5 | date: "2023-05-17" 6 | --- 7 | ## New Node Types: Fields, Live Search, and References 8 | 9 | This release we added some exciting new features: Fields, Live Search, and References. These new node types represent our first step into becoming much more than a simple outliner. Quality of live improvements include making the node menu easier to click, initial work towards a mobile view, and improving interactivity of the command palette. We also cleaned up all our UI elements to match our design system, allowing for better custom CSS support. 10 | 11 | *Update 7/10/2023: Live Search is now called Smart Nodes* 12 | 13 | [![Watch Demo](http://i3.ytimg.com/vi/PjWibMkKBOE/hqdefault.jpg)](https://www.youtube.com/watch?v=PjWibMkKBOE) 14 | [Demo video](https://www.youtube.com/watch?v=PjWibMkKBOE) 15 | 16 | ### New Feature: Fields 17 | 18 | A field is a node that can store a key-value pair. This introduces structured data to your nodes, letting you create nodes as data records. You can also search for nodes by field. This initial pass supports text values, but we'll soon provide more value types. 19 | 20 | To turn a node into a field: 21 | 1. Indent underneath the node you want to contain the field, and type the field name 22 | 2. Command/Control + K to open the command palette, and choose "Create Field" 23 | 3. Add your field value in the value section 24 | 25 | ### New Feature: Live Search 26 | 27 | Live Search allows you to create search nodes where the children are auto-updating search results. Simply type a keyword, or use the format "fieldname:value" to filter by fields. The Live Search nodes will update automatically as your workspace content changes. This is a powerful way to view your data in new configurations. 28 | 29 | To create a Live Search: 30 | 1. Create a new node where you want your search node, and type your search value 31 | 2. Command/Control + K to open the command palette, and choose "Create Search Node" 32 | 33 | Tips: Search terms are case-insensitive, and you can filter on multiple fields (uses AND, not OR) like so: "fieldname:value fieldname:value". 34 | 35 | ### New Feature: References 36 | 37 | References are nodes that refer to another node and its children inline. They're sort of like symlinks on the filesystem. This lets you have a node exist in multiple places at once. References are differentiated from normal nodes with a dashed outline around their outline bullet. Deleting a reference node does not delete the node it points to. You may notice that Live Search results are reference nodes! 38 | 39 | To create a reference node: 40 | 1. Select the node you want to make a reference to 41 | 2. Command/Control + K to open the command palette, and choose "Create Reference" 42 | 43 | ## Bugfixes 44 | * Command palette now uses normalized title [#117](https://github.com/treehousedev/treehouse/issues/117) 45 | * Command palette supports mouse interactivity [#102](https://github.com/treehousedev/treehouse/issues/102) 46 | * Components no longer fail with bundled library [#119](https://github.com/treehousedev/treehouse/issues/119) 47 | * Ensure Node IDs are unique [#104](https://github.com/treehousedev/treehouse/issues/104) 48 | * Reference sub-nodes now include fields [#121](https://github.com/treehousedev/treehouse/issues/121) 49 | * Search nodes no longer production workspace [#118](https://github.com/treehousedev/treehouse/issues/118) 50 | * Prevent indenting/outdenting a field node [#116](https://github.com/treehousedev/treehouse/issues/116) 51 | 52 | ## Enhancements and Chores 53 | * New feature: Field nodes 54 | * New feature: Live Search nodes 55 | * New feature: Reference nodes 56 | * Clear out non-existent node keys in workspace document under expanded [#120](https://github.com/treehousedev/treehouse/issues/120) 57 | * Don't show bullet for empty nodes [#31](https://github.com/treehousedev/treehouse/issues/31) 58 | * Allow hovering over menu area to show menu [#76](https://github.com/treehousedev/treehouse/issues/76) 59 | * Clean up styles on search bar, palette, quick add, menu, buttons [#81](https://github.com/treehousedev/treehouse/issues/81), [#85](https://github.com/treehousedev/treehouse/issues/85), [#96](https://github.com/treehousedev/treehouse/issues/96), [#108](https://github.com/treehousedev/treehouse/issues/108), [#110](https://github.com/treehousedev/treehouse/issues/110) 60 | * Show placeholder text for empty field key/value inputs [#135](https://github.com/treehousedev/treehouse/issues/135) 61 | * Implement initial version of mobile web app [#103](https://github.com/treehousedev/treehouse/issues/103) 62 | 63 | --- 64 | [*Discuss on GitHub*](https://github.com/treehousedev/treehouse/discussions/156) -------------------------------------------------------------------------------- /web/blog/v0-4-0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Release 0.4.0" 4 | author: "Jeff Lindsay" 5 | date: "2023-06-19" 6 | --- 7 | ## Mobile support and CSS themes 8 | 9 | In this release we focused on making Treehouse more powerful and enjoyable to use from more places. Editing text on mobile is always a tricky prospect, but we've taken our first step to being mobile-friendly with customized navigation and easier touch targets. 10 | 11 | White background a bit too bright? We've added several built-in themes (including sepia and dark mode) *and* we've made it super easy to [create your own CSS themes](https://treehouse.sh/docs/user/#css-theming) using our component variables. 12 | 13 | Otherwise, lots of quality of life improvements as usual, especially for fields and Smart Nodes. 14 | 15 | [![Watch Demo](http://i3.ytimg.com/vi/byZnYzzrP7E/hqdefault.jpg)](https://www.youtube.com/watch?v=byZnYzzrP7E) 16 | [Demo video](https://www.youtube.com/watch?v=byZnYzzrP7E) 17 | 18 | ## Enhancements and Chores 19 | * Support setting a theme in the UI, and custom CSS themes [#168](https://github.com/treehousedev/treehouse/issues/168), [#122](https://github.com/treehousedev/treehouse/issues/122) 20 | * Mobile improvements: new navigation and improved interactivity [#114](https://github.com/treehousedev/treehouse/issues/114), [#115](https://github.com/treehousedev/treehouse/issues/115) 21 | * Allow partial phrase match for command palette search [#148](https://github.com/treehousedev/treehouse/issues/148) 22 | * UX improvements for search nodes [#134](https://github.com/treehousedev/treehouse/issues/134) 23 | * Only show new-node plus sign if a node is expanded and has no children [#149](https://github.com/treehousedev/treehouse/issues/149) 24 | * Add keyboard shortcuts to command palette [#129](https://github.com/treehousedev/treehouse/issues/129) 25 | * Prevent turning nodes with children into search nodes [#161](https://github.com/treehousedev/treehouse/issues/161) 26 | * Support multiple workflows for creating fields [#152](https://github.com/treehousedev/treehouse/issues/152) 27 | * Show visual indicator when search node is collapsed [#150](https://github.com/treehousedev/treehouse/issues/150) 28 | * Consolidate search logic [#159](https://github.com/treehousedev/treehouse/issues/159) 29 | * Consolidate overlays with dialog element [#158](https://github.com/treehousedev/treehouse/issues/158) 30 | * Support multiple workflows for creating fields[#152](https://github.com/treehousedev/treehouse/issues/152) 31 | 32 | ## Bugfixes 33 | * Backspace behavior on fields should be predictable [#130](https://github.com/treehousedev/treehouse/issues/130) 34 | * Don't collapse above node when outdenting [#131](https://github.com/treehousedev/treehouse/issues/131) 35 | * Backspacing from beginning of node deletes children [#151](https://github.com/treehousedev/treehouse/issues/151) 36 | * Prevent edit interactions on search node [#105](https://github.com/treehousedev/treehouse/issues/105) 37 | 38 | --- 39 | [*Discuss on GitHub*](https://github.com/treehousedev/treehouse/discussions/215) -------------------------------------------------------------------------------- /web/blog/v0-5-0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Release 0.5.0" 4 | author: "Jeff Lindsay" 5 | date: "2023-07-06" 6 | --- 7 | ## Tags, templates, and table view 8 | 9 | Treehouse now supports tags and templates! For example, you can now create a template node with children and fields, name it "book", then any node tagged with #book will automatically get those fields and children. To turn a node into a template, select "Make Template" from the Command Palette. You can also tag nodes without an equivalent template. Tagging is done by simply adding hashtags to a node's text. 10 | 11 | You can search for tagged nodes in the search bar using the hashtag notation (#book), however there is a known issue preventing you from searching for nodes by tag in Smart Nodes. This will be resolved in the next release, and in `main` much sooner. 12 | 13 | You can now take a list of nodes with fields and turn them into an easy-to-scan table. Simply go to the parent node of the rows, open the Command Palette with Command+K, and choose "View as Table". We'll be adding more features to the table view in coming releases. 14 | 15 | Double-quoted search terms now allow spaces and search results are a little tighter now. Last but not least we've added cut, copy, and paste commands for nodes, making it easier to duplicate and move nodes around. 16 | 17 | [![Watch Demo](http://i3.ytimg.com/vi/qzsGuO6sfC0/hqdefault.jpg)](https://www.youtube.com/watch?v=qzsGuO6sfC0) 18 | [Demo video](https://www.youtube.com/watch?v=qzsGuO6sfC0) 19 | 20 | ## Enhancements and Chores 21 | * Tags and templates [#206](https://github.com/treehousedev/treehouse/issues/206) 22 | * Cut/copy/paste nodes [#194](https://github.com/treehousedev/treehouse/issues/194) 23 | * Table view for nodes [#106](https://github.com/treehousedev/treehouse/issues/106), [#120](https://github.com/treehousedev/treehouse/issues/210) 24 | * Support field search terms containing spaces [#153](https://github.com/treehousedev/treehouse/issues/153) 25 | * Link to documentation from within Treehouse [#175](https://github.com/treehousedev/treehouse/issues/175) 26 | 27 | ## Bugfixes 28 | * Search bug when capitalizing search term [#217](https://github.com/treehousedev/treehouse/issues/217) 29 | * Search is behaving too broadly when matching [#197](https://github.com/treehousedev/treehouse/issues/197) 30 | * Support multiple concurrent dialogs [#203](https://github.com/treehousedev/treehouse/issues/203) 31 | * Support editing "Calendar" node without Today making a new one [#232](https://github.com/treehousedev/treehouse/issues/232) 32 | 33 | 34 | --- 35 | [*Discuss on GitHub*](https://github.com/treehousedev/treehouse/discussions/244) -------------------------------------------------------------------------------- /web/blog/v0-6-0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Release 0.6.0" 4 | author: "Jeff Lindsay" 5 | date: "2023-07-20" 6 | --- 7 | ## New Document and Tab Views 8 | In this release we're introducing two new views: Document view, and Tab view. Views are a powerful idea in Treehouse that can either be used to create embedded mini-apps or act as building blocks for you to make your own. These new views are an example of each. 9 | 10 | Document view gives you a more conventional note-taking experience. In Document view each child node is rendered as a paragraph in the center of the panel. Documents support simple Markdown formatting like bold, italic, strikethrough, and ordered and unordered lists. Turn a node into a document by choosing "Make Document" from the Command Palette. Unlike other views, changing to Document view is a one-way action and nodes can't be converted back to other views. 11 | 12 | Tab view renders each child node as a tab. Their children (grandchild nodes) are shown beneath when the tab is selected. These nodes will retain their view, so you can combine Tab view with others to create a more complex larger view of your data. In general, Tab view is useful for saving vertical space across several categories. Turn nodes into tabs by selecting the parent node and choose "View as Tabs" from the Command Palette. Since Tab view is read-only, you can modify tabs by switching back to List view. 13 | 14 | We've also renamed Live Search Nodes to Smart Nodes, which can now be named separate from their query. This also resolves an issue where previously you couldn't use them for tag searches. 15 | 16 | [![Watch Demo](http://i3.ytimg.com/vi/-CsRlyJx2cU/hqdefault.jpg)](https://www.youtube.com/watch?v=-CsRlyJx2cU) 17 | [Demo video](https://www.youtube.com/watch?v=-CsRlyJx2cU) 18 | 19 | ## Enhancements and Chores 20 | * Tabs view [#126](https://github.com/treehousedev/treehouse/issues/126) 21 | * Document view [#246](https://github.com/treehousedev/treehouse/issues/246) 22 | * Support setting a name for a Smart Node [#235](https://github.com/treehousedev/treehouse/issues/235) 23 | * Clean up command palette commands [#242](https://github.com/treehousedev/treehouse/issues/242) 24 | * Rename live search/search node to Smart Node [#248](https://github.com/treehousedev/treehouse/issues/248) 25 | 26 | ## Bugfixes 27 | * Range error when turning a tag into a search node [#236](https://github.com/treehousedev/treehouse/issues/236) 28 | * Don't allow outdenting a top-level node [#234](https://github.com/treehousedev/treehouse/issues/234) 29 | * Can't interact with an empty node in Quick Add (mobile) [#204](https://github.com/treehousedev/treehouse/issues/204) 30 | * Capital letters in GitHub username prevent login [#245](https://github.com/treehousedev/treehouse/issues/245) 31 | * Can't select a theme in Chrome [#266](https://github.com/treehousedev/treehouse/issues/266) 32 | 33 | --- 34 | [*Discuss on GitHub*](https://github.com/treehousedev/treehouse/discussions/273) -------------------------------------------------------------------------------- /web/blog/v0-7-0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Release 0.7.0" 4 | author: "Jeff Lindsay" 5 | date: "2023-08-18" 6 | --- 7 | ## Descriptions, better paste, shortcut drawer 8 | 9 | We're excited to be adding a handful of minor enhancements in this release. The first is node descriptions, which allow you to add small text below your node content, as part of the same node. Use it to add context or any kind of secondary information. To use, select your node, open the Command Palette with Command/Control + K, and select "Add Description". 10 | 11 | We've made several improvements related to cut/copy/pasting text. Now if you paste text containing new lines, we'll convert each newline to a separate node. We've also fixed some bugs and UX flows. 12 | 13 | Lastly, check out the keyboard shortcut drawer! We want it to be super easy to learn and use keyboard shortcuts and hope this helps. 14 | 15 | 16 | ## Enhancements and Chores 17 | * Node descriptions [#295](https://github.com/treehousedev/treehouse/issues/295) 18 | * Support searching for field keys containing spaces [#277](https://github.com/treehousedev/treehouse/issues/277) 19 | * Newlines should be translated as new nodes when pasting from outside sources [#250](https://github.com/treehousedev/treehouse/issues/250) 20 | * When multiple panels, all panels should be equal width [#259](https://github.com/treehousedev/treehouse/issues/259) 21 | * Keyboard shortcut reference drawer [#264](https://github.com/treehousedev/treehouse/issues/264) 22 | * Sublime theme - adjust pink to improve contrast [#284](https://github.com/treehousedev/treehouse/issues/284) 23 | * Use real italics/bold font variant for Codemirror (treehouse) [#272](https://github.com/treehousedev/treehouse/issues/272) 24 | 25 | ## Bugs 26 | * Can't paste into (+) node [#275](https://github.com/treehousedev/treehouse/issues/275) 27 | * Prevent multiple checkboxes [#299](https://github.com/treehousedev/treehouse/issues/299) 28 | * Cut node pastes when you select cut, not paste [#293](https://github.com/treehousedev/treehouse/issues/293) 29 | * "Back" link should not require two clicks [#257](https://github.com/treehousedev/treehouse/issues/257) 30 | * Cutting the node should remove it from view [#274](https://github.com/treehousedev/treehouse/issues/274) 31 | * TypeError when smart node returns results [#269](https://github.com/treehousedev/treehouse/issues/269) 32 | * TypeError related to command palette + tags [#258](https://github.com/treehousedev/treehouse/issues/258) 33 | 34 | --- 35 | [*Discuss on GitHub*](https://github.com/treehousedev/treehouse/discussions/302) -------------------------------------------------------------------------------- /web/blog/welcome.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/blog.tsx 3 | title: "Welcome to Treehouse" 4 | author: "Jeff Lindsay" 5 | date: "2023-02-23" 6 | --- 7 | I'm excited to announce the start of a new major project that I'll be sharing the journey of on this fancy new blog. The project is called [Treehouse](https://treehouse.sh/), which is starting as an open source note-taking frontend and will evolve into something much more. 8 | 9 | ![Treehouse screenshot](https://treehouse.sh/photos/hero-image.png) 10 | 11 | We're creating a simple, hackable kernel of a note-taking tool as a web frontend that can be extended and customized by developers. Then we'll use that frontend to build a new kind of note-taking product. Today we have an early preview of our first minimum viable prototype of the frontend, and we'll be finishing up this release in the open on GitHub. 12 | 13 | The [open source project](https://github.com/treehousedev/treehouse) is a functional app frontend that you can deploy and back in various ways. For developers, this "bring your own backend" approach makes Treehouse a great starting point to build your own note-taking tool, just as we intend to do. We also have pre-made backends for those who want to use it with little-to-no programming. Either way, you'll always own your data, and now the system presenting it to you as well. 14 | 15 | In this post, I'm going to talk about our minimum viable prototype and the approach we're taking with Treehouse as an open source project. 16 | 17 | ## Out of the Box 18 | 19 | While inspired by powerful tools like [Notion](https://www.notion.so/), [Tana](https://tana.inc/), and [Obsidian](https://obsidian.md/), we wanted to boil our initial goal down to the essentials while still being usable enough to use ourselves. As we continue to plan our prototype release milestone, we'd love to hear what you think is essential for you to have in a tool and platform like this, but here is what to expect as a baseline. 20 | 21 | ### Outliner with Markdown Pages 22 | 23 | At the heart of Treehouse is a graph-like system I've been developing called Manifold. This will play a big part in the long-term extensibility of Treehouse, which I'll talk more about in the future. For now, this maps very cleanly to the outliner model popularized by Workflowy, Tana, and others. 24 | 25 | However, I'm also a fan of Notion-style pages and Obsidian's commitment to working with plain Markdown files. The Manifold system allows us to make certain nodes into Markdown pages. This hybrid model gets us the best of both worlds with room for even more possibilities later on. 26 | 27 | ### Quick Add and Daily Notes 28 | 29 | Being able to quickly get thoughts and information into the system has been a key aspect of every good note-taking system. This can be exposed a number of ways from browser extensions to desktop shortcuts, but what makes any "quick add" functionality quick is not having to think about where it will be organized. 30 | 31 | Luckily, "daily notes" has become such a common pattern that many recent tools have managed daily notes built-in. This typical gives you a "today" note that is automatically organized into a calendar-like structure. This conveniently becomes the perfect destination for "quick add" notes. 32 | 33 | ### Full-text Search 34 | 35 | Whether you are an obsessive organizer or are too busy to take the time, solid full-text search is basically a necessity. It's how we quickly get to our data. 36 | 37 | Out of the box we have a full-text search that doesn't require a backend. However, our pluggable backend will allow you to power search in a number of ways, and also opens up the ability to search into external systems that are important to you. 38 | 39 | ### Built-in Backends 40 | 41 | "Bring your own backend" is a critical part of the design of this project, but Treehouse also needs to be usable by people who prefer not to *build* their own backend. Our prototype release is planned to include a handful of built-in backends to get started with. 42 | 43 | Our preview demo is using a localStorage backend, which works for development and special use cases. Of course, we will have a simple local filesystem backend for desktop scenarios, similar to Obsidian. 44 | 45 | However, we're most excited for the GitHub backend. This functions similarly to the local filesystem backend, but it would be versioned in a central repository on your GitHub account and be accessible on any online platform. 46 | 47 | Backends will not only let you extend storage, search, and authentication, but other aspects in the future. 48 | 49 | ## Project Overview 50 | 51 | The Treehouse codebase and open source project is as much the product as its features. The entire user and developer experience is being designed around simplicity and human ergonomics. 52 | 53 | The Treehouse frontend is built with web technologies intended for use across web, mobile, and desktop platforms. The project is written in TypeScript using [Deno](https://deno.land/) as its JavaScript toolchain. 54 | 55 | The JavaScript ecosystem is a mess, but Deno provides a forward-looking, self-contained toolchain that's easy to love. We actually have a zero Node.js policy and avoid NPM modules as much as possible. 56 | 57 | With minimal dependencies and an ongoing effort to keep the codebase small, it will always be easy to understand the entire Treehouse system. This will be further supported with a focus on documentation, both API docs and guides. 58 | 59 | We're using a permissive open source license, allowing you to use or change our code as you see fit. Contribution and participation is welcome, but so is simply consuming downstream. 60 | 61 | The project is actively being developed but we keep a [preview demo](https://treehouse.sh/demo/) running off the main branch that should always be in working order. 62 | 63 | ## Coming Soon 64 | 65 | I'm excited to give you a deeper look at the project stack in a future post. For now, keep an eye out for the next post digging into the influences for Treehouse. We'll also try to answer "why another note-taking tool", and tease some long-term ideas for the future. 66 | 67 | Lastly, I want to mention the project is being developed in the open, not just with the code on GitHub, but most of my work is streamed and archived on [Twitch](https://www.twitch.tv/progrium). Feel free to come and co-work with me. 68 | 69 | Thanks for reading, and a big thanks to my [sponsors](https://github.com/sponsors/progrium/) for supporting this kind of open source work. -------------------------------------------------------------------------------- /web/demo/index.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Treehouse 16 | 17 | 18 |
    19 |
    20 |
    21 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /web/docs/dev/1-overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Overview 4 | --- 5 | ## Overview 6 | 7 | Treehouse is a frontend written in TypeScript made to be rendered in a browser or webview for building note-taking and information management tools. It is a "thick" frontend in that it holds most application state in-memory and executes user triggered commands to mutate that state. Persistence of that state, and a few other features, are expected to be provided by a "backend", which the frontend code interacts with via a backend adapter. 8 | Even without a backend adapter, the Treehouse frontend is still a fully functional application packaged as a JavaScript library that can be loaded onto any HTML page. 9 | 10 | ### Architecture 11 | 12 | The library exposes a `setup()` function that: 13 | 14 | 1. Takes a DOM document and backend adapter object 15 | 1. Sets up a central controller for the UI called Workbench 16 | 1. Loads a Workspace using the backend adapter 17 | 1. Registers built-in commands and keybindings 18 | 1. Uses [Mithril.js](https://mithril.js.org/) to mount a top level Mithril component to the document 19 | 20 | The UI is represented by a class called Workbench. This class orchestrates and provides most of the API for the rest of the system. The UI is broken down into Mithril components that implement the views for each part of the UI, pulling state from Workbench and connecting interactions to registered commands that represent all user actions. The Workbench and commands manipulate a Workspace, which represents the main data model for the system. 21 | 22 | ### Stack 23 | 24 | To avoid the complexity and dependency hell, Treehouse avoids most common JavaScript tooling such as Node.js and NPM. Instead, Treehouse uses [Deno](https://deno.land/) as a toolchain and otherwise avoids dependencies as much as possible. The other main dependency we have is [Mithril.js](https://mithril.js.org/), which was chosen for its simplicity and lack of further dependencies. 25 | 26 | That said, there are other dependencies beyond this core stack which are chosen very deliberately. Out of the box search indexing depends on [MiniSearch](https://lucaong.github.io/minisearch/), and our most complex (but unavoidable) dependency is [CodeMirror](https://codemirror.net/). We're very conscious of project dependencies, including development and toolchain dependencies. -------------------------------------------------------------------------------- /web/docs/dev/2-data-model.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Data Model 4 | --- 5 | ## Data Model 6 | 7 | The "document" for Treehouse is the Workspace, which is mostly a container for nodes. These nodes are based on a versatile and extensible API and data model called Manifold. In short, nodes: 8 | 9 | * have a unique ID 10 | * have a text name 11 | * can be children to other nodes 12 | * can have key-value attributes 13 | * can have components 14 | 15 | Most importantly, nodes build a tree-like structure where each node can be extended with components. Components extend the state and functionality of a node. For example, a checkbox component can be added to a node. This gives it a state (checked or not) and allows the UI to render it differently (add a checkbox). -------------------------------------------------------------------------------- /web/docs/dev/3-user-actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: User Actions 4 | --- 5 | ## User Actions 6 | 7 | All user performable actions are modeled as commands and registered with a command system. Commands are functions with some extra metadata, like a user displayable name and system identifier. They can be called throughout the system by their system identifier, such as when a user clicks a something. 8 | 9 | Menus are often defined upfront, usually by commands. Commands can have keybindings registered for them for keyboard shortcuts. These systems work together. For example, a menu item for a command will show the keybinding for it. 10 | 11 | Commands can take arguments and usually take at least a single argument called a context. This is a way to represent state of the user’s current context, such as the currently selected node. 12 | 13 | In addition to menus, keyboard shortcuts, and UI event handlers, there is a command palette the user can trigger to show all commands that can be run in the current context. -------------------------------------------------------------------------------- /web/docs/dev/4-workbench-ui.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Workbench UI 4 | --- 5 | ## Workbench UI 6 | 7 | ### Components 8 | 9 | The workbench UI is made up of Mithril components, which are similar to React components. They take parameters, can have state, and specify a view using JSX. Instead of many atomic components like buttons and labels, Treehouse focuses on larger functional components that map to areas of the UI. Reusable visual elements are represented by CSS classes. Only if a reusable atomic visual element becomes so re-used and is too complex to re-implement is it made into a Mithril component. 10 | 11 | All the Mithril components can be found under `lib/ui`. We use Typescript to make sure their parameters are typed so they're picked up by API documentation; no need for custom documentation for view components. Mithril components are just plain old JavaScript objects with a `view` method. 12 | 13 | ### User Context 14 | 15 | Most components are explicitly passed a reference to the Workbench, which they can use to execute commands or pull data in the current Workspace or Context. The Workbench provides a top level context that has the current selected node or nodes, the current panel, etc. However, when passing a Context around it can be given overrides. For example, you may be currently editing a particular node, but you use the mouse to perform a command on another node. The menu ensures the command will receive a Context with that node being acted on. 16 | 17 | ### Design System 18 | 19 | Our design system is inspired by projects like [Pollen](https://www.pollen.style/), where instead of generating CSS classes from JavaScript like Tailwind (which requires a compile step and Node.js based tooling), we simply use and override CSS custom properties. This means they can be used in inline styles as well. We also have a subset of common Tailwind utility classes defined, though using the custom properties. -------------------------------------------------------------------------------- /web/docs/dev/5-backend-adapters.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Backend Adapters 4 | --- 5 | ## Backend Adapters 6 | 7 | Backend adapters are classes that implement the backend API for a given backend. If you wanted to make your own custom backend, you would implement your own backend adapter 8 | implementing the APIs you wish to hook into and pass that into the `setup` function when initializing Treehouse. We also have a handful of built-in adapters for public or 9 | well-known backend interfaces: 10 | 11 | ### lib/backend/browser.ts 12 | 13 | This backend implements the FileStorage API using localStorage. This means data will be stored in the browser for a particular device. It also implements search indexing 14 | using MiniSearch. It does not implement the Authenticator API. 15 | 16 | ### lib/backend/github.ts 17 | 18 | This backend implements the FileStorage API using the GitHub API to store data in a GitHub hosted Git repository. It also implements the Authenticator API against a 19 | script that can be hosted on CloudFlare Workers that implements an OAuth client for the GitHub API. This backend adapter does not implement a search index, it simply 20 | uses the browser implementation (MiniSearch). 21 | 22 | ### lib/backend/filesystem.ts (coming soon) 23 | 24 | This backend implements the FileStorage API using a local filesystem. Since there isn't a good standard filesystem API in browsers, this implementation operates against 25 | a simple REST API that can be implemented by a backend host process, such as Electron, Apptron, or something custom. 26 | 27 | ### Writing an Adapter 28 | 29 | An adapter is just an object that implements this API: 30 | 31 | ```js 32 | interface Backend { 33 | auth: Authenticator|null; 34 | index: SearchIndex; 35 | files: FileStore; 36 | } 37 | 38 | interface Authenticator { 39 | login(); 40 | logout(); 41 | currentUser(): User|null; 42 | } 43 | 44 | interface User { 45 | userID(): string; 46 | displayName(): string; 47 | avatarURL(): string; 48 | } 49 | 50 | interface SearchIndex { 51 | index(node: RawNode); 52 | remove(id: string); 53 | search(query: string): string[]; 54 | } 55 | 56 | interface FileStore { 57 | async readFile(path: string): string|null; 58 | async writeFile(path: string, contents: string); 59 | } 60 | ``` 61 | 62 | The Authenticator API is optional (and soon so might the SearchIndex API, always defaulting to MiniSearch). Typically a backend adapter will 63 | set `auth`, `index`, and `files` to `this` and implement each of those interfaces on that same object. -------------------------------------------------------------------------------- /web/docs/dev/6-api-reference.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: API Reference 4 | --- 5 | ## API Reference 6 | 7 | TypeScript documentation for Treehouse is available via [Deno Land](https://deno.land/x/treehouse@0.1.0). -------------------------------------------------------------------------------- /web/docs/dev/index.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Docs"; 2 | export const active = "docs"; 3 | export const heading = "Documentation"; 4 | export const layout = "layouts/docs.tsx"; 5 | export default (data, filters) => { 6 | const url = data.url; 7 | const nav = data.nav; 8 | const menu = nav.menu(url); 9 | const children = menu.children || []; 10 | return children.map(child =>
    ); 11 | } -------------------------------------------------------------------------------- /web/docs/index.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Treehouse"; 2 | export default (data) => ( 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /web/docs/project/1-contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Contributing 4 | --- 5 | ## Contributing 6 | 7 | We'd love your contributions to the project, especially fixes to long-standing issues. If you'd like to work on something that necessitates discussion first, please create an issue so we can provide feedback and avoid stepping on each other's toes. 8 | 9 | You can also contribute by: 10 | * submitting bugs as [issues](https://github.com/treehousedev/treehouse/issues) 11 | * sharing feedback on our [discussion forum](https://discord.gg/6Ae3VNqJbr) 12 | 13 | We welcome any kind of feedback about features you'd like, and we'd especially love to learn about your use case. -------------------------------------------------------------------------------- /web/docs/project/2-roadmap.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Roadmap 4 | --- 5 | ## Roadmap 6 | 7 | We're still formalizing our roadmap, how to organize it, share it, and collaborate with the community on it. For now, keep an eye on the [GitHub issues](https://github.com/treehousedev/treehouse/issues) and [discussions](https://discord.gg/6Ae3VNqJbr). -------------------------------------------------------------------------------- /web/docs/project/index.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Docs"; 2 | export const active = "docs"; 3 | export const heading = "Documentation"; 4 | export const layout = "layouts/docs.tsx"; 5 | export default (data, filters) => { 6 | const url = data.url; 7 | const nav = data.nav; 8 | const menu = nav.menu(url); 9 | const children = menu.children || []; 10 | return children.map(child =>
    ); 11 | } -------------------------------------------------------------------------------- /web/docs/quickstart/1-using.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Using from CDN 4 | --- 5 | ## Using from CDN 6 | 7 | First, you have to directly include Mithril and MiniSearch for now: 8 | 9 | ```html 10 | 11 | 12 | ``` 13 | 14 | If you want to use our CSS, you'll also need to include the Google Font it uses: 15 | 16 | ```html 17 | 18 | 19 | ``` 20 | 21 | Then you just need some JavaScript to import and call setup with a built-in or custom backend adapter: 22 | 23 | ```html 24 | 28 | ``` 29 | -------------------------------------------------------------------------------- /web/docs/quickstart/2-building.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Building from Source 4 | --- 5 | ## Building from Source 6 | 7 | You can build from source by cloning or forking the project and installing [Deno](https://deno.land/). Then you can run: 8 | 9 | `deno task bundle` 10 | 11 | This will produce a JS file you can use at `web/static/lib/treehouse.min.js`. 12 | 13 | For development or debugging, you can run: 14 | 15 | `deno task serve` 16 | 17 | This will build and serve this website locally, including the live demo site at `localhost:9000/demo`, which will use 18 | and watch for changes in the `lib` source. -------------------------------------------------------------------------------- /web/docs/quickstart/3-customizing.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Customizing the Frontend 4 | --- 5 | ## Customizing the Frontend 6 | 7 | The easiest aspect to customize is the look and feel, which you can do with custom CSS. Our CSS design system is built with 8 | custom properties, so you can include your own CSS and just override them with basic CSS. 9 | 10 | Further customization might require you to fork and change the source. The source code is very straight forward, but learn 11 | more in the Developer Guide. 12 | 13 | Lastly, of course, you can implement a custom backend adapter. Although there are just a few things to change this way, 14 | more extension points will be exposed in the future so you won't have to change source. Let us know in the forum what kind 15 | of extension points you'd like! -------------------------------------------------------------------------------- /web/docs/quickstart/index.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Docs"; 2 | export const active = "docs"; 3 | export const heading = "Documentation"; 4 | export const layout = "layouts/docs.tsx"; 5 | export default (data, filters) => { 6 | const url = data.url; 7 | const nav = data.nav; 8 | const menu = nav.menu(url); 9 | const children = menu.children || []; 10 | return children.map(child =>
    ); 11 | } -------------------------------------------------------------------------------- /web/docs/user/01-what-is-treehouse.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: What is Treehouse? 4 | --- 5 | ## What is Treehouse? 6 | 7 | Treehouse is an outline editor, which you can think of as a nested bulleted list. Each bullet item is called a **node**. You can use the nodes to create nested layers of folders and notes. -------------------------------------------------------------------------------- /web/docs/user/02-data-storage.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Data Storage 4 | --- 5 | ## Data Storage 6 | 7 | ### Localstorage 8 | 9 | By default, data is stored in your browser’s local storage. That means that the data is linked to your specific browser and device. If you clear your browser cache, the data will be wiped. 10 | 11 | ### GitHub 12 | 13 | If you choose to log in with GitHub, we’ll create a repository and store your data there. 14 | 15 | To store your workspace, we will create a public repository called .treehouse.sh if it doesn’t already exist. If you want to make the repository private, you can do so in GitHub. 16 | 17 | To switch back to the local storage backend, log out from the Options menu. 18 | 19 | #### Multiple sessions 20 | 21 | If you log in to Treehouse from multiple devices, the most recent device will have edit and save access. Other sessions will be prompted to refresh the page; if/when you do so, that session becomes the new active session. -------------------------------------------------------------------------------- /web/docs/user/03-nodes.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Nodes 4 | --- 5 | ## Nodes 6 | 7 | Your **Workspace** is your top level node, which all other nodes are nested under. 8 | 9 | Learn how to manage and edit your nodes. 10 | 11 | ### Add 12 | 13 | You can add a new node in a few ways. 14 | 15 | 1. Hit ENTER on your keyboard and start typing. 16 | 2. Click into the blank area next to the plus symbol and start typing. 17 | 18 | ### Indent 19 | 20 | * Indent a node using TAB ↹ 21 | * Outdent a node using SHIFT + TAB ⇧ ↹ 22 | 23 | ### Move 24 | 25 | * Move a node up with SHIFT + COMMAND + UP ARROW ⇧ **⌘ ↑** 26 | * Move a node down with SHIFT + COMMAND + DOWN ARROW ⇧ **⌘** ↓ 27 | 28 | ### Expand or collapse a node 29 | 30 | If a node bullet has an outline around it, that’s an indication that it has nested content that is currently hidden. Click the node to expand it. 31 | 32 | Click an expanded node once to collapse it. 33 | 34 | ### Node menu 35 | 36 | When you hover over a node, you’ll see a menu to the left of the node bullet. Click it to access node options, such as indent/outdent, open in a panel, etc. 37 | 38 | ### Node formatting 39 | 40 | Currently, all nodes are formatted as plain text. You can, however, add a checkbox to a node. With a node selected, open the command palette (⌘ K) and select "Add checkbox". 41 | 42 | ### View 43 | 44 | Double click a node to zoom in. 45 | 46 | ### Side-by-side (Panel) view 47 | 48 | To view two nodes side-by-side: 49 | 50 | Open the node you want to be in the righthand panel. From its menu, choose “Open in New Panel”. 51 | 52 | You can close or expand either panel to return to a single panel view. -------------------------------------------------------------------------------- /web/docs/user/04-fields.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Fields 4 | --- 5 | ## Fields 6 | 7 | A **field** is a node that can store structured information. Fields provide your data with structure, and allow for special search syntax (see [Smart Nodes](#smart-nodes)). 8 | 9 | ### Create a field 10 | 11 | To add a field to a node: 12 | 1. Indent underneath the node you want to contain the field, and type the field name 13 | 2. Use Command/Control + K to open the command palette, and choose "Create field" 14 | 3. Add your field value in the value section -------------------------------------------------------------------------------- /web/docs/user/05-smart-nodes.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Smart Nodes 4 | --- 5 | ## Smart Nodes 6 | 7 | Smart Nodes allow you to create an auto-updating search of all the nodes in your workspace. Simply type your search term, or use the format "fieldname:valuename" to filter specifically by field values. The Smart Node will update automatically as your node content changes. This is a simple but super powerful way to view your data in new configurations. 8 | 9 | To create a Smart Node: 10 | 1. Create a new node where you want your Smart Node, and type your search value 11 | 2. Use Command/Control + K to open the command palette, and choose "Create Smart Node" 12 | 13 | ### Tips for using Smart Nodes 14 | * You can filter on multiple fieldname values (using AND, not OR) like so: "fieldname:valuename fieldname:valuename" etc. 15 | * If your fieldname has spaces, put quotes around it (fieldname:"Value name") 16 | * Search terms are case-insensitive -------------------------------------------------------------------------------- /web/docs/user/06-tags.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Tags 4 | --- 5 | ## Tags 6 | 7 | Tags are versatile metadata. Here are some of the ways you can use them: 8 | 9 | * Conduct a keyword search in the searchbar 10 | * Use in Smart Nodes to create custom views 11 | * Use to add a template to a node 12 | 13 | ### Create a tag 14 | On any node, with your cursor at the end of the text, type "#" then the tag name to create a tag. Any tags you have already created will show up in an autocomplete list when you type "#". 15 | 16 | ### Delete a tag 17 | Click on the tag and press Backspace on your keyboard to delete it. -------------------------------------------------------------------------------- /web/docs/user/07-templates.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Templates 4 | --- 5 | ## Templates 6 | 7 | Templates allow you to add a set of predefined fields and other child nodes to a node simply by adding a tag to that node. 8 | 9 | ### Create and use a template 10 | 11 | In this example, we'll create a template called "book" with child fields Author and Release Date. 12 | 13 | 1. Wherever you want to store your template, type "book" in the node. 14 | 2. Add child fields Author and Release Date, and leave the value fields empty. (To turn a regular node into a Field: Command Palette > Create field) 15 | 3. With your cursor in the parent "book" node, open the Command Palette (Command/Control+K) and choose Make Template. 16 | 4. Create a list of books wherever you'd like, if you haven't already. With your cursor on one if the individual books, type "#book" and hit ENTER to create the tag. 17 | 5. Check out one of your book nodes—it should have the child fields Author and Release Date that you set up in your template. -------------------------------------------------------------------------------- /web/docs/user/07.1-views.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Views 4 | --- 5 | ## Views 6 | 7 | You can view your data in many different ways using commands in the Command Palette. 8 | 9 | ### Document 10 | Converting your node(s) to document view centers them in the panel, styles the nodes like paragraphs, and supports basic Markdown formatting. 11 | 12 | *Note: document view is a one-way conversion. Document nodes can't be converted back to List (outline) view.* 13 | 14 | #### Create a Document 15 | With your cursor on the parent node of the node(s) you want to convert to a document, open the Command Palette and select **Make Document**. 16 | 17 | #### Markdown formatting 18 | 19 | ##### Text styles 20 | ```html 21 | *italic* or _italic_ 22 | **bold** 23 | ~~strikethrough~~ 24 | ``` 25 | ##### Lists 26 | ```html 27 | - unordered lists 28 | * with any of 29 | + these characters 30 | 31 | 1. ordered 32 | 2. lists 33 | ``` 34 | 35 | ### Table 36 | Useful for a set of nodes with common fields. Node fields will become columns. 37 | 38 | #### Use Table View 39 | With your cursor on the parent node of the nodes you want to convert to a document, open the Command Palette and select **View As Table**. 40 | 41 | To revert back to List (outline) view, select the parent node and choose "View as List" from the Command Palette. 42 | 43 | ### Tabs 44 | The tab view tidies up a common group of nodes, allowing you to view one tab at a time. 45 | 46 | #### Use Tabs View 47 | With your cursor on the parent node of the nodes you want to convert to a document, open the Command Palette and select **View As Tabs**. -------------------------------------------------------------------------------- /web/docs/user/08-calendar.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Calendar 4 | --- 5 | ## Calendar 6 | 7 | The Calendar is a default node that is automatically generated for every workspace. Nodes inside the calendar are grouped by date, week, then year. 8 | 9 | ### Today 10 | 11 | The Today shortcut allows you to quickly view the node for Today in your Calendar. -------------------------------------------------------------------------------- /web/docs/user/09-quick-add.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Quick Add 4 | --- 5 | ## Quick Add 6 | 7 | Quick Add in the top navigation is a shortcut that opens a modal in which you can jot a quick note. It will be added to today’s date in your Calendar. -------------------------------------------------------------------------------- /web/docs/user/10-command-palette.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Command Palette 4 | --- 5 | ## Command Palette 6 | 7 | With a node selected, open the command palette (⌘ K) to view all the available actions for that node. -------------------------------------------------------------------------------- /web/docs/user/11-keyboard-shortcuts.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Keyboard Shortcuts 4 | --- 5 | ## Keyboard Shortcuts 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
    indent
    outdent⇧ ↹
    move node up⇧ ⌘ ↑
    move node down⇧ ⌘ ↓
    delete node⇧ ⌘ ⌫
    add or remove checkbox⌘ ↵
    mark checkbox as done⌘ ↵
    open command palette⌘ K
    -------------------------------------------------------------------------------- /web/docs/user/12-css-theming.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: CSS Theming 4 | --- 5 | ## CSS Theming 6 | 7 | You can create your own custom theme for Treehouse using our built-in variables a.k.a. custom properties. 8 | 9 | ### Create a theme 10 | 1. Add a top level folder called "ext" to your treehouse.sh repository 11 | 2. Create a CSS file inside the ext folder 12 | 3. Use the format below to populate the variables with hex code values. *Tip: Create color variables inside the root block to reuse color styles between custom properties.* 13 | 14 | ```css 15 | :root { 16 | --font: 'Font name'; 17 | 18 | --color-primary: #hex; 19 | 20 | --color-background: #hex; 21 | --color-background-sidebar: #hex; 22 | 23 | --color-icon: #hex; 24 | --color-icon-secondary: #hex; 25 | 26 | --color-nav-label: #hex; 27 | 28 | --color-text: #hex; 29 | --color-text-placeholder: #hex; 30 | --color-text-secondary: #hex; 31 | 32 | --color-highlight: #hex; 33 | 34 | --color-node-handle: #hex; 35 | --color-node-handle-secondary: #hex; 36 | 37 | --color-outline: #hex; 38 | --color-outline-secondary: #hex; 39 | } 40 | ``` 41 | 42 | ### Managing multiple CSS files 43 | If you have multiple CSS files you want to swap between, append ".disabled" to the end of the unused CSS filename(s). 44 | 45 | ### Variable Reference 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
    VariableDescription
    --fontGlobal font definition. Change the font itself but not sizes or styles with this.
    --color-primaryBackground color of primary button
    --color-backgroundBackground color of main panels, menus, and modals
    --color-background-sidebarBackground color of sidebar navigation
    --color-iconHigh contrast color used for primary icons. For example: icons in the top navigation
    --color-icon-secondaryLow-contrast color used for secondary icons
    --color-nav-labelUsed for top and sidebar navigation labels
    --color-textDefault text color used for body text, navigation, and primary icons
    --color-text-placeholderLower-contrast color used for placeholder text in inputs
    --color-text-secondaryLower-contrast color used for secondary text
    --color-highlightLowest-contrast color to subtly highlight selected item in the menu, search, and command palette
    --color-node-handleBullet color for nodes (a.k.a. the node handle)
    --color-node-handle-secondaryLower-contrast accent color on node handles. For instance, the outer filled circle on a node indicating collapsed children.
    --color-outlineHigh contrast border color on pop-over containers like modals and menus.
    --color-outline-secondaryLower contrast border color where less extreme contrast is needed, such as the divider between panels and the navigation.
    109 | 110 | ### Fonts 111 | 112 | To use a non-system font, you may import a font into your CSS file using either the @import method or the @font-face method. -------------------------------------------------------------------------------- /web/docs/user/13-backend-extensions.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/docs.tsx 3 | title: Backend Extensions 4 | --- 5 | ## Backend Extensions 6 | 7 | Backends can change or extend how a Treehouse application behaves and are exposed to the frontend via adapters. 8 | 9 | ### Workspace Storage 10 | 11 | The Treehouse frontend uses its backend adapter to store the state of your workspace into a JSON document. The backend can decide where and how that JSON document is stored. For example, the Browser backend adapter will store the JSON into localStorage. 12 | 13 | ### User Authentication 14 | 15 | An optional capability of the frontend is to know whether a particular user is authenticated. You typically want user authentication for cloud or web-based deployments, but not necessarily for a local desktop app. 16 | 17 | ### Search Indexing 18 | 19 | Out of the box, Treehouse will index your workspace for full-text search using [Minisearch](https://lucaong.github.io/minisearch/), 20 | which will be good enough for many cases. However, a backend adapter can choose to hook into the index and searching 21 | so that you could have a more powerful search index such as ElasticSearch. -------------------------------------------------------------------------------- /web/docs/user/index.tsx: -------------------------------------------------------------------------------- 1 | export const title = "Docs"; 2 | export const active = "docs"; 3 | export const heading = "Documentation"; 4 | export const layout = "layouts/docs.tsx"; 5 | export default (data, filters) => { 6 | const url = data.url; 7 | const nav = data.nav; 8 | const menu = nav.menu(url); 9 | const children = menu.children || []; 10 | return children.map(child =>
    ); 11 | } -------------------------------------------------------------------------------- /web/static/CNAME: -------------------------------------------------------------------------------- 1 | treehouse.sh -------------------------------------------------------------------------------- /web/static/analytics.js: -------------------------------------------------------------------------------- 1 | window.dataLayer = window.dataLayer || []; 2 | function gtag(){dataLayer.push(arguments);} 3 | gtag('js', new Date()); 4 | gtag('config', 'G-SGR1T7X7KS'); 5 | let script = document.createElement('script'); 6 | script.setAttribute('src','https://www.googletagmanager.com/gtag/js?id=G-SGR1T7X7KS'); 7 | document.head.appendChild(script); -------------------------------------------------------------------------------- /web/static/app/main.js: -------------------------------------------------------------------------------- 1 | import { Octokit } from "/vnd/octokit-18.12.0.min.js"; 2 | import {setup, BrowserBackend, GitHubBackend} from "/lib/treehouse.min.js"; 3 | 4 | setup(document, document.body, { 5 | "browser": new BrowserBackend(), 6 | "github": new GitHubBackend(`${window.backend.url}?scope=repo`, Octokit) 7 | }[window.backend.name]); 8 | 9 | setTimeout(() => { 10 | if (!localStorage.getItem("firsttime")) { 11 | window.workbench.showNotice('firsttime'); 12 | } 13 | }, 2000) 14 | -------------------------------------------------------------------------------- /web/static/app/main.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TreeNote" 3 | } -------------------------------------------------------------------------------- /web/static/blog/v0.1.0/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /web/static/blog/v0.2.0/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /web/static/blog/v0.3.0/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /web/static/blog/v0.4.0/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /web/static/halftone_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/halftone_green.png -------------------------------------------------------------------------------- /web/static/halftone_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/halftone_white.png -------------------------------------------------------------------------------- /web/static/halftone_white_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/halftone_white_15.png -------------------------------------------------------------------------------- /web/static/halftone_white_50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/halftone_white_50.png -------------------------------------------------------------------------------- /web/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/icon.png -------------------------------------------------------------------------------- /web/static/photos/blog/nls.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/blog/nls.jpeg -------------------------------------------------------------------------------- /web/static/photos/blog/notion.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/blog/notion.jpeg -------------------------------------------------------------------------------- /web/static/photos/blog/obsidian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/blog/obsidian.png -------------------------------------------------------------------------------- /web/static/photos/blog/tana.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/blog/tana.webp -------------------------------------------------------------------------------- /web/static/photos/blog/tiddlywiki.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/blog/tiddlywiki.png -------------------------------------------------------------------------------- /web/static/photos/blog/workflowy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/blog/workflowy.png -------------------------------------------------------------------------------- /web/static/photos/hero-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/hero-image.png -------------------------------------------------------------------------------- /web/static/photos/live-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/live-search.png -------------------------------------------------------------------------------- /web/static/photos/outline-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/outline-image.png -------------------------------------------------------------------------------- /web/static/photos/quickadd-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/quickadd-image.png -------------------------------------------------------------------------------- /web/static/photos/screenshot-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/screenshot-small.png -------------------------------------------------------------------------------- /web/static/photos/search-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treehousedev/treehouse/8ac92928d2e6caa9501147dd317e83e54263bff3/web/static/photos/search-image.png -------------------------------------------------------------------------------- /web/static/style.css: -------------------------------------------------------------------------------- 1 | .\? { 2 | border: 1px solid red; 3 | } 4 | 5 | .\?\? { 6 | border: 1px solid green; 7 | } 8 | 9 | .\?\?\? { 10 | border: 1px solid blue; 11 | } 12 | :root { 13 | font-family: "Figtree"; 14 | --teal: #00819E; 15 | --green: #1F842A; 16 | --ruby: #B71B3A; 17 | --purple: #852E8E; 18 | 19 | --primary-color: var(--green); 20 | 21 | 22 | --neutral-900: #1D2020; 23 | --neutral-800: #353A3B; 24 | --neutral-700: #575A5B; 25 | --neutral-600: #75797A; 26 | --neutral-500: #949899; 27 | --neutral-400: #BBBEBE; 28 | --neutral-300: #D2D5D5; 29 | --neutral-200: #E8EBEB; 30 | --neutral-100: #F6F7F7; 31 | --neutral-0: #fff; 32 | 33 | --heading-weight: 800; 34 | --heading-line-height: 1.4; 35 | --h1-size: 3rem; 36 | --h2-size: 2rem; 37 | --h3-size: 1.5rem; 38 | 39 | 40 | color: var(--neutral-900); 41 | } 42 | body { 43 | min-width: 320px; 44 | } 45 | a.hyperlink { 46 | font-weight: 700; 47 | text-decoration: underline; 48 | color: var(--teal) !important; 49 | } 50 | h1, h2, h3 { 51 | line-height: var(--heading-line-height) !important;; 52 | font-weight: var(--heading-weight) !important;; 53 | } 54 | h1 { 55 | font-size: var(--h1-size) !important; 56 | } 57 | h2 { 58 | font-size: var(--h2-size) !important; 59 | } 60 | h3 { 61 | font-size: var(--h3-size) !important; 62 | margin-bottom: 0.5rem; 63 | } 64 | body { 65 | font-size: 1.125rem; 66 | } 67 | section > div, header > div, footer > div { 68 | max-width: 94.5rem; 69 | padding-left: 4rem; 70 | padding-right: 4rem; 71 | } 72 | section > div { 73 | padding-top: 8rem; 74 | padding-bottom: 8rem; 75 | } 76 | p { 77 | font-size: 1.125rem; 78 | margin-top: 0.5rem !important; 79 | /* margin-bottom: 0.5rem; */ 80 | } 81 | button { 82 | border-radius: 10px; 83 | padding-left: 2rem !important; 84 | padding-right: 2rem !important; 85 | height: 3rem; 86 | font-weight: 700 !important; 87 | white-space: nowrap; 88 | } 89 | .image { 90 | border-radius: 10px; 91 | border: 2px solid var(--neutral-900); 92 | } 93 | img { 94 | max-width: 100%; 95 | height: auto; 96 | } 97 | a button { 98 | color: var(--neutral-900); 99 | } 100 | button.primary, .button.primary { 101 | color: var(--neutral-0); 102 | background-color: var(--primary-color); 103 | } 104 | button.secondary { 105 | border: 2px solid var(--neutral-900); 106 | background-color: var(--neutral-0); 107 | } 108 | .icon { 109 | margin-right: 0.375rem; 110 | } 111 | section > div .header { 112 | margin-bottom: 2rem; 113 | } 114 | pre.code { 115 | border-radius: 10px; 116 | background-color: var(--neutral-900); 117 | color: var(--neutral-100); 118 | padding-left: 1.5rem; 119 | padding-right: 1.5rem; 120 | padding-top: 0.75rem; 121 | padding-bottom: 0.75rem; 122 | margin-top: 1.5rem; 123 | } 124 | section.subscribe input[type="email"] { 125 | border-radius: 10px; 126 | padding-left: 1rem !important; 127 | padding-right: 1rem !important; 128 | height: 3rem; 129 | width: 100%; 130 | max-width: 32rem; 131 | border: 2px solid var(--neutral-900); 132 | background-color: var(--neutral-0); 133 | } 134 | .text-small { 135 | font-size: 0.875rem; 136 | } 137 | 138 | @media screen and (max-width: 640px) 139 | { 140 | body { 141 | --h1-size: 2rem; 142 | --h2-size: 1.5rem; 143 | --h3-size: 1.25rem; 144 | } 145 | 146 | section > div, header > div, footer > div { 147 | padding-left: 1.5rem; 148 | padding-right: 1.5rem; 149 | } 150 | section > div { 151 | padding-top: 2rem; 152 | padding-bottom: 2rem; 153 | } 154 | 155 | button, .button { 156 | border-radius: 8px; 157 | padding-left: 1rem !important; 158 | padding-right: 1rem !important; 159 | height: 2.5rem; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /web/static/style/design.css: -------------------------------------------------------------------------------- 1 | @import url(./normalize.css); 2 | 3 | :root { 4 | --0: 0px; 5 | --1: 0.25rem; 6 | --2: 0.5rem; 7 | --3: 0.75rem; 8 | --4: 1rem; 9 | --5: 1.25rem; 10 | --6: 1.5rem; 11 | --7: 1.75rem; 12 | --8: 2rem; 13 | --9: 2.25rem; 14 | --10: 2.5rem; 15 | --11: 2.75rem; 16 | --12: 3rem; 17 | --13: 3.25rem; 18 | --14: 3.5rem; 19 | --15: 3.75rem; 20 | --16: 4rem; 21 | --17: 4.25rem; 22 | --18: 4.5rem; 23 | --19: 4.75rem; 24 | --20: 5rem; 25 | --24: 6rem; 26 | --28: 7rem; 27 | --32: 8rem; 28 | 29 | --blue-700: #00819E; 30 | --blue-600: #007C9E; 31 | --blue-500: #00819E; 32 | --blue-300: #00819E; 33 | 34 | --green-700: #1F842A; 35 | --green-500: #1F842A; 36 | --green-300: #1F842A; 37 | 38 | --red-700: #B71B3A; 39 | --red-500: #B71B3A; 40 | --red-300: #B71B3A; 41 | 42 | --purple-700: #852E8E; 43 | --purple-500: #852E8E; 44 | --purple-300: #852E8E; 45 | 46 | --gray-900: #1D2020; 47 | --gray-800: #353A3B; 48 | --gray-700: #575A5B; 49 | --gray-600: #75797A; 50 | --gray-500: #949899; 51 | --gray-400: #BBBEBE; 52 | --gray-300: #D2D5D5; 53 | --gray-200: #E8EBEB; 54 | --gray-100: #F6F7F7; 55 | 56 | --primary: var(--green-500); 57 | 58 | --white: #fff; 59 | --black: var(--gray-900); 60 | 61 | --weight-light: 300; 62 | --weight-regular: 400; 63 | --weight-semibold: 600; 64 | --weight-bold: 700; 65 | --weight-black: 800; 66 | 67 | --layer-1: 10; 68 | --layer-2: 20; 69 | --layer-3: 30; 70 | --layer-4: 40; 71 | --layer-5: 50; 72 | --layer-below: -1; 73 | --layer-top: 2147483647; 74 | 75 | --h1-size: var(--12); 76 | --h2-size: var(--8); 77 | --h3-size: var(--6); 78 | 79 | --border-radius: var(--1); 80 | 81 | --body-line-height: 1.5; 82 | --text-small: 0.875rem; 83 | --text-body: 1rem; 84 | 85 | --padding: 1rem; 86 | 87 | } 88 | 89 | .h1,h1,.h2,h2,.h3,h3,.h4,h4,.h5,h5 { 90 | line-height: 1.1; 91 | font-weight: var(--weight-bold); 92 | } 93 | .h2,h2,.h3,h3,.h4,h4,.h5,h5 { 94 | font-weight: var(--weight-bold); 95 | } 96 | .h1,h1 { 97 | font-size: var(--h1-size); 98 | } 99 | .h2,h2 { 100 | font-size: var(--h2-size); 101 | } 102 | .h3,h3 { 103 | font-size: var(--h3-size); 104 | } 105 | .h5, h5 { 106 | font-weight: var(--weight-semibold); 107 | text-transform: uppercase; 108 | font-size: var(--text-small); 109 | letter-spacing: 1px; 110 | } 111 | 112 | .link { 113 | font-weight: var(--weight-semibold); 114 | text-decoration: underline; 115 | color: var(--blue-600); 116 | } 117 | 118 | /* Treehouse Utilities */ 119 | 120 | /*TO DO: replace this with a variable*/ 121 | .text-small { 122 | font-size: 0.875rem; 123 | } 124 | .row { 125 | display: flex; 126 | align-items: center; 127 | flex-direction: row; 128 | gap: var(--8); 129 | } 130 | a.row { 131 | gap: 0.375rem; 132 | } 133 | 134 | .\? { 135 | border: 1px solid red; 136 | } 137 | 138 | .\?\? { 139 | border: 1px solid green; 140 | } 141 | 142 | .\?\?\? { 143 | border: 1px solid blue; 144 | } 145 | 146 | 147 | 148 | 149 | /* Tailwind Utilities */ 150 | 151 | .flex { 152 | display: flex; 153 | } 154 | .flex-row { 155 | flex-direction: row; 156 | } 157 | .flex-col { 158 | flex-direction: column; 159 | } 160 | .items-center { 161 | align-items: center; 162 | } 163 | .grow { 164 | flex-grow: 1; 165 | } 166 | .text-center { 167 | text-align: center; 168 | } 169 | .text-right { 170 | text-align: right; 171 | } 172 | .w-full { 173 | width: 100%; 174 | } 175 | .justify-center { 176 | justify-content: center; 177 | } 178 | .items-start { 179 | align-items: flex-start; 180 | } 181 | .shrink-0 { 182 | flex-shrink: 0; 183 | } 184 | .gap-2 { gap: var(--2); } 185 | .gap-8 { gap: var(--8); } 186 | .m-0 { margin: 0; } 187 | .inset-0 { 188 | top: 0px; 189 | right: 0px; 190 | bottom: 0px; 191 | left: 0px; 192 | } 193 | .absolute { position: absolute; } 194 | 195 | -------------------------------------------------------------------------------- /web/static/style/normalize.css: -------------------------------------------------------------------------------- 1 | /*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */ 2 | 3 | /* 4 | Document 5 | ======== 6 | */ 7 | 8 | /** 9 | Use a better box model (opinionated). 10 | */ 11 | 12 | *, 13 | ::before, 14 | ::after { 15 | box-sizing: border-box; 16 | } 17 | 18 | /** 19 | Use a more readable tab size (opinionated). 20 | */ 21 | 22 | html { 23 | -moz-tab-size: 4; 24 | tab-size: 4; 25 | } 26 | 27 | /** 28 | 1. Correct the line height in all browsers. 29 | 2. Prevent adjustments of font size after orientation changes in iOS. 30 | */ 31 | 32 | html { 33 | line-height: 1.15; /* 1 */ 34 | -webkit-text-size-adjust: 100%; /* 2 */ 35 | } 36 | 37 | /* 38 | Sections 39 | ======== 40 | */ 41 | 42 | /** 43 | Remove the margin in all browsers. 44 | */ 45 | 46 | body { 47 | margin: 0; 48 | } 49 | 50 | /** 51 | Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 52 | */ 53 | 54 | body { 55 | font-family: 56 | system-ui, 57 | -apple-system, /* Firefox supports this but not yet `system-ui` */ 58 | 'Segoe UI', 59 | Roboto, 60 | Helvetica, 61 | Arial, 62 | sans-serif, 63 | 'Apple Color Emoji', 64 | 'Segoe UI Emoji'; 65 | } 66 | 67 | /* 68 | Grouping content 69 | ================ 70 | */ 71 | 72 | /** 73 | 1. Add the correct height in Firefox. 74 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 75 | */ 76 | 77 | hr { 78 | height: 0; /* 1 */ 79 | color: inherit; /* 2 */ 80 | } 81 | 82 | /* 83 | Text-level semantics 84 | ==================== 85 | */ 86 | 87 | /** 88 | Add the correct text decoration in Chrome, Edge, and Safari. 89 | */ 90 | 91 | abbr[title] { 92 | text-decoration: underline dotted; 93 | } 94 | 95 | /** 96 | Add the correct font weight in Edge and Safari. 97 | */ 98 | 99 | b, 100 | strong { 101 | font-weight: bolder; 102 | } 103 | 104 | /** 105 | 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 106 | 2. Correct the odd 'em' font sizing in all browsers. 107 | */ 108 | 109 | code, 110 | kbd, 111 | samp, 112 | pre { 113 | font-family: 114 | ui-monospace, 115 | SFMono-Regular, 116 | Consolas, 117 | 'Liberation Mono', 118 | Menlo, 119 | monospace; /* 1 */ 120 | font-size: 1em; /* 2 */ 121 | } 122 | 123 | /** 124 | Add the correct font size in all browsers. 125 | */ 126 | 127 | small { 128 | font-size: 80%; 129 | } 130 | 131 | /** 132 | Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. 133 | */ 134 | 135 | sub, 136 | sup { 137 | font-size: 75%; 138 | line-height: 0; 139 | position: relative; 140 | vertical-align: baseline; 141 | } 142 | 143 | sub { 144 | bottom: -0.25em; 145 | } 146 | 147 | sup { 148 | top: -0.5em; 149 | } 150 | 151 | /* 152 | Tabular data 153 | ============ 154 | */ 155 | 156 | /** 157 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 158 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 159 | */ 160 | 161 | table { 162 | text-indent: 0; /* 1 */ 163 | border-color: inherit; /* 2 */ 164 | } 165 | 166 | /* 167 | Forms 168 | ===== 169 | */ 170 | 171 | /** 172 | 1. Change the font styles in all browsers. 173 | 2. Remove the margin in Firefox and Safari. 174 | */ 175 | 176 | button, 177 | input, 178 | optgroup, 179 | select, 180 | textarea { 181 | font-family: inherit; /* 1 */ 182 | font-size: 100%; /* 1 */ 183 | line-height: 1.15; /* 1 */ 184 | margin: 0; /* 2 */ 185 | } 186 | 187 | /** 188 | Remove the inheritance of text transform in Edge and Firefox. 189 | 1. Remove the inheritance of text transform in Firefox. 190 | */ 191 | 192 | button, 193 | select { /* 1 */ 194 | text-transform: none; 195 | } 196 | 197 | /** 198 | Correct the inability to style clickable types in iOS and Safari. 199 | */ 200 | 201 | button, 202 | [type='button'], 203 | [type='reset'], 204 | [type='submit'] { 205 | -webkit-appearance: button; 206 | } 207 | 208 | /** 209 | Remove the inner border and padding in Firefox. 210 | */ 211 | 212 | ::-moz-focus-inner { 213 | border-style: none; 214 | padding: 0; 215 | } 216 | 217 | /** 218 | Restore the focus styles unset by the previous rule. 219 | */ 220 | 221 | :-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | Remove the additional ':invalid' styles in Firefox. 227 | See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737 228 | */ 229 | 230 | :-moz-ui-invalid { 231 | box-shadow: none; 232 | } 233 | 234 | /** 235 | Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. 236 | */ 237 | 238 | legend { 239 | padding: 0; 240 | } 241 | 242 | /** 243 | Add the correct vertical alignment in Chrome and Firefox. 244 | */ 245 | 246 | progress { 247 | vertical-align: baseline; 248 | } 249 | 250 | /** 251 | Correct the cursor style of increment and decrement buttons in Safari. 252 | */ 253 | 254 | ::-webkit-inner-spin-button, 255 | ::-webkit-outer-spin-button { 256 | height: auto; 257 | } 258 | 259 | /** 260 | 1. Correct the odd appearance in Chrome and Safari. 261 | 2. Correct the outline style in Safari. 262 | */ 263 | 264 | [type='search'] { 265 | -webkit-appearance: textfield; /* 1 */ 266 | outline-offset: -2px; /* 2 */ 267 | } 268 | 269 | /** 270 | Remove the inner padding in Chrome and Safari on macOS. 271 | */ 272 | 273 | ::-webkit-search-decoration { 274 | -webkit-appearance: none; 275 | } 276 | 277 | /** 278 | 1. Correct the inability to style clickable types in iOS and Safari. 279 | 2. Change font properties to 'inherit' in Safari. 280 | */ 281 | 282 | ::-webkit-file-upload-button { 283 | -webkit-appearance: button; /* 1 */ 284 | font: inherit; /* 2 */ 285 | } 286 | 287 | /* 288 | Interactive 289 | =========== 290 | */ 291 | 292 | /* 293 | Add the correct display in Chrome and Safari. 294 | */ 295 | 296 | summary { 297 | display: list-item; 298 | } 299 | 300 | /* 301 | Custom extensions 302 | =========== 303 | */ 304 | a{text-decoration:none; color:inherit; cursor:pointer;} 305 | h1,h2,h3,h4,h5 {margin:0;} -------------------------------------------------------------------------------- /web/static/style/site.css: -------------------------------------------------------------------------------- 1 | @import url(./design.css); 2 | 3 | /* treehouse specific */ 4 | 5 | 6 | :root { 7 | background-color: var(--black); 8 | } 9 | body, body > header > nav { 10 | background-color: var(--white); 11 | color: var(--black); 12 | font-family: "Figtree"; 13 | font-size: 1.125rem; 14 | } 15 | body > section > *, 16 | body > header > *, 17 | body > footer > * { 18 | max-width: 94.5rem; 19 | padding-left: var(--16); 20 | padding-right: var(--16); 21 | margin-left: auto; 22 | margin-right: auto; 23 | 24 | } 25 | body > header > nav { 26 | padding-top: var(--6); 27 | padding-bottom: var(--6); 28 | } 29 | body > header > nav .item { 30 | margin-left: var(--4); 31 | margin-right: var(--4); 32 | } 33 | body > header > nav .item.active { 34 | border-bottom: 4px solid var(--primary); 35 | } 36 | body > footer { 37 | color: var(--white); 38 | background-color: var(--black); 39 | background-image: url("/halftone_white_15.png"); 40 | background-size: 50px 50px; 41 | } 42 | body > footer > nav { 43 | padding-top: var(--8); 44 | padding-bottom: var(--8); 45 | } 46 | body > section { 47 | padding-top: var(--16); 48 | padding-bottom: var(--16); 49 | } 50 | body > section:last-of-type { 51 | padding-bottom: var(--32) !important; 52 | } 53 | body > section > * .header { 54 | margin-bottom: var(--8); 55 | } 56 | body > section.title { 57 | color: var(--white); 58 | background-color: var(--primary); 59 | 60 | text-align: center; 61 | line-height: 1.4; 62 | padding-top: var(--8); 63 | padding-bottom: var(--8); 64 | 65 | font-size: var(--16); 66 | font-weight: var(--weight-bold); 67 | } 68 | body > section.title > * { 69 | filter: drop-shadow(2px 2px 0px var(--black)); 70 | } 71 | body > section.title .subtitle { 72 | font-size: var(--8); 73 | font-weight: var(--weight-semibold); 74 | } 75 | body > #subscribe { 76 | background-color: var(--gray-100); 77 | margin-top: var(--16); 78 | } 79 | body > section.alt { 80 | background-color: var(--gray-100); 81 | background-image: url("/halftone_white_50.png"); 82 | background-size: 50px 50px; 83 | } 84 | body > section.hero .dropshadow { 85 | border-radius: 10px; 86 | position: absolute; 87 | margin-left: 18px; 88 | margin-top: 18px; 89 | left: 0; 90 | top: 0; 91 | width: 100%; 92 | height: 420px; 93 | background-image: url("/halftone_green.png"); 94 | background-size: 50px 50px; 95 | } 96 | 97 | table { 98 | border-collapse: collapse; 99 | } 100 | th, td { 101 | border: 1px solid var(--gray-400); 102 | padding: calc(var(--padding)/2); 103 | font-size: var(--text-small); 104 | } 105 | 106 | /*------------ARTICLE------------*/ 107 | /*used for blog posts and documentation*/ 108 | 109 | /*add extra height for readibility*/ 110 | article { 111 | line-height: 1.7; 112 | max-width: 768px; 113 | } 114 | article .author { 115 | font-weight: var(--weight-semibold); 116 | } 117 | article .date, body.blog > section nav > div .date { 118 | color: var(--gray-600); 119 | margin-bottom: var(--4); 120 | } 121 | /*grouped because bottom margin should stay consistent*/ 122 | article h1, article h2, article h3, article h4, article p { 123 | margin-bottom: var(--3); 124 | } 125 | article h2 { 126 | margin-top: var(--14); 127 | } 128 | article h3 { 129 | margin-top: var(--7); 130 | } 131 | article h4 { 132 | margin-top: var(--6); 133 | } 134 | article ul, article p { 135 | margin-top: 0px; 136 | } 137 | article img { 138 | margin-top: var(--2); 139 | margin-bottom: var(--4); 140 | width: 100%; 141 | border: 1px solid var(--border-color-light); 142 | } 143 | article a { 144 | text-decoration: underline; 145 | font-weight: var(--weight-semibold); 146 | color: var(--blue-600); 147 | } 148 | 149 | body.blog > section nav > div { 150 | margin-top: var(--4); 151 | margin-bottom: var(--4); 152 | line-height: var(--7); 153 | } 154 | 155 | body.docs > section nav { 156 | position: sticky; top: 0px; 157 | } 158 | body.docs > section nav ul { 159 | list-style-type: none; 160 | margin-top: 0; 161 | margin-bottom: var(--4); 162 | padding: 0; 163 | font-weight: var(--weight-regular); 164 | } 165 | body.docs > section nav h5 { 166 | font-weight: var(--weight-bold); 167 | text-transform: none; 168 | letter-spacing: 0px; 169 | } 170 | body.docs > section nav a.active { 171 | background-color: #E3FBE5; 172 | } 173 | body.docs > section nav li a { 174 | font-weight: var(--weight-regular); 175 | padding-left: var(--3); 176 | border-radius: 4px; 177 | display: block; 178 | } 179 | body.docs > section nav li a, 180 | body.docs > section nav h5 a { 181 | font-size: 1.125rem; 182 | line-height: var(--10); 183 | } 184 | article h1 > a, 185 | article h2 > a, 186 | article h3 > a, 187 | article h4 > a, 188 | article h5 > a { 189 | text-decoration: none; 190 | color: inherit; 191 | font-weight: inherit; 192 | } 193 | 194 | a { 195 | font-weight: var(--weight-semibold); 196 | } 197 | 198 | .button, button { 199 | border-radius: 10px; 200 | padding-left: var(--8); 201 | padding-right: var(--8); 202 | height: var(--12); 203 | font-weight: var(--weight-bold); 204 | white-space: nowrap; 205 | 206 | display: flex; 207 | flex-direction: row; 208 | align-items: center; 209 | gap: 0.375rem; 210 | 211 | border: 2px solid var(--black); 212 | background-color: var(--white); 213 | } 214 | 215 | input[type="email"], 216 | input[type="text"] { 217 | border-radius: 10px; 218 | padding-left: var(--4); 219 | padding-right: var(--4); 220 | height: var(--12); 221 | width: 100%; 222 | max-width: 32rem; 223 | 224 | border: 2px solid var(--black); 225 | background-color: var(--white); 226 | } 227 | 228 | .logo { 229 | font-size: var(--5); 230 | font-weight: var(--weight-black); 231 | 232 | display: flex; 233 | flex-direction: row; 234 | align-items: center; 235 | gap: 0.375rem; 236 | } 237 | 238 | .primary { 239 | color: var(--white); 240 | border: none; 241 | background-color: var(--primary); 242 | } 243 | 244 | code:not(pre > code), pre > code > div, .hljs { 245 | background-color: var(--gray-900); 246 | color: var(--white); 247 | font-size: smaller; 248 | line-height: var(--6); 249 | border-radius: 4px; 250 | padding: var(--2); 251 | } 252 | 253 | pre > code { 254 | display: flex; 255 | overflow-x: auto; 256 | max-width: 100%; 257 | } 258 | 259 | /*code editor color theme (temporarily borrowed from nite owl)*/ 260 | .hljs-name { 261 | color: #AEDB67; /*green*/ 262 | } 263 | .hljs-attr { 264 | color: #55A3E2; /*blue*/ 265 | } 266 | .hljs-string { 267 | color: #80DBCA; /*teal*/ 268 | } 269 | .hljs-doctag, .hljs-keyword, .hljs-meta .hljs-keyword, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-variable.language_ { 270 | color: #C792EA; /*purple*/ 271 | } 272 | .hljs-title, .hljs-title.class_, .hljs-title.class_.inherited__, .hljs-title.function_ { 273 | color: #FEEB95; /*yellow*/ 274 | } 275 | 276 | 277 | /* Responsive */ 278 | 279 | @media screen and (max-width: 1024px) 280 | { 281 | .md\:hidden { 282 | display: none; 283 | } 284 | 285 | body > section > *, 286 | body > header > *, 287 | body > footer > * { 288 | padding-left: var(--6); 289 | padding-right: var(--6); 290 | } 291 | body > section { 292 | padding-top: var(--8); 293 | padding-bottom: var(--8); 294 | } 295 | body > section:last-of-type { 296 | padding-bottom: var(--16) !important; 297 | } 298 | body > #subscribe { 299 | margin-top: var(--8); 300 | } 301 | } 302 | 303 | @media screen and (max-width: 640px) 304 | { 305 | .sm\:hidden { 306 | display: none; 307 | } 308 | .sm\:stack { 309 | flex-direction: column; 310 | } 311 | 312 | body { 313 | --h1-size: var(--8); 314 | --h2-size: var(--6); 315 | --h3-size: var(--5); 316 | } 317 | 318 | body.docs > section nav { 319 | position: relative; 320 | } 321 | 322 | body > header > nav { 323 | padding-top: var(--4); 324 | padding-bottom: var(--4); 325 | } 326 | body > footer > nav { 327 | padding-top: var(--4); 328 | padding-bottom: var(--4); 329 | } 330 | 331 | button, .button { 332 | border-radius: 8px; 333 | padding-left: 1rem !important; 334 | padding-right: 1rem !important; 335 | height: 2.5rem; 336 | } 337 | article h2, article h3, img, p { 338 | margin-bottom: var(--2); 339 | } 340 | article h2, article h3 { 341 | margin-top: var(--3); 342 | } 343 | nav > .logo > span { 344 | display: none; 345 | } 346 | .blog .title { 347 | padding-top: var(--2); 348 | padding-bottom: var(--2); 349 | 350 | } 351 | .blog .title h4 { 352 | font-size: var(--8); 353 | } 354 | .blog .title .subtitle { 355 | font-size: var(--4); 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /web/static/style/themes/darkmode.css: -------------------------------------------------------------------------------- 1 | 2 | :root { 3 | --font: 'Figtree', sans-serif; 4 | 5 | --color-primary: var(--white); 6 | 7 | --color-background: var(--black); 8 | --color-background-sidebar: var(--gray-800); 9 | 10 | --color-icon: var(--white); 11 | --color-icon-secondary: var(--gray-400); 12 | 13 | --color-nav-label: var(--white); 14 | 15 | --color-text: var(--white); 16 | --color-text-placeholder: var(--gray-600); 17 | --color-text-secondary: var(--gray-400); 18 | 19 | --color-highlight: var(--gray-700); 20 | 21 | --color-node-handle: var(--gray-400); 22 | --color-node-handle-secondary: var(--gray-700); 23 | 24 | --color-outline: var(--gray-600); 25 | --color-outline-secondary: var(--gray-700); 26 | } -------------------------------------------------------------------------------- /web/static/style/themes/sepia.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font: 'Figtree', sans-serif; 3 | 4 | --sepia-900: #362008; 5 | --sepia-700: #6F5B49; 6 | --sepia-500: #9C8C7D; 7 | --sepia-300: #C4AE9C; 8 | --sepia-200: #CEBCAB; 9 | --sepia-100: #D6C4B3; 10 | 11 | --color-primary: var(--sepia-900); 12 | 13 | --color-background: var(--sepia-100); 14 | --color-background-sidebar: var(--sepia-200); 15 | 16 | --color-icon: var(--sepia-900); 17 | --color-icon-secondary: var(--sepia-700); 18 | 19 | --color-nav-label: var(--sepia-900); 20 | 21 | --color-text: var(--sepia-900); 22 | --color-text-placeholder: var(--sepia-500); 23 | --color-text-secondary: var(--sepia-700); 24 | 25 | --color-highlight: var(--sepia-300); 26 | 27 | --color-node-handle: var(--sepia-700); 28 | --color-node-handle-secondary: var(--sepia-300); 29 | 30 | --color-outline: var(--sepia-700); 31 | --color-outline-secondary: var(--sepia-300); 32 | } -------------------------------------------------------------------------------- /web/static/style/themes/sublime.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto+Mono&display=swap'); 2 | 3 | :root { 4 | --sublime-black: #2B2C26; 5 | --sublime-dark-gray: #3F3F3B; 6 | --sublime-medium-gray: #878783; 7 | --sublime-white: #F9F9F3; 8 | --sublime-blue: #72DDF3; 9 | --sublime-lime: #ABE700; 10 | --sublime-yellow: #E9E076; 11 | --sublime-purple: #B889FF; 12 | --sublime-orange: #FDA100; 13 | --sublime-pink: #CF1C67; 14 | 15 | --font: "Roboto Mono", monospace; 16 | 17 | --color-primary: var(--sublime-purple); 18 | 19 | --color-background: var(--sublime-black); 20 | --color-background-sidebar: var(--sublime-dark-gray); 21 | 22 | --color-icon: var(--sublime-pink); 23 | --color-icon-secondary: var(--sublime-blue); 24 | 25 | --color-nav-label: var(--sublime-blue); 26 | 27 | --color-text: var(--white); 28 | --color-text-placeholder: var(--sublime-lime); 29 | --color-text-secondary: var(--sublime-yellow); 30 | 31 | --color-highlight: var(--sublime-pink); 32 | 33 | --color-node-handle: var(--sublime-white); 34 | --color-node-handle-secondary: var(--sublime-purple); 35 | 36 | --color-outline: var(--sublime-orange); 37 | --color-outline-secondary: var(--sublime-purple); 38 | } --------------------------------------------------------------------------------