mirror of
https://github.com/sirwanafifi/inkplane.git
synced 2026-07-22 08:32:24 +00:00
Initial Ink Layer release
This commit is contained in:
commit
564f2b4af1
26 changed files with 4862 additions and 0 deletions
25
.github/workflows/ci.yml
vendored
Normal file
25
.github/workflows/ci.yml
vendored
Normal 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
36
.github/workflows/release.yml
vendored
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
main.js
|
||||
*.map
|
||||
.DS_Store
|
||||
coverage/
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22
|
||||
11
CHANGELOG.md
Normal file
11
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
79
README.md
Normal 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 tool’s 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 plugin’s 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 note’s 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 Obsidian’s 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
26
esbuild.config.mjs
Normal 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
10
manifest.json
Normal 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
2270
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
package.json
Normal file
43
package.json
Normal 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
37
scripts/check-release.mjs
Normal 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
743
src/controller.ts
Normal 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
264
src/main.ts
Normal 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
273
src/model.ts
Normal 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
245
src/render.ts
Normal 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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
142
src/settings.ts
Normal file
142
src/settings.ts
Normal 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 Obsidian’s 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
228
src/storage.ts
Normal 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
73
src/types.ts
Normal 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
134
styles.css
Normal 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
79
tests/model.test.ts
Normal 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
77
tests/storage.test.ts
Normal 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
22
tsconfig.json
Normal 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
14
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.7.2"
|
||||
}
|
||||
Loading…
Reference in a new issue