Initial Ink Layer release

This commit is contained in:
Sirwan Afifi 2026-07-16 12:33:33 +01:00
commit 564f2b4af1
26 changed files with 4862 additions and 0 deletions

25
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci
- name: Test and build
run: npm run check

36
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Release
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci
- name: Test and build
run: npm run check
- name: Validate tag
run: node scripts/check-release.mjs "$GITHUB_REF_NAME"
- name: Publish release assets
env:
GH_TOKEN: ${{ github.token }}
run: >-
gh release create "$GITHUB_REF_NAME"
main.js manifest.json styles.css
--verify-tag
--title "Ink Layer $GITHUB_REF_NAME"
--generate-notes

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
main.js
*.map
.DS_Store
coverage/

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

11
CHANGELOG.md Normal file
View file

@ -0,0 +1,11 @@
# Changelog
All notable changes to Ink Layer are documented here.
## 0.1.0 — 2026-07-16
- Initial public release.
- Added pressure-sensitive pen and translucent highlighter tools.
- Added palm rejection, finger scrolling, stroke erasing, and lasso selection.
- Added undo, redo, color and size controls, theme-aware rendering, and SVG export.
- Added per-note persistence across Source and Reading views, split panes, and note renames.

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sirwan Afifi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

79
README.md Normal file
View file

@ -0,0 +1,79 @@
# Ink Layer
Ink Layer adds a pressure-sensitive handwriting layer to Markdown notes in Obsidian. It is designed around Apple Pencil on iPad, while still supporting other pens and an optional mouse workflow on desktop.
## Installation
### BRAT
Until Ink Layer is approved for the Obsidian Community directory, the easiest installation method is [BRAT](https://github.com/TfTHacker/obsidian42-brat):
1. Install and enable BRAT.
2. Run **BRAT: Add a beta plugin for testing**.
3. Enter `https://github.com/SirwanAfifi/ink-layer`.
4. Enable **Ink Layer** in **Settings → Community plugins**.
### Manual installation
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/SirwanAfifi/ink-layer/releases/latest).
2. Create `<vault>/.obsidian/plugins/ink-layer/`.
3. Copy the three files into that folder, then reload Obsidian.
4. Enable **Ink Layer** in **Settings → Community plugins**.
## Experience
- Pressure-sensitive, smoothed pen strokes using coalesced pointer samples when the WebView provides them.
- A low-latency wet/dry canvas split, so drawing a new stroke does not repaint the full note on every sample.
- A translucent highlighter, whole-stroke eraser, and lasso selection with drag-to-move and delete.
- Pencil-first input: Apple Pencil draws while a finger continues to scroll by default.
- Palm rejection while a pen is active or has just been detected in proximity.
- Undo and redo from the toolbar, command palette, or `Cmd/Ctrl+Z` while the ink layer has focus.
- Ink remains visible in Source and Reading views, split panes, and after drawing mode closes.
- Per-note persistence that follows a note when it is renamed.
- Theme-adaptive pen color and an SVG export command.
- Large, safe-area-aware controls for iPad.
## Use it
1. Open a Markdown note.
2. Select the pen icon in the ribbon, or run **Toggle ink mode** from the command palette.
3. Write with Apple Pencil. With the defaults, a finger scrolls and does not draw.
4. Choose Pen, Highlighter, Eraser, or Lasso from the floating toolbar. The palette button changes the active tools color and size without leaving the note.
5. Select **Done** to return the note to normal interaction. Saved ink stays visible.
The command **Switch between pen and eraser** is intended for a hardware shortcut or hotkey. Standard pen eraser/barrel-button events are recognized automatically when iPadOS reports them to the WebView.
## Storage and privacy
Ink is stored compactly in the plugins Obsidian-managed `data.json`, keyed by note path. No note text is changed, no network requests are made, and there is no telemetry. Obsidian Sync users should include installed community-plugin settings if they want the ink data synchronized.
The SVG export command writes a normal attachment into the vault using the configured attachment location.
## Platform notes
Ink Layer uses the web-standard `PointerEvent` pen type, normalized pressure, tilt samples, pointer capture, and coalesced events. Apple Pencil double-tap and Pencil Pro squeeze are not exposed consistently to community plugins by the iPad WebView, so the plugin does not claim to intercept those private hardware gestures. The pen/eraser command can be mapped wherever Obsidian or iPadOS exposes a configurable shortcut.
Full-note ink uses the notes rendered scroll coordinates. Major text edits, font changes, or width changes can reflow Markdown underneath existing strokes. Export important handwriting to SVG before radically changing the note layout.
## Development
```sh
npm install
npm run check
```
For development, clone the repository into `.obsidian/plugins/ink-layer`, run `npm run dev`, and reload Obsidian. A release contains `main.js`, `manifest.json`, and `styles.css`.
The implementation is mobile-safe: it uses Obsidians Vault/plugin APIs and does not import Node or Electron APIs at runtime.
Issues and feature requests are tracked on [GitHub](https://github.com/SirwanAfifi/ink-layer/issues).
## References
- [Obsidian plugin API and mobile review guidance](https://docs.obsidian.md/oo/plugin)
- [Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)
- [Coalesced pointer events](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/getCoalescedEvents)
## License
MIT

26
esbuild.config.mjs Normal file
View file

@ -0,0 +1,26 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const production = process.argv[2] === "production";
const context = await esbuild.context({
banner: { js: "/* Ink Layer for Obsidian */" },
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian", "electron", "@codemirror/*", "@lezer/*", ...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: production ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: production
});
if (production) {
await context.rebuild();
await context.dispose();
} else {
await context.watch();
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "ink-layer",
"name": "Ink Layer",
"version": "0.1.0",
"minAppVersion": "1.7.2",
"description": "Pressure-sensitive handwriting and annotation layers designed for Apple Pencil and other pens.",
"author": "Sirwan Afifi",
"authorUrl": "https://github.com/SirwanAfifi",
"isDesktopOnly": false
}

2270
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "ink-layer",
"version": "0.1.0",
"description": "Pencil-first, pressure-sensitive handwriting layers for Obsidian.",
"private": true,
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest",
"validate:release": "node scripts/check-release.mjs",
"check": "npm run test && npm run build && npm run validate:release",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"apple-pencil",
"handwriting",
"ink"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/SirwanAfifi/ink-layer.git"
},
"bugs": {
"url": "https://github.com/SirwanAfifi/ink-layer/issues"
},
"homepage": "https://github.com/SirwanAfifi/ink-layer#readme",
"dependencies": {
"perfect-freehand": "^1.2.3"
},
"devDependencies": {
"@types/node": "^24.0.0",
"builtin-modules": "^5.0.0",
"esbuild": "^0.28.1",
"obsidian": "^1.13.1",
"tslib": "^2.8.1",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
}
}

37
scripts/check-release.mjs Normal file
View file

@ -0,0 +1,37 @@
import { access, readFile } from "node:fs/promises";
import process from "node:process";
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const manifest = JSON.parse(await readFile("manifest.json", "utf8"));
const versions = JSON.parse(await readFile("versions.json", "utf8"));
const requestedTag = process.argv[2];
const errors = [];
if (packageJson.version !== manifest.version) {
errors.push(`package.json is ${packageJson.version}, but manifest.json is ${manifest.version}`);
}
if (versions[manifest.version] !== manifest.minAppVersion) {
errors.push(
`versions.json must map ${manifest.version} to minimum Obsidian ${manifest.minAppVersion}`
);
}
if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) {
errors.push(`manifest version must use x.y.z format: ${manifest.version}`);
}
if (requestedTag && requestedTag !== manifest.version) {
errors.push(`release tag ${requestedTag} does not match manifest version ${manifest.version}`);
}
for (const filename of ["main.js", "manifest.json", "styles.css"]) {
try {
await access(filename);
} catch {
errors.push(`missing release asset: ${filename}`);
}
}
if (errors.length > 0) {
throw new Error(`Release validation failed:\n- ${errors.join("\n- ")}`);
}
process.stdout.write(`Release ${manifest.version} is consistent and complete.\n`);

743
src/controller.ts Normal file
View file

@ -0,0 +1,743 @@
import { MarkdownView, Menu, setIcon } from "obsidian";
import {
cloneStrokes,
combinedBounds,
createInkPoint,
createStroke,
hitTestStroke,
pointInBounds,
selectStrokesInPolygon,
simplifyPoints,
translateStrokes
} from "./model";
import { exportStrokesToSvg, InkRenderer, type InkRenderState } from "./render";
import type { InkStore } from "./storage";
import type { InkDocument, InkPoint, InkSettings, InkStroke, InkTool, Point2D } from "./types";
const HISTORY_LIMIT = 50;
const PALM_REJECTION_WINDOW_MS = 900;
type GestureMode = "draw" | "erase" | "lasso" | "move-selection" | null;
export interface InkControllerCallbacks {
onActiveChange(controller: InkController, active: boolean): void;
onToolChange(controller: InkController, tool: InkTool): void;
onDocumentChange(controller: InkController, notePath: string): void;
}
export class InkController {
private readonly host: HTMLElement;
private readonly layer: HTMLDivElement;
private readonly dryCanvas: HTMLCanvasElement;
private readonly canvas: HTMLCanvasElement;
private readonly toolbar: HTMLDivElement;
private readonly renderer: InkRenderer;
private readonly toolButtons = new Map<InkTool, HTMLButtonElement>();
private readonly undoButton: HTMLButtonElement;
private readonly redoButton: HTMLButtonElement;
private readonly deleteButton: HTMLButtonElement;
private readonly paletteButton: HTMLButtonElement;
private readonly resizeObserver: ResizeObserver;
private readonly mutationObserver: MutationObserver;
private scroller: HTMLElement;
private document: InkDocument;
private active = false;
private tool: InkTool = "pen";
private draft: InkStroke | null = null;
private selectedIds = new Set<string>();
private lassoPoints: Point2D[] = [];
private eraserPoint: Point2D | null = null;
private activePointerId: number | null = null;
private activePointerType = "";
private gestureMode: GestureMode = null;
private gestureBefore: InkStroke[] | null = null;
private gestureChanged = false;
private dragPrevious: Point2D | null = null;
private lastPenSignal = 0;
private undoStack: InkStroke[][] = [];
private redoStack: InkStroke[][] = [];
private animationFrame: number | null = null;
private dryLayerDirty = true;
private destroyed = false;
private readonly handlePointerDown = (event: PointerEvent): void => this.onPointerDown(event);
private readonly handlePointerMove = (event: PointerEvent): void => this.onPointerMove(event);
private readonly handlePointerUp = (event: PointerEvent): void => this.onPointerUp(event);
private readonly handlePointerCancel = (event: PointerEvent): void => this.onPointerUp(event);
private readonly handlePointerLeave = (event: PointerEvent): void => this.onPointerLeave(event);
private readonly handleScroll = (): void => {
this.dryLayerDirty = true;
this.scheduleRender();
};
private readonly handleKeyDown = (event: KeyboardEvent): void => this.onKeyDown(event);
constructor(
readonly view: MarkdownView,
private readonly store: InkStore,
private readonly callbacks: InkControllerCallbacks
) {
this.host = view.contentEl;
this.host.classList.add("ink-layer-host");
this.layer = document.createElement("div");
this.layer.className = "ink-layer";
this.dryCanvas = document.createElement("canvas");
this.dryCanvas.className = "ink-layer-canvas ink-layer-canvas-dry";
this.dryCanvas.setAttribute("aria-hidden", "true");
this.layer.appendChild(this.dryCanvas);
this.canvas = document.createElement("canvas");
this.canvas.className = "ink-layer-canvas";
this.canvas.setAttribute("aria-hidden", "true");
this.layer.appendChild(this.canvas);
this.toolbar = document.createElement("div");
this.toolbar.className = "ink-layer-toolbar";
this.toolbar.setAttribute("role", "toolbar");
this.toolbar.setAttribute("aria-label", "Ink tools");
this.toolbar.setAttribute("aria-hidden", "false");
this.layer.appendChild(this.toolbar);
this.addToolButton("pen", "pen-tool", "Pen");
this.addToolButton("highlighter", "highlighter", "Highlighter");
this.addToolButton("eraser", "eraser", "Stroke eraser");
this.addToolButton("lasso", "lasso", "Lasso select");
this.paletteButton = this.addActionButton("palette", "Color and size", (event) => this.openToolOptions(event));
this.addDivider();
this.undoButton = this.addActionButton("undo-2", "Undo ink", () => this.undo());
this.redoButton = this.addActionButton("redo-2", "Redo ink", () => this.redo());
this.deleteButton = this.addActionButton("trash-2", "Delete selection", () => this.deleteSelection());
this.addDivider();
this.addActionButton("check", "Done drawing", () => this.setActive(false));
this.host.appendChild(this.layer);
this.renderer = new InkRenderer(this.dryCanvas, this.canvas, this.host);
this.scroller = this.findScroller();
this.document = this.store.getDocument(this.view.file?.path ?? "");
this.host.addEventListener("pointerdown", this.handlePointerDown, { capture: true });
this.host.addEventListener("pointermove", this.handlePointerMove, { capture: true });
this.host.addEventListener("pointerup", this.handlePointerUp, { capture: true });
this.host.addEventListener("pointercancel", this.handlePointerCancel, { capture: true });
this.host.addEventListener("pointerleave", this.handlePointerLeave, { capture: true });
this.host.addEventListener("keydown", this.handleKeyDown, { capture: true });
this.scroller.addEventListener("scroll", this.handleScroll, { passive: true });
this.resizeObserver = new ResizeObserver(() => {
this.dryLayerDirty = true;
this.scheduleRender();
});
this.resizeObserver.observe(this.host);
if (this.scroller !== this.host) this.resizeObserver.observe(this.scroller);
this.mutationObserver = new MutationObserver(() => {
this.refreshScroller();
this.dryLayerDirty = true;
this.scheduleRender();
});
this.mutationObserver.observe(this.host, { childList: true, subtree: true });
this.refreshSettings();
this.updateToolbar();
this.scheduleRender();
}
get isActive(): boolean {
return this.active;
}
get currentTool(): InkTool {
return this.tool;
}
get notePath(): string {
return this.document.notePath;
}
get hasInk(): boolean {
return this.document.strokes.length > 0;
}
exportSvg(): string | null {
return exportStrokesToSvg(this.document.strokes, this.host);
}
setActive(active: boolean): void {
if (this.active === active || this.destroyed) return;
if (!active && this.activePointerId !== null) this.finishGesture();
this.active = active;
this.layer.classList.toggle("is-active", active);
this.host.classList.toggle("ink-layer-input-active", active);
this.updateVisibility();
this.callbacks.onActiveChange(this, active);
this.scheduleRender();
}
toggleActive(): void {
this.setActive(!this.active);
}
setTool(tool: InkTool): void {
if (this.tool === tool) return;
this.tool = tool;
if (tool !== "lasso") this.selectedIds.clear();
this.eraserCursorForTool();
this.updateToolbar();
this.callbacks.onToolChange(this, tool);
this.scheduleRender();
}
refreshSettings(): void {
const position = this.settings.toolbarPosition;
this.layer.classList.toggle("toolbar-at-bottom", position === "bottom");
this.dryLayerDirty = true;
this.updateVisibility();
this.scheduleRender();
}
reloadDocument(): void {
const path = this.view.file?.path ?? "";
if (path === this.document.notePath) return;
this.cancelGesture();
this.document = this.store.getDocument(path);
this.dryLayerDirty = true;
this.undoStack = [];
this.redoStack = [];
this.selectedIds.clear();
this.updateToolbar();
this.updateVisibility();
this.scheduleRender();
}
refreshDocumentFromStore(notePath: string): void {
if (notePath !== this.document.notePath || this.activePointerId !== null) return;
this.document = this.store.getDocument(notePath);
this.dryLayerDirty = true;
this.selectedIds.clear();
this.updateToolbar();
this.updateVisibility();
this.scheduleRender();
}
undo(): void {
if (this.undoStack.length === 0 || this.activePointerId !== null) return;
const previous = this.undoStack.pop();
if (!previous) return;
this.redoStack.push(cloneStrokes(this.document.strokes));
this.document.strokes = cloneStrokes(previous);
this.selectedIds.clear();
this.saveDocument();
}
redo(): void {
if (this.redoStack.length === 0 || this.activePointerId !== null) return;
const next = this.redoStack.pop();
if (!next) return;
this.undoStack.push(cloneStrokes(this.document.strokes));
this.document.strokes = cloneStrokes(next);
this.selectedIds.clear();
this.saveDocument();
}
deleteSelection(): void {
if (this.selectedIds.size === 0) return;
const before = cloneStrokes(this.document.strokes);
this.document.strokes = this.document.strokes.filter((stroke) => !this.selectedIds.has(stroke.id));
this.selectedIds.clear();
this.commit(before);
}
clearAll(): void {
if (this.document.strokes.length === 0) return;
const before = cloneStrokes(this.document.strokes);
this.document.strokes = [];
this.selectedIds.clear();
this.commit(before);
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.cancelGesture();
if (this.animationFrame !== null) window.cancelAnimationFrame(this.animationFrame);
this.resizeObserver.disconnect();
this.mutationObserver.disconnect();
this.scroller.removeEventListener("scroll", this.handleScroll);
this.host.removeEventListener("pointerdown", this.handlePointerDown, { capture: true });
this.host.removeEventListener("pointermove", this.handlePointerMove, { capture: true });
this.host.removeEventListener("pointerup", this.handlePointerUp, { capture: true });
this.host.removeEventListener("pointercancel", this.handlePointerCancel, { capture: true });
this.host.removeEventListener("pointerleave", this.handlePointerLeave, { capture: true });
this.host.removeEventListener("keydown", this.handleKeyDown, { capture: true });
this.host.classList.remove("ink-layer-host", "ink-layer-input-active");
this.layer.remove();
}
private get settings(): InkSettings {
return this.store.settings;
}
private addToolButton(tool: InkTool, icon: string, label: string): void {
const button = this.addActionButton(icon, label, () => this.setTool(tool));
button.dataset.tool = tool;
button.setAttribute("aria-pressed", "false");
this.toolButtons.set(tool, button);
}
private addActionButton(
icon: string,
label: string,
action: (event: MouseEvent) => void
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "ink-layer-tool";
button.setAttribute("aria-label", label);
button.setAttribute("title", label);
setIcon(button, icon);
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
action(event);
});
this.toolbar.appendChild(button);
return button;
}
private addDivider(): void {
const divider = document.createElement("span");
divider.className = "ink-layer-divider";
divider.setAttribute("aria-hidden", "true");
this.toolbar.appendChild(divider);
}
private openToolOptions(event: MouseEvent): void {
const menu = new Menu();
if (this.tool === "pen" || this.tool === "highlighter") {
const isPen = this.tool === "pen";
const currentColor = isPen ? this.settings.penColor : this.settings.highlighterColor;
const colors = isPen
? [
["Match theme", "adaptive"],
["Black", "#111827"],
["Red", "#dc2626"],
["Blue", "#2563eb"],
["Green", "#16a34a"],
["Purple", "#9333ea"]
]
: [
["Yellow", "#facc15"],
["Green", "#4ade80"],
["Blue", "#60a5fa"],
["Pink", "#f472b6"],
["Orange", "#fb923c"]
];
for (const [label, color] of colors) {
menu.addItem((item) =>
item
.setTitle(label)
.setIcon("circle")
.setChecked(currentColor.toLowerCase() === color.toLowerCase())
.onClick(() => this.applyToolSetting(isPen ? { penColor: color } : { highlighterColor: color }))
);
}
menu.addSeparator();
const currentWidth = isPen ? this.settings.penWidth : this.settings.highlighterWidth;
const widths: Array<[string, number]> = isPen
? [["Fine", 1.8], ["Medium", 3.2], ["Broad", 5.5]]
: [["Narrow", 10], ["Medium", 18], ["Wide", 28]];
for (const [label, width] of widths) {
menu.addItem((item) =>
item
.setTitle(`${label} · ${width}px`)
.setIcon("minus")
.setChecked(Math.abs(currentWidth - width) < 0.01)
.onClick(() => this.applyToolSetting(isPen ? { penWidth: width } : { highlighterWidth: width }))
);
}
} else if (this.tool === "eraser") {
for (const [label, width] of [["Small", 12], ["Medium", 20], ["Large", 36]] as Array<[string, number]>) {
menu.addItem((item) =>
item
.setTitle(`${label} · ${width}px`)
.setIcon("circle-dashed")
.setChecked(Math.abs(this.settings.eraserWidth - width) < 0.01)
.onClick(() => this.applyToolSetting({ eraserWidth: width }))
);
}
}
menu.showAtMouseEvent(event);
}
private applyToolSetting(patch: Partial<InkSettings>): void {
this.store.updateSettings(patch);
this.refreshSettings();
}
private onPointerDown(event: PointerEvent): void {
if (event.pointerType === "pen") this.lastPenSignal = Date.now();
if (!this.active || this.isToolbarEvent(event)) return;
if (this.shouldRejectPalm(event)) {
consumeEvent(event);
return;
}
if (!this.canDrawWith(event) || this.activePointerId !== null) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
consumeEvent(event);
this.activePointerId = event.pointerId;
this.activePointerType = event.pointerType;
this.gestureBefore = cloneStrokes(this.document.strokes);
this.gestureChanged = false;
try {
this.host.setPointerCapture(event.pointerId);
} catch {
// Some iOS WebViews capture implicitly and reject an explicit capture.
}
const point = this.toDocumentPoint(event);
const gestureTool = isEraserButton(event) ? "eraser" : this.tool;
if (gestureTool === "pen" || gestureTool === "highlighter") {
this.gestureMode = "draw";
this.selectedIds.clear();
const settings = this.settings;
this.draft = createStroke(
gestureTool,
gestureTool === "pen" ? settings.penColor : settings.highlighterColor,
gestureTool === "pen" ? settings.penWidth : settings.highlighterWidth,
gestureTool === "pen" ? 1 : 0.34,
[this.eventToInkPoint(event)]
);
} else if (gestureTool === "eraser") {
this.gestureMode = "erase";
this.selectedIds.clear();
this.eraserPoint = point;
this.eraseAt(point);
} else {
const bounds = combinedBounds(
this.document.strokes.filter((stroke) => this.selectedIds.has(stroke.id))
);
if (bounds && pointInBounds(point, bounds, 12)) {
this.gestureMode = "move-selection";
this.dragPrevious = point;
} else {
this.gestureMode = "lasso";
this.selectedIds.clear();
this.lassoPoints = [point];
}
}
this.updateToolbar();
this.scheduleRender();
}
private onPointerMove(event: PointerEvent): void {
if (event.pointerType === "pen") this.lastPenSignal = Date.now();
if (!this.active || this.isToolbarEvent(event)) return;
if (this.shouldRejectPalm(event)) {
consumeEvent(event);
return;
}
if (this.activePointerId !== event.pointerId) {
if (this.tool === "eraser" && this.canDrawWith(event)) {
this.eraserPoint = this.toDocumentPoint(event);
this.scheduleRender();
}
return;
}
consumeEvent(event);
const events = coalescedEvents(event);
if (this.gestureMode === "draw" && this.draft) {
for (const sample of events) this.appendDraftPoint(this.eventToInkPoint(sample));
} else if (this.gestureMode === "erase") {
for (const sample of events) {
const point = this.toDocumentPoint(sample);
this.eraserPoint = point;
this.eraseAt(point);
}
} else if (this.gestureMode === "lasso") {
for (const sample of events) this.appendLassoPoint(this.toDocumentPoint(sample));
} else if (this.gestureMode === "move-selection") {
const point = this.toDocumentPoint(event);
if (this.dragPrevious) {
const dx = point.x - this.dragPrevious.x;
const dy = point.y - this.dragPrevious.y;
if (dx !== 0 || dy !== 0) {
translateStrokes(this.document.strokes, this.selectedIds, dx, dy);
this.gestureChanged = true;
this.dryLayerDirty = true;
}
}
this.dragPrevious = point;
}
this.scheduleRender();
}
private onPointerUp(event: PointerEvent): void {
if (event.pointerType === "pen") this.lastPenSignal = Date.now();
if (this.activePointerId !== event.pointerId) return;
consumeEvent(event);
if (this.gestureMode === "draw" && this.draft) {
this.appendDraftPoint(this.eventToInkPoint(event));
this.draft.points = simplifyPoints(this.draft.points);
if (this.draft.points.length > 0) {
this.document.strokes.push(this.draft);
this.gestureChanged = true;
}
} else if (this.gestureMode === "lasso") {
this.appendLassoPoint(this.toDocumentPoint(event));
this.selectedIds = selectStrokesInPolygon(this.document.strokes, this.lassoPoints);
}
this.finishGesture();
}
private onPointerLeave(event: PointerEvent): void {
if (event.pointerType === "pen") this.lastPenSignal = Date.now();
if (this.activePointerId === null) {
this.eraserPoint = null;
this.scheduleRender();
}
}
private onKeyDown(event: KeyboardEvent): void {
if (!this.active) return;
const commandKey = event.metaKey || event.ctrlKey;
if (commandKey && event.key.toLowerCase() === "z") {
consumeEvent(event);
if (event.shiftKey) this.redo();
else this.undo();
return;
}
if ((event.key === "Backspace" || event.key === "Delete") && this.selectedIds.size > 0) {
consumeEvent(event);
this.deleteSelection();
return;
}
if (event.key === "Escape") {
consumeEvent(event);
if (this.selectedIds.size > 0) {
this.selectedIds.clear();
this.updateToolbar();
this.scheduleRender();
} else {
this.setActive(false);
}
}
}
private finishGesture(): void {
const pointerId = this.activePointerId;
if (pointerId !== null) {
try {
this.host.releasePointerCapture(pointerId);
} catch {
// Pointer capture may already have been released by the WebView.
}
}
if (this.gestureChanged && this.gestureBefore) this.commit(this.gestureBefore);
this.activePointerId = null;
this.activePointerType = "";
this.gestureMode = null;
this.gestureBefore = null;
this.gestureChanged = false;
this.draft = null;
this.lassoPoints = [];
this.dragPrevious = null;
if (this.tool !== "eraser") this.eraserPoint = null;
this.updateToolbar();
this.scheduleRender();
}
private cancelGesture(): void {
if (this.gestureBefore && this.gestureChanged) {
this.document.strokes = cloneStrokes(this.gestureBefore);
}
this.activePointerId = null;
this.activePointerType = "";
this.gestureMode = null;
this.gestureBefore = null;
this.gestureChanged = false;
this.draft = null;
this.lassoPoints = [];
this.dragPrevious = null;
this.eraserPoint = null;
}
private appendDraftPoint(point: InkPoint): void {
if (!this.draft) return;
const previous = this.draft.points[this.draft.points.length - 1];
if (previous && squaredDistance(previous, point) < 0.04) return;
this.draft.points.push(point);
}
private appendLassoPoint(point: Point2D): void {
const previous = this.lassoPoints[this.lassoPoints.length - 1];
if (previous && squaredDistance(previous, point) < 4) return;
this.lassoPoints.push(point);
}
private eraseAt(point: Point2D): void {
const radius = this.settings.eraserWidth / 2;
const nextStrokes = this.document.strokes.filter((stroke) => !hitTestStroke(stroke, point, radius));
if (nextStrokes.length !== this.document.strokes.length) {
this.document.strokes = nextStrokes;
this.gestureChanged = true;
this.dryLayerDirty = true;
}
}
private commit(before: InkStroke[]): void {
this.undoStack.push(cloneStrokes(before));
if (this.undoStack.length > HISTORY_LIMIT) this.undoStack.shift();
this.redoStack = [];
this.saveDocument();
}
private saveDocument(): void {
this.document.updatedAt = Date.now();
this.store.putDocument(this.document);
this.dryLayerDirty = true;
this.callbacks.onDocumentChange(this, this.document.notePath);
this.updateToolbar();
this.updateVisibility();
this.scheduleRender();
}
private updateToolbar(): void {
for (const [tool, button] of this.toolButtons) {
const selected = tool === this.tool;
button.classList.toggle("is-selected", selected);
button.setAttribute("aria-pressed", selected ? "true" : "false");
}
this.undoButton.disabled = this.undoStack.length === 0;
this.redoButton.disabled = this.redoStack.length === 0;
this.deleteButton.disabled = this.selectedIds.size === 0;
this.paletteButton.disabled = this.tool === "lasso";
}
private updateVisibility(): void {
const visible = this.active || (this.settings.showInkWhenInactive && this.document.strokes.length > 0);
this.layer.classList.toggle("is-visible", visible);
}
private scheduleRender(): void {
if (this.animationFrame !== null || this.destroyed) return;
this.animationFrame = window.requestAnimationFrame(() => {
this.animationFrame = null;
this.render();
});
}
private render(): void {
const hostRect = this.host.getBoundingClientRect();
if (this.renderer.resize(hostRect.width, hostRect.height)) this.dryLayerDirty = true;
const scrollerRect = this.scroller.getBoundingClientRect();
const offsetX = scrollerRect.left - hostRect.left;
const offsetY = scrollerRect.top - hostRect.top;
const state: InkRenderState = {
scrollLeft: this.scroller.scrollLeft - offsetX,
scrollTop: this.scroller.scrollTop - offsetY,
selectedIds: this.selectedIds,
lassoPoints: this.lassoPoints,
eraserPoint: this.eraserPoint,
eraserRadius: this.settings.eraserWidth / 2
};
this.renderer.render(this.document.strokes, this.draft, state, this.dryLayerDirty);
this.dryLayerDirty = false;
}
private refreshScroller(): void {
const nextScroller = this.findScroller();
if (nextScroller === this.scroller) return;
this.scroller.removeEventListener("scroll", this.handleScroll);
if (this.scroller !== this.host) this.resizeObserver.unobserve(this.scroller);
this.scroller = nextScroller;
this.dryLayerDirty = true;
this.scroller.addEventListener("scroll", this.handleScroll, { passive: true });
if (this.scroller !== this.host) this.resizeObserver.observe(this.scroller);
}
private findScroller(): HTMLElement {
const sourceScroller = this.host.querySelector<HTMLElement>(".markdown-source-view .cm-scroller");
if (sourceScroller && sourceScroller.offsetParent !== null) return sourceScroller;
const previewScroller = this.host.querySelector<HTMLElement>(".markdown-preview-view");
if (previewScroller && previewScroller.offsetParent !== null) return previewScroller;
return sourceScroller ?? previewScroller ?? this.host;
}
private toDocumentPoint(event: PointerEvent): Point2D {
const rect = this.scroller.getBoundingClientRect();
return {
x: event.clientX - rect.left + this.scroller.scrollLeft,
y: event.clientY - rect.top + this.scroller.scrollTop
};
}
private eventToInkPoint(event: PointerEvent): InkPoint {
const point = this.toDocumentPoint(event);
const rawPressure = event.pressure > 0 ? event.pressure : event.pointerType === "pen" ? 0.12 : 0.5;
const sensitivity = this.settings.pressureSensitivity;
const pressure = 0.5 + (rawPressure - 0.5) * sensitivity;
return createInkPoint(
point.x,
point.y,
pressure,
event.tiltX,
event.tiltY,
event.timeStamp
);
}
private canDrawWith(event: PointerEvent): boolean {
if (event.pointerType === "pen") return true;
if (event.pointerType === "touch") return this.settings.allowFingerDrawing;
if (event.pointerType === "mouse") return this.settings.allowMouseDrawing;
return false;
}
private shouldRejectPalm(event: PointerEvent): boolean {
if (event.pointerType !== "touch" || !this.settings.palmRejection) return false;
return (
this.activePointerType === "pen" ||
(this.lastPenSignal > 0 && Date.now() - this.lastPenSignal < PALM_REJECTION_WINDOW_MS)
);
}
private isToolbarEvent(event: Event): boolean {
return event.target instanceof Node && this.toolbar.contains(event.target);
}
private eraserCursorForTool(): void {
if (this.tool !== "eraser") this.eraserPoint = null;
}
}
function coalescedEvents(event: PointerEvent): PointerEvent[] {
if (typeof event.getCoalescedEvents !== "function") return [event];
const events = event.getCoalescedEvents();
if (events.length === 0) return [event];
const last = events[events.length - 1];
if (last.clientX !== event.clientX || last.clientY !== event.clientY) return [...events, event];
return events;
}
function isEraserButton(event: PointerEvent): boolean {
return event.pointerType === "pen" && (event.button === 5 || (event.buttons & 32) !== 0);
}
function consumeEvent(event: Event): void {
event.preventDefault();
event.stopPropagation();
}
function squaredDistance(first: Point2D, second: Point2D): number {
const dx = first.x - second.x;
const dy = first.y - second.y;
return dx * dx + dy * dy;
}

264
src/main.ts Normal file
View file

@ -0,0 +1,264 @@
import { App, MarkdownView, Modal, Notice, Plugin, Setting, TFile, type WorkspaceLeaf } from "obsidian";
import { InkController, type InkControllerCallbacks } from "./controller";
import { InkSettingTab, type InkSettingsHost } from "./settings";
import { InkStore } from "./storage";
import type { InkTool } from "./types";
export default class InkLayerPlugin
extends Plugin
implements InkControllerCallbacks, InkSettingsHost
{
store!: InkStore;
private readonly controllers = new Map<WorkspaceLeaf, InkController>();
private inputActive = false;
private preferredTool: InkTool = "pen";
private changingActiveController = false;
private ribbonIcon: HTMLElement | null = null;
async onload(): Promise<void> {
this.store = new InkStore(this);
await this.store.load();
this.ribbonIcon = this.addRibbonIcon("pen-tool", "Toggle ink mode", () => this.toggleInkMode());
this.addSettingTab(new InkSettingTab(this.app, this));
this.registerCommands();
this.app.workspace.onLayoutReady(() => {
this.syncControllers();
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.handleActiveLeafChange()));
this.registerEvent(this.app.workspace.on("file-open", () => this.reloadDocuments()));
this.registerEvent(this.app.workspace.on("layout-change", () => this.syncControllers()));
this.registerEvent(this.app.workspace.on("resize", () => this.refreshControllers()));
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
if (file instanceof TFile) {
this.store.renameDocument(oldPath, file.path);
this.reloadDocuments();
}
})
);
});
}
async onunload(): Promise<void> {
for (const controller of this.controllers.values()) controller.destroy();
this.controllers.clear();
await this.store.dispose();
}
onActiveChange(controller: InkController, active: boolean): void {
if (this.changingActiveController) return;
if (active) {
this.inputActive = true;
this.changingActiveController = true;
for (const candidate of this.controllers.values()) {
if (candidate !== controller) candidate.setActive(false);
}
this.changingActiveController = false;
} else if (this.activeController() === controller || !this.anyControllerActive()) {
this.inputActive = false;
}
this.updateRibbon();
}
onToolChange(_controller: InkController, tool: InkTool): void {
this.preferredTool = tool;
}
onDocumentChange(source: InkController, notePath: string): void {
for (const controller of this.controllers.values()) {
if (controller !== source) controller.refreshDocumentFromStore(notePath);
}
}
refreshControllers(): void {
for (const controller of this.controllers.values()) controller.refreshSettings();
}
private registerCommands(): void {
this.addCommand({
id: "toggle-ink-mode",
name: "Toggle ink mode",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => controller.toggleActive())
});
this.addCommand({
id: "select-pen",
name: "Select pen",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "pen"))
});
this.addCommand({
id: "select-highlighter",
name: "Select highlighter",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "highlighter"))
});
this.addCommand({
id: "select-eraser",
name: "Select eraser",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "eraser"))
});
this.addCommand({
id: "select-lasso",
name: "Select lasso",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "lasso"))
});
this.addCommand({
id: "toggle-pen-eraser",
name: "Switch between pen and eraser",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => {
this.activateTool(controller, controller.currentTool === "eraser" ? "pen" : "eraser");
})
});
this.addCommand({
id: "undo-ink",
name: "Undo ink",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => controller.undo())
});
this.addCommand({
id: "redo-ink",
name: "Redo ink",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => controller.redo())
});
this.addCommand({
id: "clear-note-ink",
name: "Clear ink from current note",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => {
if (controller.hasInk) new ClearInkModal(this.app, () => controller.clearAll()).open();
})
});
this.addCommand({
id: "export-note-ink-svg",
name: "Export current ink as SVG",
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => {
if (controller.hasInk) void this.exportSvg(controller);
})
});
}
private withMarkdownController(checking: boolean, action: (controller: InkController) => void): boolean {
const controller = this.ensureActiveController();
const available = controller !== null && controller.notePath.length > 0;
if (!checking && controller && available) action(controller);
return available;
}
private activateTool(controller: InkController, tool: InkTool): void {
controller.setTool(tool);
controller.setActive(true);
}
private toggleInkMode(): void {
const controller = this.ensureActiveController();
if (!controller || controller.notePath.length === 0) {
new Notice("Open a Markdown note to use Ink Layer.");
return;
}
controller.toggleActive();
}
private handleActiveLeafChange(): void {
this.syncControllers();
if (!this.inputActive) return;
const active = this.ensureActiveController();
this.changingActiveController = true;
for (const controller of this.controllers.values()) {
const shouldActivate = controller === active;
if (shouldActivate) controller.setTool(this.preferredTool);
controller.setActive(shouldActivate);
}
this.changingActiveController = false;
if (!active) this.inputActive = false;
this.updateRibbon();
}
private syncControllers(): void {
const liveLeaves = new Set(this.app.workspace.getLeavesOfType("markdown"));
for (const leaf of liveLeaves) {
if (!(leaf.view instanceof MarkdownView) || this.controllers.has(leaf)) continue;
this.controllers.set(leaf, new InkController(leaf.view, this.store, this));
}
for (const [leaf, controller] of this.controllers) {
if (liveLeaves.has(leaf) && leaf.view instanceof MarkdownView) continue;
controller.destroy();
this.controllers.delete(leaf);
}
this.reloadDocuments();
}
private reloadDocuments(): void {
for (const controller of this.controllers.values()) controller.reloadDocument();
}
private ensureActiveController(): InkController | null {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return null;
let controller = this.controllers.get(view.leaf);
if (!controller) {
controller = new InkController(view, this.store, this);
this.controllers.set(view.leaf, controller);
}
controller.reloadDocument();
return controller;
}
private activeController(): InkController | null {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
return view ? this.controllers.get(view.leaf) ?? null : null;
}
private anyControllerActive(): boolean {
for (const controller of this.controllers.values()) {
if (controller.isActive) return true;
}
return false;
}
private updateRibbon(): void {
this.ribbonIcon?.classList.toggle("is-active", this.inputActive);
this.ribbonIcon?.setAttribute("aria-pressed", this.inputActive ? "true" : "false");
}
private async exportSvg(controller: InkController): Promise<void> {
const svg = controller.exportSvg();
const file = controller.view.file;
if (!svg || !file) return;
try {
const exportPath = await this.app.fileManager.getAvailablePathForAttachment(
`${file.basename}.ink.svg`,
file.path
);
const exportedFile = await this.app.vault.create(exportPath, svg);
new Notice(`Exported ink to ${exportedFile.path}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
new Notice(`Could not export ink: ${message}`);
}
}
}
class ClearInkModal extends Modal {
constructor(app: App, private readonly onConfirm: () => void) {
super(app);
}
onOpen(): void {
this.titleEl.textContent = "Clear ink from this note?";
const description = document.createElement("p");
description.textContent = "This removes every ink stroke from the current note. You can undo it while the note remains open.";
this.contentEl.appendChild(description);
new Setting(this.contentEl)
.addButton((button) => button.setButtonText("Cancel").onClick(() => this.close()))
.addButton((button) =>
button
.setButtonText("Clear ink")
.setWarning()
.onClick(() => {
this.onConfirm();
this.close();
})
);
}
onClose(): void {
this.contentEl.replaceChildren();
}
}

273
src/model.ts Normal file
View file

@ -0,0 +1,273 @@
import type {
Bounds,
InkDocument,
InkPoint,
InkStroke,
Point2D,
StrokeTool
} from "./types";
export function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function createInkPoint(
x: number,
y: number,
pressure: number,
tiltX: number,
tiltY: number,
time: number
): InkPoint {
return {
x: finiteOr(x, 0),
y: finiteOr(y, 0),
pressure: clamp(finiteOr(pressure, 0.5), 0, 1),
tiltX: clamp(finiteOr(tiltX, 0), -90, 90),
tiltY: clamp(finiteOr(tiltY, 0), -90, 90),
time: Math.max(0, finiteOr(time, 0))
};
}
export function createStroke(
tool: StrokeTool,
color: string,
width: number,
opacity: number,
points: InkPoint[] = []
): InkStroke {
return {
id: createId(),
tool,
color,
width: clamp(width, 0.5, 80),
opacity: clamp(opacity, 0.05, 1),
points
};
}
export function emptyDocument(notePath: string): InkDocument {
return { version: 1, notePath, strokes: [], updatedAt: Date.now() };
}
export function cloneDocument(document: InkDocument): InkDocument {
return {
version: 1,
notePath: document.notePath,
updatedAt: document.updatedAt,
strokes: cloneStrokes(document.strokes)
};
}
export function cloneStrokes(strokes: InkStroke[]): InkStroke[] {
return strokes.map((stroke) => ({
...stroke,
points: stroke.points.map((point) => ({ ...point }))
}));
}
export function simplifyPoints(points: InkPoint[], tolerance = 0.35): InkPoint[] {
if (points.length <= 2) return points.map((point) => ({ ...point }));
const squareTolerance = tolerance * tolerance;
const radiallyReduced: InkPoint[] = [points[0]];
let previous = points[0];
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index];
if (distanceSquared(point, previous) > squareTolerance) {
radiallyReduced.push(point);
previous = point;
}
}
radiallyReduced.push(points[points.length - 1]);
if (radiallyReduced.length <= 2) {
return radiallyReduced.map((point) => ({ ...point }));
}
const markers = new Uint8Array(radiallyReduced.length);
markers[0] = 1;
markers[markers.length - 1] = 1;
const stack: Array<[number, number]> = [[0, radiallyReduced.length - 1]];
while (stack.length > 0) {
const range = stack.pop();
if (!range) break;
const [first, last] = range;
let furthestIndex = -1;
let furthestDistance = squareTolerance;
for (let index = first + 1; index < last; index += 1) {
const squareDistance = distanceToSegmentSquared(
radiallyReduced[index],
radiallyReduced[first],
radiallyReduced[last]
);
if (squareDistance > furthestDistance) {
furthestDistance = squareDistance;
furthestIndex = index;
}
}
if (furthestIndex !== -1) {
markers[furthestIndex] = 1;
if (furthestIndex - first > 1) stack.push([first, furthestIndex]);
if (last - furthestIndex > 1) stack.push([furthestIndex, last]);
}
}
return radiallyReduced
.filter((_point, index) => markers[index] === 1)
.map((point) => ({ ...point }));
}
export function strokeBounds(stroke: InkStroke): Bounds | null {
if (stroke.points.length === 0) return null;
let minX = stroke.points[0].x;
let maxX = minX;
let minY = stroke.points[0].y;
let maxY = minY;
for (let index = 1; index < stroke.points.length; index += 1) {
const point = stroke.points[index];
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
const padding = stroke.width / 2;
return {
minX: minX - padding,
minY: minY - padding,
maxX: maxX + padding,
maxY: maxY + padding
};
}
export function combinedBounds(strokes: InkStroke[]): Bounds | null {
let result: Bounds | null = null;
for (const stroke of strokes) {
const bounds = strokeBounds(stroke);
if (!bounds) continue;
if (!result) {
result = { ...bounds };
continue;
}
result.minX = Math.min(result.minX, bounds.minX);
result.minY = Math.min(result.minY, bounds.minY);
result.maxX = Math.max(result.maxX, bounds.maxX);
result.maxY = Math.max(result.maxY, bounds.maxY);
}
return result;
}
export function pointInBounds(point: Point2D, bounds: Bounds, padding = 0): boolean {
return (
point.x >= bounds.minX - padding &&
point.x <= bounds.maxX + padding &&
point.y >= bounds.minY - padding &&
point.y <= bounds.maxY + padding
);
}
export function hitTestStroke(stroke: InkStroke, point: Point2D, radius: number): boolean {
const bounds = strokeBounds(stroke);
if (!bounds || !pointInBounds(point, bounds, radius)) return false;
const hitRadius = radius + stroke.width / 2;
const squareHitRadius = hitRadius * hitRadius;
if (stroke.points.length === 1) {
return distanceSquared(stroke.points[0], point) <= squareHitRadius;
}
for (let index = 1; index < stroke.points.length; index += 1) {
if (
distanceToSegmentSquared(point, stroke.points[index - 1], stroke.points[index]) <=
squareHitRadius
) {
return true;
}
}
return false;
}
export function pointInPolygon(point: Point2D, polygon: Point2D[]): boolean {
if (polygon.length < 3) return false;
let inside = false;
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) {
const currentPoint = polygon[index];
const previousPoint = polygon[previous];
const crosses =
currentPoint.y > point.y !== previousPoint.y > point.y &&
point.x <
((previousPoint.x - currentPoint.x) * (point.y - currentPoint.y)) /
(previousPoint.y - currentPoint.y) +
currentPoint.x;
if (crosses) inside = !inside;
}
return inside;
}
export function selectStrokesInPolygon(strokes: InkStroke[], polygon: Point2D[]): Set<string> {
const selected = new Set<string>();
for (const stroke of strokes) {
const bounds = strokeBounds(stroke);
if (!bounds) continue;
const center = { x: (bounds.minX + bounds.maxX) / 2, y: (bounds.minY + bounds.maxY) / 2 };
if (pointInPolygon(center, polygon) || stroke.points.some((point) => pointInPolygon(point, polygon))) {
selected.add(stroke.id);
}
}
return selected;
}
export function translateStrokes(strokes: InkStroke[], selectedIds: Set<string>, dx: number, dy: number): void {
for (const stroke of strokes) {
if (!selectedIds.has(stroke.id)) continue;
for (const point of stroke.points) {
point.x += dx;
point.y += dy;
}
}
}
export function distanceToSegmentSquared(point: Point2D, start: Point2D, end: Point2D): number {
let x = start.x;
let y = start.y;
let dx = end.x - x;
let dy = end.y - y;
if (dx !== 0 || dy !== 0) {
const t = ((point.x - x) * dx + (point.y - y) * dy) / (dx * dx + dy * dy);
if (t > 1) {
x = end.x;
y = end.y;
} else if (t > 0) {
x += dx * t;
y += dy * t;
}
}
dx = point.x - x;
dy = point.y - y;
return dx * dx + dy * dy;
}
function distanceSquared(first: Point2D, second: Point2D): number {
const dx = first.x - second.x;
const dy = first.y - second.y;
return dx * dx + dy * dy;
}
function finiteOr(value: number, fallback: number): number {
return Number.isFinite(value) ? value : fallback;
}
function createId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

245
src/render.ts Normal file
View file

@ -0,0 +1,245 @@
import { getStroke } from "perfect-freehand";
import { combinedBounds } from "./model";
import type { Bounds, InkPoint, InkStroke, Point2D } from "./types";
export interface InkRenderState {
scrollLeft: number;
scrollTop: number;
selectedIds: Set<string>;
lassoPoints: Point2D[];
eraserPoint: Point2D | null;
eraserRadius: number;
}
export class InkRenderer {
private readonly dryContext: CanvasRenderingContext2D;
private readonly wetContext: CanvasRenderingContext2D;
private cssWidth = 0;
private cssHeight = 0;
private pixelRatio = 1;
constructor(
private readonly dryCanvas: HTMLCanvasElement,
private readonly wetCanvas: HTMLCanvasElement,
private readonly themeElement: HTMLElement
) {
const dryContext = dryCanvas.getContext("2d");
const wetContext = wetCanvas.getContext("2d");
if (!dryContext || !wetContext) throw new Error("Ink Layer requires Canvas 2D support.");
this.dryContext = dryContext;
this.wetContext = wetContext;
}
resize(width: number, height: number): boolean {
const nextWidth = Math.max(1, Math.floor(width));
const nextHeight = Math.max(1, Math.floor(height));
const nextRatio = Math.min(window.devicePixelRatio || 1, 3);
if (
nextWidth === this.cssWidth &&
nextHeight === this.cssHeight &&
nextRatio === this.pixelRatio
) {
return false;
}
this.cssWidth = nextWidth;
this.cssHeight = nextHeight;
this.pixelRatio = nextRatio;
this.dryCanvas.width = Math.ceil(nextWidth * nextRatio);
this.dryCanvas.height = Math.ceil(nextHeight * nextRatio);
this.wetCanvas.width = Math.ceil(nextWidth * nextRatio);
this.wetCanvas.height = Math.ceil(nextHeight * nextRatio);
return true;
}
render(
strokes: InkStroke[],
draft: InkStroke | null,
state: InkRenderState,
redrawDryLayer: boolean
): void {
if (redrawDryLayer) {
this.prepareContext(this.dryContext, state);
for (const stroke of strokes) this.drawStroke(this.dryContext, stroke);
this.dryContext.restore();
}
this.prepareContext(this.wetContext, state);
if (draft) this.drawStroke(this.wetContext, draft);
this.drawSelection(this.wetContext, strokes, state.selectedIds);
this.drawLasso(this.wetContext, state.lassoPoints);
if (state.eraserPoint) {
this.drawEraser(this.wetContext, state.eraserPoint, state.eraserRadius);
}
this.wetContext.restore();
}
private prepareContext(context: CanvasRenderingContext2D, state: InkRenderState): void {
context.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
context.clearRect(0, 0, this.cssWidth, this.cssHeight);
context.save();
context.translate(-state.scrollLeft, -state.scrollTop);
}
private drawStroke(context: CanvasRenderingContext2D, stroke: InkStroke): void {
if (stroke.points.length === 0) return;
const color = resolveInkColor(stroke.color, this.themeElement);
const freehandPoints = stroke.points.map((point) => [
point.x,
point.y,
normalizedPressure(point)
] as [number, number, number]);
const outline = getStroke(freehandPoints, {
size: stroke.width,
thinning: stroke.tool === "pen" ? 0.68 : 0,
smoothing: stroke.tool === "pen" ? 0.62 : 0.72,
streamline: stroke.tool === "pen" ? 0.38 : 0.5,
simulatePressure: false,
start: { cap: true, taper: 0 },
end: { cap: true, taper: 0 }
});
if (outline.length === 0) return;
context.save();
context.globalAlpha = stroke.opacity;
context.fillStyle = color;
context.fill(new Path2D(svgPathFromOutline(outline)));
context.restore();
}
private drawSelection(
context: CanvasRenderingContext2D,
strokes: InkStroke[],
selectedIds: Set<string>
): void {
if (selectedIds.size === 0) return;
const selectedStrokes = strokes.filter((stroke) => selectedIds.has(stroke.id));
const bounds = combinedBounds(selectedStrokes);
if (!bounds) return;
this.drawDashedBounds(context, bounds);
}
private drawLasso(context: CanvasRenderingContext2D, points: Point2D[]): void {
if (points.length < 2) return;
context.save();
context.strokeStyle = resolveThemeColor("--interactive-accent", this.themeElement, "#7c3aed");
context.lineWidth = 1.5;
context.setLineDash([6, 4]);
context.beginPath();
context.moveTo(points[0].x, points[0].y);
for (let index = 1; index < points.length; index += 1) {
context.lineTo(points[index].x, points[index].y);
}
context.stroke();
context.restore();
}
private drawDashedBounds(context: CanvasRenderingContext2D, bounds: Bounds): void {
context.save();
context.strokeStyle = resolveThemeColor("--interactive-accent", this.themeElement, "#7c3aed");
context.fillStyle = resolveThemeColor("--interactive-accent", this.themeElement, "#7c3aed");
context.globalAlpha = 0.9;
context.lineWidth = 1.5;
context.setLineDash([6, 4]);
context.strokeRect(
bounds.minX - 5,
bounds.minY - 5,
bounds.maxX - bounds.minX + 10,
bounds.maxY - bounds.minY + 10
);
context.restore();
}
private drawEraser(context: CanvasRenderingContext2D, point: Point2D, radius: number): void {
context.save();
context.strokeStyle = resolveThemeColor("--text-muted", this.themeElement, "#64748b");
context.fillStyle = resolveThemeColor("--background-primary", this.themeElement, "#ffffff");
context.globalAlpha = 0.82;
context.lineWidth = 1.5;
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
context.stroke();
context.restore();
}
}
export function resolveInkColor(color: string, element: HTMLElement): string {
if (color === "adaptive") {
return resolveThemeColor("--text-normal", element, "#1f2937");
}
return color;
}
export function exportStrokesToSvg(strokes: InkStroke[], themeElement: HTMLElement): string | null {
const bounds = combinedBounds(strokes);
if (!bounds) return null;
const padding = 12;
const minX = Math.floor(bounds.minX - padding);
const minY = Math.floor(bounds.minY - padding);
const width = Math.max(1, Math.ceil(bounds.maxX - bounds.minX + padding * 2));
const height = Math.max(1, Math.ceil(bounds.maxY - bounds.minY + padding * 2));
const paths = strokes.flatMap((stroke) => {
if (stroke.points.length === 0) return [];
const outline = getStroke(
stroke.points.map((point) => [point.x, point.y, normalizedPressure(point)] as [number, number, number]),
{
size: stroke.width,
thinning: stroke.tool === "pen" ? 0.68 : 0,
smoothing: stroke.tool === "pen" ? 0.62 : 0.72,
streamline: stroke.tool === "pen" ? 0.38 : 0.5,
simulatePressure: false,
start: { cap: true, taper: 0 },
end: { cap: true, taper: 0 }
}
);
if (outline.length === 0) return [];
const color = escapeXml(resolveInkColor(stroke.color, themeElement));
const path = escapeXml(svgPathFromOutline(outline));
return [` <path d="${path}" fill="${color}" fill-opacity="${stroke.opacity.toFixed(3)}"/>`];
});
return [
'<?xml version="1.0" encoding="UTF-8"?>',
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${width} ${height}" width="${width}" height="${height}">`,
...paths,
"</svg>"
].join("\n");
}
export function svgPathFromOutline(points: number[][]): string {
if (points.length === 0) return "";
const first = points[0];
if (points.length === 1) {
return `M ${first[0]} ${first[1]} Z`;
}
let path = `M ${first[0].toFixed(2)} ${first[1].toFixed(2)} Q`;
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index];
const next = points[index + 1];
const midX = (point[0] + next[0]) / 2;
const midY = (point[1] + next[1]) / 2;
path += ` ${point[0].toFixed(2)} ${point[1].toFixed(2)} ${midX.toFixed(2)} ${midY.toFixed(2)}`;
}
const last = points[points.length - 1];
return `${path} ${last[0].toFixed(2)} ${last[1].toFixed(2)} Z`;
}
function normalizedPressure(point: InkPoint): number {
if (point.pressure <= 0) return 0.12;
return Math.max(0.05, Math.min(1, point.pressure));
}
function resolveThemeColor(variable: string, element: HTMLElement, fallback: string): string {
const value = getComputedStyle(element).getPropertyValue(variable).trim();
return value || fallback;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

142
src/settings.ts Normal file
View file

@ -0,0 +1,142 @@
import { Plugin, PluginSettingTab, Setting, type App } from "obsidian";
import type { InkStore } from "./storage";
import type { InkSettings } from "./types";
export interface InkSettingsHost {
store: InkStore;
refreshControllers(): void;
}
export class InkSettingTab extends PluginSettingTab {
constructor(app: App, private readonly pluginHost: Plugin & InkSettingsHost) {
super(app, pluginHost);
}
display(): void {
this.containerEl.replaceChildren();
const settings = this.pluginHost.store.settings;
new Setting(this.containerEl).setName("Ink appearance").setHeading();
new Setting(this.containerEl)
.setName("Match pen to the theme")
.setDesc("Use Obsidians current text color so pen strokes stay legible in light and dark themes.")
.addToggle((toggle) =>
toggle.setValue(settings.penColor === "adaptive").onChange((enabled) => {
this.applyPatch({ penColor: enabled ? "adaptive" : "#1f2937" });
this.display();
})
);
new Setting(this.containerEl)
.setName("Pen color")
.setDesc(settings.penColor === "adaptive" ? "Disable theme matching to choose a fixed color." : "Color for new pen strokes.")
.setDisabled(settings.penColor === "adaptive")
.addColorPicker((picker) =>
picker
.setValue(settings.penColor === "adaptive" ? "#1f2937" : settings.penColor)
.setDisabled(settings.penColor === "adaptive")
.onChange((value) => this.applyPatch({ penColor: value }))
);
new Setting(this.containerEl)
.setName("Pen width")
.setDesc("Base width in screen pixels. Pencil pressure varies the final width.")
.addSlider((slider) =>
slider
.setLimits(1, 12, 0.2)
.setValue(settings.penWidth)
.setInstant(false)
.onChange((value) => this.applyPatch({ penWidth: value }))
);
new Setting(this.containerEl)
.setName("Highlighter color")
.setDesc("Color for new translucent highlighter strokes.")
.addColorPicker((picker) =>
picker.setValue(settings.highlighterColor).onChange((value) => this.applyPatch({ highlighterColor: value }))
);
new Setting(this.containerEl)
.setName("Highlighter width")
.addSlider((slider) =>
slider
.setLimits(6, 40, 1)
.setValue(settings.highlighterWidth)
.setInstant(false)
.onChange((value) => this.applyPatch({ highlighterWidth: value }))
);
new Setting(this.containerEl)
.setName("Eraser size")
.setDesc("The eraser removes complete strokes that it touches.")
.addSlider((slider) =>
slider
.setLimits(8, 56, 2)
.setValue(settings.eraserWidth)
.setInstant(false)
.onChange((value) => this.applyPatch({ eraserWidth: value }))
);
new Setting(this.containerEl)
.setName("Pressure response")
.setDesc("Zero gives a uniform line; one follows the full pressure range reported by the pen.")
.addSlider((slider) =>
slider
.setLimits(0, 1, 0.05)
.setValue(settings.pressureSensitivity)
.setInstant(false)
.onChange((value) => this.applyPatch({ pressureSensitivity: value }))
);
new Setting(this.containerEl).setName("Input and behavior").setHeading();
new Setting(this.containerEl)
.setName("Palm rejection")
.setDesc("Ignore touch contacts while a pen is active or was just detected nearby.")
.addToggle((toggle) =>
toggle.setValue(settings.palmRejection).onChange((value) => this.applyPatch({ palmRejection: value }))
);
new Setting(this.containerEl)
.setName("Draw with a finger")
.setDesc("Off by default so one-finger scrolling continues to work while ink mode is active.")
.addToggle((toggle) =>
toggle
.setValue(settings.allowFingerDrawing)
.onChange((value) => this.applyPatch({ allowFingerDrawing: value }))
);
new Setting(this.containerEl)
.setName("Draw with a mouse")
.setDesc("Useful for desktop editing and testing without a pen.")
.addToggle((toggle) =>
toggle.setValue(settings.allowMouseDrawing).onChange((value) => this.applyPatch({ allowMouseDrawing: value }))
);
new Setting(this.containerEl)
.setName("Show ink outside drawing mode")
.setDesc("Keep saved handwriting visible after selecting Done.")
.addToggle((toggle) =>
toggle
.setValue(settings.showInkWhenInactive)
.onChange((value) => this.applyPatch({ showInkWhenInactive: value }))
);
new Setting(this.containerEl)
.setName("Toolbar position")
.setDesc("The bottom position respects the iPad safe area.")
.addDropdown((dropdown) =>
dropdown
.addOption("top", "Top")
.addOption("bottom", "Bottom")
.setValue(settings.toolbarPosition)
.onChange((value) => this.applyPatch({ toolbarPosition: value === "bottom" ? "bottom" : "top" }))
);
}
private applyPatch(patch: Partial<InkSettings>): void {
this.pluginHost.store.updateSettings(patch);
this.pluginHost.refreshControllers();
}
}

228
src/storage.ts Normal file
View file

@ -0,0 +1,228 @@
import { cloneDocument, createInkPoint, emptyDocument } from "./model";
import { DEFAULT_SETTINGS, type InkDocument, type InkPluginData, type InkSettings, type InkStroke } from "./types";
export interface PluginDataHost {
loadData(): Promise<unknown>;
saveData(data: unknown): Promise<void>;
}
export class InkStore {
private data: InkPluginData = {
version: 1,
settings: { ...DEFAULT_SETTINGS },
documents: {}
};
private saveTimer: number | null = null;
private saveChain: Promise<void> = Promise.resolve();
constructor(private readonly host: PluginDataHost) {}
async load(): Promise<void> {
this.data = parsePluginData(await this.host.loadData());
}
get settings(): InkSettings {
return this.data.settings;
}
updateSettings(patch: Partial<InkSettings>): void {
this.data.settings = sanitizeSettings({ ...this.data.settings, ...patch });
this.scheduleSave();
}
getDocument(notePath: string): InkDocument {
const document = this.data.documents[notePath];
return document ? cloneDocument(document) : emptyDocument(notePath);
}
putDocument(document: InkDocument): void {
const copy = cloneDocument(document);
copy.updatedAt = Date.now();
this.data.documents[copy.notePath] = copy;
this.scheduleSave();
}
renameDocument(oldPath: string, newPath: string): void {
const document = this.data.documents[oldPath];
if (!document) return;
delete this.data.documents[oldPath];
document.notePath = newPath;
document.updatedAt = Date.now();
this.data.documents[newPath] = document;
this.scheduleSave();
}
hasInk(notePath: string): boolean {
return (this.data.documents[notePath]?.strokes.length ?? 0) > 0;
}
async flush(): Promise<void> {
if (this.saveTimer !== null) {
window.clearTimeout(this.saveTimer);
this.saveTimer = null;
}
const snapshot = serializePluginData(this.data);
this.saveChain = this.saveChain.then(() => this.host.saveData(snapshot));
await this.saveChain;
}
dispose(): Promise<void> {
return this.flush();
}
private scheduleSave(): void {
if (this.saveTimer !== null) window.clearTimeout(this.saveTimer);
this.saveTimer = window.setTimeout(() => {
this.saveTimer = null;
void this.flush();
}, 350);
}
}
function parsePluginData(value: unknown): InkPluginData {
const fallback: InkPluginData = {
version: 1,
settings: { ...DEFAULT_SETTINGS },
documents: {}
};
if (!isRecord(value)) return fallback;
const rawDocuments = isRecord(value.documents) ? value.documents : {};
const documents: Record<string, InkDocument> = {};
for (const [path, rawDocument] of Object.entries(rawDocuments)) {
const document = parseDocument(rawDocument, path);
if (document) documents[path] = document;
}
return {
version: 1,
settings: sanitizeSettings(isRecord(value.settings) ? value.settings : {}),
documents
};
}
function parseDocument(value: unknown, fallbackPath: string): InkDocument | null {
if (!isRecord(value) || !Array.isArray(value.strokes)) return null;
const strokes: InkStroke[] = value.strokes.flatMap((rawStroke, strokeIndex) => {
if (!isRecord(rawStroke) || !Array.isArray(rawStroke.points)) return [];
const tool = rawStroke.tool === "highlighter" ? "highlighter" : rawStroke.tool === "pen" ? "pen" : null;
if (!tool) return [];
const points = rawStroke.points.flatMap((rawPoint) => {
const point = parsePoint(rawPoint);
return point ? [point] : [];
});
if (points.length === 0) return [];
return [{
id: typeof rawStroke.id === "string" ? rawStroke.id : `${Date.now()}-${strokeIndex}`,
tool,
color: typeof rawStroke.color === "string" ? rawStroke.color : "adaptive",
width: numberInRange(rawStroke.width, 3.2, 0.5, 80),
opacity: numberInRange(rawStroke.opacity, 1, 0.05, 1),
points
}];
});
return {
version: 1,
notePath: typeof value.notePath === "string" ? value.notePath : fallbackPath,
updatedAt: isFiniteNumber(value.updatedAt) ? value.updatedAt : Date.now(),
strokes
};
}
function sanitizeSettings(value: Record<string, unknown> | InkSettings): InkSettings {
return {
penColor: stringOr(value.penColor, DEFAULT_SETTINGS.penColor),
penWidth: numberInRange(value.penWidth, DEFAULT_SETTINGS.penWidth, 1, 20),
highlighterColor: stringOr(value.highlighterColor, DEFAULT_SETTINGS.highlighterColor),
highlighterWidth: numberInRange(value.highlighterWidth, DEFAULT_SETTINGS.highlighterWidth, 4, 48),
eraserWidth: numberInRange(value.eraserWidth, DEFAULT_SETTINGS.eraserWidth, 6, 64),
pressureSensitivity: numberInRange(
value.pressureSensitivity,
DEFAULT_SETTINGS.pressureSensitivity,
0,
1
),
palmRejection: booleanOr(value.palmRejection, DEFAULT_SETTINGS.palmRejection),
allowFingerDrawing: booleanOr(value.allowFingerDrawing, DEFAULT_SETTINGS.allowFingerDrawing),
allowMouseDrawing: booleanOr(value.allowMouseDrawing, DEFAULT_SETTINGS.allowMouseDrawing),
showInkWhenInactive: booleanOr(value.showInkWhenInactive, DEFAULT_SETTINGS.showInkWhenInactive),
toolbarPosition: value.toolbarPosition === "bottom" ? "bottom" : "top"
};
}
function serializePluginData(data: InkPluginData): unknown {
const documents: Record<string, unknown> = {};
for (const [path, document] of Object.entries(data.documents)) {
documents[path] = {
version: 1,
notePath: document.notePath,
updatedAt: document.updatedAt,
strokes: document.strokes.map((stroke) => ({
...stroke,
points: stroke.points.map((point) => [
round(point.x, 2),
round(point.y, 2),
round(point.pressure, 3),
round(point.tiltX, 1),
round(point.tiltY, 1),
Math.round(point.time)
])
}))
};
}
return {
version: 1,
settings: { ...data.settings },
documents
};
}
function parsePoint(value: unknown): ReturnType<typeof createInkPoint> | null {
if (Array.isArray(value)) {
if (!isFiniteNumber(value[0]) || !isFiniteNumber(value[1])) return null;
return createInkPoint(
value[0],
value[1],
isFiniteNumber(value[2]) ? value[2] : 0.5,
isFiniteNumber(value[3]) ? value[3] : 0,
isFiniteNumber(value[4]) ? value[4] : 0,
isFiniteNumber(value[5]) ? value[5] : 0
);
}
if (!isRecord(value) || !isFiniteNumber(value.x) || !isFiniteNumber(value.y)) return null;
return createInkPoint(
value.x,
value.y,
isFiniteNumber(value.pressure) ? value.pressure : 0.5,
isFiniteNumber(value.tiltX) ? value.tiltX : 0,
isFiniteNumber(value.tiltY) ? value.tiltY : 0,
isFiniteNumber(value.time) ? value.time : 0
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function stringOr(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function booleanOr(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function numberInRange(value: unknown, fallback: number, minimum: number, maximum: number): number {
if (!isFiniteNumber(value)) return fallback;
return Math.min(maximum, Math.max(minimum, value));
}
function round(value: number, decimalPlaces: number): number {
const multiplier = 10 ** decimalPlaces;
return Math.round(value * multiplier) / multiplier;
}

73
src/types.ts Normal file
View file

@ -0,0 +1,73 @@
export type InkTool = "pen" | "highlighter" | "eraser" | "lasso";
export type StrokeTool = "pen" | "highlighter";
export interface InkPoint {
x: number;
y: number;
pressure: number;
tiltX: number;
tiltY: number;
time: number;
}
export interface InkStroke {
id: string;
tool: StrokeTool;
color: string;
width: number;
opacity: number;
points: InkPoint[];
}
export interface InkDocument {
version: 1;
notePath: string;
strokes: InkStroke[];
updatedAt: number;
}
export interface InkSettings {
penColor: string;
penWidth: number;
highlighterColor: string;
highlighterWidth: number;
eraserWidth: number;
pressureSensitivity: number;
palmRejection: boolean;
allowFingerDrawing: boolean;
allowMouseDrawing: boolean;
showInkWhenInactive: boolean;
toolbarPosition: "top" | "bottom";
}
export interface InkPluginData {
version: 1;
settings: InkSettings;
documents: Record<string, InkDocument>;
}
export interface Point2D {
x: number;
y: number;
}
export interface Bounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export const DEFAULT_SETTINGS: InkSettings = {
penColor: "adaptive",
penWidth: 3.2,
highlighterColor: "#facc15",
highlighterWidth: 18,
eraserWidth: 20,
pressureSensitivity: 0.72,
palmRejection: true,
allowFingerDrawing: false,
allowMouseDrawing: true,
showInkWhenInactive: true,
toolbarPosition: "top"
};

134
styles.css Normal file
View file

@ -0,0 +1,134 @@
.ink-layer-host {
position: relative;
}
.ink-layer {
position: absolute;
inset: 0;
z-index: 20;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.ink-layer.is-visible {
visibility: visible;
}
.ink-layer-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ink-layer-toolbar {
position: absolute;
top: max(10px, env(safe-area-inset-top));
left: 50%;
display: none;
align-items: center;
gap: 2px;
max-width: calc(100% - 24px);
padding: 5px;
overflow-x: auto;
color: var(--text-normal);
background: color-mix(in srgb, var(--background-primary) 92%, transparent);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-l);
box-shadow: var(--shadow-s);
transform: translateX(-50%);
pointer-events: auto;
touch-action: manipulation;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.ink-layer-toolbar::-webkit-scrollbar {
display: none;
}
.ink-layer.is-active .ink-layer-toolbar {
display: flex;
}
.ink-layer.toolbar-at-bottom .ink-layer-toolbar {
top: auto;
bottom: max(10px, env(safe-area-inset-bottom));
}
.ink-layer-tool {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: var(--text-muted);
background: transparent;
border: 0;
border-radius: var(--radius-s);
box-shadow: none;
}
.ink-layer-tool:hover,
.ink-layer-tool:focus-visible {
color: var(--text-normal);
background: var(--background-modifier-hover);
}
.ink-layer-tool.is-selected {
color: var(--text-on-accent);
background: var(--interactive-accent);
}
.ink-layer-tool:disabled {
opacity: 0.35;
}
.ink-layer-tool svg {
width: 19px;
height: 19px;
}
.ink-layer-divider {
width: 1px;
height: 22px;
margin: 0 3px;
background: var(--background-modifier-border);
}
.ink-layer-input-active {
cursor: crosshair;
}
.workspace-ribbon .clickable-icon.is-active,
.mobile-navbar-action.is-active {
color: var(--text-on-accent);
background: var(--interactive-accent);
}
@media (max-width: 600px) {
.ink-layer-toolbar {
gap: 3px;
padding: 6px;
}
.ink-layer-tool {
width: 42px;
height: 42px;
}
.ink-layer-tool svg {
width: 21px;
height: 21px;
}
}
@media (prefers-reduced-transparency: reduce) {
.ink-layer-toolbar {
background: var(--background-primary);
}
}

79
tests/model.test.ts Normal file
View file

@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
cloneDocument,
createInkPoint,
createStroke,
emptyDocument,
hitTestStroke,
pointInPolygon,
selectStrokesInPolygon,
simplifyPoints,
translateStrokes
} from "../src/model";
import { svgPathFromOutline } from "../src/render";
describe("ink points", () => {
it("sanitizes unsupported pointer values", () => {
const point = createInkPoint(Number.NaN, 12, 4, -120, 200, -5);
expect(point).toEqual({ x: 0, y: 12, pressure: 1, tiltX: -90, tiltY: 90, time: 0 });
});
it("simplifies a straight stroke without losing its endpoints", () => {
const points = Array.from({ length: 101 }, (_value, index) =>
createInkPoint(index, index * 2, 0.2 + index / 200, 0, 0, index)
);
const simplified = simplifyPoints(points, 0.25);
expect(simplified.length).toBeLessThan(10);
expect(simplified[0]).toEqual(points[0]);
expect(simplified[simplified.length - 1]).toEqual(points[points.length - 1]);
});
});
describe("stroke editing geometry", () => {
const horizontalStroke = createStroke("pen", "adaptive", 4, 1, [
createInkPoint(10, 10, 0.5, 0, 0, 0),
createInkPoint(100, 10, 0.5, 0, 0, 1)
]);
it("hits a stroke using both eraser and stroke radii", () => {
expect(hitTestStroke(horizontalStroke, { x: 50, y: 15 }, 4)).toBe(true);
expect(hitTestStroke(horizontalStroke, { x: 50, y: 30 }, 4)).toBe(false);
});
it("selects strokes enclosed by a lasso", () => {
const polygon = [
{ x: 0, y: 0 },
{ x: 120, y: 0 },
{ x: 120, y: 30 },
{ x: 0, y: 30 }
];
expect(pointInPolygon({ x: 50, y: 10 }, polygon)).toBe(true);
expect(selectStrokesInPolygon([horizontalStroke], polygon)).toEqual(new Set([horizontalStroke.id]));
});
it("moves only selected strokes", () => {
const other = createStroke("highlighter", "#ffff00", 18, 0.34, [
createInkPoint(5, 50, 0.5, 0, 0, 0)
]);
const strokes = [horizontalStroke, other];
translateStrokes(strokes, new Set([horizontalStroke.id]), 8, -3);
expect(horizontalStroke.points[0]).toMatchObject({ x: 18, y: 7 });
expect(other.points[0]).toMatchObject({ x: 5, y: 50 });
});
});
describe("document safety", () => {
it("deep-clones strokes before history or persistence writes", () => {
const document = emptyDocument("Notes/Test.md");
document.strokes.push(createStroke("pen", "adaptive", 3, 1, [createInkPoint(1, 2, 0.5, 0, 0, 0)]));
const clone = cloneDocument(document);
clone.strokes[0].points[0].x = 99;
expect(document.strokes[0].points[0].x).toBe(1);
});
it("creates a closed SVG path from a freehand outline", () => {
const path = svgPathFromOutline([[0, 0], [10, 0], [10, 10], [0, 10]]);
expect(path.startsWith("M 0.00 0.00 Q")).toBe(true);
expect(path.endsWith("Z")).toBe(true);
});
});

77
tests/storage.test.ts Normal file
View file

@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInkPoint, createStroke } from "../src/model";
import { InkStore, type PluginDataHost } from "../src/storage";
afterEach(() => vi.unstubAllGlobals());
describe("InkStore", () => {
it("loads compact point tuples and sanitizes persisted values", async () => {
const host: PluginDataHost = {
loadData: async () => ({
version: 1,
settings: { penWidth: 999, palmRejection: false },
documents: {
"Notes/Test.md": {
version: 1,
notePath: "Notes/Test.md",
updatedAt: 10,
strokes: [{
id: "stroke-1",
tool: "pen",
color: "adaptive",
width: 500,
opacity: 2,
points: [[1, 2, 4, -100, 100, -1]]
}]
}
}
}),
saveData: async () => undefined
};
const store = new InkStore(host);
await store.load();
expect(store.settings.penWidth).toBe(20);
expect(store.settings.palmRejection).toBe(false);
expect(store.getDocument("Notes/Test.md").strokes[0]).toMatchObject({ width: 80, opacity: 1 });
expect(store.getDocument("Notes/Test.md").strokes[0].points[0]).toEqual({
x: 1,
y: 2,
pressure: 1,
tiltX: -90,
tiltY: 90,
time: 0
});
});
it("writes points as compact, quantized tuples", async () => {
vi.stubGlobal("window", globalThis);
let saved: unknown;
const host: PluginDataHost = {
loadData: async () => null,
saveData: async (data) => {
saved = data;
}
};
const store = new InkStore(host);
await store.load();
const document = store.getDocument("Notes/Test.md");
document.strokes.push(createStroke("pen", "adaptive", 3.2, 1, [
createInkPoint(1.2345, 9.8765, 0.45678, 12.34, -56.78, 99.6)
]));
store.putDocument(document);
await store.flush();
const data = saved as {
documents: Record<string, { strokes: Array<{ points: number[][] }> }>;
};
expect(data.documents["Notes/Test.md"].strokes[0].points[0]).toEqual([
1.23,
9.88,
0.457,
12.3,
-56.8,
100
]);
});
});

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2018",
"allowJs": false,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"moduleResolution": "Bundler",
"importHelpers": true,
"esModuleInterop": true,
"lib": ["DOM", "ES2018"],
"types": ["node"],
"useUnknownInCatchVariables": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFile, writeFile } from "node:fs/promises";
import process from "node:process";
const version = process.env.npm_package_version;
if (!version) throw new Error("npm_package_version is unavailable. Run this through npm version.");
const manifest = JSON.parse(await readFile("manifest.json", "utf8"));
const versions = JSON.parse(await readFile("versions.json", "utf8"));
manifest.version = version;
versions[version] = manifest.minAppVersion;
await writeFile("manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
await writeFile("versions.json", `${JSON.stringify(versions, null, 2)}\n`);

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.7.2"
}