chore: project scaffold, build config, scripts and README
Bun + SvelteKit (static adapter) + Electron project files, icon resources, dev/e2e/bench scripts and the README documenting the app. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# one-off patch scripts, kept out of the way
|
||||||
|
old-patches/
|
||||||
|
*.bak
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Ink
|
||||||
|
|
||||||
|
A calm, offline Markdown notes app. Electron + SvelteKit (Svelte 5), plain `.md` files on disk, SQLite full-text search.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
Requires [Bun](https://bun.sh) and Node 20.19+ (22 LTS recommended; tests run on Node's test runner).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install # also downloads Electron and rebuilds better-sqlite3 for it
|
||||||
|
bun run dev # Vite dev server + Electron; the page hot-reloads, Electron restarts when electron/ changes
|
||||||
|
```
|
||||||
|
|
||||||
|
Other scripts:
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
| ----------------- | ---------------------------------------------------------------- |
|
||||||
|
| `bun run dev:web` | UI only, in a browser, with an in-memory mock instead of files |
|
||||||
|
| `bun run start` | Production build of the UI, then Electron loads it |
|
||||||
|
| `bun run dist` | Packaged app in `dist/` (dmg / nsis / AppImage via electron-builder) |
|
||||||
|
| `bun run icons` | Re-render `resources/icon.svg` into every icon size (needs `rsvg-convert` and ImageMagick) |
|
||||||
|
| `bun run test` | Markdown round-trip, vault, front matter and search index tests |
|
||||||
|
|
||||||
|
If `bun install` fails while rebuilding `better-sqlite3`, there was no prebuilt binary for your Electron version and it tried to compile one. Install build tools (Xcode CLT on macOS, "Desktop development with C++" on Windows, `build-essential` + `python3` on Linux) and run `bunx electron-rebuild -f -w better-sqlite3`.
|
||||||
|
|
||||||
|
## How it fits together
|
||||||
|
|
||||||
|
```
|
||||||
|
renderer (SvelteKit SPA, sandboxed) preload main process (Node)
|
||||||
|
───────────────────────────────── ───────── ────────────────────
|
||||||
|
Editor (contenteditable blocks)
|
||||||
|
└─ onChange ─> store.edit()
|
||||||
|
└─ 600 ms ─> toMarkdown() ─> window.vault.write() ─> ipcMain ─> vault.cjs ─> ~/Notes/*.md
|
||||||
|
└─> db.cjs ─> .notes/index.sqlite (FTS5)
|
||||||
|
Palette ─> window.vault.search() ───────────────────────────> ipcMain ─> db.cjs
|
||||||
|
store <─ 'vault:changed' <───────────────────────────────── fs.watch (edits from other editors)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Files are the source of truth.** `Projects.md` is a page; `Projects/Roadmap.md` is its child. A folder without a matching `.md` still shows as a page. Delete `.notes/` any time and the index rebuilds on launch.
|
||||||
|
- **Front matter** holds `icon`, `cover`, `tags`, `created`. Unknown keys are preserved, so Obsidian vaults are safe to open.
|
||||||
|
- **Renaming** a page renames the file and its folder. **Moving** (drag it onto another page in the sidebar, or onto empty space for the top level) moves both, plus the images it shows. **Deleting** moves both to `.notes/trash/`, where the Trash dialog (sidebar footer, or `Ctrl/⌘ K` → Open trash) can restore them; the toast after a delete also has **Undo**. Entries older than 30 days, or deleted from the Trash dialog, go to the OS trash.
|
||||||
|
- **Web links** `[text](https://…)` and bare `https://…` URLs are clickable (`Ctrl/⌘`-click, or the bubble that shows while the caret is in one). Pasting a URL over selected text links it; a typed URL becomes a link at the next space. Only `http(s)` and `mailto` links open; others stay plain text.
|
||||||
|
- **Tables** are GitHub-style Markdown tables, edited cell by cell (right-click a cell for rows, columns and alignment; `/table` makes one). A table you don't touch is saved exactly as it was written; an edited one is written back with aligned columns.
|
||||||
|
- **Links** are Obsidian-style `[[Page]]`, `[[Folder/Page]]` or `[[Page|label]]`. A bare title matches the page with that title (shortest path wins). Renaming or moving a page rewrites the links that point to it. Each page lists its backlinks at the bottom.
|
||||||
|
- **Canvas** (like Obsidian's): an endless surface of cards, notes, pictures, web links and groups, joined by arrows. Make one with `Ctrl/⌘ N` → **Canvas**, "New canvas" in the sidebar's right-click menu, or the search palette. It is a `.canvas` file in the JSON Canvas format Obsidian uses, written the way Obsidian writes it, so canvases open in both apps (unknown fields are kept; a file that can't be read is shown as such and never saved over).
|
||||||
|
- **Move around**: scroll (Shift+scroll sideways), Space+drag or middle-drag; `Ctrl/⌘`+scroll or `Ctrl/⌘ +/−` zooms, `Ctrl/⌘ 0` is 100%, `Shift+1` shows everything. Each canvas remembers where you were looking.
|
||||||
|
- **Add**: double-click empty space for a card (Markdown, shown rendered; double-click or Enter to edit, Escape or click away to finish; a new card left empty disappears), or use the bar at the bottom: card, a note from your notes (shown as a live preview; `[[links]]` in cards and notes open), a picture (or drop pictures from the file manager, or paste one), a group (around the selection, if any). Pasting a web address makes a link card.
|
||||||
|
- **Arrange**: drag to move (snaps to the grid; Alt for free), the corner to resize; dragging a group takes what's inside along. Drag over empty space to select with a box, Shift+click to add; `Ctrl/⌘ A`, arrow keys nudge, `Delete` removes, `Ctrl/⌘ D` duplicates, `Ctrl/⌘ C/X/V` copy, cut and paste (as canvas text, like Obsidian). The bar over a selection sets one of six colours, opens a note, edits an arrow's label, or deletes.
|
||||||
|
- **Arrows**: drag one of the dots on a card's sides onto another card; let go on empty space to make a new card there. Double-click an arrow to label it.
|
||||||
|
- Undo/redo (`Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z`). Canvases count as links: the notes on them list the canvas under backlinks, they appear in the graph, their text is searchable, and renaming or moving a note (or renaming one of its headings) updates every canvas showing it. `[[Name.canvas]]` links to a canvas; `[[Name]]` never means one.
|
||||||
|
- **New page** (`Ctrl/⌘ N`, the + buttons) opens a picker: **Blank page**, or a template. Templates are Markdown files in `<notes folder>/.notes/templates/` (front matter such as `icon` and `tags` carries over, and the file name becomes the page's title); each shows as a card with its icon and a preview. The first card is focused, so `Ctrl/⌘ N` then `Enter` gives a blank page; arrow keys move, **Open folder** opens the templates folder (creating it), and the list refreshes when you come back to the window.
|
||||||
|
- **Right-click** opens the app's own menu instead of the system one: spelling suggestions and "Add to dictionary" for a misspelled word; open / copy / edit / remove for a web link (open for a page link); a row of format buttons for selected text in a note; Cut, Copy, Paste and Select all; and "Search notes for …" for a short selection. It doesn't take focus, so the selection stays; arrow keys, Enter and Escape work. (Electron's main process reports what was clicked, since only it has the spelling suggestions, and does the clipboard and spelling actions; `TextMenu.svelte` draws the menu.) Pages in the sidebar and table cells keep their own menus.
|
||||||
|
- **Math and diagrams**: a `$$ … $$` block is a LaTeX formula (KaTeX) and a ```` ```mermaid ```` block is a Mermaid diagram (`/math`, `/mermaid`). Each shows the rendered result on top and its source underneath, and the result updates as you type; a mistake shows an error message instead. Diagrams follow the light/dark theme when they're drawn. Embeds show math but show a diagram's source.
|
||||||
|
- **Highlight**: `==text==` shows highlighted. Select text and use the toolbar's **H** button or `Ctrl/⌘ Shift H` to add or remove it.
|
||||||
|
- **Footnotes**: `[^id]` marks a reference and a `[^id]: text` line holds its text (indented lines continue it); `^[text]` is an inline note. References are numbered in reading order, hovering one shows its text, clicking it jumps to the text, and clicking a footnote's number jumps back. `/footnote` adds one with the next free number and takes you to its text; Enter at the end of a footnote text takes you back to the sentence, just after the reference (Shift+Enter for a new line inside the footnote). Footnote texts stay at the bottom: clicking below the page adds a line above them; typing `[^id]` or `^[…]` turns into a reference as you type, and `[^id]: ` at the start of a line starts a footnote text. A reference with no text is shown grey (click it to write one); a text nothing points to keeps its id. HTML export numbers and links them.
|
||||||
|
- **Heading links**: `[[Page#Heading]]` opens the page and scrolls to that heading (matched ignoring case and spacing); `[[#Heading]]` jumps within the page you're on. They're shown as `Page › Heading`, and typing `#` inside `[[` suggests the page's headings. Renaming a page keeps the `#Heading` part. **Renaming a heading** updates them too: once you stop typing for ~2 seconds (or at once when you leave the page or window), every `[[Page#Heading]]` link and `![[Page#Heading]]` embed in your notes, including those in board cards, and the `[[#Heading]]` links in the page itself, are re-pointed at the new name, keeping aliases, and a toast says how many. Only the first heading of a given name counts (links point at it), a change of case alone leaves links alone, a brand-new or emptied heading isn't a rename, and characters that would break a link (`# | ^ [ ]`) become spaces in the link. Ctrl+Z on the heading takes the links back.
|
||||||
|
- **Embeds**: `![[Page]]` on a line of its own shows that note's content inline, read-only, under its title (click the title to open it); `![[Page#Heading]]` shows just that section, down to the next heading of the same or higher level. Long ones fold with a "Show more" button. Type `![[` for a page picker, or use `/embed`. Embeds count as links for backlinks, the graph, and rename/move rewriting. `![[photo.png]]` still shows a picture; other files (pdf, audio…) stay plain text. A note can't embed itself, boards aren't shown, and an embed inside an embed appears as a link.
|
||||||
|
- **Cover pictures**: a page's cover is a gradient, or your own picture. Hover the cover → **Change cover** → **Upload an image…** or **From a link…** (paste an image address; the app downloads it, public http(s) addresses only), or drop an image file onto the cover. The picture is saved next to the page and named in its front matter (`cover: header.png`); it moves with the page when you move it, and database galleries show it on the page's card. "Remove the image" goes back to a gradient (the file stays where it is).
|
||||||
|
- **Images** pasted, dropped or added with `/image` are saved next to the note and written as ``; `![[name.png]]` is read too. They are served to the page through a `notes-media://` protocol that only reads inside the notes folder.
|
||||||
|
- **Split view**: a second page beside the one you're on, editable, with its own saving. Right-click a page → **Open in split**, Alt+click it in the sidebar, or drag it from the sidebar onto the right half of the page (the left half makes it the main page and moves the current one to the side), or use the top bar's columns button / `Ctrl/⌘ Shift \` (which opens the page you were on before). The side pane's title swaps the panes; ✕ closes it. Boards, databases and canvases work in the side pane too; links clicked in either pane open on the left.
|
||||||
|
- **Favorites** (the star in the top bar, or right-click a page) and **recent pages** (shown in `Ctrl/⌘ K` before you type) are kept per notes folder in the app settings.
|
||||||
|
- **Kanban boards** are pages with `kanban-plugin: basic` in their front matter, in the Obsidian Kanban plugin's format: each `## Heading` is a column, each `- [ ]` item a card (indented lines continue it), and a trailing `%% kanban:settings %%` block is kept. Create one with **New board** (`Ctrl/⌘ K`, or right-click in the sidebar), or turn any page into a board and back from the page's `⋯` menu. Cards and columns drag around; `Ctrl/⌘ Z` undoes board changes. Cards are drawn with the editor's own Markdown, so bold / italic / highlight / `code` / math, `[[links]]`, bullet and numbered lists, `>` quotes, headings, fenced code, tables and images (put them on the lines indented under the card) look as they do on a page; clicking a card edits its Markdown as written. Column titles are colour pills: the column's `⋯` menu picks one of eight colours (or leaves it automatic, from the name). Colours are kept in the page's front matter (`ink-colors`), never in the board text, so the file stays what the Kanban plugin writes; renaming a column keeps its colour.
|
||||||
|
- **Databases** are pages whose sub-pages are *records*, with typed properties and several views of the same records. Make one with **Database** in the new-page dialog (`Ctrl/⌘ N`), "New database" in the sidebar's right-click menu, or the search palette. A record is an ordinary note: its property values are keys in its own front matter (`status: Doing`, `due: 2026-03-01`, `done: true`, `owner: [Ana, Ben]`), so Obsidian reads them too, and a record can hold text like any page. The database page holds the schema (properties and views) in a `%% ink-database … %%` comment block and shows the records instead of a text editor; its records aren't listed under it in the sidebar.
|
||||||
|
- **Properties**: text, number, select, multi-select, date, checkbox, people, files and URL. Add one with **+** in the table header; its menu renames it, changes its type, edits select options (renaming an option updates the records), sorts, hides, moves or deletes it. Deleting a property or option leaves the values in the files. Files are copied next to the record and open with the system's app.
|
||||||
|
- **Views**: table, board (set up like a Kanban board: drag cards between groups; each group's header has its colour pill and a menu to rename it, pick its colour, add a record or delete it; drag a header to reorder the groups, "Add a group" adds an option, a card's `⋯` menu moves, renames or deletes it. New databases start with a Table and a Calendar; add a board with the `+` beside the view tabs), calendar (drag a record to another day, `+` on a day adds one), gallery (cover picture from a Files property, else the record's cover), timeline (bars from a start date to an optional end date; drag a bar to move it) and list. Each view keeps its own sort, filters, hidden properties and grouping or date property. Right-click a view's tab to rename, change its layout, duplicate or delete it.
|
||||||
|
- **Table**: drag a column's edge to resize it (kept per view) and its header to reorder; arrow keys move between cells (hold Alt where the arrows mean something in the field, e.g. a text caret or a number), Enter finishes and drops down. Tick rows (Shift-click for a range) for a bar that sets one property on all of them or trashes them in one Undo-able step.
|
||||||
|
- **Option colours** (select and multi-select): in the property menu, click an option's dot. **Number formats**: number with commas, percent, dollar, euro or a progress bar (set what a full bar is); the file always holds the plain number.
|
||||||
|
- **Calendar** has Month and Week; drag the right edge of a record to give it an end date (an "End" date property is added if there is none). **Timeline**: drag a bar's edges to change its start or end. A **Relation** property holds the titles of other records; point a timeline's *Dependencies* at one and it draws arrows (red when the earlier record ends after the later one starts). Renaming a record updates relations that name it.
|
||||||
|
- **In a page**: `![[Tasks]]` shows the database live, in place: filter, sort, switch views and edit values right there. `![[Tasks#Board]]` picks the saved view called Board (typing `#` after the name lists the views). An embed's own sort, filters and hidden properties are kept in its alias, e.g. `![[Tasks#Board|sort=due:desc; filter=status:is:Done; hide=owner]]`; they sit on top of the saved view's and never change the database. Embeds can't add properties or views; open the database for that.
|
||||||
|
- **CSV**: **Export as CSV** (page `⋯` menu, or the search palette) writes a Name column and one per property, every record. **Import CSV…** (same menu, the palette, or the sidebar's right-click menu for a new database) reads `,`, `;` or tab separated files with quoted cells and lets you choose what each column becomes: the name, an existing property (matched by header), a new property of a guessed type (number, date, checkbox, select, multi-select, URL, text) or nothing. Dates like `3/9/2025` and amounts like `$5,200` are understood; select options are collected from the values.
|
||||||
|
- **Search and graph**: property values are indexed. Plain words match them (`Ana`), `property:value` filters (`status:Done`, `status:"In progress"`, `owner:!Ana`, `points:>=5`, `due:2026-03`, `has:due`) and combines with words, `tag:` and `in:`. With only filters, every matching record is listed with the values that matched. `[[links]]` written in a property value count as links (graph, backlinks, renames), and so do relation properties between records.
|
||||||
|
- **Moving a record** out of its database (the record page's `⋯` menu has "Move out of …"; dragging a page onto a database makes it a record) says which of its properties stayed in the file, with a **Remove** button; moving between databases only mentions the ones the new database doesn't have.
|
||||||
|
- **Formulas**: a *Formula* property is worked out from the record's other values and never stored in the file. `prop("Price") * prop("Quantity")` (or `{Price}`), `if(cond, a, b)`, `concat(…)`, `round(x, 2)`, `dateBetween(prop("Expiry"), today(), "days")`, `dateAdd(d, 1, "months")`, `contains`, `min/max/sum/average` and more; `+ - * / % ^`, comparisons and `and / or / not`. An empty number counts as 0. Dates work with `+` and `-` (`date - date` is days). The property's menu has the editor with a live result for the first record, a number format (euro, dollar, percent, commas), and a list of functions; a mistake shows what is wrong and `Error` in the cells. Formulas can use each other, sort and filter numerically (`>`, `≥` …) and show on boards, galleries, record pages and in CSV exports.
|
||||||
|
- **Totals**: the table's bottom row has a **Calculate** cell under every column (hover it): count, count values / empty / unique, percent filled, and for numbers and formulas sum, average, median, min, max and range; earliest / latest / span for dates; checked and percent checked for checkboxes. They follow the view's filters, use the column's number format and are kept in the view.
|
||||||
|
- **Undo** (`Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z`) steps through changes to the schema: properties, options, views, sorts, filters, widths. Changes to record values aren't part of it (use the editor's own undo, or version history on the record).
|
||||||
|
- **Records** open as pages with the database's properties on top, edited with the same controls. In the table, rename a record in place and use the trash button to delete one (Undo is in the toast).
|
||||||
|
- **Outline** (the list button in the top bar, `Ctrl/⌘ Shift O`, or Ctrl+K) docks a list of the page's `#`/`##`/`###` headings on the right, indented by level. Click one to scroll there; the section you're reading stays highlighted, and the list updates as you type. Pages only, not boards. Whether it's open is remembered.
|
||||||
|
- **Graph view** draws every note as a dot and every `[[link]]` as a line, laid out by a small force simulation (`src/lib/graph.js`: springs for links, Barnes–Hut repulsion, a pull to the centre). Drag dots, drag the background to pan, scroll to zoom, hover to light a note's neighbours, click to open. The gear shows filters: highlight, this page only (1–3 links away), notes without links, links to missing pages, page hierarchy, spacing.
|
||||||
|
- **Tasks** (checklist button in the sidebar footer, `Ctrl/⌘ Shift T`, or Ctrl+K → Open tasks) lists every `- [ ]` / `- [x]` item in your notes, grouped by page. Filter by open/done, due date (`📅 2026-03-01` or `due:2026-03-01` in the task: overdue, today, next 7 days, none), tag (the page's tags and `#tags` in the task) and text. Ticking one edits the line in its file; if the line changed on disk since the list was made, nothing is written. Code blocks, boards and canvases are skipped.
|
||||||
|
- **Unlinked mentions**: under *Linked from*, "Mentioned without a link" lists pages that write this page's title in plain text (whole words, not in code, links or URLs; titles under 3 letters are ignored). **Link it** turns the first mention on that page into `[[Page]]` (or `[[Page|as written]]`).
|
||||||
|
- **Saved searches**: type a query in the palette (`tag:work in:plan status:Done`) and choose *Save this search to the sidebar*. The saved search is listed under Favorites, per notes folder; clicking it opens the palette with the query, and ✕ removes it.
|
||||||
|
- **Quick capture**: a global shortcut (default `Ctrl/⌘ Alt N`, change it in Settings → General; also Ctrl+K → Quick capture) opens a small window. Enter adds what you typed to today's note or to a top-level **Inbox** page (your choice, also in the window): a single line becomes a bullet, several lines are added as written. Some Wayland desktops don't let apps register global shortcuts; the palette command still works.
|
||||||
|
- **Daily notes: previous / next and calendar.** On a daily note the top bar shows ‹ 📅 ›: the arrows go to the nearest existing daily note before or after (past the last one: today), the calendar marks days that have a note and opens or creates any day (also Ctrl+K → *Open a daily note by date*). A template for new daily notes is in Settings → Notes (`{{date}}`, `{{weekday}}`).
|
||||||
|
- **Properties on any page** are typed by their value: `true`/`false` is a checkbox, a number a number, `YYYY-MM-DD` a date, a list a multi-select (choices come from the same property on other pages), an `https://` address a link. The icon before a property's name changes its type; **Add property** asks for the type first. Only the value is written to the front matter.
|
||||||
|
- **Tag manager** (the gear by *Tags*, a tag's right-click menu, or Ctrl+K → *Manage tags*): every tag with its page count; rename (into an existing tag it merges) or remove a tag from all pages.
|
||||||
|
- **Import**: Settings → General → *Import notes*, or Ctrl+K. An Obsidian vault or a Notion "Markdown & CSV" export (folder or .zip) goes into a new top-level folder: Notion's ids are removed from names, its relative links become `[[wiki links]]`, pictures keep working, `.obsidian` is skipped. **Clip a web page** (Ctrl+K) saves a public page's main text as a note with `source:` in its front matter. **Pasting** from a web page or rich editor keeps headings, lists, tables, links and bold as Markdown (Settings → Editor turns it off).
|
||||||
|
- **Export**: Markdown, HTML and PDF per page (page `⋯` menu); *Export all notes as a zip* (Settings → General, or Ctrl+K) writes the whole folder, without history and trash.
|
||||||
|
- **Daily notes** live under `Daily/` as `YYYY-MM-DD.md`; `Ctrl/⌘ Shift D` or **Today** in the sidebar opens or creates today's.
|
||||||
|
- **Selecting across blocks** works the way it does in a text editor: drag from one block into another (up or down; the page scrolls along at its edges), Shift+click to extend, Shift+arrows, Ctrl/⌘+Shift+arrows (words, paragraphs), Shift+Home/End and Ctrl/⌘+Shift+Home/End (start or end of the page). Delete, typing and copy then act on the whole selection.
|
||||||
|
- **Undo** (`Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z` or `Ctrl Y`) works across blocks: typing in one block is one step, and every new, deleted, converted or moved block is another.
|
||||||
|
- **Edits made elsewhere** (another editor, a sync tool, `git`) show up in the open page as soon as the file changes, unless you have unsaved edits there. If you do, and the file changed on disk too, your version is saved and the other one is first put into the page's version history, with a toast that opens it. Nothing is overwritten without a copy. (Notes folders are watched folder by folder; dot folders such as `.notes` and `.git` are ignored.)
|
||||||
|
- **Saving** is debounced (600 ms) and flushed on page switch, window blur, `Ctrl/⌘ S`, and before the window closes.
|
||||||
|
- **Security:** `contextIsolation`, `sandbox`, no `nodeIntegration`; the UI is served from a custom `app://` protocol with a hashed CSP and no network access. Fonts are bundled.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
electron/
|
||||||
|
main.cjs window, app:// and notes-media:// protocols, IPC handlers, safe close
|
||||||
|
preload.cjs window.vault bridge (the only API the page has)
|
||||||
|
vault.cjs read / write / create / rename / move / remove / watch Markdown files, save images
|
||||||
|
links.cjs find, rewrite and backlink [[page links]] on disk
|
||||||
|
canvas.cjs .canvas files on disk: read/write, text for search, keeping them in step with renames
|
||||||
|
frontmatter.cjs minimal YAML front matter
|
||||||
|
db.cjs SQLite FTS5 search index
|
||||||
|
settings.cjs theme, window size, last page (in the app's userData folder)
|
||||||
|
seed.cjs "Getting started" page for an empty folder
|
||||||
|
src/
|
||||||
|
routes/ +layout (SPA, fonts, css) and +page (the app shell)
|
||||||
|
lib/
|
||||||
|
editor.js block editor engine (plain DOM, mounted by Editor.svelte)
|
||||||
|
markdown.js Markdown <-> blocks, HTML export
|
||||||
|
links.js how a [[link]] finds its page (same rules as electron/links.cjs)
|
||||||
|
kanban.js Kanban board <-> Markdown
|
||||||
|
database.js database schema <-> Markdown, property values, filters, sorting, calendar and timeline maths
|
||||||
|
canvas.js .canvas format (same as electron/canvas.cjs), arrow and zoom geometry
|
||||||
|
graph.js force simulation for the graph view
|
||||||
|
embed.js headings, sections and read-only HTML for ![[embeds]]
|
||||||
|
scroll.js scroll a block into view and flash it (outline, heading links, footnotes)
|
||||||
|
footnotes.js number footnote references and texts on screen
|
||||||
|
state.svelte.js the store (Svelte 5 runes)
|
||||||
|
api.js / mock.js Electron bridge, browser mock
|
||||||
|
components/ Sidebar, TreeNode, Topbar, PageView, Editor, Backlinks, Board, Database, PropCell, CanvasView, FindBar, Outline, GraphView, SettingsDialog, Palette, ContextMenu, TrashDialog, ExportDialog, Toast, Welcome, WindowControls
|
||||||
|
resources/
|
||||||
|
icon.svg the app icon (source); icon.png, icon.ico and icons/NxN.png are rendered from it
|
||||||
|
scripts/icons.sh renders the icon files
|
||||||
|
test/ node --test suites
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
| ---- | ------ |
|
||||||
|
| `Ctrl/⌘ K` | Search notes and run commands |
|
||||||
|
| `Ctrl/⌘ N` | New page |
|
||||||
|
| `Ctrl/⌘ Shift D` | Today's daily note |
|
||||||
|
| `Ctrl/⌘ G` | Graph view (`Esc` goes back) |
|
||||||
|
| `Ctrl/⌘ Shift H` | Highlight the selection (`==text==`) |
|
||||||
|
| `Ctrl/⌘ Shift O` | Show / hide the page outline |
|
||||||
|
| `Ctrl/⌘ ,` | Settings: theme, text size, page width, notes folder, spellcheck |
|
||||||
|
| `Ctrl/⌘ \` | Show or hide the sidebar |
|
||||||
|
| `Ctrl/⌘ S` | Save now |
|
||||||
|
| `Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z` | Undo, redo (across blocks) |
|
||||||
|
| `Ctrl/⌘ F` | Find in page (`Enter` / `Shift Enter` to step, `Esc` to close) |
|
||||||
|
| `Ctrl/⌘ B`, `I`, `E`, `Shift S` | Bold, italic, code, strikethrough (a toolbar also appears over selected text) |
|
||||||
|
| `Ctrl/⌘ K` with text selected | Link the selection to a web address (on a link: edit it) |
|
||||||
|
| `Ctrl/⌘` click a link | Open it in the browser |
|
||||||
|
| `Tab`, `Shift Tab`, `Enter` in a table | Next / previous cell, cell below (adding a row at the end) |
|
||||||
|
| `Ctrl/⌘ D` | Duplicate the current block |
|
||||||
|
| Click `⋮⋮` | Block menu: turn into, duplicate, delete |
|
||||||
|
| `/` | Block menu |
|
||||||
|
| `[[` | Link to a page (or create one) |
|
||||||
|
| `#`, `##`, `###`, `-`, `1.`, `[]`, `>`, ```` ``` ````, `---` | Markdown shortcuts at the start of a line |
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
`bun run dist` builds for the current OS into `dist/`: an AppImage on Linux (`Ink-<version>-x86_64.AppImage`, with a desktop entry and icons), an NSIS installer on Windows (lets you pick the install folder, adds Start menu and desktop shortcuts), a dmg on macOS. Installer settings live in the `build` field of `package.json`; `resources/` holds their icons (`build/` is Vite's output, so it can't be used for that).
|
||||||
|
|
||||||
|
On Linux, docks find a window's icon through a `.desktop` file named after its app id (`ink`, from `desktopName` in `package.json`). An AppImage installs nothing itself, so when Ink runs from one it writes `~/.local/share/applications/ink.desktop` (pointing at the AppImage's current location) and `~/.local/share/icons/hicolor/*/apps/ink.png`. Delete those to remove it.
|
||||||
|
|
||||||
|
The app was called "Notes" before; on first launch Ink copies the old settings (notes folder, favorites, window size) from the `Notes` settings folder.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"name": "ink",
|
||||||
|
"productName": "Ink",
|
||||||
|
"desktopName": "ink.desktop",
|
||||||
|
"version": "1.0.0-beta",
|
||||||
|
"description": "A calm, offline Markdown notes app.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k -n web,app -c magenta,cyan \"vite dev\" \"wait-on http://localhost:5173 && node scripts/dev-app.mjs\"",
|
||||||
|
"dev:web": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"start": "vite build && electron .",
|
||||||
|
"dist": "vite build && electron-builder",
|
||||||
|
"test": "ELECTRON_RUN_AS_NODE=1 electron --test test/*.test.js",
|
||||||
|
"e2e": "node scripts/e2e.mjs",
|
||||||
|
"postinstall": "bun node_modules/electron/install.js && svelte-kit sync && electron-rebuild -f -w better-sqlite3",
|
||||||
|
"icons": "./scripts/icons.sh",
|
||||||
|
"test:e2e": "node scripts/e2e.mjs"
|
||||||
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"electron"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.9.1",
|
||||||
|
"katex": "^0.19.0",
|
||||||
|
"mermaid": "^12.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron/rebuild": "^3.7.1",
|
||||||
|
"@fontsource-variable/inter": "^5.2.5",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.5",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
|
"@sveltejs/kit": "^2.20.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"electron": "^35.1.2",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"svelte": "^5.25.3",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
"wait-on": "^8.0.3"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "app.ink.desktop",
|
||||||
|
"productName": "Ink",
|
||||||
|
"copyright": "Copyright © 2026",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist",
|
||||||
|
"buildResources": "resources"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build/**/*",
|
||||||
|
"electron/**/*",
|
||||||
|
"resources/icons/*.png",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/better-sqlite3/**/*"
|
||||||
|
],
|
||||||
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.productivity",
|
||||||
|
"icon": "resources/icon.png",
|
||||||
|
"target": [
|
||||||
|
"dmg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dmg": {
|
||||||
|
"title": "${productName} ${version}"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"icon": "resources/icon.ico",
|
||||||
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "Ink",
|
||||||
|
"installerIcon": "resources/icon.ico",
|
||||||
|
"uninstallerIcon": "resources/icon.ico",
|
||||||
|
"uninstallDisplayName": "Ink",
|
||||||
|
"deleteAppDataOnUninstall": false
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "resources/icons",
|
||||||
|
"target": [
|
||||||
|
"AppImage"
|
||||||
|
],
|
||||||
|
"category": "Office",
|
||||||
|
"executableName": "ink",
|
||||||
|
"synopsis": "A calm, offline Markdown notes app",
|
||||||
|
"desktop": {
|
||||||
|
"Name": "Ink",
|
||||||
|
"Comment": "A calm, offline Markdown notes app",
|
||||||
|
"Keywords": "notes;markdown;writing;kanban;journal;",
|
||||||
|
"StartupWMClass": "ink"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 492 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<!-- Ink app icon: a blush fountain-pen nib on the app's neutral dark ground.
|
||||||
|
Source for every size in resources/ (run `bun run icons` after editing). -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2a2a2d"/>
|
||||||
|
<stop offset="1" stop-color="#121213"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.45">
|
||||||
|
<stop offset="0" stop-color="#e4ada5" stop-opacity="0.22"/>
|
||||||
|
<stop offset="1" stop-color="#e4ada5" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="nib" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#f7d3c9"/>
|
||||||
|
<stop offset="0.6" stop-color="#e4ada5"/>
|
||||||
|
<stop offset="1" stop-color="#c97a6d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ffffff" stop-opacity="0.14"/>
|
||||||
|
<stop offset="1" stop-color="#ffffff" stop-opacity="0.02"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="tileShadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000" flood-opacity="0.45"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="nibShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#0b0807" flood-opacity="0.55"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="grain" x="0" y="0" width="100%" height="100%">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.9 0"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="190"/></clipPath>
|
||||||
|
<clipPath id="nibShape">
|
||||||
|
<path d="M330 340 C330 296 362 266 406 266 H618 C662 266 694 296 694 340 C694 448 646 528 598 600 C560 656 530 724 512 806 C494 724 464 656 426 600 C378 528 330 448 330 340 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- tile -->
|
||||||
|
<g filter="url(#tileShadow)">
|
||||||
|
<rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="100" y="100" width="824" height="824" fill="url(#glow)" clip-path="url(#tile)"/>
|
||||||
|
<!-- the same fine grain as the app's background; also hides gradient banding -->
|
||||||
|
<rect x="100" y="100" width="824" height="824" filter="url(#grain)" opacity="0.07" clip-path="url(#tile)"/>
|
||||||
|
<rect x="101.5" y="101.5" width="821" height="821" rx="188.5" fill="none" stroke="url(#rim)" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- nib -->
|
||||||
|
<g filter="url(#nibShadow)">
|
||||||
|
<path d="M330 340 C330 296 362 266 406 266 H618 C662 266 694 296 694 340 C694 448 646 528 598 600 C560 656 530 724 512 806 C494 724 464 656 426 600 C378 528 330 448 330 340 Z" fill="url(#nib)"/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#nibShape)">
|
||||||
|
<!-- right half a little darker, so the nib reads as two folded tines -->
|
||||||
|
<rect x="512" y="250" width="200" height="580" fill="#5a2a22" opacity="0.16"/>
|
||||||
|
<!-- collar where the nib meets the pen -->
|
||||||
|
<rect x="320" y="250" width="400" height="92" fill="#5a2a22" opacity="0.22"/>
|
||||||
|
<rect x="320" y="340" width="400" height="5" fill="#fff6f0" opacity="0.35"/>
|
||||||
|
<!-- slit and breather hole -->
|
||||||
|
<path d="M512 520 V800" stroke="#2a1a16" stroke-width="15" stroke-linecap="round"/>
|
||||||
|
<circle cx="512" cy="500" r="30" fill="#2a1a16"/>
|
||||||
|
<!-- shine on the left tine -->
|
||||||
|
<path d="M378 378 C382 444 408 506 446 562" stroke="#fffaf6" stroke-width="16" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 663 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
// Timing of the vault-side work on a big synthetic vault: node scripts/bench.cjs [notes=10000]
|
||||||
|
// Run with the Electron binary as node so better-sqlite3 loads: ELECTRON_RUN_AS_NODE=1 electron scripts/bench.cjs
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { Vault } = require('../electron/vault.cjs');
|
||||||
|
const { openDb } = require('../electron/db.cjs');
|
||||||
|
const links = require('../electron/links.cjs');
|
||||||
|
const tasks = require('../electron/tasks.cjs');
|
||||||
|
|
||||||
|
const N = Number(process.argv[2]) || 10000;
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-bench-'));
|
||||||
|
const words = 'alpha beta gamma delta project roadmap meeting notes idea review plan budget design draft summary weekly sync'.split(' ');
|
||||||
|
const rnd = (n) => Math.floor(Math.random() * n);
|
||||||
|
const sentence = () => Array.from({ length: 8 + rnd(10) }, () => words[rnd(words.length)]).join(' ');
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const folder = path.join(dir, `Area ${i % 40}`, `Topic ${i % 400}`);
|
||||||
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
let body = `# Note ${i}\n\n`;
|
||||||
|
for (let p = 0; p < 6; p++) body += sentence() + (rnd(3) === 0 ? ` [[Note ${rnd(N)}]]` : '') + '.\n\n';
|
||||||
|
body += `- [ ] task ${i} #work\n`;
|
||||||
|
fs.writeFileSync(path.join(folder, `Note ${i}.md`), `---\ntags: [t${i % 50}]\n---\n\n${body}`);
|
||||||
|
}
|
||||||
|
console.log(`generated ${N} notes in ${Date.now() - t0} ms`);
|
||||||
|
|
||||||
|
const time = async (label, fn) => { const s = process.hrtime.bigint(); const r = await fn(); console.log(label.padEnd(34), String(Number(process.hrtime.bigint() - s) / 1e6 | 0).padStart(7), 'ms'); return r; };
|
||||||
|
(async () => {
|
||||||
|
const v = new Vault(dir, { trash: async () => {} });
|
||||||
|
const pages = await time('vault.list (cold)', () => v.list());
|
||||||
|
await time('vault.list (warm cache)', () => v.list());
|
||||||
|
const db = openDb(path.join(dir, 'index.sqlite'));
|
||||||
|
await time('db.rebuild', async () => db.rebuild(pages));
|
||||||
|
await time('db.search "roadmap budget"', async () => db.search('roadmap budget'));
|
||||||
|
await time('links.graph', async () => links.graph(pages));
|
||||||
|
const target = pages.find((p) => p.title === 'Note 5').id;
|
||||||
|
await time('links.backlinks (one page)', async () => links.backlinks(target, pages));
|
||||||
|
await time('links.unlinked (one page)', async () => links.unlinked(target, pages));
|
||||||
|
await time('tasks.findTasks', async () => tasks.findTasks(pages));
|
||||||
|
await time('JSON size of page list (no body)', async () => JSON.stringify(pages.map(({ body, ...p }) => p)).length);
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
})();
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// `bun run dev` runs this: Electron with --dev, restarted whenever a file in electron/ changes.
|
||||||
|
// (Vite hot-reloads the page by itself, but the main process only loads its code once.)
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { watch } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const electron = path.join(root, 'node_modules', '.bin', 'electron');
|
||||||
|
let child = null;
|
||||||
|
let restarting = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
child = spawn(electron, ['.', '--dev', ...process.argv.slice(2)], { cwd: root, stdio: 'inherit' });
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
if (restarting) { restarting = false; start(); return; }
|
||||||
|
process.exit(code ?? 0); // the window was closed: stop dev mode, as before
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(path.join(root, 'electron'), { recursive: true }, (_event, file) => {
|
||||||
|
if (!file || !/\.(c?js|json)$/.test(file)) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
console.log(`[app] ${file} changed, restarting Electron`);
|
||||||
|
restarting = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { restarting = false; child?.kill(sig); });
|
||||||
|
start();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Runs the end-to-end suites. No arguments: all of them, in parallel. Names: only those, e.g. `bun run e2e canvas tags`.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const electron = path.join(root, 'node_modules/.bin/electron');
|
||||||
|
const want = process.argv.slice(2);
|
||||||
|
const all = readdirSync(path.join(root, 'test')).filter((f) => /^e2e.*\.test\.cjs$/.test(f)).sort();
|
||||||
|
const files = want.length ? all.filter((f) => want.some((w) => f === `e2e-${w}.test.cjs` || f === `e2e.test.cjs` && w === 'e2e')) : all;
|
||||||
|
if (!files.length) { console.error(`No suite matches: ${want.join(', ')}`); process.exit(2); }
|
||||||
|
|
||||||
|
// Timing-sensitive suites (they measure frame rate) run alone, after the rest, so the others can't slow them down.
|
||||||
|
const SERIAL = ['e2e-board-drag.test.cjs'];
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const run = (file) => new Promise((resolve) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const child = spawn(electron, [path.join('test', file), '--ozone-platform=headless'], { cwd: root });
|
||||||
|
let out = '';
|
||||||
|
child.stdout.on('data', (d) => (out += d));
|
||||||
|
child.stderr.on('data', (d) => (out += d));
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const lines = out.split('\n').filter((l) => /^(PASS|FAIL)|passed!|failed|harness/.test(l) && !/Fontconfig/.test(l));
|
||||||
|
const fails = lines.filter((l) => l.startsWith('FAIL') || /failed|harness/.test(l));
|
||||||
|
resolve({ file, code, secs: ((Date.now() - t0) / 1000).toFixed(0), fails, checks: lines.filter((l) => l.startsWith('PASS')).length });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const parallel = files.filter((f) => !SERIAL.includes(f));
|
||||||
|
const results = await Promise.all(parallel.map(run));
|
||||||
|
for (const f of files.filter((f) => SERIAL.includes(f))) results.push(await run(f));
|
||||||
|
for (const r of results) {
|
||||||
|
const ok = r.code === 0;
|
||||||
|
console.log(`${ok ? 'ok ' : 'FAIL'} ${r.file.padEnd(28)} ${String(r.checks).padStart(3)} checks ${r.secs}s`);
|
||||||
|
if (!ok) for (const l of r.fails) console.log(' ' + l);
|
||||||
|
}
|
||||||
|
console.log(`\n${results.filter((r) => r.code === 0).length}/${results.length} suites passed in ${((Date.now() - started) / 1000).toFixed(0)}s`);
|
||||||
|
process.exit(results.every((r) => r.code === 0) ? 0 : 1);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Render resources/icon.svg into every icon file the app and its installers use.
|
||||||
|
# Needs rsvg-convert (librsvg) and ImageMagick. The results are committed, so building doesn't need these.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../resources"
|
||||||
|
mkdir -p icons
|
||||||
|
for s in 16 24 32 48 64 128 256 512 1024; do
|
||||||
|
rsvg-convert -w $s -h $s icon.svg -o icons/${s}x${s}.png
|
||||||
|
done
|
||||||
|
cp icons/1024x1024.png icon.png # electron-builder: macOS .icns is made from this
|
||||||
|
magick icons/16x16.png icons/24x24.png icons/32x32.png icons/48x48.png \
|
||||||
|
icons/64x64.png icons/128x128.png icons/256x256.png icon.ico # Windows
|
||||||
|
rm icons/1024x1024.png # Linux sizes stop at 512
|
||||||
|
cp icon.svg ../static/icon.svg # the welcome screen shows it
|
||||||
|
cp icons/64x64.png ../static/favicon.png
|
||||||
|
echo "icons written to resources/ and static/"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// How the app feels on a big vault. Not part of `bun run e2e` (it is slow): electron scripts/perf-large.cjs [notes=10000]
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const N = Number(process.argv.find((a) => /^\d+$/.test(a))) || 10000;
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-perf-'));
|
||||||
|
const vault = path.join(tmp, 'Vault');
|
||||||
|
const words = 'alpha beta gamma delta project roadmap meeting notes idea review plan budget design draft summary weekly sync'.split(' ');
|
||||||
|
const rnd = (n) => Math.floor(Math.random() * n);
|
||||||
|
const sentence = () => Array.from({ length: 8 + rnd(10) }, () => words[rnd(words.length)]).join(' ');
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const folder = path.join(vault, `Area ${i % 40}`);
|
||||||
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
let body = `# Note ${i}\n\n`;
|
||||||
|
for (let p = 0; p < 6; p++) body += sentence() + (rnd(3) === 0 ? ` [[Note ${rnd(N)}]]` : '') + '.\n\n';
|
||||||
|
fs.writeFileSync(path.join(folder, `Note ${i}.md`), `---\ntags: [t${i % 50}]\n---\n\n${body}`);
|
||||||
|
}
|
||||||
|
for (let a = 0; a < 40; a++) fs.writeFileSync(path.join(vault, `Area ${a}.md`), `Area ${a}\n`);
|
||||||
|
app.setPath('userData', tmp);
|
||||||
|
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Area 1', theme: 'dark', bounds: { width: 1300, height: 850 } }));
|
||||||
|
const started = Date.now();
|
||||||
|
require('../electron/main.cjs');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
let win;
|
||||||
|
for (let i = 0; i < 200 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(50);
|
||||||
|
const wc = win.webContents;
|
||||||
|
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
|
||||||
|
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
|
||||||
|
const report = (label, ms, extra = '') => console.log(`${label.padEnd(40)} ${String(Math.round(ms)).padStart(6)} ms ${extra}`);
|
||||||
|
// frames the page could not draw: longest gap between animation frames over a period
|
||||||
|
const jank = (ms) => js(`let last = performance.now(), worst = 0; const end = last + ${ms}; await new Promise((res) => { const f = (t) => { worst = Math.max(worst, t - last); last = t; t < end ? requestAnimationFrame(f) : res(); }; requestAnimationFrame(f); }); return worst`);
|
||||||
|
const timeIn = (code) => js(`const t = performance.now(); ${code}; return performance.now() - t`);
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 300; i++) { if (await js(`return !!document.querySelector('.tree .row')`)) break; await sleep(100); }
|
||||||
|
report('launch to sidebar shown', Date.now() - started);
|
||||||
|
report('sidebar rows (folded)', 0, String(await js(`return document.querySelectorAll('.tree .row').length`)));
|
||||||
|
// open every top-level folder
|
||||||
|
const t0 = Date.now();
|
||||||
|
await js(`for (const r of document.querySelectorAll('.tree > .node > .row')) { r.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); }`);
|
||||||
|
await sleep(200);
|
||||||
|
report('open all 40 folders', Date.now() - t0, 'rows now: ' + (await js(`return document.querySelectorAll('.tree .row').length`)));
|
||||||
|
report('worst frame gap after that (1s)', await jank(1000));
|
||||||
|
// open a page that has many backlinks / mentions
|
||||||
|
const o = Date.now();
|
||||||
|
await js(`document.querySelector('.tree .row[data-id="Area 2/Note 2"]')?.click()`);
|
||||||
|
for (let i = 0; i < 100; i++) { if ((await js(`return document.querySelector('.title')?.textContent`)) === 'Note 2') break; await sleep(20); }
|
||||||
|
report('open a page', Date.now() - o);
|
||||||
|
// search
|
||||||
|
const s = Date.now();
|
||||||
|
await js(`document.querySelector('.sidebar button[aria-label="Search"]').click()`);
|
||||||
|
await sleep(100);
|
||||||
|
await js(`const i = document.querySelector('.pal-top input'); i.value = 'roadmap budget'; i.dispatchEvent(new Event('input', { bubbles: true }))`);
|
||||||
|
for (let i = 0; i < 100; i++) { if (await js(`return document.querySelectorAll('.pal-list .pi').length > 3`)) break; await sleep(20); }
|
||||||
|
report('search shows results', Date.now() - s);
|
||||||
|
await js(`document.querySelector('.overlay')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))`);
|
||||||
|
await sleep(400);
|
||||||
|
// graph
|
||||||
|
const g = Date.now();
|
||||||
|
await js(`document.querySelector('.sidebar button[aria-label="Graph view"]').click()`);
|
||||||
|
for (let i = 0; i < 200; i++) { if (await js(`return !!document.querySelector('canvas, .graph svg, .gv')`)) break; await sleep(25); }
|
||||||
|
report('graph view appears', Date.now() - g);
|
||||||
|
report('worst frame gap while it settles (3s)', await jank(3000));
|
||||||
|
await js(`document.querySelector('.sidebar button[aria-label="Graph view"]').click()`);
|
||||||
|
await sleep(500);
|
||||||
|
// tasks list
|
||||||
|
const k = Date.now();
|
||||||
|
await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`);
|
||||||
|
for (let i = 0; i < 100; i++) { if (await js(`return !document.querySelector('.tasks-panel .trash-empty') || false`)) break; await sleep(20); }
|
||||||
|
report('tasks dialog', Date.now() - k);
|
||||||
|
} catch (err) {
|
||||||
|
console.log('harness', err.stack);
|
||||||
|
}
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const { run } = require('node:test');
|
||||||
|
const { spec } = require('node:test/reporters');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { app } = require('electron');
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
const dir = path.join(__dirname, '../test');
|
||||||
|
const files = fs.readdirSync(dir)
|
||||||
|
.filter(f => f.endsWith('.test.js'))
|
||||||
|
.map(f => path.join(dir, f));
|
||||||
|
|
||||||
|
const stream = run({ files });
|
||||||
|
stream.on('test:fail', () => { process.exitCode = 1; });
|
||||||
|
stream.compose(new spec()).pipe(process.stdout);
|
||||||
|
|
||||||
|
stream.on('end', () => app.quit());
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<title>Ink</title>
|
||||||
|
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="off">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<!-- Ink app icon: a blush fountain-pen nib on the app's neutral dark ground.
|
||||||
|
Source for every size in resources/ (run `bun run icons` after editing). -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2a2a2d"/>
|
||||||
|
<stop offset="1" stop-color="#121213"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.45">
|
||||||
|
<stop offset="0" stop-color="#e4ada5" stop-opacity="0.22"/>
|
||||||
|
<stop offset="1" stop-color="#e4ada5" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="nib" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#f7d3c9"/>
|
||||||
|
<stop offset="0.6" stop-color="#e4ada5"/>
|
||||||
|
<stop offset="1" stop-color="#c97a6d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ffffff" stop-opacity="0.14"/>
|
||||||
|
<stop offset="1" stop-color="#ffffff" stop-opacity="0.02"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="tileShadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000" flood-opacity="0.45"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="nibShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#0b0807" flood-opacity="0.55"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="grain" x="0" y="0" width="100%" height="100%">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.9 0"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="190"/></clipPath>
|
||||||
|
<clipPath id="nibShape">
|
||||||
|
<path d="M330 340 C330 296 362 266 406 266 H618 C662 266 694 296 694 340 C694 448 646 528 598 600 C560 656 530 724 512 806 C494 724 464 656 426 600 C378 528 330 448 330 340 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- tile -->
|
||||||
|
<g filter="url(#tileShadow)">
|
||||||
|
<rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="100" y="100" width="824" height="824" fill="url(#glow)" clip-path="url(#tile)"/>
|
||||||
|
<!-- the same fine grain as the app's background; also hides gradient banding -->
|
||||||
|
<rect x="100" y="100" width="824" height="824" filter="url(#grain)" opacity="0.07" clip-path="url(#tile)"/>
|
||||||
|
<rect x="101.5" y="101.5" width="821" height="821" rx="188.5" fill="none" stroke="url(#rim)" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- nib -->
|
||||||
|
<g filter="url(#nibShadow)">
|
||||||
|
<path d="M330 340 C330 296 362 266 406 266 H618 C662 266 694 296 694 340 C694 448 646 528 598 600 C560 656 530 724 512 806 C494 724 464 656 426 600 C378 528 330 448 330 340 Z" fill="url(#nib)"/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#nibShape)">
|
||||||
|
<!-- right half a little darker, so the nib reads as two folded tines -->
|
||||||
|
<rect x="512" y="250" width="200" height="580" fill="#5a2a22" opacity="0.16"/>
|
||||||
|
<!-- collar where the nib meets the pen -->
|
||||||
|
<rect x="320" y="250" width="400" height="92" fill="#5a2a22" opacity="0.22"/>
|
||||||
|
<rect x="320" y="340" width="400" height="5" fill="#fff6f0" opacity="0.35"/>
|
||||||
|
<!-- slit and breather hole -->
|
||||||
|
<path d="M512 520 V800" stroke="#2a1a16" stroke-width="15" stroke-linecap="round"/>
|
||||||
|
<circle cx="512" cy="500" r="30" fill="#2a1a16"/>
|
||||||
|
<!-- shine on the left tine -->
|
||||||
|
<path d="M378 378 C382 444 408 506 446 562" stroke="#fffaf6" stroke-width="16" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
export default {
|
||||||
|
kit: {
|
||||||
|
// Electron serves build/ through the app:// protocol, so the app is a pure SPA.
|
||||||
|
adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', strict: false }),
|
||||||
|
csp: {
|
||||||
|
mode: 'hash',
|
||||||
|
directives: {
|
||||||
|
'default-src': ['self'],
|
||||||
|
'script-src': ['self'],
|
||||||
|
'style-src': ['self', 'unsafe-inline'],
|
||||||
|
'font-src': ['self', 'data:'],
|
||||||
|
'img-src': ['self', 'data:', 'blob:', 'notes-media:'],
|
||||||
|
'connect-src': ['self', 'ws://localhost:5173', 'http://localhost:5173']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
server: { port: 5173, strictPort: true },
|
||||||
|
build: { target: 'chrome130' }
|
||||||
|
});
|
||||||