From e769ebc8bd845afef2582dc9f9eb6ff7eb17cb10 Mon Sep 17 00:00:00 2001 From: Sirwan Afifi Date: Fri, 17 Jul 2026 14:01:41 +0100 Subject: [PATCH] fix: address Obsidian plugin review recommendations --- .github/workflows/release.yml | 10 ++ esbuild.config.mjs | 3 +- package-lock.json | 14 --- package.json | 1 - src/drawing-surface.ts | 125 +++++++++-------------- src/embed-manager.ts | 37 +++---- src/main.ts | 30 ++++-- src/settings.ts | 161 ++++++++++++++++++++++++++++- src/tool-inspector.ts | 187 +++++++++++++++------------------- 9 files changed, 338 insertions(+), 230 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 794eb39..1c8bd85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/esbuild.config.mjs b/esbuild.config.mjs index c0b777e..11e7b7c 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -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 */" }, diff --git a/package-lock.json b/package-lock.json index cfd52a7..3bc375e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 96d45c9..be5a8f5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/drawing-surface.ts b/src/drawing-surface.ts index aedceac..0f5a307 100644 --- a/src/drawing-surface.ts +++ b/src/drawing-surface.ts @@ -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; } diff --git a/src/embed-manager.ts b/src/embed-manager.ts index 994fa1d..d77ce01 100644 --- a/src/embed-manager.ts +++ b/src/embed-manager.ts @@ -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); diff --git a/src/main.ts b/src/main.ts index deeb955..1804da4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -75,9 +75,13 @@ export default class InkLayerPlugin this.registerDomEvent(window, "pagehide", () => void this.flushOpenDrawings()); } - async onunload(): Promise { - await this.flushOpenDrawings(); + onunload(): void { this.embedManager.destroy(); + void this.persistBeforeUnload(); + } + + private async persistBeforeUnload(): Promise { + 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 { diff --git a/src/settings.ts b/src/settings.ts index 5a8cc22..433c11b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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[] { + 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 Obsidian’s 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 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(); + 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)); +} diff --git a/src/tool-inspector.ts b/src/tool-inspector.ts index 8b06ec3..cd54a70 100644 --- a/src/tool-inspector.ts +++ b/src/tool-inspector.ts @@ -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("[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; }