fix: address Obsidian plugin review recommendations

This commit is contained in:
Sirwan Afifi 2026-07-17 14:01:41 +01:00
parent 9bcfe75160
commit e769ebc8bd
9 changed files with 338 additions and 230 deletions

View file

@ -6,7 +6,10 @@ on:
- "[0-9]+.[0-9]+.[0-9]+"
permissions:
artifact-metadata: write
attestations: write
contents: write
id-token: write
jobs:
release:
@ -25,6 +28,13 @@ jobs:
run: npm run check
- name: Validate tag
run: node scripts/check-release.mjs "$GITHUB_REF_NAME"
- name: Attest release assets
uses: actions/attest@v4
with:
subject-path: |
main.js
manifest.json
styles.css
- name: Publish release assets
env:
GH_TOKEN: ${{ github.token }}

View file

@ -1,8 +1,9 @@
import esbuild from "esbuild";
import { builtinModules } from "node:module";
import process from "process";
import builtins from "builtin-modules";
const production = process.argv[2] === "production";
const builtins = [...builtinModules, ...builtinModules.map((name) => `node:${name}`)];
const context = await esbuild.context({
banner: { js: "/* Inkplane for Obsidian */" },

14
package-lock.json generated
View file

@ -13,7 +13,6 @@
},
"devDependencies": {
"@types/node": "^24.0.0",
"builtin-modules": "^5.0.0",
"esbuild": "^0.28.1",
"obsidian": "^1.13.1",
"tslib": "^2.8.1",
@ -1366,19 +1365,6 @@
"node": ">=12"
}
},
"node_modules/builtin-modules": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.3.0.tgz",
"integrity": "sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",

View file

@ -35,7 +35,6 @@
},
"devDependencies": {
"@types/node": "^24.0.0",
"builtin-modules": "^5.0.0",
"esbuild": "^0.28.1",
"obsidian": "^1.13.1",
"tslib": "^2.8.1",

View file

@ -114,36 +114,28 @@ export class DrawingSurface {
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.root = host.createDiv({ cls: "ink-canvas-view", attr: { tabindex: "0" } });
this.viewport = this.root.createDiv({
cls: "ink-canvas-viewport",
attr: { "aria-label": "Ink drawing canvas" }
});
this.dryCanvas = this.viewport.createEl("canvas", {
cls: "ink-canvas-layer ink-canvas-layer-dry",
attr: { "aria-hidden": "true" }
});
this.wetCanvas = this.viewport.createEl("canvas", {
cls: "ink-canvas-layer",
attr: { "aria-hidden": "true" }
});
this.toolbar = this.root.createDiv({
cls: "ink-layer-toolbar",
attr: { role: "toolbar", "aria-label": "Ink tools" }
});
this.startToolbarGroup("History", "ink-toolbar-history");
this.undoButton = this.addActionButton("undo-2", "Undo ink", () => this.undo());
@ -174,27 +166,18 @@ export class DrawingSurface {
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";
this.emptyState = this.viewport.createDiv({
cls: "ink-canvas-empty-state",
attr: { "aria-hidden": "true" }
});
const emptyIcon = this.emptyState.createSpan({ cls: "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);
this.emptyState.createEl("strong", { text: "Draw with Apple Pencil" });
this.emptyState.createSpan({ text: "One finger pans · two fingers zoom" });
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);
const status = this.root.createDiv({ cls: "ink-canvas-status" });
this.statusLabel = status.createSpan({ cls: "ink-canvas-status-tool" });
this.statusMeta = status.createSpan({ cls: "ink-canvas-status-meta" });
this.toolInspector = new ToolInspector(
this.root,
@ -366,17 +349,13 @@ export class DrawingSurface {
const button = this.addActionButton(icon, label, () => this.setTool(tool));
button.dataset.tool = tool;
button.setAttribute("aria-pressed", "false");
const indicator = this.toolbar.ownerDocument.createElement("span");
indicator.className = "ink-tool-selection-indicator";
indicator.setAttribute("aria-hidden", "true");
button.appendChild(indicator);
button.createSpan({ cls: "ink-tool-selection-indicator", attr: { "aria-hidden": "true" } });
this.toolButtons.set(tool, button);
}
private addQuickColorButton(index: number): HTMLButtonElement {
const button = this.toolbar.ownerDocument.createElement("button");
button.type = "button";
button.className = "ink-quick-color";
const parent = this.toolbarGroup ?? this.toolbar;
const button = parent.createEl("button", { cls: "ink-quick-color", attr: { type: "button" } });
button.dataset.colorIndex = String(index);
button.setAttribute("aria-pressed", "false");
button.addEventListener("click", (event) => {
@ -391,7 +370,6 @@ export class DrawingSurface {
this.updateToolbar();
this.root.focus({ preventScroll: true });
});
(this.toolbarGroup ?? this.toolbar).appendChild(button);
return button;
}
@ -400,11 +378,11 @@ export class DrawingSurface {
label: string,
action: (event: MouseEvent) => void
): HTMLButtonElement {
const button = this.toolbar.ownerDocument.createElement("button");
button.type = "button";
button.className = "ink-layer-tool clickable-icon";
button.setAttribute("aria-label", label);
button.setAttribute("title", label);
const parent = this.toolbarGroup ?? this.toolbar;
const button = parent.createEl("button", {
cls: "ink-layer-tool clickable-icon",
attr: { type: "button", "aria-label": label, title: label }
});
button.dataset.inkTooltip = label;
try {
setIcon(button, icon);
@ -412,43 +390,40 @@ export class DrawingSurface {
// Older mobile Obsidian builds can lack newer Lucide icon identifiers.
}
if (!button.querySelector("svg")) {
const fallback = this.toolbar.ownerDocument.createElement("span");
fallback.className = "ink-tool-glyph";
fallback.textContent = fallbackIcon(icon);
fallback.setAttribute("aria-hidden", "true");
button.appendChild(fallback);
button.createSpan({
cls: "ink-tool-glyph",
text: fallbackIcon(icon),
attr: { "aria-hidden": "true" }
});
}
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);
const parent = this.toolbarGroup ?? this.toolbar;
const button = parent.createEl("button", {
cls: "ink-layer-tool ink-layer-zoom",
text,
attr: { type: "button", "aria-label": label, 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, className = ""): void {
const group = this.toolbar.ownerDocument.createElement("div");
group.className = `ink-toolbar-group ${className}`.trim();
group.setAttribute("role", "group");
group.setAttribute("aria-label", label);
this.toolbar.appendChild(group);
const group = this.toolbar.createDiv({
cls: `ink-toolbar-group ${className}`.trim(),
attr: { role: "group", "aria-label": label }
});
this.toolbarGroup = group;
}

View file

@ -19,11 +19,11 @@ export class InkEmbedManager {
this.observer = new MutationObserver((mutations) => {
this.removeDetachedPreviews();
for (const mutation of mutations) {
if (mutation.target instanceof HTMLElement) {
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));
if (node.instanceOf(HTMLElement)) this.scan(node, this.sourcePathFor(node));
}
}
});
@ -112,33 +112,26 @@ class InkEmbedPreview {
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.shell = container.createDiv({ cls: "ink-embed-preview", attr: { tabindex: "0" } });
this.dryCanvas = this.shell.createEl("canvas", {
cls: "ink-embed-layer",
attr: { "aria-hidden": "true" }
});
this.wetCanvas = this.shell.createEl("canvas", {
cls: "ink-embed-layer",
attr: { "aria-hidden": "true" }
});
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");
const openButton = this.shell.createEl("button", {
cls: "ink-embed-open clickable-icon",
attr: { type: "button", "aria-label": `Open ${file.basename}`, 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);

View file

@ -75,9 +75,13 @@ export default class InkLayerPlugin
this.registerDomEvent(window, "pagehide", () => void this.flushOpenDrawings());
}
async onunload(): Promise<void> {
await this.flushOpenDrawings();
onunload(): void {
this.embedManager.destroy();
void this.persistBeforeUnload();
}
private async persistBeforeUnload(): Promise<void> {
await this.flushOpenDrawings();
await this.store.dispose();
}
@ -317,18 +321,24 @@ class ClearInkModal extends Modal {
onOpen(): void {
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);
this.contentEl.createEl("p", {
text: "This removes every stroke. You can undo it while the drawing remains open."
});
new Setting(this.contentEl)
.addButton((button) => button.setButtonText("Cancel").onClick(() => this.close()))
.addButton((button) => button
.setButtonText("Clear drawing")
.setWarning()
.onClick(() => {
.addButton((button) => {
button.setButtonText("Clear drawing");
if (typeof button.setDestructive === "function") {
button.setDestructive().setCta();
} else {
// Preserve destructive styling on supported Obsidian versions before 1.13.0.
button.buttonEl.addClasses(["mod-warning", "mod-cta"]);
}
button.onClick(() => {
this.onConfirm();
this.close();
}));
});
});
}
onClose(): void {

View file

@ -1,4 +1,10 @@
import { Plugin, PluginSettingTab, Setting, type App } from "obsidian";
import {
Plugin,
PluginSettingTab,
Setting,
type App,
type SettingDefinitionItem
} from "obsidian";
import type { InkStore } from "./storage";
import type { InkSettings } from "./types";
@ -7,12 +13,155 @@ export interface InkSettingsHost {
refreshInkUI(): void;
}
type InkSettingKey = keyof InkSettings | "matchPenToTheme";
export class InkSettingTab extends PluginSettingTab {
constructor(app: App, private readonly pluginHost: Plugin & InkSettingsHost) {
super(app, pluginHost);
}
getSettingDefinitions(): SettingDefinitionItem<InkSettingKey>[] {
return [
{
type: "group",
heading: "Drawing files",
items: [
{
name: "Drawing folder",
desc: "New .inklayer canvases are stored here inside the vault. Leave blank for the vault root.",
control: { type: "folder", key: "drawingFolder", placeholder: "Inkplane" }
},
{
name: "Embed width",
desc: "Default width for previews inserted into notes.",
control: { type: "number", key: "defaultEmbedWidth", min: 240, max: 2400, step: 1 }
},
{
name: "Embed height",
desc: "Default height for previews inserted into notes.",
control: { type: "number", key: "defaultEmbedHeight", min: 180, max: 1800, step: 1 }
}
]
},
{
type: "group",
heading: "Ink appearance",
items: [
{
name: "Match pen to the theme",
desc: "Use Obsidians current text color for new pen strokes.",
control: { type: "toggle", key: "matchPenToTheme" }
},
{
name: "Pen color",
desc: "Color for new pen strokes when theme matching is disabled.",
control: {
type: "color",
key: "penColor",
disabled: () => this.pluginHost.store.settings.penColor === "adaptive"
}
},
{
name: "Pen width",
desc: "Base width. Pencil pressure varies the final width.",
control: { type: "slider", key: "penWidth", min: 1, max: 12, step: 0.2 }
},
{
name: "Highlighter color",
control: { type: "color", key: "highlighterColor" }
},
{
name: "Highlighter width",
control: { type: "slider", key: "highlighterWidth", min: 6, max: 40, step: 1 }
},
{
name: "Eraser size",
desc: "The eraser removes only the area of a stroke that it touches.",
control: { type: "slider", key: "eraserWidth", min: 8, max: 56, step: 2 }
},
{
name: "Pressure response",
desc: "Zero gives a uniform line; one follows the full pressure range reported by the pen.",
control: { type: "slider", key: "pressureSensitivity", min: 0, max: 1, step: 0.05 }
}
]
},
{
type: "group",
heading: "Input and navigation",
items: [
{
name: "Palm rejection",
desc: "Ignore touch contacts while a pen is active or was just detected nearby.",
control: { type: "toggle", key: "palmRejection" }
},
{
name: "Draw with a finger",
desc: "Off by default: one finger pans and two fingers pinch-zoom while Apple Pencil draws.",
control: { type: "toggle", key: "allowFingerDrawing" }
},
{
name: "Draw with a mouse",
desc: "Useful for desktop editing and testing without a pen.",
control: { type: "toggle", key: "allowMouseDrawing" }
},
{
name: "Toolbar position",
desc: "The bottom position respects the iPad safe area.",
control: {
type: "dropdown",
key: "toolbarPosition",
options: { top: "Top", bottom: "Bottom" }
}
}
]
}
];
}
getControlValue(key: string): unknown {
const settings = this.pluginHost.store.settings;
if (key === "matchPenToTheme") return settings.penColor === "adaptive";
if (key === "penColor" && settings.penColor === "adaptive") return "#1f2937";
return settings[key as keyof InkSettings];
}
setControlValue(key: string, value: unknown): void {
if (key === "matchPenToTheme") {
if (typeof value !== "boolean") return;
this.applyPatch({ penColor: value ? "adaptive" : "#1f2937" });
this.update();
return;
}
if (key === "drawingFolder" || key === "penColor" || key === "highlighterColor") {
if (typeof value !== "string") return;
this.applyPatch({ [key]: key === "drawingFolder" ? value.trim() : value });
return;
}
if (key === "palmRejection" || key === "allowFingerDrawing" || key === "allowMouseDrawing") {
if (typeof value !== "boolean") return;
this.applyPatch({ [key]: value });
return;
}
if (key === "toolbarPosition") {
this.applyPatch({ toolbarPosition: value === "bottom" ? "bottom" : "top" });
return;
}
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) return;
if (key === "defaultEmbedWidth") this.applyPatch({ defaultEmbedWidth: clampRound(numberValue, 240, 2400) });
else if (key === "defaultEmbedHeight") this.applyPatch({ defaultEmbedHeight: clampRound(numberValue, 180, 1800) });
else if (key === "penWidth") this.applyPatch({ penWidth: clamp(numberValue, 1, 12) });
else if (key === "highlighterWidth") this.applyPatch({ highlighterWidth: clamp(numberValue, 6, 40) });
else if (key === "eraserWidth") this.applyPatch({ eraserWidth: clamp(numberValue, 8, 56) });
else if (key === "pressureSensitivity") this.applyPatch({ pressureSensitivity: clamp(numberValue, 0, 1) });
}
display(): void {
this.renderLegacy();
}
private renderLegacy(): void {
this.containerEl.replaceChildren();
const settings = this.pluginHost.store.settings;
@ -50,7 +199,7 @@ export class InkSettingTab extends PluginSettingTab {
.setDesc("Use Obsidians current text color for new pen strokes.")
.addToggle((toggle) => toggle.setValue(settings.penColor === "adaptive").onChange((enabled) => {
this.applyPatch({ penColor: enabled ? "adaptive" : "#1f2937" });
this.display();
this.renderLegacy();
}));
new Setting(this.containerEl)
@ -161,3 +310,11 @@ export class InkSettingTab extends PluginSettingTab {
this.pluginHost.refreshInkUI();
}
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
function clampRound(value: number, minimum: number, maximum: number): number {
return Math.round(clamp(value, minimum, maximum));
}

View file

@ -91,13 +91,15 @@ export class ToolInspector {
private readonly onOpenChange?: (open: boolean) => void,
private readonly anchor?: HTMLElement
) {
this.element = parent.ownerDocument.createElement("div");
this.element.className = "ink-tool-inspector";
this.element.setAttribute("role", "dialog");
this.element.setAttribute("aria-label", "Tool settings");
this.element.setAttribute("aria-hidden", "true");
this.element.tabIndex = -1;
this.parent.appendChild(this.element);
this.element = parent.createDiv({
cls: "ink-tool-inspector",
attr: {
role: "dialog",
"aria-label": "Tool settings",
"aria-hidden": "true",
tabindex: "-1"
}
});
}
get isOpen(): boolean {
@ -182,67 +184,52 @@ export class ToolInspector {
this.element.replaceChildren();
this.element.setAttribute("aria-label", `${design.label} settings`);
const header = doc.createElement("div");
header.className = "ink-inspector-header";
const heading = doc.createElement("div");
heading.className = "ink-inspector-heading";
const toolIcon = doc.createElement("span");
toolIcon.className = "ink-inspector-tool-icon";
toolIcon.setAttribute("aria-hidden", "true");
const header = this.element.createDiv({ cls: "ink-inspector-header" });
const heading = header.createDiv({ cls: "ink-inspector-heading" });
const toolIcon = heading.createSpan({
cls: "ink-inspector-tool-icon",
attr: { "aria-hidden": "true" }
});
setIcon(toolIcon, design.icon);
const titleWrap = doc.createElement("div");
const title = doc.createElement("div");
title.className = "ink-inspector-title";
title.textContent = `${design.label} settings`;
const description = doc.createElement("div");
description.className = "ink-inspector-description";
description.textContent = design.description;
titleWrap.append(title, description);
heading.append(toolIcon, titleWrap);
const closeButton = doc.createElement("button");
closeButton.type = "button";
closeButton.className = "ink-inspector-close clickable-icon";
closeButton.setAttribute("aria-label", "Close color and size");
const titleWrap = heading.createDiv();
titleWrap.createDiv({ cls: "ink-inspector-title", text: `${design.label} settings` });
titleWrap.createDiv({ cls: "ink-inspector-description", text: design.description });
const closeButton = header.createEl("button", {
cls: "ink-inspector-close clickable-icon",
attr: { type: "button", "aria-label": "Close color and size" }
});
closeButton.dataset.inkFocus = "close";
setIcon(closeButton, "x");
closeButton.addEventListener("click", () => this.close());
header.append(heading, closeButton);
this.element.appendChild(header);
const preview = doc.createElement("div");
preview.className = `ink-stroke-preview is-${this.tool}`;
preview.setAttribute("role", "img");
preview.setAttribute("aria-label", `${design.label} preview at ${formatWidth(width)}`);
const previewLine = doc.createElement("div");
previewLine.className = "ink-stroke-preview-line";
const preview = this.element.createDiv({
cls: `ink-stroke-preview is-${this.tool}`,
attr: { role: "img", "aria-label": `${design.label} preview at ${formatWidth(width)}` }
});
const previewLine = preview.createDiv({ cls: "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) {
const selectedChoice = design.colors.find((choice) => colorsMatch(color, choice.value));
const isCustomColor = color !== "adaptive" && selectedChoice === undefined;
const colorHeader = doc.createElement("div");
colorHeader.className = "ink-inspector-section-row is-color";
colorHeader.appendChild(this.sectionLabel("Color"));
const colorValue = doc.createElement("output");
colorValue.className = "ink-color-value";
colorValue.textContent = selectedChoice?.label ?? color.toUpperCase();
colorHeader.appendChild(colorValue);
this.element.appendChild(colorHeader);
const colorHeader = this.element.createDiv({ cls: "ink-inspector-section-row is-color" });
this.sectionLabel(colorHeader, "Color");
const colorValue = colorHeader.createEl("output", {
cls: "ink-color-value",
text: selectedChoice?.label ?? color.toUpperCase()
});
const swatches = doc.createElement("div");
swatches.className = "ink-color-grid";
swatches.setAttribute("role", "group");
swatches.setAttribute("aria-label", "Color presets");
const swatches = this.element.createDiv({
cls: "ink-color-grid",
attr: { role: "group", "aria-label": "Color presets" }
});
for (const choice of design.colors) {
const button = doc.createElement("button");
button.type = "button";
button.className = "ink-color-swatch";
const button = swatches.createEl("button", {
cls: "ink-color-swatch",
attr: { type: "button", "aria-label": choice.label, title: choice.label }
});
button.classList.toggle("is-selected", colorsMatch(color, choice.value));
button.setAttribute("aria-label", choice.label);
button.setAttribute("title", choice.label);
button.setAttribute("aria-pressed", button.classList.contains("is-selected") ? "true" : "false");
button.dataset.inkFocus = `color-${choice.value}`;
if (choice.value === "adaptive") {
@ -251,21 +238,18 @@ export class ToolInspector {
} else {
button.style.setProperty("--ink-swatch-color", choice.value);
}
button.appendChild(this.selectionMark());
this.selectionMark(button);
button.addEventListener("click", () => {
this.applyColor(choice.value);
this.render();
});
swatches.appendChild(button);
}
const custom = doc.createElement("label");
custom.className = "ink-custom-color";
const custom = swatches.createEl("label", { cls: "ink-custom-color", attr: { title: "Custom color" } });
custom.classList.toggle("is-selected", isCustomColor);
custom.setAttribute("title", "Custom color");
const colorInput = doc.createElement("input");
colorInput.type = "color";
colorInput.setAttribute("aria-label", "Custom color");
const colorInput = custom.createEl("input", {
attr: { type: "color", "aria-label": "Custom color" }
});
colorInput.dataset.inkFocus = "color-custom";
colorInput.value = color === "adaptive" ? "#111827" : color;
colorInput.addEventListener("input", () => {
@ -278,56 +262,52 @@ export class ToolInspector {
}
custom.classList.add("is-selected");
});
const customIcon = doc.createElement("span");
customIcon.className = "ink-custom-color-glyph";
customIcon.setAttribute("aria-hidden", "true");
const customIcon = custom.createSpan({
cls: "ink-custom-color-glyph",
attr: { "aria-hidden": "true" }
});
setIcon(customIcon, "pipette");
custom.append(colorInput, customIcon, this.selectionMark());
swatches.appendChild(custom);
this.element.appendChild(swatches);
this.selectionMark(custom);
}
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.setAttribute("aria-live", "polite");
widthValue.textContent = formatWidth(width);
widthHeader.appendChild(widthValue);
this.element.appendChild(widthHeader);
const widthHeader = this.element.createDiv({ cls: "ink-inspector-section-row" });
this.sectionLabel(widthHeader, this.tool === "eraser" ? "Eraser size" : "Stroke width");
const widthValue = widthHeader.createEl("output", {
cls: "ink-width-value",
text: formatWidth(width),
attr: { "aria-live": "polite" }
});
const presets = doc.createElement("div");
presets.className = "ink-width-presets";
presets.setAttribute("role", "group");
presets.setAttribute("aria-label", "Width presets");
const presets = this.element.createDiv({
cls: "ink-width-presets",
attr: { role: "group", "aria-label": "Width presets" }
});
for (const choice of design.widths) {
const button = doc.createElement("button");
button.type = "button";
button.className = "ink-width-preset";
const button = presets.createEl("button", {
cls: "ink-width-preset",
attr: { type: "button" }
});
button.classList.toggle("is-selected", Math.abs(width - choice.value) < 0.01);
button.setAttribute("aria-pressed", button.classList.contains("is-selected") ? "true" : "false");
button.dataset.inkFocus = `width-${choice.value}`;
button.dataset.width = String(choice.value);
const sample = doc.createElement("span");
sample.className = `ink-width-preset-sample is-${this.tool}`;
const sample = button.createSpan({
cls: `ink-width-preset-sample is-${this.tool}`,
attr: { "aria-hidden": "true" }
});
sample.style.setProperty("--ink-preset-width", `${previewWidth(this.tool, choice.value)}px`);
sample.style.setProperty("--ink-preset-color", cssColor(color));
sample.setAttribute("aria-hidden", "true");
const label = doc.createElement("span");
label.textContent = choice.label;
button.append(sample, label);
button.createSpan({ text: 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";
const range = this.element.createEl("input", {
cls: "ink-width-slider",
attr: { type: "range" }
});
range.min = String(design.minimum);
range.max = String(design.maximum);
range.step = String(design.step);
@ -350,7 +330,6 @@ export class ToolInspector {
button.setAttribute("aria-pressed", selected ? "true" : "false");
}
});
this.element.appendChild(range);
if (shouldRestoreFocus) {
const controls = this.element.querySelectorAll<HTMLElement>("[data-ink-focus]");
const focusTarget = Array.from(controls).find((control) => control.dataset.inkFocus === focusKey);
@ -358,17 +337,15 @@ export class ToolInspector {
}
}
private sectionLabel(text: string): HTMLDivElement {
const label = this.element.ownerDocument.createElement("div");
label.className = "ink-inspector-section-label";
label.textContent = text;
return label;
private sectionLabel(parent: HTMLElement, text: string): HTMLDivElement {
return parent.createDiv({ cls: "ink-inspector-section-label", text });
}
private selectionMark(): HTMLSpanElement {
const mark = this.element.ownerDocument.createElement("span");
mark.className = "ink-color-swatch-check";
mark.setAttribute("aria-hidden", "true");
private selectionMark(parent: HTMLElement): HTMLSpanElement {
const mark = parent.createSpan({
cls: "ink-color-swatch-check",
attr: { "aria-hidden": "true" }
});
setIcon(mark, "check");
return mark;
}