diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad4d7d..cd17ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to O-Tie are documented here. +## [1.0.12] — 2026-06-27 + +### Fixed + +- **Inspector label field** — The label input now auto-expands vertically like the notes field, so long text is fully visible while editing. +- **Multiline node labels** — Line breaks entered in the inspector or inline editor are shown on diagram nodes, and node height grows to fit. +- **Inline label edit** — Clearing a label with delete/backspace now commits correctly; inline edit uses a textarea so multiline labels match the inspector. +- **Pan from nodes** — Drag-pan works when the pointer starts on a node (mouse, touch, or pen), not only on empty canvas space. +- **Node tap/click** — A tap or click without movement still selects the node and opens the inspector bar after the pan-on-node fix. + +--- + ## [1.0.11] — 2026-06-27 ### Changed diff --git a/manifest.json b/manifest.json index 7fece2b..0f97607 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "o-tie", "name": "O-Tie", - "version": "1.0.11", + "version": "1.0.12", "minAppVersion": "1.4.0", "description": "Build risk bowtie diagrams in your vault: threats, prevention and mitigation barriers, escalation factors, barrier analysis stacks, and PNG export.", "author": "Andre482", diff --git a/package.json b/package.json index b17128b..e5fe013 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "o-tie", - "version": "1.0.11", + "version": "1.0.12", "description": "Obsidian plugin for building risk bowtie diagrams", "main": "main.js", "scripts": { diff --git a/src/bowtieView.ts b/src/bowtieView.ts index 56cf386..def1f81 100644 --- a/src/bowtieView.ts +++ b/src/bowtieView.ts @@ -32,6 +32,7 @@ import { type DegradationChain, type EscalationNode, nodeRefKey, + parseNodeRefKey, type NodeRef, serializeBowtie, sortBarrierStack, @@ -65,6 +66,10 @@ export class BowtieView extends TextFileView { private pinchLastDistance: number | null = null; private gestureMoved = false; private panStartedOnNode = false; + private panDeferFromNode = false; + private panDeferPointerId: number | null = null; + private panDeferStart = { x: 0, y: 0 }; + private panDeferTarget: HTMLElement | null = null; private suppressNextClick = false; private saveTimeout: number | null = null; private viewSaveTimeout: number | null = null; @@ -72,6 +77,7 @@ export class BowtieView extends TextFileView { private panZoomReady = false; private externalSyncReady = false; private lastSelfSaveAt = 0; + private static readonly PAN_DRAG_THRESHOLD = 6; private static readonly ICON_SIZE = 22; private static readonly LANE_ADD_SIZE = 26; private static readonly STACK_ADD_SIZE = 20; @@ -448,16 +454,18 @@ export class BowtieView extends TextFileView { }); const fields = row.createDiv({ cls: "o-tie-inspector-fields" }); - const input = fields.createEl("input", { - type: "text", + const labelArea = fields.createEl("textarea", { cls: "o-tie-inspector-input", + placeholder: "Label", + attr: { rows: "1" }, }); - input.value = label; - input.placeholder = "Label"; - input.addEventListener("change", () => { - if (input.value === label) return; + labelArea.value = label; + window.requestAnimationFrame(() => this.fitInspectorTextArea(labelArea)); + labelArea.addEventListener("input", () => this.fitInspectorTextArea(labelArea)); + labelArea.addEventListener("change", () => { + if (labelArea.value === label) return; this.commitEdit(); - this.setNodeLabel(this.selectedRef!, input.value); + this.setNodeLabel(this.selectedRef!, labelArea.value); this.render(); this.scheduleSave(); }); @@ -469,8 +477,8 @@ export class BowtieView extends TextFileView { attr: { rows: "2" }, }); notesArea.value = notes; - window.requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea)); - notesArea.addEventListener("input", () => this.fitInspectorNotesArea(notesArea)); + window.requestAnimationFrame(() => this.fitInspectorTextArea(notesArea)); + notesArea.addEventListener("input", () => this.fitInspectorTextArea(notesArea)); notesArea.addEventListener("change", () => { if (notesArea.value === notes) return; this.commitEdit(); @@ -539,7 +547,7 @@ export class BowtieView extends TextFileView { }); } - private fitInspectorNotesArea(textarea: HTMLTextAreaElement): void { + private fitInspectorTextArea(textarea: HTMLTextAreaElement): void { textarea.addClass("o-tie-inspector-notes-resize"); textarea.setCssStyles({ height: `${textarea.scrollHeight}px` }); textarea.removeClass("o-tie-inspector-notes-resize"); @@ -1265,14 +1273,16 @@ export class BowtieView extends TextFileView { initial: string, onCommit: (value: string) => void ): void { - const input = activeDocument.createElement("input"); - input.type = "text"; + const input = activeDocument.createElement("textarea"); input.className = "o-tie-inline-edit"; + input.rows = 1; input.value = initial; el.empty(); el.appendChild(input); input.focus(); input.select(); + window.requestAnimationFrame(() => this.fitInspectorTextArea(input)); + input.addEventListener("input", () => this.fitInspectorTextArea(input)); // onCommit/cancel can detach the focused input (via render), which fires a // trailing blur. Guard so commit/cancel run exactly once. @@ -1282,7 +1292,7 @@ export class BowtieView extends TextFileView { if (finished) return; finished = true; const v = input.value.trim(); - if (v && v !== initial) { + if (v !== initial.trim()) { this.commitEdit(); onCommit(v); } else { @@ -1298,9 +1308,8 @@ export class BowtieView extends TextFileView { input.addEventListener("blur", commit); input.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - commit(); + if (e.key === "Delete" || e.key === "Backspace") { + e.stopPropagation(); } if (e.key === "Escape") { e.preventDefault(); @@ -1341,10 +1350,9 @@ export class BowtieView extends TextFileView { const target = e.target as HTMLElement; const isTouch = e.pointerType === "touch" || e.pointerType === "pen"; const onEmptyCanvas = this.isPanZoomTarget(target); - // On touch/pen, let a single-finger drag pan from anywhere on the - // canvas (including on top of a node), as long as the finger isn't on - // an interactive control. A tap still selects the node (see pointerup). - const fromNode = isTouch && !onEmptyCanvas && !this.isInteractiveControl(target); + // Drag-pan can start on a node after a small movement threshold. A tap/click + // without movement selects the node (see releasePointer). + const fromNode = this.isNodePanSurface(target); if (!onEmptyCanvas && !fromNode) return; if (isTouch && onEmptyCanvas) { @@ -1355,16 +1363,26 @@ export class BowtieView extends TextFileView { this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (this.activePointers.size === 2) { + this.cancelDeferredNodePan(); this.endPan(); this.pinchLastDistance = this.pointerDistance(); return; } if (this.activePointers.size === 1) { - this.panPointerId = e.pointerId; this.pinchLastDistance = null; this.gestureMoved = false; - this.panStartedOnNode = fromNode; + if (fromNode) { + this.panDeferFromNode = true; + this.panDeferPointerId = e.pointerId; + this.panDeferStart = { x: e.clientX, y: e.clientY }; + this.panDeferTarget = target; + this.panStartedOnNode = false; + return; + } + + this.panPointerId = e.pointerId; + this.panStartedOnNode = false; this.containerEl_.setPointerCapture(e.pointerId); this.startPan(e.clientX, e.clientY); } @@ -1378,6 +1396,14 @@ export class BowtieView extends TextFileView { (e) => { if (!this.activePointers.has(e.pointerId)) return; + if (this.panDeferFromNode && e.pointerId === this.panDeferPointerId) { + const dx = e.clientX - this.panDeferStart.x; + const dy = e.clientY - this.panDeferStart.y; + if (Math.hypot(dx, dy) > BowtieView.PAN_DRAG_THRESHOLD) { + this.beginPanFromDeferredNode(e); + } + } + if (this.shouldBlockGesture()) { e.preventDefault(); e.stopPropagation(); @@ -1399,6 +1425,12 @@ export class BowtieView extends TextFileView { const releasePointer = (e: PointerEvent): void => { if (!this.activePointers.has(e.pointerId)) return; + + if (this.panDeferFromNode && e.pointerId === this.panDeferPointerId) { + this.trySelectNodeTarget(this.panDeferTarget); + this.cancelDeferredNodePan(); + } + this.activePointers.delete(e.pointerId); if (this.containerEl_.hasPointerCapture(e.pointerId)) { @@ -1410,8 +1442,6 @@ export class BowtieView extends TextFileView { } if (e.pointerId === this.panPointerId) { - // A drag that began on a node must not also fire the node's tap-select - // click. A pure tap (no movement) falls through and selects normally. if (this.gestureMoved && this.panStartedOnNode) { this.suppressNextClick = true; } @@ -1443,15 +1473,10 @@ export class BowtieView extends TextFileView { (e) => { const target = e.target as HTMLElement; if (this.isCanvasControl(target)) return; - if (e.touches.length === 1) { - this.canvasTouchShield = true; - if (this.isPanZoomTarget(target)) { - this.shieldCanvasTouch(e); - } - } else { - this.canvasTouchShield = true; - this.shieldCanvasTouch(e); - } + // Defer touch shield on nodes so a tap can select; pan enables shield once dragging. + if (e.touches.length === 1 && this.isNodePanSurface(target)) return; + this.canvasTouchShield = true; + this.shieldCanvasTouch(e); }, gestureOptions ); @@ -1460,6 +1485,7 @@ export class BowtieView extends TextFileView { this.containerEl_, "touchmove", (e) => { + if (this.isPanning) this.canvasTouchShield = true; if (!this.canvasTouchShield) return; this.shieldCanvasTouch(e, true); }, @@ -1737,13 +1763,48 @@ export class BowtieView extends TextFileView { e.stopPropagation(); } + private isNodePanSurface(target: HTMLElement): boolean { + return !this.isPanZoomTarget(target) && !this.isInteractiveControl(target); + } + + private cancelDeferredNodePan(): void { + this.panDeferFromNode = false; + this.panDeferPointerId = null; + this.panDeferTarget = null; + } + + private beginPanFromDeferredNode(e: PointerEvent): void { + this.panDeferFromNode = false; + this.panDeferTarget = null; + this.panPointerId = e.pointerId; + this.panStartedOnNode = true; + this.gestureMoved = true; + this.canvasTouchShield = true; + this.containerEl_.setPointerCapture(e.pointerId); + this.startPan(this.panDeferStart.x, this.panDeferStart.y); + this.updatePan(e.clientX, e.clientY); + } + + private trySelectNodeTarget(target: HTMLElement | null): void { + if (!target) return; + if (target.closest("button")) return; + if (target.closest(".o-tie-stack-row-clickable")) return; + const wrap = target.closest(".o-tie-node-wrap"); + if (!wrap) return; + const ref = parseNodeRefKey(wrap.getAttribute("data-ref") ?? ""); + if (!ref) return; + this.suppressNextClick = true; + this.selectNodeElement(ref, wrap); + } + private isInteractiveControl(target: HTMLElement): boolean { - return !!( - target.closest(".o-tie-controls-overlay") || - target.closest(".o-tie-lane-add") || - target.closest("button") || - target.closest('[role="button"]') - ); + if (target.closest(".o-tie-controls-overlay")) return true; + if (target.closest(".o-tie-lane-add")) return true; + if (target.closest("button")) return true; + const roleBtn = target.closest('[role="button"]'); + // Node wraps use role="button" for keyboard a11y only — touch drag should still pan. + if (roleBtn && !roleBtn.hasClass("o-tie-node-wrap")) return true; + return false; } private isPanZoomTarget(target: HTMLElement): boolean { diff --git a/src/layout.ts b/src/layout.ts index 5fade1f..3871ddd 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -48,13 +48,9 @@ const NODE_BOX_BUFFER = 8; const ESCALATION_NODE_BUFFER = 6; const ESCALATION_GAP_FACTOR = 0.5; -function estimateLabelLines(label: string, width: number): number { - const text = label?.trim() ?? ""; - if (!text) return 1; - - const usable = Math.max(20, width - LABEL_PAD_X); - const charsPerLine = Math.max(6, Math.floor(usable / AVG_CHAR_WIDTH)); +function estimateWrappedLines(text: string, charsPerLine: number): number { const words = text.split(/\s+/).filter(Boolean); + if (words.length === 0) return 1; let lines = 1; let lineLen = 0; @@ -79,7 +75,23 @@ function estimateLabelLines(label: string, width: number): number { } } - return Math.max(1, lines); + return lines; +} + +function estimateLabelLines(label: string, width: number): number { + const text = label ?? ""; + if (!text.trim() && !text.includes("\n")) return 1; + + const usable = Math.max(20, width - LABEL_PAD_X); + const charsPerLine = Math.max(6, Math.floor(usable / AVG_CHAR_WIDTH)); + const paragraphs = text.split("\n"); + + let totalLines = 0; + for (const para of paragraphs) { + totalLines += para.trim() ? estimateWrappedLines(para, charsPerLine) : 1; + } + + return Math.max(1, totalLines); } function labelBlockHeight(label: string, width: number): number { diff --git a/src/model.ts b/src/model.ts index 98f4993..db65d3e 100644 --- a/src/model.ts +++ b/src/model.ts @@ -496,3 +496,18 @@ export function nodeRefKey(ref: NodeRef): string { ]; return parts.join(":"); } + +export function parseNodeRefKey(key: string): NodeRef | null { + const [kind, eventId, threatId, consequenceId, barrierId, chainId, safeguardId] = + key.split(":"); + if (!kind) return null; + return { + kind: kind as NodeKind, + eventId: eventId || undefined, + threatId: threatId || undefined, + consequenceId: consequenceId || undefined, + barrierId: barrierId || undefined, + chainId: chainId || undefined, + safeguardId: safeguardId || undefined, + }; +} diff --git a/styles.css b/styles.css index 2e3a01e..7535b89 100644 --- a/styles.css +++ b/styles.css @@ -496,6 +496,7 @@ cursor: pointer; z-index: 2; isolation: isolate; + touch-action: none; } .o-tie-node-wrap:hover { @@ -549,8 +550,8 @@ line-height: 1.3; word-break: break-word; overflow-wrap: anywhere; - display: flex; - align-items: flex-start; + white-space: pre-wrap; + display: block; /* Node bodies use fixed light pastels — always use dark text for contrast */ color: #1a1a1a; } @@ -598,10 +599,8 @@ .o-tie-top-event-label { flex: 0 1 auto; max-width: 78%; - justify-content: center; text-align: center; padding: 0 12px; - align-items: center; color: #1a1a1a; } @@ -1044,6 +1043,11 @@ background: transparent; font-size: inherit; font-family: inherit; + line-height: inherit; + white-space: pre-wrap; + resize: none; + overflow: hidden; + box-sizing: border-box; outline: 2px solid var(--interactive-accent); border-radius: 2px; padding: 2px 4px; @@ -1108,12 +1112,16 @@ .o-tie-inspector-input { flex: 2; min-width: 0; - height: 1.85rem; - padding: 0 0.55rem; + min-height: 1.85rem; + padding: 0.3rem 0.55rem; border-radius: var(--radius-s); border: 1px solid var(--background-modifier-border); background: var(--background-secondary); font-size: var(--font-ui-smaller); + line-height: 1.35; + resize: none; + overflow-y: hidden; + box-sizing: border-box; } .o-tie-inspector-input:focus { diff --git a/tests/layout.test.ts b/tests/layout.test.ts index bf6e36d..189a1ec 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -41,6 +41,12 @@ describe("nodeBoxHeight", () => { ); expect(long).toBeGreaterThan(short); }); + + it("grows for explicit line breaks", () => { + const single = nodeBoxHeight("Line one", 200, 40); + const multiline = nodeBoxHeight("Line one\nLine two\nLine three", 200, 40); + expect(multiline).toBeGreaterThan(single); + }); }); describe("barrierHeaderHeightFor", () => { diff --git a/versions.json b/versions.json index 7847a6c..26a5bbc 100644 --- a/versions.json +++ b/versions.json @@ -10,5 +10,6 @@ "1.0.8": "1.4.0", "1.0.9": "1.4.0", "1.0.10": "1.4.0", - "1.0.11": "1.4.0" + "1.0.11": "1.4.0", + "1.0.12": "1.4.0" } \ No newline at end of file