mirror of
https://github.com/sirwanafifi/inkplane.git
synced 2026-07-22 08:32:24 +00:00
Launch Inkplane infinite canvas (#1)
Rebrand Ink Layer as Inkplane and introduce standalone infinite-canvas drawing files with pen-first tools, stroke-framed embeds, and refreshed release metadata.
This commit is contained in:
parent
564f2b4af1
commit
27141f27b1
23 changed files with 2836 additions and 1087 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -32,5 +32,5 @@ jobs:
|
|||
gh release create "$GITHUB_REF_NAME"
|
||||
main.js manifest.json styles.css
|
||||
--verify-tag
|
||||
--title "Ink Layer $GITHUB_REF_NAME"
|
||||
--title "Inkplane $GITHUB_REF_NAME"
|
||||
--generate-notes
|
||||
|
|
|
|||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -1,6 +1,16 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to Ink Layer are documented here.
|
||||
All notable changes to Inkplane are documented here.
|
||||
|
||||
## 0.2.0 — 2026-07-16
|
||||
|
||||
- Rebranded Ink Layer as Inkplane to reflect the new standalone infinite-canvas experience.
|
||||
- Replaced Markdown note overlays with standalone `.inklayer` drawing files.
|
||||
- Added a dedicated edge-to-edge infinite canvas with Pencil drawing, one-finger pan, pinch zoom, trackpad navigation, and stroke-aware fit controls.
|
||||
- Added read-only Markdown embeds with configurable dimensions and an open-source drawing control.
|
||||
- Added commands to create, insert, choose, export, and clear drawings.
|
||||
- Added a safe legacy conversion command; version 0.1 overlay data remains intact as a backup.
|
||||
- Added background-save flushing and standalone file-format recovery tests.
|
||||
|
||||
## 0.1.0 — 2026-07-16
|
||||
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -1,59 +1,59 @@
|
|||
# Ink Layer
|
||||
# Inkplane
|
||||
|
||||
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.
|
||||
Inkplane adds a pressure-sensitive infinite drawing canvas to Obsidian. Each drawing is a normal `.inklayer` file in your vault. Open it as a dedicated pen-first workspace, then embed it in any Markdown note with ordinary Obsidian embed syntax.
|
||||
|
||||
## Installation
|
||||
|
||||
### BRAT
|
||||
|
||||
Until Ink Layer is approved for the Obsidian Community directory, the easiest installation method is [BRAT](https://github.com/TfTHacker/obsidian42-brat):
|
||||
Until Inkplane is approved for the Obsidian Community directory, install it with [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**.
|
||||
3. Enter `https://github.com/SirwanAfifi/inkplane`.
|
||||
4. Enable **Inkplane** 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).
|
||||
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/SirwanAfifi/inkplane/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**.
|
||||
3. Copy the three files into that folder, reload Obsidian, and enable **Inkplane**.
|
||||
|
||||
## 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.
|
||||
- Dedicated `.inklayer` files instead of a drawing overlay tied to Markdown layout.
|
||||
- An edge-to-edge infinite canvas with pan, trackpad navigation, pinch-to-zoom, stroke-aware fit-to-view, and zoom controls.
|
||||
- Pencil-first input: Apple Pencil draws while one finger pans and two fingers zoom by default.
|
||||
- Pressure-sensitive smoothed strokes using coalesced pointer samples when the WebView provides them.
|
||||
- Pen, translucent highlighter, whole-stroke eraser, lasso selection, move, undo, and redo.
|
||||
- Read-only Markdown previews that never capture note scrolling; select the expand control or double-click to open the source canvas.
|
||||
- Theme-adaptive pen color, SVG export, iPad-safe controls, and save flushing when the app moves to the background.
|
||||
|
||||
## 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.
|
||||
Run **Inkplane: Create new drawing** or select the pen icon in the ribbon. New drawings are stored in the `Inkplane` folder by default.
|
||||
|
||||
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.
|
||||
From a Markdown note, run **Inkplane: Insert new drawing in current note** to create, embed, and open a canvas in one step. Use **Insert existing drawing in current note** to choose a drawing already in the vault.
|
||||
|
||||
## Storage and privacy
|
||||
The generated Markdown is ordinary Obsidian syntax:
|
||||
|
||||
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.
|
||||
```md
|
||||
![[Inkplane/Project sketch.inklayer|800x600]]
|
||||
```
|
||||
|
||||
The SVG export command writes a normal attachment into the vault using the configured attachment location.
|
||||
Change `800x600` to control that preview’s dimensions. The defaults and the destination folder are configurable in Inkplane settings.
|
||||
|
||||
## Storage, migration, and privacy
|
||||
|
||||
Every new drawing is readable JSON inside its `.inklayer` vault file. Points are quantized into compact tuples, while strokes retain pressure and tilt data. This makes drawings follow the same Sync, backup, rename, and file-management workflow as the rest of the vault.
|
||||
|
||||
Ink created by version 0.1 remains untouched in the plugin’s `data.json`. Open its original note and run **Convert legacy ink from current note to a drawing** to copy those strokes into a standalone file and insert an embed. The legacy copy is deliberately retained as a backup.
|
||||
|
||||
Inkplane makes no network requests and has no telemetry. SVG export writes a normal attachment into the vault.
|
||||
|
||||
## 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.
|
||||
Inkplane uses the web-standard `PointerEvent` pen type, pressure, tilt, 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.
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -64,15 +64,9 @@ 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.
|
||||
The runtime uses Obsidian’s mobile-safe APIs and does not import Node or Electron APIs. Issues and feature requests are tracked on [GitHub](https://github.com/SirwanAfifi/inkplane/issues).
|
||||
|
||||
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)
|
||||
The internal plugin ID remains `ink-layer`, and drawing files retain the `.inklayer` extension. This preserves updates, existing installations, embeds, and vault data across the rebrand.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import builtins from "builtin-modules";
|
|||
const production = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: "/* Ink Layer for Obsidian */" },
|
||||
banner: { js: "/* Inkplane for Obsidian */" },
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: ["obsidian", "electron", "@codemirror/*", "@lezer/*", ...builtins],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "ink-layer",
|
||||
"name": "Ink Layer",
|
||||
"version": "0.1.0",
|
||||
"name": "Inkplane",
|
||||
"version": "0.2.0",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Pressure-sensitive handwriting and annotation layers designed for Apple Pencil and other pens.",
|
||||
"description": "An infinite handwriting canvas with note embeds, designed for Apple Pencil and other pens.",
|
||||
"author": "Sirwan Afifi",
|
||||
"authorUrl": "https://github.com/SirwanAfifi",
|
||||
"isDesktopOnly": false
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "ink-layer",
|
||||
"version": "0.1.0",
|
||||
"name": "inkplane",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ink-layer",
|
||||
"version": "0.1.0",
|
||||
"name": "inkplane",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"perfect-freehand": "^1.2.3"
|
||||
|
|
|
|||
14
package.json
14
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "ink-layer",
|
||||
"version": "0.1.0",
|
||||
"description": "Pencil-first, pressure-sensitive handwriting layers for Obsidian.",
|
||||
"name": "inkplane",
|
||||
"version": "0.2.0",
|
||||
"description": "A Pencil-first infinite drawing canvas with embeds for Obsidian.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -16,18 +16,20 @@
|
|||
"keywords": [
|
||||
"obsidian",
|
||||
"apple-pencil",
|
||||
"canvas",
|
||||
"handwriting",
|
||||
"infinite-canvas",
|
||||
"ink"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/SirwanAfifi/ink-layer.git"
|
||||
"url": "git+https://github.com/SirwanAfifi/inkplane.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/SirwanAfifi/ink-layer/issues"
|
||||
"url": "https://github.com/SirwanAfifi/inkplane/issues"
|
||||
},
|
||||
"homepage": "https://github.com/SirwanAfifi/ink-layer#readme",
|
||||
"homepage": "https://github.com/SirwanAfifi/inkplane#readme",
|
||||
"dependencies": {
|
||||
"perfect-freehand": "^1.2.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,14 +2,27 @@ import { access, readFile } from "node:fs/promises";
|
|||
import process from "node:process";
|
||||
|
||||
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const packageLock = JSON.parse(await readFile("package-lock.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 (manifest.id !== "ink-layer") {
|
||||
errors.push(`manifest id must remain ink-layer for update compatibility: ${manifest.id}`);
|
||||
}
|
||||
if (manifest.name !== "Inkplane") {
|
||||
errors.push(`manifest name must be Inkplane: ${manifest.name}`);
|
||||
}
|
||||
if (packageJson.name !== "inkplane" || packageLock.name !== "inkplane" || packageLock.packages?.[""]?.name !== "inkplane") {
|
||||
errors.push("package metadata must use the Inkplane brand");
|
||||
}
|
||||
if (packageJson.version !== manifest.version) {
|
||||
errors.push(`package.json is ${packageJson.version}, but manifest.json is ${manifest.version}`);
|
||||
}
|
||||
if (packageLock.version !== manifest.version || packageLock.packages?.[""]?.version !== manifest.version) {
|
||||
errors.push(`package-lock.json does not match ${manifest.version}`);
|
||||
}
|
||||
if (versions[manifest.version] !== manifest.minAppVersion) {
|
||||
errors.push(
|
||||
`versions.json must map ${manifest.version} to minimum Obsidian ${manifest.minAppVersion}`
|
||||
|
|
|
|||
|
|
@ -1,743 +0,0 @@
|
|||
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;
|
||||
}
|
||||
68
src/drawing-repository.ts
Normal file
68
src/drawing-repository.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { normalizePath, TFile, TFolder, type App } from "obsidian";
|
||||
import { emptyDrawing, serializeDrawingFile } from "./file-format";
|
||||
import type { InkDrawing, InkSettings } from "./types";
|
||||
|
||||
export const INK_EXTENSION = "inklayer";
|
||||
|
||||
export class DrawingRepository {
|
||||
constructor(private readonly app: App, private readonly getSettings: () => InkSettings) {}
|
||||
|
||||
async create(title = "Untitled drawing", drawing?: InkDrawing): Promise<TFile> {
|
||||
const settings = this.getSettings();
|
||||
const folder = normalizePath(settings.drawingFolder.trim());
|
||||
if (folder.length > 0 && folder !== "/") await this.ensureFolder(folder);
|
||||
const baseName = sanitizeFileName(title) || "Untitled drawing";
|
||||
const path = this.availablePath(folder, baseName);
|
||||
const initial = drawing ?? emptyDrawing(settings.defaultCanvasWidth, settings.defaultCanvasHeight);
|
||||
return this.app.vault.create(path, serializeDrawingFile(initial));
|
||||
}
|
||||
|
||||
drawingFiles(): TFile[] {
|
||||
return this.app.vault.getFiles()
|
||||
.filter((file) => file.extension.toLowerCase() === INK_EXTENSION)
|
||||
.sort((first, second) => first.path.localeCompare(second.path));
|
||||
}
|
||||
|
||||
resolve(linkPath: string, sourcePath: string): TFile | null {
|
||||
const withoutSubpath = linkPath.split("#", 1)[0].trim();
|
||||
const direct = this.app.vault.getAbstractFileByPath(normalizePath(withoutSubpath));
|
||||
if (direct instanceof TFile && direct.extension.toLowerCase() === INK_EXTENSION) return direct;
|
||||
const resolved = this.app.metadataCache.getFirstLinkpathDest(withoutSubpath, sourcePath);
|
||||
return resolved?.extension.toLowerCase() === INK_EXTENSION ? resolved : null;
|
||||
}
|
||||
|
||||
async open(file: TFile, newLeaf = false): Promise<void> {
|
||||
await this.app.workspace.getLeaf(newLeaf ? "tab" : false).openFile(file);
|
||||
}
|
||||
|
||||
private availablePath(folder: string, baseName: string): string {
|
||||
let suffix = 0;
|
||||
while (true) {
|
||||
const name = suffix === 0 ? baseName : `${baseName} ${suffix + 1}`;
|
||||
const candidate = normalizePath(`${folder.length > 0 ? `${folder}/` : ""}${name}.${INK_EXTENSION}`);
|
||||
if (!this.app.vault.getAbstractFileByPath(candidate)) return candidate;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureFolder(path: string): Promise<void> {
|
||||
const segments = normalizePath(path).split("/").filter(Boolean);
|
||||
let current = "";
|
||||
for (const segment of segments) {
|
||||
current = current.length > 0 ? `${current}/${segment}` : segment;
|
||||
const existing = this.app.vault.getAbstractFileByPath(current);
|
||||
if (existing instanceof TFolder) continue;
|
||||
if (existing) throw new Error(`${current} already exists and is not a folder.`);
|
||||
await this.app.vault.createFolder(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
return value
|
||||
.replace(/[\\/:*?"<>|#^[\]]/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^\.+|\.+$/g, "")
|
||||
.slice(0, 120);
|
||||
}
|
||||
880
src/drawing-surface.ts
Normal file
880
src/drawing-surface.ts
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import { cloneDrawing, emptyDrawing } from "./file-format";
|
||||
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 { ToolInspector, toolColor, toolWidth } from "./tool-inspector";
|
||||
import type { InkDrawing, InkPoint, InkSettings, InkStroke, InkTool, Point2D } from "./types";
|
||||
|
||||
const HISTORY_LIMIT = 75;
|
||||
const PALM_REJECTION_WINDOW_MS = 900;
|
||||
const MIN_ZOOM = 0.08;
|
||||
const MAX_ZOOM = 6;
|
||||
|
||||
type GestureMode = "draw" | "erase" | "lasso" | "move-selection" | null;
|
||||
|
||||
export interface DrawingSurfaceCallbacks {
|
||||
onChange(drawing: InkDrawing): void;
|
||||
onToolChange?(tool: InkTool): void;
|
||||
onSettingsChange?(): void;
|
||||
}
|
||||
|
||||
export class DrawingSurface {
|
||||
readonly root: HTMLDivElement;
|
||||
private readonly viewport: HTMLDivElement;
|
||||
private readonly dryCanvas: HTMLCanvasElement;
|
||||
private readonly wetCanvas: HTMLCanvasElement;
|
||||
private readonly toolbar: HTMLDivElement;
|
||||
private toolbarGroup: HTMLDivElement | null = null;
|
||||
private readonly toolInspector: ToolInspector;
|
||||
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 paletteDot: HTMLSpanElement;
|
||||
private readonly zoomButton: HTMLButtonElement;
|
||||
private readonly emptyState: HTMLDivElement;
|
||||
private readonly statusLabel: HTMLSpanElement;
|
||||
private readonly statusMeta: HTMLSpanElement;
|
||||
private readonly resizeObserver: ResizeObserver;
|
||||
private drawing: InkDrawing;
|
||||
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 navigationPointers = new Map<number, Point2D>();
|
||||
private scale = 1;
|
||||
private offsetX = 0;
|
||||
private offsetY = 0;
|
||||
private spacePressed = false;
|
||||
private shouldFit = true;
|
||||
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.onPointerCancel(event);
|
||||
private readonly handlePointerLeave = (event: PointerEvent): void => this.onPointerLeave(event);
|
||||
private readonly handleWheel = (event: WheelEvent): void => this.onWheel(event);
|
||||
private readonly handleKeyDown = (event: KeyboardEvent): void => this.onKeyDown(event);
|
||||
private readonly handleKeyUp = (event: KeyboardEvent): void => this.onKeyUp(event);
|
||||
|
||||
constructor(
|
||||
host: HTMLElement,
|
||||
private readonly store: InkStore,
|
||||
private readonly callbacks: DrawingSurfaceCallbacks,
|
||||
drawing?: InkDrawing
|
||||
) {
|
||||
const doc = host.ownerDocument;
|
||||
this.drawing = cloneDrawing(drawing ?? emptyDrawing(
|
||||
store.settings.defaultCanvasWidth,
|
||||
store.settings.defaultCanvasHeight
|
||||
));
|
||||
|
||||
this.root = doc.createElement("div");
|
||||
this.root.className = "ink-canvas-view";
|
||||
this.root.tabIndex = 0;
|
||||
|
||||
this.viewport = doc.createElement("div");
|
||||
this.viewport.className = "ink-canvas-viewport";
|
||||
this.viewport.setAttribute("aria-label", "Ink drawing canvas");
|
||||
this.root.appendChild(this.viewport);
|
||||
|
||||
this.dryCanvas = doc.createElement("canvas");
|
||||
this.dryCanvas.className = "ink-canvas-layer ink-canvas-layer-dry";
|
||||
this.dryCanvas.setAttribute("aria-hidden", "true");
|
||||
this.viewport.appendChild(this.dryCanvas);
|
||||
|
||||
this.wetCanvas = doc.createElement("canvas");
|
||||
this.wetCanvas.className = "ink-canvas-layer";
|
||||
this.wetCanvas.setAttribute("aria-hidden", "true");
|
||||
this.viewport.appendChild(this.wetCanvas);
|
||||
|
||||
this.toolbar = doc.createElement("div");
|
||||
this.toolbar.className = "ink-layer-toolbar";
|
||||
this.toolbar.setAttribute("role", "toolbar");
|
||||
this.toolbar.setAttribute("aria-label", "Ink tools");
|
||||
this.root.appendChild(this.toolbar);
|
||||
|
||||
this.startToolbarGroup("Drawing tools");
|
||||
this.addToolButton("pen", "pen-tool", "Pen");
|
||||
this.addToolButton("highlighter", "highlighter", "Highlighter");
|
||||
this.addToolButton("eraser", "eraser", "Stroke eraser");
|
||||
this.addToolButton("lasso", "lasso", "Lasso select");
|
||||
this.addToolButton("pan", "hand", "Pan canvas");
|
||||
this.paletteButton = this.addActionButton("sliders-horizontal", "Color and size", () => {
|
||||
this.toolInspector.toggle(this.tool);
|
||||
});
|
||||
this.paletteButton.classList.add("ink-tool-style");
|
||||
this.paletteButton.setAttribute("aria-haspopup", "dialog");
|
||||
this.paletteButton.setAttribute("aria-expanded", "false");
|
||||
this.paletteDot = doc.createElement("span");
|
||||
this.paletteDot.className = "ink-tool-color-dot";
|
||||
this.paletteDot.setAttribute("aria-hidden", "true");
|
||||
this.paletteButton.appendChild(this.paletteDot);
|
||||
|
||||
this.startToolbarGroup("History");
|
||||
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.startToolbarGroup("Canvas view");
|
||||
this.addActionButton("minus", "Zoom out", () => this.zoomBy(0.8));
|
||||
this.zoomButton = this.addTextButton("100%", "Fit drawing", () => this.fitToView());
|
||||
this.addActionButton("plus", "Zoom in", () => this.zoomBy(1.25));
|
||||
|
||||
this.emptyState = doc.createElement("div");
|
||||
this.emptyState.className = "ink-canvas-empty-state";
|
||||
this.emptyState.setAttribute("aria-hidden", "true");
|
||||
const emptyIcon = doc.createElement("span");
|
||||
emptyIcon.className = "ink-empty-icon";
|
||||
setIcon(emptyIcon, "pen-tool");
|
||||
const emptyTitle = doc.createElement("strong");
|
||||
emptyTitle.textContent = "Draw with Apple Pencil";
|
||||
const emptyHint = doc.createElement("span");
|
||||
emptyHint.textContent = "One finger pans · two fingers zoom";
|
||||
this.emptyState.append(emptyIcon, emptyTitle, emptyHint);
|
||||
this.viewport.appendChild(this.emptyState);
|
||||
|
||||
const status = doc.createElement("div");
|
||||
status.className = "ink-canvas-status";
|
||||
this.statusLabel = doc.createElement("span");
|
||||
this.statusLabel.className = "ink-canvas-status-tool";
|
||||
this.statusMeta = doc.createElement("span");
|
||||
this.statusMeta.className = "ink-canvas-status-meta";
|
||||
status.append(this.statusLabel, this.statusMeta);
|
||||
this.root.appendChild(status);
|
||||
|
||||
this.toolInspector = new ToolInspector(
|
||||
this.root,
|
||||
() => this.settings,
|
||||
(patch) => this.applyToolSetting(patch),
|
||||
() => this.updateToolbar()
|
||||
);
|
||||
|
||||
host.replaceChildren(this.root);
|
||||
this.renderer = new InkRenderer(this.dryCanvas, this.wetCanvas, this.root);
|
||||
|
||||
this.viewport.addEventListener("pointerdown", this.handlePointerDown, { capture: true });
|
||||
this.viewport.addEventListener("pointermove", this.handlePointerMove, { capture: true });
|
||||
this.viewport.addEventListener("pointerup", this.handlePointerUp, { capture: true });
|
||||
this.viewport.addEventListener("pointercancel", this.handlePointerCancel, { capture: true });
|
||||
this.viewport.addEventListener("pointerleave", this.handlePointerLeave, { capture: true });
|
||||
this.viewport.addEventListener("wheel", this.handleWheel, { passive: false });
|
||||
this.root.addEventListener("keydown", this.handleKeyDown, { capture: true });
|
||||
this.root.addEventListener("keyup", this.handleKeyUp, { capture: true });
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.shouldFit) this.fitToView();
|
||||
else {
|
||||
this.dryLayerDirty = true;
|
||||
this.scheduleRender();
|
||||
}
|
||||
});
|
||||
this.resizeObserver.observe(this.viewport);
|
||||
|
||||
this.refreshSettings();
|
||||
this.updateToolbar();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
get currentTool(): InkTool {
|
||||
return this.tool;
|
||||
}
|
||||
|
||||
get hasInk(): boolean {
|
||||
return this.drawing.strokes.length > 0;
|
||||
}
|
||||
|
||||
getDrawing(): InkDrawing {
|
||||
return cloneDrawing(this.drawing);
|
||||
}
|
||||
|
||||
setDrawing(drawing: InkDrawing, clearHistory = true): void {
|
||||
this.cancelGesture();
|
||||
this.drawing = cloneDrawing(drawing);
|
||||
this.selectedIds.clear();
|
||||
if (clearHistory) {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.shouldFit = true;
|
||||
}
|
||||
this.dryLayerDirty = true;
|
||||
this.updateToolbar();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
setTool(tool: InkTool): void {
|
||||
if (this.tool === tool) return;
|
||||
this.tool = tool;
|
||||
if (tool !== "lasso") this.selectedIds.clear();
|
||||
if (tool !== "eraser") this.eraserPoint = null;
|
||||
this.toolInspector.switchTool(tool);
|
||||
this.updateToolbar();
|
||||
this.callbacks.onToolChange?.(tool);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
refreshSettings(): void {
|
||||
this.root.classList.toggle("toolbar-at-bottom", this.settings.toolbarPosition === "bottom");
|
||||
this.toolInspector.refresh();
|
||||
this.updateToolbar();
|
||||
this.dryLayerDirty = true;
|
||||
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.drawing.strokes));
|
||||
this.drawing.strokes = cloneStrokes(previous);
|
||||
this.selectedIds.clear();
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.redoStack.length === 0 || this.activePointerId !== null) return;
|
||||
const next = this.redoStack.pop();
|
||||
if (!next) return;
|
||||
this.undoStack.push(cloneStrokes(this.drawing.strokes));
|
||||
this.drawing.strokes = cloneStrokes(next);
|
||||
this.selectedIds.clear();
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
deleteSelection(): void {
|
||||
if (this.selectedIds.size === 0) return;
|
||||
const before = cloneStrokes(this.drawing.strokes);
|
||||
this.drawing.strokes = this.drawing.strokes.filter((stroke) => !this.selectedIds.has(stroke.id));
|
||||
this.selectedIds.clear();
|
||||
this.commit(before);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
if (this.drawing.strokes.length === 0) return;
|
||||
const before = cloneStrokes(this.drawing.strokes);
|
||||
this.drawing.strokes = [];
|
||||
this.selectedIds.clear();
|
||||
this.commit(before);
|
||||
}
|
||||
|
||||
exportSvg(): string | null {
|
||||
return exportStrokesToSvg(this.drawing.strokes, this.root);
|
||||
}
|
||||
|
||||
fitToView(): void {
|
||||
const rect = this.viewport.getBoundingClientRect();
|
||||
if (rect.width < 2 || rect.height < 2) return;
|
||||
const padding = Math.min(72, Math.max(24, rect.width * 0.06));
|
||||
const bounds = combinedBounds(this.drawing.strokes);
|
||||
if (bounds) {
|
||||
const width = Math.max(1, bounds.maxX - bounds.minX);
|
||||
const height = Math.max(1, bounds.maxY - bounds.minY);
|
||||
this.scale = clamp(Math.min(
|
||||
(rect.width - padding * 2) / width,
|
||||
(rect.height - padding * 2) / height,
|
||||
1
|
||||
), MIN_ZOOM, MAX_ZOOM);
|
||||
this.offsetX = (rect.width - width * this.scale) / 2 - bounds.minX * this.scale;
|
||||
this.offsetY = (rect.height - height * this.scale) / 2 - bounds.minY * this.scale;
|
||||
} else {
|
||||
this.scale = 1;
|
||||
this.offsetX = rect.width / 2 - this.drawing.width / 2;
|
||||
this.offsetY = rect.height / 2 - this.drawing.height / 2;
|
||||
}
|
||||
this.shouldFit = false;
|
||||
this.markCameraChanged();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.cancelGesture();
|
||||
if (this.animationFrame !== null) window.cancelAnimationFrame(this.animationFrame);
|
||||
this.resizeObserver.disconnect();
|
||||
this.viewport.removeEventListener("pointerdown", this.handlePointerDown, { capture: true });
|
||||
this.viewport.removeEventListener("pointermove", this.handlePointerMove, { capture: true });
|
||||
this.viewport.removeEventListener("pointerup", this.handlePointerUp, { capture: true });
|
||||
this.viewport.removeEventListener("pointercancel", this.handlePointerCancel, { capture: true });
|
||||
this.viewport.removeEventListener("pointerleave", this.handlePointerLeave, { capture: true });
|
||||
this.viewport.removeEventListener("wheel", this.handleWheel);
|
||||
this.root.removeEventListener("keydown", this.handleKeyDown, { capture: true });
|
||||
this.root.removeEventListener("keyup", this.handleKeyUp, { capture: true });
|
||||
this.root.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 = this.toolbar.ownerDocument.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) => {
|
||||
consumeEvent(event);
|
||||
action(event);
|
||||
this.root.focus({ preventScroll: true });
|
||||
});
|
||||
(this.toolbarGroup ?? this.toolbar).appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
private addTextButton(text: string, label: string, action: () => void): HTMLButtonElement {
|
||||
const button = this.toolbar.ownerDocument.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ink-layer-tool ink-layer-zoom";
|
||||
button.textContent = text;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.setAttribute("title", label);
|
||||
button.addEventListener("click", (event) => {
|
||||
consumeEvent(event);
|
||||
action();
|
||||
this.root.focus({ preventScroll: true });
|
||||
});
|
||||
(this.toolbarGroup ?? this.toolbar).appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
private startToolbarGroup(label: string): void {
|
||||
const group = this.toolbar.ownerDocument.createElement("div");
|
||||
group.className = "ink-toolbar-group";
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-label", label);
|
||||
this.toolbar.appendChild(group);
|
||||
this.toolbarGroup = group;
|
||||
}
|
||||
|
||||
private applyToolSetting(patch: Partial<InkSettings>): void {
|
||||
this.store.updateSettings(patch);
|
||||
if (this.callbacks.onSettingsChange) this.callbacks.onSettingsChange();
|
||||
else this.refreshSettings();
|
||||
}
|
||||
|
||||
private onPointerDown(event: PointerEvent): void {
|
||||
this.toolInspector.close();
|
||||
if (event.pointerType === "pen") this.lastPenSignal = Date.now();
|
||||
this.root.focus({ preventScroll: true });
|
||||
|
||||
if (this.shouldRejectPalm(event)) {
|
||||
consumeEvent(event);
|
||||
return;
|
||||
}
|
||||
if (this.shouldNavigate(event)) {
|
||||
consumeEvent(event);
|
||||
this.beginNavigation(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.drawing.strokes);
|
||||
this.gestureChanged = false;
|
||||
this.capturePointer(event.pointerId);
|
||||
|
||||
const point = this.toDrawingPoint(event);
|
||||
const gestureTool = isEraserButton(event) ? "eraser" : this.tool;
|
||||
if (gestureTool === "pen" || gestureTool === "highlighter") {
|
||||
this.gestureMode = "draw";
|
||||
this.selectedIds.clear();
|
||||
this.draft = createStroke(
|
||||
gestureTool,
|
||||
gestureTool === "pen" ? this.settings.penColor : this.settings.highlighterColor,
|
||||
gestureTool === "pen" ? this.settings.penWidth : this.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 if (gestureTool === "lasso") {
|
||||
const bounds = combinedBounds(this.drawing.strokes.filter((stroke) => this.selectedIds.has(stroke.id)));
|
||||
if (bounds && pointInBounds(point, bounds, 12 / this.scale)) {
|
||||
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.navigationPointers.has(event.pointerId)) {
|
||||
consumeEvent(event);
|
||||
this.updateNavigation(event);
|
||||
return;
|
||||
}
|
||||
if (this.shouldRejectPalm(event)) {
|
||||
consumeEvent(event);
|
||||
return;
|
||||
}
|
||||
if (this.activePointerId !== event.pointerId) {
|
||||
if (this.tool === "eraser" && this.canDrawWith(event)) {
|
||||
this.eraserPoint = this.toDrawingPoint(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.toDrawingPoint(sample);
|
||||
this.eraserPoint = point;
|
||||
this.eraseAt(point);
|
||||
}
|
||||
} else if (this.gestureMode === "lasso") {
|
||||
for (const sample of events) this.appendLassoPoint(this.toDrawingPoint(sample));
|
||||
} else if (this.gestureMode === "move-selection") {
|
||||
const point = this.toDrawingPoint(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.drawing.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.navigationPointers.has(event.pointerId)) {
|
||||
consumeEvent(event);
|
||||
this.endNavigation(event.pointerId);
|
||||
return;
|
||||
}
|
||||
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, 0.35 / this.scale);
|
||||
if (this.draft.points.length > 0) {
|
||||
this.drawing.strokes.push(this.draft);
|
||||
this.gestureChanged = true;
|
||||
}
|
||||
} else if (this.gestureMode === "lasso") {
|
||||
this.appendLassoPoint(this.toDrawingPoint(event));
|
||||
this.selectedIds = selectStrokesInPolygon(this.drawing.strokes, this.lassoPoints);
|
||||
}
|
||||
this.finishGesture();
|
||||
}
|
||||
|
||||
private onPointerCancel(event: PointerEvent): void {
|
||||
if (this.navigationPointers.has(event.pointerId)) {
|
||||
this.endNavigation(event.pointerId);
|
||||
return;
|
||||
}
|
||||
if (this.activePointerId !== event.pointerId) return;
|
||||
this.cancelGesture();
|
||||
this.dryLayerDirty = true;
|
||||
this.updateToolbar();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
private onPointerLeave(event: PointerEvent): void {
|
||||
if (event.pointerType === "pen") this.lastPenSignal = Date.now();
|
||||
if (this.activePointerId === null && !this.navigationPointers.has(event.pointerId)) {
|
||||
this.eraserPoint = null;
|
||||
this.scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
private onWheel(event: WheelEvent): void {
|
||||
consumeEvent(event);
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const rect = this.viewport.getBoundingClientRect();
|
||||
const anchor = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
this.setZoom(this.scale * Math.exp(-event.deltaY * 0.002), anchor);
|
||||
} else {
|
||||
this.offsetX -= event.deltaX;
|
||||
this.offsetY -= event.deltaY;
|
||||
this.markCameraChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyDown(event: KeyboardEvent): void {
|
||||
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 === "0" && commandKey) {
|
||||
consumeEvent(event);
|
||||
this.fitToView();
|
||||
return;
|
||||
}
|
||||
if (event.code === "Space" && !event.repeat) {
|
||||
consumeEvent(event);
|
||||
this.spacePressed = true;
|
||||
this.root.classList.add("is-panning");
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && this.toolInspector.isOpen) {
|
||||
consumeEvent(event);
|
||||
this.toolInspector.close();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && this.selectedIds.size > 0) {
|
||||
consumeEvent(event);
|
||||
this.selectedIds.clear();
|
||||
this.updateToolbar();
|
||||
this.scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyUp(event: KeyboardEvent): void {
|
||||
if (event.code !== "Space") return;
|
||||
this.spacePressed = false;
|
||||
this.root.classList.remove("is-panning");
|
||||
}
|
||||
|
||||
private beginNavigation(event: PointerEvent): void {
|
||||
this.navigationPointers.set(event.pointerId, this.toViewportPoint(event));
|
||||
this.capturePointer(event.pointerId);
|
||||
this.root.classList.add("is-panning");
|
||||
}
|
||||
|
||||
private updateNavigation(event: PointerEvent): void {
|
||||
const previousPoints = [...this.navigationPointers.values()];
|
||||
this.navigationPointers.set(event.pointerId, this.toViewportPoint(event));
|
||||
const nextPoints = [...this.navigationPointers.values()];
|
||||
if (previousPoints.length >= 2 && nextPoints.length >= 2) {
|
||||
const oldCenter = midpoint(previousPoints[0], previousPoints[1]);
|
||||
const newCenter = midpoint(nextPoints[0], nextPoints[1]);
|
||||
const oldDistance = Math.max(1, distance(previousPoints[0], previousPoints[1]));
|
||||
const newDistance = Math.max(1, distance(nextPoints[0], nextPoints[1]));
|
||||
const world = {
|
||||
x: (oldCenter.x - this.offsetX) / this.scale,
|
||||
y: (oldCenter.y - this.offsetY) / this.scale
|
||||
};
|
||||
this.scale = clamp(this.scale * (newDistance / oldDistance), MIN_ZOOM, MAX_ZOOM);
|
||||
this.offsetX = newCenter.x - world.x * this.scale;
|
||||
this.offsetY = newCenter.y - world.y * this.scale;
|
||||
} else if (previousPoints[0] && nextPoints[0]) {
|
||||
this.offsetX += nextPoints[0].x - previousPoints[0].x;
|
||||
this.offsetY += nextPoints[0].y - previousPoints[0].y;
|
||||
}
|
||||
this.markCameraChanged();
|
||||
}
|
||||
|
||||
private endNavigation(pointerId: number): void {
|
||||
this.navigationPointers.delete(pointerId);
|
||||
this.releasePointer(pointerId);
|
||||
if (this.navigationPointers.size === 0) this.root.classList.remove("is-panning");
|
||||
}
|
||||
|
||||
private finishGesture(): void {
|
||||
const pointerId = this.activePointerId;
|
||||
if (pointerId !== null) this.releasePointer(pointerId);
|
||||
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.drawing.strokes = cloneStrokes(this.gestureBefore);
|
||||
if (this.activePointerId !== null) this.releasePointer(this.activePointerId);
|
||||
for (const pointerId of this.navigationPointers.keys()) this.releasePointer(pointerId);
|
||||
this.navigationPointers.clear();
|
||||
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;
|
||||
this.root.classList.remove("is-panning");
|
||||
}
|
||||
|
||||
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 / (this.scale * this.scale)) 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 / (this.scale * this.scale)) return;
|
||||
this.lassoPoints.push(point);
|
||||
}
|
||||
|
||||
private eraseAt(point: Point2D): void {
|
||||
const radius = this.settings.eraserWidth / 2;
|
||||
const nextStrokes = this.drawing.strokes.filter((stroke) => !hitTestStroke(stroke, point, radius));
|
||||
if (nextStrokes.length !== this.drawing.strokes.length) {
|
||||
this.drawing.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.notifyChange();
|
||||
}
|
||||
|
||||
private notifyChange(): void {
|
||||
this.drawing.updatedAt = Date.now();
|
||||
this.dryLayerDirty = true;
|
||||
this.callbacks.onChange(this.getDrawing());
|
||||
this.updateToolbar();
|
||||
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" || this.tool === "pan";
|
||||
this.paletteButton.classList.toggle("is-selected", this.toolInspector.isOpen);
|
||||
this.paletteButton.setAttribute("aria-expanded", this.toolInspector.isOpen ? "true" : "false");
|
||||
const color = toolColor(this.tool, this.settings);
|
||||
this.paletteDot.style.setProperty("--ink-tool-color", color === "adaptive" ? "var(--text-normal)" : color);
|
||||
this.paletteDot.classList.toggle("is-eraser", this.tool === "eraser");
|
||||
this.zoomButton.textContent = `${Math.round(this.scale * 100)}%`;
|
||||
const width = toolWidth(this.tool, this.settings);
|
||||
const labels: Record<InkTool, string> = {
|
||||
pen: "Pen",
|
||||
highlighter: "Highlighter",
|
||||
eraser: "Eraser",
|
||||
lasso: "Lasso select",
|
||||
pan: "Pan canvas"
|
||||
};
|
||||
this.statusLabel.textContent = width > 0 ? `${labels[this.tool]} · ${formatToolWidth(width)}` : labels[this.tool];
|
||||
this.statusMeta.textContent = "Infinite canvas";
|
||||
const hasInk = this.drawing.strokes.length > 0;
|
||||
this.root.classList.toggle("has-ink", hasInk);
|
||||
this.emptyState.setAttribute("aria-hidden", hasInk ? "true" : "false");
|
||||
}
|
||||
|
||||
private zoomBy(factor: number): void {
|
||||
const rect = this.viewport.getBoundingClientRect();
|
||||
this.setZoom(this.scale * factor, { x: rect.width / 2, y: rect.height / 2 });
|
||||
}
|
||||
|
||||
private setZoom(nextScale: number, anchor: Point2D): void {
|
||||
const worldX = (anchor.x - this.offsetX) / this.scale;
|
||||
const worldY = (anchor.y - this.offsetY) / this.scale;
|
||||
this.scale = clamp(nextScale, MIN_ZOOM, MAX_ZOOM);
|
||||
this.offsetX = anchor.x - worldX * this.scale;
|
||||
this.offsetY = anchor.y - worldY * this.scale;
|
||||
this.markCameraChanged();
|
||||
}
|
||||
|
||||
private markCameraChanged(): void {
|
||||
this.shouldFit = false;
|
||||
this.dryLayerDirty = true;
|
||||
this.updateToolbar();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
private scheduleRender(): void {
|
||||
if (this.animationFrame !== null || this.destroyed) return;
|
||||
this.animationFrame = window.requestAnimationFrame(() => {
|
||||
this.animationFrame = null;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const rect = this.viewport.getBoundingClientRect();
|
||||
if (this.renderer.resize(rect.width, rect.height)) this.dryLayerDirty = true;
|
||||
const state: InkRenderState = {
|
||||
scale: this.scale,
|
||||
offsetX: this.offsetX,
|
||||
offsetY: this.offsetY,
|
||||
selectedIds: this.selectedIds,
|
||||
lassoPoints: this.lassoPoints,
|
||||
eraserPoint: this.eraserPoint,
|
||||
eraserRadius: this.settings.eraserWidth / 2
|
||||
};
|
||||
this.renderer.render(this.drawing.strokes, this.draft, state, this.dryLayerDirty);
|
||||
this.dryLayerDirty = false;
|
||||
}
|
||||
|
||||
private toViewportPoint(event: PointerEvent): Point2D {
|
||||
const rect = this.viewport.getBoundingClientRect();
|
||||
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
}
|
||||
|
||||
private toDrawingPoint(event: PointerEvent): Point2D {
|
||||
const point = this.toViewportPoint(event);
|
||||
return {
|
||||
x: (point.x - this.offsetX) / this.scale,
|
||||
y: (point.y - this.offsetY) / this.scale
|
||||
};
|
||||
}
|
||||
|
||||
private eventToInkPoint(event: PointerEvent): InkPoint {
|
||||
const point = this.toDrawingPoint(event);
|
||||
const rawPressure = event.pressure > 0 ? event.pressure : event.pointerType === "pen" ? 0.12 : 0.5;
|
||||
const sensitivity = this.settings.pressureSensitivity;
|
||||
return createInkPoint(
|
||||
point.x,
|
||||
point.y,
|
||||
0.5 + (rawPressure - 0.5) * sensitivity,
|
||||
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 shouldNavigate(event: PointerEvent): boolean {
|
||||
if (isEraserButton(event)) return false;
|
||||
if (event.pointerType === "touch") return !this.settings.allowFingerDrawing;
|
||||
return this.tool === "pan" || this.spacePressed || (event.pointerType === "mouse" && event.button === 1);
|
||||
}
|
||||
|
||||
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 capturePointer(pointerId: number): void {
|
||||
try {
|
||||
this.viewport.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
// iOS WebViews can capture Pencil input implicitly.
|
||||
}
|
||||
}
|
||||
|
||||
private releasePointer(pointerId: number): void {
|
||||
try {
|
||||
this.viewport.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// The WebView may already have released capture.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function distance(first: Point2D, second: Point2D): number {
|
||||
return Math.sqrt(squaredDistance(first, second));
|
||||
}
|
||||
|
||||
function midpoint(first: Point2D, second: Point2D): Point2D {
|
||||
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function formatToolWidth(width: number): string {
|
||||
return `${Number.isInteger(width) ? width : width.toFixed(1)} px`;
|
||||
}
|
||||
238
src/embed-manager.ts
Normal file
238
src/embed-manager.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { MarkdownView, Notice, setIcon, TFile, type App } from "obsidian";
|
||||
import { DrawingRepository, INK_EXTENSION } from "./drawing-repository";
|
||||
import { parseDrawingFile } from "./file-format";
|
||||
import { combinedBounds } from "./model";
|
||||
import { InkRenderer, type InkRenderState } from "./render";
|
||||
import type { InkDrawing, InkSettings } from "./types";
|
||||
|
||||
const EMBED_SELECTOR = ".internal-embed[src], .internal-embed[data-src]";
|
||||
|
||||
export class InkEmbedManager {
|
||||
private readonly previews = new Map<HTMLElement, InkEmbedPreview>();
|
||||
private readonly observer: MutationObserver;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly repository: DrawingRepository,
|
||||
private readonly getSettings: () => InkSettings
|
||||
) {
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
this.removeDetachedPreviews();
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.target instanceof HTMLElement) {
|
||||
this.scan(mutation.target, this.sourcePathFor(mutation.target));
|
||||
}
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node instanceof HTMLElement) this.scan(node, this.sourcePathFor(node));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.observer.observe(this.app.workspace.containerEl, { childList: true, subtree: true });
|
||||
this.scan(this.app.workspace.containerEl, "");
|
||||
}
|
||||
|
||||
scan(root: HTMLElement, sourcePath: string): void {
|
||||
const candidates: HTMLElement[] = [];
|
||||
if (root.matches(EMBED_SELECTOR)) candidates.push(root);
|
||||
candidates.push(...root.querySelectorAll<HTMLElement>(EMBED_SELECTOR));
|
||||
for (const element of candidates) this.mount(element, sourcePath || this.sourcePathFor(element));
|
||||
}
|
||||
|
||||
refresh(file?: TFile): void {
|
||||
for (const preview of this.previews.values()) {
|
||||
if (!file || preview.file.path === file.path) void preview.reload();
|
||||
}
|
||||
}
|
||||
|
||||
refreshSettings(): void {
|
||||
for (const preview of this.previews.values()) preview.refreshSettings();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.observer.disconnect();
|
||||
for (const preview of this.previews.values()) preview.destroy();
|
||||
this.previews.clear();
|
||||
}
|
||||
|
||||
private mount(element: HTMLElement, sourcePath: string): void {
|
||||
if (this.previews.has(element)) return;
|
||||
const linkPath = element.getAttribute("src") ?? element.getAttribute("data-src") ?? "";
|
||||
if (!isInkLink(linkPath)) return;
|
||||
const file = this.repository.resolve(linkPath, sourcePath);
|
||||
if (!file) {
|
||||
element.classList.add("ink-embed-missing");
|
||||
return;
|
||||
}
|
||||
const preview = new InkEmbedPreview(
|
||||
this.app,
|
||||
this.repository,
|
||||
element,
|
||||
file,
|
||||
this.getSettings
|
||||
);
|
||||
this.previews.set(element, preview);
|
||||
void preview.reload();
|
||||
}
|
||||
|
||||
private sourcePathFor(element: HTMLElement): string {
|
||||
for (const leaf of this.app.workspace.getLeavesOfType("markdown")) {
|
||||
if (leaf.view instanceof MarkdownView && leaf.view.contentEl.contains(element)) {
|
||||
return leaf.view.file?.path ?? "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private removeDetachedPreviews(): void {
|
||||
for (const [element, preview] of this.previews) {
|
||||
if (element.isConnected && preview.isMounted()) continue;
|
||||
preview.destroy();
|
||||
this.previews.delete(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InkEmbedPreview {
|
||||
private readonly shell: HTMLDivElement;
|
||||
private readonly dryCanvas: HTMLCanvasElement;
|
||||
private readonly wetCanvas: HTMLCanvasElement;
|
||||
private readonly renderer: InkRenderer;
|
||||
private readonly resizeObserver: ResizeObserver;
|
||||
private drawing: InkDrawing | null = null;
|
||||
private loadSequence = 0;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly repository: DrawingRepository,
|
||||
private readonly container: HTMLElement,
|
||||
readonly file: TFile,
|
||||
private readonly getSettings: () => InkSettings
|
||||
) {
|
||||
const doc = container.ownerDocument;
|
||||
this.shell = doc.createElement("div");
|
||||
this.shell.className = "ink-embed-preview";
|
||||
this.shell.tabIndex = 0;
|
||||
|
||||
this.dryCanvas = doc.createElement("canvas");
|
||||
this.dryCanvas.className = "ink-embed-layer";
|
||||
this.dryCanvas.setAttribute("aria-hidden", "true");
|
||||
this.shell.appendChild(this.dryCanvas);
|
||||
|
||||
this.wetCanvas = doc.createElement("canvas");
|
||||
this.wetCanvas.className = "ink-embed-layer";
|
||||
this.wetCanvas.setAttribute("aria-hidden", "true");
|
||||
this.shell.appendChild(this.wetCanvas);
|
||||
|
||||
const openButton = doc.createElement("button");
|
||||
openButton.type = "button";
|
||||
openButton.className = "ink-embed-open clickable-icon";
|
||||
openButton.setAttribute("aria-label", `Open ${file.basename}`);
|
||||
openButton.setAttribute("title", "Open drawing");
|
||||
setIcon(openButton, "maximize-2");
|
||||
openButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.repository.open(this.file);
|
||||
});
|
||||
this.shell.appendChild(openButton);
|
||||
this.shell.addEventListener("dblclick", (event) => {
|
||||
event.preventDefault();
|
||||
void this.repository.open(this.file);
|
||||
});
|
||||
|
||||
this.container.replaceChildren(this.shell);
|
||||
this.container.classList.add("ink-layer-embed");
|
||||
this.renderer = new InkRenderer(this.dryCanvas, this.wetCanvas, this.shell);
|
||||
this.resizeObserver = new ResizeObserver(() => this.render());
|
||||
this.resizeObserver.observe(this.shell);
|
||||
this.refreshSettings();
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
const sequence = ++this.loadSequence;
|
||||
try {
|
||||
const data = await this.app.vault.cachedRead(this.file);
|
||||
if (this.destroyed || sequence !== this.loadSequence) return;
|
||||
this.drawing = parseDrawingFile(data);
|
||||
this.shell.classList.remove("is-error");
|
||||
this.render();
|
||||
} catch (error) {
|
||||
if (this.destroyed || sequence !== this.loadSequence) return;
|
||||
this.shell.classList.add("is-error");
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
this.shell.setAttribute("aria-label", `Could not render drawing: ${message}`);
|
||||
new Notice(`Could not render ${this.file.basename}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
refreshSettings(): void {
|
||||
const dimensions = embedDimensions(this.container, this.getSettings());
|
||||
this.shell.style.setProperty("--ink-embed-width", `${dimensions.width}px`);
|
||||
this.shell.style.setProperty("--ink-embed-ratio", `${dimensions.width} / ${dimensions.height}`);
|
||||
this.render();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
isMounted(): boolean {
|
||||
return this.shell.isConnected && this.container.contains(this.shell);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.drawing || this.destroyed) return;
|
||||
const rect = this.shell.getBoundingClientRect();
|
||||
if (rect.width < 2 || rect.height < 2) return;
|
||||
this.renderer.resize(rect.width, rect.height);
|
||||
const bounds = combinedBounds(this.drawing.strokes);
|
||||
const padding = Math.min(28, Math.max(12, rect.width * 0.04));
|
||||
const width = bounds ? Math.max(1, bounds.maxX - bounds.minX) : 1;
|
||||
const height = bounds ? Math.max(1, bounds.maxY - bounds.minY) : 1;
|
||||
const scale = bounds ? Math.min(
|
||||
4,
|
||||
(rect.width - padding * 2) / width,
|
||||
(rect.height - padding * 2) / height
|
||||
) : 1;
|
||||
const state: InkRenderState = {
|
||||
scale,
|
||||
offsetX: bounds ? (rect.width - width * scale) / 2 - bounds.minX * scale : 0,
|
||||
offsetY: bounds ? (rect.height - height * scale) / 2 - bounds.minY * scale : 0,
|
||||
selectedIds: new Set(),
|
||||
lassoPoints: [],
|
||||
eraserPoint: null,
|
||||
eraserRadius: 0
|
||||
};
|
||||
this.renderer.render(this.drawing.strokes, null, state, true);
|
||||
}
|
||||
}
|
||||
|
||||
function isInkLink(linkPath: string): boolean {
|
||||
const normalized = linkPath.split("#", 1)[0].split("?", 1)[0].toLowerCase();
|
||||
return normalized.endsWith(`.${INK_EXTENSION}`);
|
||||
}
|
||||
|
||||
function embedDimensions(element: HTMLElement, settings: InkSettings): { width: number; height: number } {
|
||||
const explicitWidth = parseDimension(element.getAttribute("width"));
|
||||
const explicitHeight = parseDimension(element.getAttribute("height"));
|
||||
const alias = element.getAttribute("alt") ?? "";
|
||||
const match = alias.match(/(?:^|\|)(\d{2,4})x(\d{2,4})$/i);
|
||||
const width = explicitWidth ?? (match ? Number(match[1]) : settings.defaultEmbedWidth);
|
||||
const height = explicitHeight ?? (match ? Number(match[2]) : settings.defaultEmbedHeight);
|
||||
return {
|
||||
width: Math.min(2400, Math.max(240, width)),
|
||||
height: Math.min(1800, Math.max(180, height))
|
||||
};
|
||||
}
|
||||
|
||||
function parseDimension(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
136
src/file-format.ts
Normal file
136
src/file-format.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { cloneStrokes, createInkPoint } from "./model";
|
||||
import type { InkDrawing, InkStroke } from "./types";
|
||||
|
||||
const MIN_CANVAS_SIZE = 320;
|
||||
const MAX_CANVAS_SIZE = 12000;
|
||||
|
||||
export function emptyDrawing(width = 1600, height = 1200): InkDrawing {
|
||||
return {
|
||||
version: 1,
|
||||
width: clampCanvasSize(width, 1600),
|
||||
height: clampCanvasSize(height, 1200),
|
||||
background: "paper",
|
||||
strokes: [],
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneDrawing(drawing: InkDrawing): InkDrawing {
|
||||
return {
|
||||
...drawing,
|
||||
strokes: cloneStrokes(drawing.strokes)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDrawingFile(data: string): InkDrawing {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(data);
|
||||
} catch {
|
||||
return emptyDrawing();
|
||||
}
|
||||
if (!isRecord(value)) return emptyDrawing();
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
width: clampCanvasSize(value.width, 1600),
|
||||
height: clampCanvasSize(value.height, 1200),
|
||||
background: "paper",
|
||||
strokes: parseStrokes(value.strokes),
|
||||
updatedAt: isFiniteNumber(value.updatedAt) ? Math.max(0, value.updatedAt) : Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeDrawingFile(drawing: InkDrawing): string {
|
||||
return `${JSON.stringify({
|
||||
version: 1,
|
||||
width: clampCanvasSize(drawing.width, 1600),
|
||||
height: clampCanvasSize(drawing.height, 1200),
|
||||
background: "paper",
|
||||
updatedAt: Math.round(drawing.updatedAt),
|
||||
strokes: drawing.strokes.map((stroke) => ({
|
||||
id: stroke.id,
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
width: round(stroke.width, 2),
|
||||
opacity: round(stroke.opacity, 3),
|
||||
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)
|
||||
])
|
||||
}))
|
||||
}, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function parseStrokes(value: unknown): InkStroke[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((rawStroke, index) => {
|
||||
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.length > 0
|
||||
? rawStroke.id
|
||||
: `recovered-${index}`,
|
||||
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
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
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 clampCanvasSize(value: unknown, fallback: number): number {
|
||||
return Math.round(numberInRange(value, fallback, MIN_CANVAS_SIZE, MAX_CANVAS_SIZE));
|
||||
}
|
||||
|
||||
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 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 round(value: number, decimalPlaces: number): number {
|
||||
const multiplier = 10 ** decimalPlaces;
|
||||
return Math.round(value * multiplier) / multiplier;
|
||||
}
|
||||
108
src/ink-view.ts
Normal file
108
src/ink-view.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { TextFileView, type WorkspaceLeaf } from "obsidian";
|
||||
import { DrawingSurface } from "./drawing-surface";
|
||||
import { emptyDrawing, parseDrawingFile, serializeDrawingFile } from "./file-format";
|
||||
import type { InkStore } from "./storage";
|
||||
import type { InkDrawing, InkTool } from "./types";
|
||||
|
||||
export const INK_VIEW_TYPE = "ink-layer-canvas";
|
||||
|
||||
export interface InkCanvasViewHost {
|
||||
store: InkStore;
|
||||
onCanvasToolChange(tool: InkTool): void;
|
||||
refreshInkUI(): void;
|
||||
}
|
||||
|
||||
export class InkCanvasView extends TextFileView {
|
||||
private readonly surface: DrawingSurface;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, private readonly pluginHost: InkCanvasViewHost) {
|
||||
super(leaf);
|
||||
this.contentEl.replaceChildren();
|
||||
this.contentEl.classList.add("ink-canvas-view-content");
|
||||
this.surface = new DrawingSurface(this.contentEl, pluginHost.store, {
|
||||
onChange: (drawing) => {
|
||||
this.data = serializeDrawingFile(drawing);
|
||||
this.requestSave();
|
||||
},
|
||||
onToolChange: (tool) => pluginHost.onCanvasToolChange(tool),
|
||||
onSettingsChange: () => pluginHost.refreshInkUI()
|
||||
}, emptyDrawing(
|
||||
pluginHost.store.settings.defaultCanvasWidth,
|
||||
pluginHost.store.settings.defaultCanvasHeight
|
||||
));
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return INK_VIEW_TYPE;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return this.file?.basename ?? "Ink drawing";
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return "pen-tool";
|
||||
}
|
||||
|
||||
getViewData(): string {
|
||||
this.data = serializeDrawingFile(this.surface.getDrawing());
|
||||
return this.data;
|
||||
}
|
||||
|
||||
setViewData(data: string, clear: boolean): void {
|
||||
this.data = data;
|
||||
this.surface.setDrawing(parseDrawingFile(data), clear);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.surface.setDrawing(emptyDrawing(
|
||||
this.pluginHost.store.settings.defaultCanvasWidth,
|
||||
this.pluginHost.store.settings.defaultCanvasHeight
|
||||
));
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
this.surface.destroy();
|
||||
await super.onClose();
|
||||
}
|
||||
|
||||
get currentTool(): InkTool {
|
||||
return this.surface.currentTool;
|
||||
}
|
||||
|
||||
get hasInk(): boolean {
|
||||
return this.surface.hasInk;
|
||||
}
|
||||
|
||||
getDrawing(): InkDrawing {
|
||||
return this.surface.getDrawing();
|
||||
}
|
||||
|
||||
setTool(tool: InkTool): void {
|
||||
this.surface.setTool(tool);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.surface.undo();
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.surface.redo();
|
||||
}
|
||||
|
||||
clearInk(): void {
|
||||
this.surface.clearAll();
|
||||
}
|
||||
|
||||
fitToView(): void {
|
||||
this.surface.fitToView();
|
||||
}
|
||||
|
||||
refreshSettings(): void {
|
||||
this.surface.refreshSettings();
|
||||
}
|
||||
|
||||
exportSvg(): string | null {
|
||||
return this.surface.exportSvg();
|
||||
}
|
||||
}
|
||||
417
src/main.ts
417
src/main.ts
|
|
@ -1,238 +1,313 @@
|
|||
import { App, MarkdownView, Modal, Notice, Plugin, Setting, TFile, type WorkspaceLeaf } from "obsidian";
|
||||
import { InkController, type InkControllerCallbacks } from "./controller";
|
||||
import {
|
||||
App,
|
||||
FuzzySuggestModal,
|
||||
MarkdownView,
|
||||
Modal,
|
||||
Notice,
|
||||
Plugin,
|
||||
Setting,
|
||||
TFile
|
||||
} from "obsidian";
|
||||
import { DrawingRepository, INK_EXTENSION } from "./drawing-repository";
|
||||
import { InkEmbedManager } from "./embed-manager";
|
||||
import { emptyDrawing } from "./file-format";
|
||||
import { InkCanvasView, INK_VIEW_TYPE, type InkCanvasViewHost } from "./ink-view";
|
||||
import { combinedBounds } from "./model";
|
||||
import { InkSettingTab, type InkSettingsHost } from "./settings";
|
||||
import { InkStore } from "./storage";
|
||||
import type { InkTool } from "./types";
|
||||
|
||||
export default class InkLayerPlugin
|
||||
extends Plugin
|
||||
implements InkControllerCallbacks, InkSettingsHost
|
||||
implements InkCanvasViewHost, InkSettingsHost
|
||||
{
|
||||
store!: InkStore;
|
||||
private readonly controllers = new Map<WorkspaceLeaf, InkController>();
|
||||
private inputActive = false;
|
||||
private repository!: DrawingRepository;
|
||||
private embedManager!: InkEmbedManager;
|
||||
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.repository = new DrawingRepository(this.app, () => this.store.settings);
|
||||
this.embedManager = new InkEmbedManager(this.app, this.repository, () => this.store.settings);
|
||||
|
||||
this.ribbonIcon = this.addRibbonIcon("pen-tool", "Toggle ink mode", () => this.toggleInkMode());
|
||||
this.registerView(INK_VIEW_TYPE, (leaf) => new InkCanvasView(leaf, this));
|
||||
this.registerExtensions([INK_EXTENSION], INK_VIEW_TYPE);
|
||||
this.registerMarkdownPostProcessor((element, context) => {
|
||||
this.embedManager.scan(element, context.sourcePath);
|
||||
});
|
||||
|
||||
this.addRibbonIcon("pen-tool", "New ink drawing", () => void this.createAndOpenDrawing());
|
||||
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();
|
||||
}
|
||||
})
|
||||
);
|
||||
this.embedManager.start();
|
||||
this.registerEvent(this.app.workspace.on("layout-change", () => {
|
||||
this.embedManager.scan(this.app.workspace.containerEl, "");
|
||||
}));
|
||||
this.registerEvent(this.app.vault.on("modify", (file) => {
|
||||
if (file instanceof TFile && file.extension.toLowerCase() === INK_EXTENSION) {
|
||||
this.embedManager.refresh(file);
|
||||
}
|
||||
}));
|
||||
this.registerEvent(this.app.vault.on("create", (file) => {
|
||||
if (file instanceof TFile && file.extension.toLowerCase() === INK_EXTENSION) {
|
||||
this.embedManager.scan(this.app.workspace.containerEl, "");
|
||||
}
|
||||
}));
|
||||
this.registerEvent(this.app.vault.on("delete", (file) => {
|
||||
if (file instanceof TFile && file.extension.toLowerCase() === INK_EXTENSION) {
|
||||
this.embedManager.refresh(file);
|
||||
}
|
||||
}));
|
||||
this.registerEvent(this.app.vault.on("rename", (file, oldPath) => {
|
||||
if (!(file instanceof TFile)) return;
|
||||
if (file.extension.toLowerCase() === INK_EXTENSION) this.embedManager.refresh();
|
||||
else this.store.renameDocument(oldPath, file.path);
|
||||
}));
|
||||
});
|
||||
|
||||
this.registerDomEvent(document, "visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") void this.flushOpenDrawings();
|
||||
});
|
||||
this.registerDomEvent(window, "pagehide", () => void this.flushOpenDrawings());
|
||||
}
|
||||
|
||||
async onunload(): Promise<void> {
|
||||
for (const controller of this.controllers.values()) controller.destroy();
|
||||
this.controllers.clear();
|
||||
await this.flushOpenDrawings();
|
||||
this.embedManager.destroy();
|
||||
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 {
|
||||
onCanvasToolChange(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);
|
||||
refreshInkUI(): void {
|
||||
for (const leaf of this.app.workspace.getLeavesOfType(INK_VIEW_TYPE)) {
|
||||
if (leaf.view instanceof InkCanvasView) leaf.view.refreshSettings();
|
||||
}
|
||||
}
|
||||
|
||||
refreshControllers(): void {
|
||||
for (const controller of this.controllers.values()) controller.refreshSettings();
|
||||
this.embedManager.refreshSettings();
|
||||
}
|
||||
|
||||
private registerCommands(): void {
|
||||
this.addCommand({
|
||||
id: "toggle-ink-mode",
|
||||
name: "Toggle ink mode",
|
||||
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => controller.toggleActive())
|
||||
id: "new-drawing",
|
||||
name: "Create new drawing",
|
||||
callback: () => void this.createAndOpenDrawing()
|
||||
});
|
||||
this.addCommand({
|
||||
id: "insert-new-drawing",
|
||||
name: "Insert new drawing in current note",
|
||||
checkCallback: (checking) => this.withMarkdownView(checking, (view) => void this.createInsertAndOpen(view))
|
||||
});
|
||||
this.addCommand({
|
||||
id: "insert-existing-drawing",
|
||||
name: "Insert existing drawing in current note",
|
||||
checkCallback: (checking) => this.withMarkdownView(checking, (view) => {
|
||||
const files = this.repository.drawingFiles();
|
||||
if (files.length === 0) {
|
||||
new Notice("Create an ink drawing first.");
|
||||
return;
|
||||
}
|
||||
new DrawingSuggestModal(this.app, files, (file) => this.insertEmbed(view, file)).open();
|
||||
})
|
||||
});
|
||||
this.addCommand({
|
||||
id: "convert-legacy-note-ink",
|
||||
name: "Convert legacy ink from current note to a drawing",
|
||||
checkCallback: (checking) => {
|
||||
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
const path = view?.file?.path ?? "";
|
||||
const available = Boolean(view && path && this.store.hasInk(path));
|
||||
if (!checking && view && available) void this.convertLegacyInk(view);
|
||||
return available;
|
||||
}
|
||||
});
|
||||
this.addCommand({
|
||||
id: "select-pen",
|
||||
name: "Select pen",
|
||||
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "pen"))
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.setTool("pen"))
|
||||
});
|
||||
this.addCommand({
|
||||
id: "select-highlighter",
|
||||
name: "Select highlighter",
|
||||
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "highlighter"))
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.setTool("highlighter"))
|
||||
});
|
||||
this.addCommand({
|
||||
id: "select-eraser",
|
||||
name: "Select eraser",
|
||||
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "eraser"))
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.setTool("eraser"))
|
||||
});
|
||||
this.addCommand({
|
||||
id: "select-lasso",
|
||||
name: "Select lasso",
|
||||
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => this.activateTool(controller, "lasso"))
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.setTool("lasso"))
|
||||
});
|
||||
this.addCommand({
|
||||
id: "select-pan",
|
||||
name: "Select pan tool",
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.setTool("pan"))
|
||||
});
|
||||
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");
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => {
|
||||
view.setTool(view.currentTool === "eraser" ? "pen" : "eraser");
|
||||
})
|
||||
});
|
||||
this.addCommand({
|
||||
id: "undo-ink",
|
||||
name: "Undo ink",
|
||||
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => controller.undo())
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.undo())
|
||||
});
|
||||
this.addCommand({
|
||||
id: "redo-ink",
|
||||
name: "Redo ink",
|
||||
checkCallback: (checking) => this.withMarkdownController(checking, (controller) => controller.redo())
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.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();
|
||||
id: "fit-drawing",
|
||||
name: "Fit drawing to view",
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => view.fitToView())
|
||||
});
|
||||
this.addCommand({
|
||||
id: "clear-drawing",
|
||||
name: "Clear current drawing",
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => {
|
||||
if (view.hasInk) new ClearInkModal(this.app, () => view.clearInk()).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);
|
||||
id: "export-drawing-svg",
|
||||
name: "Export current drawing as SVG",
|
||||
checkCallback: (checking) => this.withCanvasView(checking, (view) => {
|
||||
if (view.hasInk) void this.exportSvg(view);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
private withCanvasView(checking: boolean, action: (view: InkCanvasView) => void): boolean {
|
||||
const view = this.app.workspace.getActiveViewOfType(InkCanvasView);
|
||||
if (!checking && view) action(view);
|
||||
return view !== null;
|
||||
}
|
||||
|
||||
private withMarkdownView(checking: boolean, action: (view: MarkdownView) => void): boolean {
|
||||
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
const available = Boolean(view?.file);
|
||||
if (!checking && view && available) action(view);
|
||||
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;
|
||||
private async createAndOpenDrawing(): Promise<void> {
|
||||
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}`);
|
||||
const file = await this.repository.create(timestampedTitle());
|
||||
await this.repository.open(file);
|
||||
const view = this.app.workspace.getActiveViewOfType(InkCanvasView);
|
||||
view?.setTool(this.preferredTool);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
new Notice(`Could not export ink: ${message}`);
|
||||
this.showError("Could not create drawing", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createInsertAndOpen(markdownView: MarkdownView): Promise<void> {
|
||||
try {
|
||||
const title = markdownView.file ? `${markdownView.file.basename} drawing` : timestampedTitle();
|
||||
const file = await this.repository.create(title);
|
||||
this.insertEmbed(markdownView, file);
|
||||
await this.repository.open(file, true);
|
||||
const view = this.app.workspace.getActiveViewOfType(InkCanvasView);
|
||||
view?.setTool(this.preferredTool);
|
||||
} catch (error) {
|
||||
this.showError("Could not insert drawing", error);
|
||||
}
|
||||
}
|
||||
|
||||
private insertEmbed(view: MarkdownView, file: TFile): void {
|
||||
const settings = this.store.settings;
|
||||
const embed = `![[${file.path}|${settings.defaultEmbedWidth}x${settings.defaultEmbedHeight}]]`;
|
||||
view.editor.replaceSelection(embed);
|
||||
new Notice(`Inserted ${file.basename}`);
|
||||
}
|
||||
|
||||
private async convertLegacyInk(view: MarkdownView): Promise<void> {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
const legacy = this.store.getDocument(file.path);
|
||||
if (legacy.strokes.length === 0) return;
|
||||
const drawing = emptyDrawing(
|
||||
this.store.settings.defaultCanvasWidth,
|
||||
this.store.settings.defaultCanvasHeight
|
||||
);
|
||||
drawing.strokes = legacy.strokes;
|
||||
drawing.updatedAt = legacy.updatedAt;
|
||||
const bounds = combinedBounds(drawing.strokes);
|
||||
if (bounds) {
|
||||
drawing.width = Math.max(drawing.width, Math.ceil(bounds.maxX + 64));
|
||||
drawing.height = Math.max(drawing.height, Math.ceil(bounds.maxY + 64));
|
||||
}
|
||||
try {
|
||||
const drawingFile = await this.repository.create(`${file.basename} legacy ink`, drawing);
|
||||
this.insertEmbed(view, drawingFile);
|
||||
await this.repository.open(drawingFile, true);
|
||||
new Notice("Legacy ink was copied into a standalone drawing. The original backup remains in plugin data.");
|
||||
} catch (error) {
|
||||
this.showError("Could not convert legacy ink", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async exportSvg(view: InkCanvasView): Promise<void> {
|
||||
const svg = view.exportSvg();
|
||||
const source = view.file;
|
||||
if (!svg || !source) return;
|
||||
try {
|
||||
const path = await this.app.fileManager.getAvailablePathForAttachment(`${source.basename}.svg`, source.path);
|
||||
const exported = await this.app.vault.create(path, svg);
|
||||
new Notice(`Exported drawing to ${exported.path}`);
|
||||
} catch (error) {
|
||||
this.showError("Could not export drawing", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async flushOpenDrawings(): Promise<void> {
|
||||
const saves: Promise<void>[] = [];
|
||||
for (const leaf of this.app.workspace.getLeavesOfType(INK_VIEW_TYPE)) {
|
||||
if (leaf.view instanceof InkCanvasView) saves.push(leaf.view.save());
|
||||
}
|
||||
await Promise.allSettled(saves);
|
||||
await this.store.flush();
|
||||
}
|
||||
|
||||
private showError(context: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
new Notice(`${context}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
class DrawingSuggestModal extends FuzzySuggestModal<TFile> {
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly files: TFile[],
|
||||
private readonly onChoose: (file: TFile) => void
|
||||
) {
|
||||
super(app);
|
||||
this.setPlaceholder("Choose an ink drawing…");
|
||||
}
|
||||
|
||||
getItems(): TFile[] {
|
||||
return this.files;
|
||||
}
|
||||
|
||||
getItemText(file: TFile): string {
|
||||
return file.path;
|
||||
}
|
||||
|
||||
onChooseItem(file: TFile): void {
|
||||
this.onChoose(file);
|
||||
}
|
||||
}
|
||||
|
||||
class ClearInkModal extends Modal {
|
||||
|
|
@ -241,24 +316,28 @@ class ClearInkModal extends Modal {
|
|||
}
|
||||
|
||||
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.titleEl.textContent = "Clear this drawing?";
|
||||
const description = this.contentEl.ownerDocument.createElement("p");
|
||||
description.textContent = "This removes every stroke. You can undo it while the drawing 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();
|
||||
})
|
||||
);
|
||||
.addButton((button) => button
|
||||
.setButtonText("Clear drawing")
|
||||
.setWarning()
|
||||
.onClick(() => {
|
||||
this.onConfirm();
|
||||
this.close();
|
||||
}));
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function timestampedTitle(): string {
|
||||
const date = new Date();
|
||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||
return `Drawing ${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}-${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { combinedBounds } from "./model";
|
|||
import type { Bounds, InkPoint, InkStroke, Point2D } from "./types";
|
||||
|
||||
export interface InkRenderState {
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
scale: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
selectedIds: Set<string>;
|
||||
lassoPoints: Point2D[];
|
||||
eraserPoint: Point2D | null;
|
||||
|
|
@ -25,7 +26,7 @@ export class InkRenderer {
|
|||
) {
|
||||
const dryContext = dryCanvas.getContext("2d");
|
||||
const wetContext = wetCanvas.getContext("2d");
|
||||
if (!dryContext || !wetContext) throw new Error("Ink Layer requires Canvas 2D support.");
|
||||
if (!dryContext || !wetContext) throw new Error("Inkplane requires Canvas 2D support.");
|
||||
this.dryContext = dryContext;
|
||||
this.wetContext = wetContext;
|
||||
}
|
||||
|
|
@ -74,11 +75,15 @@ export class InkRenderer {
|
|||
this.wetContext.restore();
|
||||
}
|
||||
|
||||
private prepareContext(context: CanvasRenderingContext2D, state: InkRenderState): void {
|
||||
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);
|
||||
context.translate(state.offsetX, state.offsetY);
|
||||
context.scale(state.scale, state.scale);
|
||||
}
|
||||
|
||||
private drawStroke(context: CanvasRenderingContext2D, stroke: InkStroke): void {
|
||||
|
|
|
|||
175
src/settings.ts
175
src/settings.ts
|
|
@ -4,7 +4,7 @@ import type { InkSettings } from "./types";
|
|||
|
||||
export interface InkSettingsHost {
|
||||
store: InkStore;
|
||||
refreshControllers(): void;
|
||||
refreshInkUI(): void;
|
||||
}
|
||||
|
||||
export class InkSettingTab extends PluginSettingTab {
|
||||
|
|
@ -16,127 +16,148 @@ export class InkSettingTab extends PluginSettingTab {
|
|||
this.containerEl.replaceChildren();
|
||||
const settings = this.pluginHost.store.settings;
|
||||
|
||||
new Setting(this.containerEl).setName("Drawing files").setHeading();
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName("Drawing folder")
|
||||
.setDesc("New .inklayer canvases are stored here inside the vault. Leave blank for the vault root.")
|
||||
.addText((text) => text
|
||||
.setPlaceholder("Inkplane")
|
||||
.setValue(settings.drawingFolder)
|
||||
.onChange((value) => this.applyPatch({ drawingFolder: value.trim() })));
|
||||
|
||||
this.addNumberSetting(
|
||||
"Embed width",
|
||||
"Default width for previews inserted into notes.",
|
||||
settings.defaultEmbedWidth,
|
||||
240,
|
||||
2400,
|
||||
(value) => ({ defaultEmbedWidth: value })
|
||||
);
|
||||
this.addNumberSetting(
|
||||
"Embed height",
|
||||
"Default height for previews inserted into notes.",
|
||||
settings.defaultEmbedHeight,
|
||||
180,
|
||||
1800,
|
||||
(value) => ({ defaultEmbedHeight: value })
|
||||
);
|
||||
|
||||
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();
|
||||
})
|
||||
);
|
||||
.setDesc("Use Obsidian’s current text color for new pen strokes.")
|
||||
.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 }))
|
||||
);
|
||||
.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 }))
|
||||
);
|
||||
.setDesc("Base width. 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 }))
|
||||
);
|
||||
.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 }))
|
||||
);
|
||||
.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 }))
|
||||
);
|
||||
.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 }))
|
||||
);
|
||||
.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("Input and navigation").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 }))
|
||||
);
|
||||
.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 }))
|
||||
);
|
||||
.setDesc("Off by default: one finger pans and two fingers pinch-zoom while Apple Pencil draws.")
|
||||
.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 }))
|
||||
);
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(settings.allowMouseDrawing)
|
||||
.onChange((value) => this.applyPatch({ allowMouseDrawing: 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" }))
|
||||
);
|
||||
.addDropdown((dropdown) => dropdown
|
||||
.addOption("top", "Top")
|
||||
.addOption("bottom", "Bottom")
|
||||
.setValue(settings.toolbarPosition)
|
||||
.onChange((value) => this.applyPatch({ toolbarPosition: value === "bottom" ? "bottom" : "top" })));
|
||||
}
|
||||
|
||||
private addNumberSetting(
|
||||
name: string,
|
||||
description: string,
|
||||
current: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
patch: (value: number) => Partial<InkSettings>
|
||||
): void {
|
||||
new Setting(this.containerEl)
|
||||
.setName(name)
|
||||
.setDesc(description)
|
||||
.addText((text) => text
|
||||
.setValue(String(current))
|
||||
.onChange((value) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
this.applyPatch(patch(Math.round(Math.min(maximum, Math.max(minimum, parsed)))));
|
||||
}));
|
||||
}
|
||||
|
||||
private applyPatch(patch: Partial<InkSettings>): void {
|
||||
this.pluginHost.store.updateSettings(patch);
|
||||
this.pluginHost.refreshControllers();
|
||||
this.pluginHost.refreshInkUI();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,8 +146,32 @@ function sanitizeSettings(value: Record<string, unknown> | InkSettings): InkSett
|
|||
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"
|
||||
toolbarPosition: value.toolbarPosition === "bottom" ? "bottom" : "top",
|
||||
drawingFolder: stringOr(value.drawingFolder, DEFAULT_SETTINGS.drawingFolder),
|
||||
defaultCanvasWidth: numberInRange(
|
||||
value.defaultCanvasWidth,
|
||||
DEFAULT_SETTINGS.defaultCanvasWidth,
|
||||
320,
|
||||
12000
|
||||
),
|
||||
defaultCanvasHeight: numberInRange(
|
||||
value.defaultCanvasHeight,
|
||||
DEFAULT_SETTINGS.defaultCanvasHeight,
|
||||
320,
|
||||
12000
|
||||
),
|
||||
defaultEmbedWidth: numberInRange(
|
||||
value.defaultEmbedWidth,
|
||||
DEFAULT_SETTINGS.defaultEmbedWidth,
|
||||
240,
|
||||
2400
|
||||
),
|
||||
defaultEmbedHeight: numberInRange(
|
||||
value.defaultEmbedHeight,
|
||||
DEFAULT_SETTINGS.defaultEmbedHeight,
|
||||
180,
|
||||
1800
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
320
src/tool-inspector.ts
Normal file
320
src/tool-inspector.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import type { InkSettings, InkTool } from "./types";
|
||||
|
||||
interface ColorChoice {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface WidthChoice {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ToolDesign {
|
||||
label: string;
|
||||
description: string;
|
||||
colors?: ColorChoice[];
|
||||
widths: WidthChoice[];
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
const PEN_COLORS: ColorChoice[] = [
|
||||
{ label: "Match theme", value: "adaptive" },
|
||||
{ label: "Ink", value: "#111827" },
|
||||
{ label: "White", value: "#f8fafc" },
|
||||
{ label: "Red", value: "#dc2626" },
|
||||
{ label: "Orange", value: "#ea580c" },
|
||||
{ label: "Green", value: "#16a34a" },
|
||||
{ label: "Blue", value: "#2563eb" },
|
||||
{ label: "Violet", value: "#7c3aed" }
|
||||
];
|
||||
|
||||
const HIGHLIGHTER_COLORS: ColorChoice[] = [
|
||||
{ label: "Yellow", value: "#facc15" },
|
||||
{ label: "Lime", value: "#a3e635" },
|
||||
{ label: "Mint", value: "#4ade80" },
|
||||
{ label: "Cyan", value: "#22d3ee" },
|
||||
{ label: "Blue", value: "#60a5fa" },
|
||||
{ label: "Violet", value: "#a78bfa" },
|
||||
{ label: "Pink", value: "#f472b6" },
|
||||
{ label: "Orange", value: "#fb923c" }
|
||||
];
|
||||
|
||||
const TOOL_DESIGNS: Record<"pen" | "highlighter" | "eraser", ToolDesign> = {
|
||||
pen: {
|
||||
label: "Pen",
|
||||
description: "Pressure-sensitive ink",
|
||||
colors: PEN_COLORS,
|
||||
widths: [{ label: "Fine", value: 1.8 }, { label: "Medium", value: 3.2 }, { label: "Bold", value: 5.5 }],
|
||||
minimum: 1,
|
||||
maximum: 12,
|
||||
step: 0.2
|
||||
},
|
||||
highlighter: {
|
||||
label: "Highlighter",
|
||||
description: "Translucent marker",
|
||||
colors: HIGHLIGHTER_COLORS,
|
||||
widths: [{ label: "Narrow", value: 10 }, { label: "Medium", value: 18 }, { label: "Wide", value: 28 }],
|
||||
minimum: 6,
|
||||
maximum: 40,
|
||||
step: 1
|
||||
},
|
||||
eraser: {
|
||||
label: "Eraser",
|
||||
description: "Removes complete strokes",
|
||||
widths: [{ label: "Small", value: 12 }, { label: "Medium", value: 20 }, { label: "Large", value: 36 }],
|
||||
minimum: 8,
|
||||
maximum: 56,
|
||||
step: 2
|
||||
}
|
||||
};
|
||||
|
||||
type DesignedTool = keyof typeof TOOL_DESIGNS;
|
||||
|
||||
export class ToolInspector {
|
||||
readonly element: HTMLDivElement;
|
||||
private tool: DesignedTool = "pen";
|
||||
private openState = false;
|
||||
|
||||
constructor(
|
||||
parent: HTMLElement,
|
||||
private readonly getSettings: () => InkSettings,
|
||||
private readonly onPatch: (patch: Partial<InkSettings>) => void,
|
||||
private readonly onOpenChange?: (open: boolean) => void
|
||||
) {
|
||||
this.element = parent.ownerDocument.createElement("div");
|
||||
this.element.className = "ink-tool-inspector";
|
||||
this.element.setAttribute("role", "dialog");
|
||||
this.element.setAttribute("aria-label", "Tool color and size");
|
||||
this.element.setAttribute("aria-hidden", "true");
|
||||
this.element.tabIndex = -1;
|
||||
parent.appendChild(this.element);
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.openState;
|
||||
}
|
||||
|
||||
contains(node: Node): boolean {
|
||||
return this.element.contains(node);
|
||||
}
|
||||
|
||||
toggle(tool: InkTool): void {
|
||||
if (!isDesignedTool(tool)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (this.openState && this.tool === tool) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.tool = tool;
|
||||
this.openState = true;
|
||||
this.render();
|
||||
this.element.classList.add("is-open");
|
||||
this.element.setAttribute("aria-hidden", "false");
|
||||
this.onOpenChange?.(true);
|
||||
}
|
||||
|
||||
switchTool(tool: InkTool): void {
|
||||
if (!this.openState) return;
|
||||
if (!isDesignedTool(tool)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.tool = tool;
|
||||
this.render();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
if (this.openState) this.render();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.openState) return;
|
||||
this.openState = false;
|
||||
this.element.classList.remove("is-open");
|
||||
this.element.setAttribute("aria-hidden", "true");
|
||||
this.onOpenChange?.(false);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const doc = this.element.ownerDocument;
|
||||
const activeElement = doc.activeElement;
|
||||
const shouldRestoreFocus = activeElement !== null && this.contains(activeElement);
|
||||
const settings = this.getSettings();
|
||||
const design = TOOL_DESIGNS[this.tool];
|
||||
const color = toolColor(this.tool, settings);
|
||||
const width = toolWidth(this.tool, settings);
|
||||
this.element.replaceChildren();
|
||||
|
||||
const header = doc.createElement("div");
|
||||
header.className = "ink-inspector-header";
|
||||
const titleWrap = doc.createElement("div");
|
||||
const title = doc.createElement("div");
|
||||
title.className = "ink-inspector-title";
|
||||
title.textContent = design.label;
|
||||
const description = doc.createElement("div");
|
||||
description.className = "ink-inspector-description";
|
||||
description.textContent = design.description;
|
||||
titleWrap.append(title, description);
|
||||
const closeButton = doc.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.className = "ink-inspector-close clickable-icon";
|
||||
closeButton.setAttribute("aria-label", "Close color and size");
|
||||
setIcon(closeButton, "x");
|
||||
closeButton.addEventListener("click", () => this.close());
|
||||
header.append(titleWrap, closeButton);
|
||||
this.element.appendChild(header);
|
||||
|
||||
const preview = doc.createElement("div");
|
||||
preview.className = `ink-stroke-preview is-${this.tool}`;
|
||||
const previewLine = doc.createElement("div");
|
||||
previewLine.className = "ink-stroke-preview-line";
|
||||
previewLine.style.setProperty("--ink-preview-color", cssColor(color));
|
||||
previewLine.style.setProperty("--ink-preview-width", `${previewWidth(this.tool, width)}px`);
|
||||
preview.appendChild(previewLine);
|
||||
this.element.appendChild(preview);
|
||||
|
||||
if (design.colors) {
|
||||
this.element.appendChild(this.sectionLabel("Color"));
|
||||
const swatches = doc.createElement("div");
|
||||
swatches.className = "ink-color-grid";
|
||||
for (const choice of design.colors) {
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ink-color-swatch";
|
||||
button.classList.toggle("is-selected", color.toLowerCase() === choice.value.toLowerCase());
|
||||
button.setAttribute("aria-label", choice.label);
|
||||
button.setAttribute("title", choice.label);
|
||||
button.setAttribute("aria-pressed", button.classList.contains("is-selected") ? "true" : "false");
|
||||
if (choice.value === "adaptive") {
|
||||
button.classList.add("is-adaptive");
|
||||
button.textContent = "Aa";
|
||||
} else {
|
||||
button.style.setProperty("--ink-swatch-color", choice.value);
|
||||
}
|
||||
button.addEventListener("click", () => {
|
||||
this.applyColor(choice.value);
|
||||
this.render();
|
||||
});
|
||||
swatches.appendChild(button);
|
||||
}
|
||||
|
||||
const custom = doc.createElement("label");
|
||||
custom.className = "ink-custom-color";
|
||||
custom.setAttribute("title", "Custom color");
|
||||
const colorInput = doc.createElement("input");
|
||||
colorInput.type = "color";
|
||||
colorInput.setAttribute("aria-label", "Custom color");
|
||||
colorInput.value = color === "adaptive" ? "#111827" : color;
|
||||
colorInput.addEventListener("input", () => {
|
||||
this.applyColor(colorInput.value);
|
||||
previewLine.style.setProperty("--ink-preview-color", colorInput.value);
|
||||
});
|
||||
const customIcon = doc.createElement("span");
|
||||
customIcon.textContent = "+";
|
||||
custom.append(colorInput, customIcon);
|
||||
swatches.appendChild(custom);
|
||||
this.element.appendChild(swatches);
|
||||
}
|
||||
|
||||
const widthHeader = doc.createElement("div");
|
||||
widthHeader.className = "ink-inspector-section-row";
|
||||
widthHeader.appendChild(this.sectionLabel(this.tool === "eraser" ? "Eraser size" : "Stroke width"));
|
||||
const widthValue = doc.createElement("output");
|
||||
widthValue.className = "ink-width-value";
|
||||
widthValue.textContent = formatWidth(width);
|
||||
widthHeader.appendChild(widthValue);
|
||||
this.element.appendChild(widthHeader);
|
||||
|
||||
const presets = doc.createElement("div");
|
||||
presets.className = "ink-width-presets";
|
||||
for (const choice of design.widths) {
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ink-width-preset";
|
||||
button.classList.toggle("is-selected", Math.abs(width - choice.value) < 0.01);
|
||||
button.setAttribute("aria-pressed", button.classList.contains("is-selected") ? "true" : "false");
|
||||
button.textContent = choice.label;
|
||||
button.addEventListener("click", () => {
|
||||
this.applyWidth(choice.value);
|
||||
this.render();
|
||||
});
|
||||
presets.appendChild(button);
|
||||
}
|
||||
this.element.appendChild(presets);
|
||||
|
||||
const range = doc.createElement("input");
|
||||
range.type = "range";
|
||||
range.className = "ink-width-slider";
|
||||
range.min = String(design.minimum);
|
||||
range.max = String(design.maximum);
|
||||
range.step = String(design.step);
|
||||
range.value = String(width);
|
||||
range.setAttribute("aria-label", this.tool === "eraser" ? "Eraser size" : "Stroke width");
|
||||
range.addEventListener("input", () => {
|
||||
const next = Number(range.value);
|
||||
this.applyWidth(next);
|
||||
widthValue.textContent = formatWidth(next);
|
||||
previewLine.style.setProperty("--ink-preview-width", `${previewWidth(this.tool, next)}px`);
|
||||
for (const button of presets.querySelectorAll<HTMLButtonElement>("button")) {
|
||||
button.classList.remove("is-selected");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
}
|
||||
});
|
||||
this.element.appendChild(range);
|
||||
if (shouldRestoreFocus) this.element.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
private sectionLabel(text: string): HTMLDivElement {
|
||||
const label = this.element.ownerDocument.createElement("div");
|
||||
label.className = "ink-inspector-section-label";
|
||||
label.textContent = text;
|
||||
return label;
|
||||
}
|
||||
|
||||
private applyColor(color: string): void {
|
||||
this.onPatch(this.tool === "pen" ? { penColor: color } : { highlighterColor: color });
|
||||
}
|
||||
|
||||
private applyWidth(width: number): void {
|
||||
if (this.tool === "pen") this.onPatch({ penWidth: width });
|
||||
else if (this.tool === "highlighter") this.onPatch({ highlighterWidth: width });
|
||||
else this.onPatch({ eraserWidth: width });
|
||||
}
|
||||
}
|
||||
|
||||
export function toolColor(tool: InkTool, settings: InkSettings): string {
|
||||
if (tool === "pen") return settings.penColor;
|
||||
if (tool === "highlighter") return settings.highlighterColor;
|
||||
return "adaptive";
|
||||
}
|
||||
|
||||
export function toolWidth(tool: InkTool, settings: InkSettings): number {
|
||||
if (tool === "pen") return settings.penWidth;
|
||||
if (tool === "highlighter") return settings.highlighterWidth;
|
||||
if (tool === "eraser") return settings.eraserWidth;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isDesignedTool(tool: InkTool): tool is DesignedTool {
|
||||
return tool === "pen" || tool === "highlighter" || tool === "eraser";
|
||||
}
|
||||
|
||||
function cssColor(color: string): string {
|
||||
return color === "adaptive" ? "var(--text-normal)" : color;
|
||||
}
|
||||
|
||||
function previewWidth(tool: DesignedTool, width: number): number {
|
||||
if (tool === "highlighter") return Math.min(22, Math.max(4, width * 0.7));
|
||||
if (tool === "eraser") return Math.min(26, Math.max(6, width * 0.55));
|
||||
return Math.min(12, Math.max(2, width));
|
||||
}
|
||||
|
||||
function formatWidth(width: number): string {
|
||||
return `${Number.isInteger(width) ? width : width.toFixed(1)} px`;
|
||||
}
|
||||
25
src/types.ts
25
src/types.ts
|
|
@ -1,4 +1,4 @@
|
|||
export type InkTool = "pen" | "highlighter" | "eraser" | "lasso";
|
||||
export type InkTool = "pen" | "highlighter" | "eraser" | "lasso" | "pan";
|
||||
export type StrokeTool = "pen" | "highlighter";
|
||||
|
||||
export interface InkPoint {
|
||||
|
|
@ -26,6 +26,15 @@ export interface InkDocument {
|
|||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface InkDrawing {
|
||||
version: 1;
|
||||
width: number;
|
||||
height: number;
|
||||
background: "paper";
|
||||
strokes: InkStroke[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface InkSettings {
|
||||
penColor: string;
|
||||
penWidth: number;
|
||||
|
|
@ -36,8 +45,12 @@ export interface InkSettings {
|
|||
palmRejection: boolean;
|
||||
allowFingerDrawing: boolean;
|
||||
allowMouseDrawing: boolean;
|
||||
showInkWhenInactive: boolean;
|
||||
toolbarPosition: "top" | "bottom";
|
||||
drawingFolder: string;
|
||||
defaultCanvasWidth: number;
|
||||
defaultCanvasHeight: number;
|
||||
defaultEmbedWidth: number;
|
||||
defaultEmbedHeight: number;
|
||||
}
|
||||
|
||||
export interface InkPluginData {
|
||||
|
|
@ -68,6 +81,10 @@ export const DEFAULT_SETTINGS: InkSettings = {
|
|||
palmRejection: true,
|
||||
allowFingerDrawing: false,
|
||||
allowMouseDrawing: true,
|
||||
showInkWhenInactive: true,
|
||||
toolbarPosition: "top"
|
||||
toolbarPosition: "top",
|
||||
drawingFolder: "Inkplane",
|
||||
defaultCanvasWidth: 1600,
|
||||
defaultCanvasHeight: 1200,
|
||||
defaultEmbedWidth: 800,
|
||||
defaultEmbedHeight: 600
|
||||
};
|
||||
|
|
|
|||
587
styles.css
587
styles.css
|
|
@ -1,21 +1,45 @@
|
|||
.ink-layer-host {
|
||||
position: relative;
|
||||
.ink-canvas-view-content {
|
||||
height: 100%;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ink-layer {
|
||||
.ink-canvas-view {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle, color-mix(in srgb, var(--text-muted) 16%, transparent) 1px, transparent 1px),
|
||||
var(--background-primary);
|
||||
background-size: 24px 24px, auto;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.ink-canvas-viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.ink-layer.is-visible {
|
||||
visibility: visible;
|
||||
.ink-canvas-view:has(.ink-layer-tool[data-tool="pan"].is-selected) .ink-canvas-viewport,
|
||||
.ink-canvas-view.is-panning .ink-canvas-viewport {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.ink-layer-canvas {
|
||||
.ink-canvas-view.is-panning .ink-canvas-viewport {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ink-canvas-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
|
|
@ -25,20 +49,22 @@
|
|||
|
||||
.ink-layer-toolbar {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 5px;
|
||||
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);
|
||||
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--background-modifier-border) 82%, transparent);
|
||||
border-radius: calc(var(--radius-l) + 2px);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 13%), inset 0 1px 0 rgb(255 255 255 / 6%);
|
||||
transform: translateX(-50%);
|
||||
backdrop-filter: blur(18px) saturate(1.25);
|
||||
pointer-events: auto;
|
||||
touch-action: manipulation;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
|
@ -49,15 +75,22 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.ink-layer.is-active .ink-layer-toolbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ink-layer.toolbar-at-bottom .ink-layer-toolbar {
|
||||
.ink-canvas-view.toolbar-at-bottom .ink-layer-toolbar {
|
||||
top: auto;
|
||||
bottom: max(10px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.ink-toolbar-group {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
background: color-mix(in srgb, var(--background-modifier-hover) 58%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--background-modifier-border) 48%, transparent);
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
|
||||
.ink-layer-tool {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
|
|
@ -71,6 +104,7 @@
|
|||
border: 0;
|
||||
border-radius: var(--radius-s);
|
||||
box-shadow: none;
|
||||
transition: color 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.ink-layer-tool:hover,
|
||||
|
|
@ -82,6 +116,11 @@
|
|||
.ink-layer-tool.is-selected {
|
||||
color: var(--text-on-accent);
|
||||
background: var(--interactive-accent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-on-accent) 12%, transparent), 0 2px 8px color-mix(in srgb, var(--interactive-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.ink-layer-tool:active:not(:disabled) {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.ink-layer-tool:disabled {
|
||||
|
|
@ -93,27 +132,455 @@
|
|||
height: 19px;
|
||||
}
|
||||
|
||||
.ink-layer-divider {
|
||||
width: 1px;
|
||||
.ink-tool-style {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ink-tool-color-dot {
|
||||
--ink-tool-color: var(--text-normal);
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 4px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: var(--ink-tool-color);
|
||||
border: 1.5px solid color-mix(in srgb, var(--background-primary) 82%, white);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.ink-tool-color-dot.is-eraser {
|
||||
background: repeating-linear-gradient(135deg, var(--text-muted) 0 2px, transparent 2px 4px);
|
||||
}
|
||||
|
||||
.ink-layer-zoom {
|
||||
width: auto;
|
||||
min-width: 50px;
|
||||
padding: 0 7px;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ink-canvas-empty-state {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ink-canvas-empty-state strong {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
font-weight: var(--font-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ink-canvas-empty-state > span:last-child {
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.ink-empty-icon {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--interactive-accent);
|
||||
background: color-mix(in srgb, var(--interactive-accent) 12%, var(--background-primary));
|
||||
border: 1px solid color-mix(in srgb, var(--interactive-accent) 25%, var(--background-modifier-border));
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 8%);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ink-empty-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0 3px;
|
||||
background: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.ink-layer-input-active {
|
||||
cursor: crosshair;
|
||||
.ink-canvas-view.has-ink .ink-canvas-empty-state {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-50% + 6px));
|
||||
}
|
||||
|
||||
.workspace-ribbon .clickable-icon.is-active,
|
||||
.mobile-navbar-action.is-active {
|
||||
color: var(--text-on-accent);
|
||||
background: var(--interactive-accent);
|
||||
.ink-canvas-status {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
right: 12px;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
padding: 3px 9px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--background-primary) 76%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--background-modifier-border) 62%, transparent);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ink-canvas-view.toolbar-at-bottom .ink-canvas-status {
|
||||
top: max(12px, env(safe-area-inset-top));
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.ink-canvas-status-tool {
|
||||
color: var(--text-normal);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.ink-canvas-status-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ink-canvas-status-meta::before {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: var(--text-faint);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.ink-tool-inspector {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: calc(env(safe-area-inset-top) + 64px);
|
||||
left: 50%;
|
||||
width: min(344px, calc(100% - 24px));
|
||||
max-height: calc(100% - env(safe-area-inset-top) - 84px);
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
color: var(--text-normal);
|
||||
background: color-mix(in srgb, var(--background-primary) 94%, transparent);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: calc(var(--radius-l) + 4px);
|
||||
box-shadow: 0 18px 54px rgb(0 0 0 / 22%), inset 0 1px 0 rgb(255 255 255 / 6%);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -8px) scale(0.98);
|
||||
transform-origin: top center;
|
||||
visibility: hidden;
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms;
|
||||
pointer-events: none;
|
||||
touch-action: manipulation;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ink-canvas-view.toolbar-at-bottom .ink-tool-inspector {
|
||||
top: auto;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 64px);
|
||||
transform: translate(-50%, 8px) scale(0.98);
|
||||
transform-origin: bottom center;
|
||||
max-height: calc(100% - env(safe-area-inset-bottom) - 84px);
|
||||
}
|
||||
|
||||
.ink-tool-inspector.is-open,
|
||||
.ink-canvas-view.toolbar-at-bottom .ink-tool-inspector.is-open {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ink-inspector-header,
|
||||
.ink-inspector-section-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ink-inspector-title {
|
||||
font-size: var(--font-ui-medium);
|
||||
font-weight: var(--font-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ink-inspector-description {
|
||||
margin-top: 1px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.ink-inspector-close {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ink-inspector-close:hover,
|
||||
.ink-inspector-close:focus-visible {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.ink-stroke-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
margin: 12px 0 14px;
|
||||
padding: 0 18px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--background-secondary) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--background-modifier-border) 70%, transparent);
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
|
||||
.ink-stroke-preview-line {
|
||||
width: 100%;
|
||||
height: var(--ink-preview-width);
|
||||
max-height: 26px;
|
||||
background: var(--ink-preview-color);
|
||||
border-radius: 999px;
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
.ink-stroke-preview.is-highlighter .ink-stroke-preview-line {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.ink-stroke-preview.is-eraser .ink-stroke-preview-line {
|
||||
height: 22px;
|
||||
background: repeating-linear-gradient(135deg, var(--background-modifier-border) 0 4px, transparent 4px 8px);
|
||||
border: 1px dashed var(--text-muted);
|
||||
}
|
||||
|
||||
.ink-inspector-section-label {
|
||||
margin: 0 0 7px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-weight: var(--font-semibold);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ink-inspector-section-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ink-inspector-section-row .ink-inspector-section-label {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.ink-width-value {
|
||||
margin-bottom: 7px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ink-color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ink-color-swatch,
|
||||
.ink-custom-color {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-normal);
|
||||
appearance: none;
|
||||
background: var(--ink-swatch-color, transparent) !important;
|
||||
border: 2px solid color-mix(in srgb, var(--background-primary) 82%, var(--background-modifier-border));
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-muted) 28%, transparent), inset 0 0 0 1px rgb(255 255 255 / 14%);
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.ink-color-swatch:hover,
|
||||
.ink-color-swatch:focus-visible,
|
||||
.ink-custom-color:hover,
|
||||
.ink-custom-color:focus-within {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.ink-color-swatch.is-selected {
|
||||
box-shadow: 0 0 0 3px var(--background-primary), 0 0 0 5px var(--interactive-accent);
|
||||
}
|
||||
|
||||
.ink-color-swatch.is-adaptive {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-weight: var(--font-semibold);
|
||||
background: linear-gradient(135deg, var(--background-primary) 0 50%, var(--text-normal) 50% 100%) !important;
|
||||
text-shadow: 0 1px 2px var(--background-primary);
|
||||
}
|
||||
|
||||
.ink-custom-color {
|
||||
background: conic-gradient(#ef4444, #f59e0b, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444) !important;
|
||||
}
|
||||
|
||||
.ink-custom-color input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ink-custom-color span {
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 38%);
|
||||
border-radius: 50%;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 45%);
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ink-width-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ink-width-preset {
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ink-width-preset:hover,
|
||||
.ink-width-preset:focus-visible {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.ink-width-preset.is-selected {
|
||||
color: var(--text-accent);
|
||||
background: color-mix(in srgb, var(--interactive-accent) 12%, var(--background-primary));
|
||||
border-color: color-mix(in srgb, var(--interactive-accent) 60%, var(--background-modifier-border));
|
||||
}
|
||||
|
||||
.ink-width-slider {
|
||||
width: 100% !important;
|
||||
height: 30px;
|
||||
margin: 8px 0 0;
|
||||
accent-color: var(--interactive-accent);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.ink-layer-embed {
|
||||
display: block !important;
|
||||
width: 100%;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.ink-embed-preview {
|
||||
position: relative;
|
||||
width: min(100%, var(--ink-embed-width));
|
||||
aspect-ratio: var(--ink-embed-ratio);
|
||||
min-height: 180px;
|
||||
margin: 0.75em auto;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle, color-mix(in srgb, var(--text-muted) 11%, transparent) 1px, transparent 1px),
|
||||
var(--background-primary);
|
||||
background-size: 20px 20px, auto;
|
||||
border: 1px solid color-mix(in srgb, var(--background-modifier-border) 78%, transparent);
|
||||
border-radius: var(--radius-l);
|
||||
box-shadow: 0 5px 22px rgb(0 0 0 / 8%);
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.ink-embed-preview:hover,
|
||||
.ink-embed-preview:focus-within {
|
||||
border-color: color-mix(in srgb, var(--interactive-accent) 32%, var(--background-modifier-border));
|
||||
box-shadow: 0 9px 30px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.ink-embed-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ink-embed-open {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
box-shadow: var(--shadow-s);
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.ink-embed-preview:hover .ink-embed-open,
|
||||
.ink-embed-preview:focus-within .ink-embed-open,
|
||||
.ink-embed-open:focus-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ink-embed-preview.is-error::after,
|
||||
.ink-embed-missing::after {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
color: var(--text-error);
|
||||
content: "Ink drawing could not be loaded";
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.ink-embed-open {
|
||||
opacity: 0.86;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ink-layer-toolbar {
|
||||
gap: 3px;
|
||||
padding: 6px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.ink-toolbar-group {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.ink-layer-tool {
|
||||
|
|
@ -121,14 +588,70 @@
|
|||
height: 42px;
|
||||
}
|
||||
|
||||
.ink-layer-zoom {
|
||||
width: auto;
|
||||
min-width: 54px;
|
||||
}
|
||||
|
||||
.ink-layer-tool svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.ink-tool-inspector {
|
||||
top: calc(env(safe-area-inset-top) + 68px);
|
||||
width: calc(100% - 20px);
|
||||
max-height: calc(100% - env(safe-area-inset-top) - 82px);
|
||||
padding: 16px;
|
||||
border-radius: calc(var(--radius-l) + 6px);
|
||||
}
|
||||
|
||||
.ink-canvas-view.toolbar-at-bottom .ink-tool-inspector {
|
||||
bottom: calc(env(safe-area-inset-bottom) + 68px);
|
||||
max-height: calc(100% - env(safe-area-inset-bottom) - 82px);
|
||||
}
|
||||
|
||||
.ink-color-swatch,
|
||||
.ink-custom-color {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.ink-width-preset {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.ink-canvas-status {
|
||||
right: 10px;
|
||||
bottom: max(10px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.ink-canvas-view.toolbar-at-bottom .ink-canvas-status {
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.ink-canvas-status-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.ink-layer-toolbar {
|
||||
.ink-layer-toolbar,
|
||||
.ink-tool-inspector,
|
||||
.ink-canvas-status,
|
||||
.ink-embed-open {
|
||||
background: var(--background-primary);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ink-layer-tool,
|
||||
.ink-canvas-empty-state,
|
||||
.ink-tool-inspector,
|
||||
.ink-color-swatch,
|
||||
.ink-custom-color,
|
||||
.ink-embed-preview,
|
||||
.ink-embed-open {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
tests/file-format.test.ts
Normal file
53
tests/file-format.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { emptyDrawing, parseDrawingFile, serializeDrawingFile } from "../src/file-format";
|
||||
import { createInkPoint, createStroke } from "../src/model";
|
||||
|
||||
describe(".inklayer file format", () => {
|
||||
it("round-trips a standalone drawing using compact point tuples", () => {
|
||||
const drawing = emptyDrawing(1800, 900);
|
||||
drawing.updatedAt = 1234;
|
||||
drawing.strokes.push(createStroke("pen", "adaptive", 3.2, 1, [
|
||||
createInkPoint(12.345, 67.891, 0.4567, 11.22, -33.44, 98.7)
|
||||
]));
|
||||
|
||||
const serialized = serializeDrawingFile(drawing);
|
||||
const raw = JSON.parse(serialized) as { strokes: Array<{ points: number[][] }> };
|
||||
expect(raw.strokes[0].points[0]).toEqual([12.35, 67.89, 0.457, 11.2, -33.4, 99]);
|
||||
|
||||
const parsed = parseDrawingFile(serialized);
|
||||
expect(parsed).toMatchObject({ version: 1, width: 1800, height: 900, updatedAt: 1234 });
|
||||
expect(parsed.strokes[0].points[0]).toEqual({
|
||||
x: 12.35,
|
||||
y: 67.89,
|
||||
pressure: 0.457,
|
||||
tiltX: 11.2,
|
||||
tiltY: -33.4,
|
||||
time: 99
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers safely from corrupt dimensions and stroke values", () => {
|
||||
const parsed = parseDrawingFile(JSON.stringify({
|
||||
version: 99,
|
||||
width: -10,
|
||||
height: 99999,
|
||||
strokes: [
|
||||
{ tool: "pen", points: [[10, 20, 5, -120, 120, -1]], width: 999, opacity: 9 },
|
||||
{ tool: "unsupported", points: [[1, 2]] },
|
||||
{ tool: "pen", points: [["bad", 2]] }
|
||||
]
|
||||
}));
|
||||
|
||||
expect(parsed.width).toBe(320);
|
||||
expect(parsed.height).toBe(12000);
|
||||
expect(parsed.strokes).toHaveLength(1);
|
||||
expect(parsed.strokes[0]).toMatchObject({ width: 80, opacity: 1 });
|
||||
expect(parsed.strokes[0].points[0]).toMatchObject({ pressure: 1, tiltX: -90, tiltY: 90, time: 0 });
|
||||
});
|
||||
|
||||
it("opens malformed JSON as a new empty canvas instead of throwing", () => {
|
||||
const parsed = parseDrawingFile("{not-json");
|
||||
expect(parsed.strokes).toEqual([]);
|
||||
expect(parsed).toMatchObject({ width: 1600, height: 1200 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"0.1.0": "1.7.2"
|
||||
"0.1.0": "1.7.2",
|
||||
"0.2.0": "1.7.2"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue