Release 1.0.12: Multiline labels, inspector auto-expand, and pan-from-node fixes.

Improves label editing in the inspector and on nodes, renders line breaks on the diagram, and lets users pan by dragging from nodes while tap/click still selects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andre482 2026-06-27 23:46:45 +03:00
parent c08b368513
commit 4bac5c0b35
9 changed files with 170 additions and 55 deletions

View file

@ -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

View file

@ -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",

View file

@ -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": {

View file

@ -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 {

View file

@ -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 {

View file

@ -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,
};
}

View file

@ -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 {

View file

@ -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", () => {

View file

@ -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"
}