mirror of
https://github.com/andre482/O-tie.git
synced 2026-07-22 07:44:11 +00:00
Release v1.0.1: fix lane add button placement on bowtie connectors.
Align + controls with bezier edge geometry, keep them behind nodes when paths cross, and document the release in CHANGELOG.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6ea43bcfc6
commit
2f379a623f
19 changed files with 357 additions and 200 deletions
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -33,13 +33,7 @@ jobs:
|
|||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: |
|
||||
Initial release of O-Tie.
|
||||
|
||||
- Interactive risk bowtie diagram editor
|
||||
- Barriers, escalation factors, and analysis stacks
|
||||
- PNG export, undo/redo, help modal
|
||||
- Auto-save to .bowtie JSON files
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
main.js
|
||||
manifest.json
|
||||
|
|
|
|||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to O-Tie are documented here.
|
||||
|
||||
## [1.0.1] — 2026-06-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Lane add (+) button placement** — Buttons on threat and consequence connectors now sit on the same bezier curve as the diagram edges, so they no longer float above nodes or overlap barrier labels.
|
||||
- **Layering** — Lane add controls render behind node boxes when paths cross, so a `+` from another row no longer covers mitigation or prevention text.
|
||||
- **Overlap guard** — Lane add buttons are skipped when they would sit on top of an unrelated node (for example, a direct top-event path crossing another row’s barrier column).
|
||||
|
||||
### Improved
|
||||
|
||||
- **Symmetric connectors** — Threat and consequence lanes both use the same placement logic, so `+` icons appear consistently on curved and straight segments.
|
||||
- **PNG export** — Export rendering aligned with on-screen diagram styling.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] — 2026-06-12
|
||||
|
||||
Initial public release.
|
||||
|
||||
- Interactive risk bowtie diagram editor for Obsidian
|
||||
- Threats, prevention barriers, top event, mitigation barriers, consequences, and hazard
|
||||
- Escalation factors, escalation barriers, and per-barrier analysis stacks
|
||||
- Pan/zoom, undo/redo, inspector panel, PNG export, and auto-save to `.bowtie` files
|
||||
|
|
@ -89,6 +89,12 @@ Open **Settings → O-Tie** to configure:
|
|||
|
||||
See [examples/steamcracker.bowtie](examples/steamcracker.bowtie).
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
||||
|
||||
**Latest (1.0.1):** Fixes lane add (`+`) button placement on curved connectors, improves layering so buttons stay behind nodes, and aligns threat/consequence lane controls.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import esbuild from "esbuild";
|
||||
import { builtinModules } from "node:module";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
|
|
@ -30,7 +30,7 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
...builtinModules,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "o-tie",
|
||||
"name": "O-Tie",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Build and edit risk bowtie diagrams in a visual editor with barriers, escalation factors, analysis stacks, and PNG export.",
|
||||
"author": "Andre482",
|
||||
|
|
|
|||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -1,19 +1,18 @@
|
|||
{
|
||||
"name": "o-tie",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "o-tie",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.20.0",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.6.2",
|
||||
|
|
@ -481,19 +480,6 @@
|
|||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
|
||||
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "o-tie",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "Obsidian plugin for building risk bowtie diagrams",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -17,7 +17,6 @@
|
|||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.20.0",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.6.2",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
type EdgePath,
|
||||
type LayoutConfig,
|
||||
type PositionedNode,
|
||||
bezierPointAt,
|
||||
connectionPorts,
|
||||
} from "./layout";
|
||||
import {
|
||||
BARRIER_STACK_FIELDS,
|
||||
|
|
@ -54,11 +56,23 @@ const STACK_ROW_COLOR_OPTIONS: { color: string; label: string }[] = [
|
|||
|
||||
const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]);
|
||||
|
||||
function isLightStackColor(color: string): boolean {
|
||||
if (LIGHT_STACK_ROW_COLORS.has(color)) return true;
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(color);
|
||||
if (!match) return false;
|
||||
const n = parseInt(match[1], 16);
|
||||
const r = (n >> 16) & 0xff;
|
||||
const g = (n >> 8) & 0xff;
|
||||
const b = n & 0xff;
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.62;
|
||||
}
|
||||
|
||||
function createColorMenuTitle(color: string, label: string): DocumentFragment {
|
||||
const frag = document.createDocumentFragment();
|
||||
const frag = activeDocument.createDocumentFragment();
|
||||
const wrap = frag.createEl("span", { cls: "o-tie-color-menu-title" });
|
||||
const swatch = wrap.createEl("span", { cls: "o-tie-color-swatch" });
|
||||
swatch.style.backgroundColor = color;
|
||||
swatch.setCssStyles({ backgroundColor: color });
|
||||
if (LIGHT_STACK_ROW_COLORS.has(color)) {
|
||||
swatch.addClass("o-tie-color-swatch-light");
|
||||
}
|
||||
|
|
@ -279,7 +293,7 @@ export class BowtieView extends TextFileView {
|
|||
|
||||
this.transformEl = this.stageEl.createDiv({ cls: "o-tie-transform" });
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
this.svgEl = document.createElementNS(ns, "svg");
|
||||
this.svgEl = activeDocument.createElementNS(ns, "svg");
|
||||
this.svgEl.classList.add("o-tie-svg");
|
||||
this.transformEl.appendChild(this.svgEl);
|
||||
|
||||
|
|
@ -440,7 +454,7 @@ export class BowtieView extends TextFileView {
|
|||
attr: { rows: "2" },
|
||||
});
|
||||
notesArea.value = notes;
|
||||
requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea));
|
||||
window.requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea));
|
||||
notesArea.addEventListener("input", () => this.fitInspectorNotesArea(notesArea));
|
||||
notesArea.addEventListener("change", () => {
|
||||
if (notesArea.value === notes) return;
|
||||
|
|
@ -497,21 +511,24 @@ export class BowtieView extends TextFileView {
|
|||
}
|
||||
|
||||
private fitInspectorNotesArea(textarea: HTMLTextAreaElement): void {
|
||||
textarea.style.height = "0";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
textarea.addClass("o-tie-inspector-notes-resize");
|
||||
textarea.setCssStyles({ height: `${textarea.scrollHeight}px` });
|
||||
textarea.removeClass("o-tie-inspector-notes-resize");
|
||||
}
|
||||
|
||||
private renderDiagram(): void {
|
||||
const layout = layoutBowtie(this.bowtie, this.getLayoutConfig());
|
||||
|
||||
this.transformEl.style.width = `${layout.bounds.width}px`;
|
||||
this.transformEl.style.height = `${layout.bounds.height}px`;
|
||||
this.transformEl.setCssStyles({
|
||||
width: `${layout.bounds.width}px`,
|
||||
height: `${layout.bounds.height}px`,
|
||||
});
|
||||
this.svgEl.setAttribute("width", String(layout.bounds.width));
|
||||
this.svgEl.setAttribute("height", String(layout.bounds.height));
|
||||
this.svgEl.innerHTML = "";
|
||||
|
||||
for (const edge of layout.edges) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const path = activeDocument.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", edge.path);
|
||||
path.setAttribute("class", `o-tie-edge o-tie-edge-${edge.kind}`);
|
||||
this.svgEl.appendChild(path);
|
||||
|
|
@ -519,10 +536,10 @@ export class BowtieView extends TextFileView {
|
|||
}
|
||||
|
||||
this.nodesEl.empty();
|
||||
this.renderLaneAddButtons(layout);
|
||||
for (const node of layout.nodes) {
|
||||
this.renderNode(node);
|
||||
}
|
||||
this.renderLaneAddButtons(layout);
|
||||
|
||||
this.applyTransform();
|
||||
|
||||
|
|
@ -536,11 +553,11 @@ export class BowtieView extends TextFileView {
|
|||
const { x, y, angleDeg } = edge.arrow;
|
||||
const size = 8;
|
||||
|
||||
const group = document.createElementNS(ns, "g");
|
||||
const group = activeDocument.createElementNS(ns, "g");
|
||||
group.setAttribute("class", `o-tie-edge-arrow o-tie-edge-arrow-${edge.kind}`);
|
||||
group.setAttribute("transform", `translate(${x} ${y}) rotate(${angleDeg})`);
|
||||
|
||||
const head = document.createElementNS(ns, "path");
|
||||
const head = activeDocument.createElementNS(ns, "path");
|
||||
head.setAttribute("d", `M0 0 L-${size} ${-size * 0.42} L-${size} ${size * 0.42} Z`);
|
||||
group.appendChild(head);
|
||||
this.svgEl.appendChild(group);
|
||||
|
|
@ -551,10 +568,12 @@ export class BowtieView extends TextFileView {
|
|||
cls: `o-tie-node-wrap o-tie-node-wrap-${node.kind}`,
|
||||
attr: { "data-ref": nodeRefKey(node.ref) },
|
||||
});
|
||||
wrap.style.left = `${node.x}px`;
|
||||
wrap.style.top = `${node.y}px`;
|
||||
wrap.style.width = `${node.width}px`;
|
||||
wrap.style.height = `${node.height}px`;
|
||||
wrap.setCssStyles({
|
||||
left: `${node.x}px`,
|
||||
top: `${node.y}px`,
|
||||
width: `${node.width}px`,
|
||||
height: `${node.height}px`,
|
||||
});
|
||||
|
||||
if (this.selectedRef && nodeRefKey(this.selectedRef) === nodeRefKey(node.ref)) {
|
||||
wrap.addClass("o-tie-node-selected");
|
||||
|
|
@ -665,8 +684,10 @@ export class BowtieView extends TextFileView {
|
|||
: layout.barrierHeaderHeight;
|
||||
|
||||
const header = el.createDiv({ cls: "o-tie-barrier-header" });
|
||||
header.style.height = `${headerH}px`;
|
||||
header.style.minHeight = `${headerH}px`;
|
||||
header.setCssStyles({
|
||||
height: `${headerH}px`,
|
||||
minHeight: `${headerH}px`,
|
||||
});
|
||||
|
||||
const stripe = header.createDiv({ cls: "o-tie-node-stripe" });
|
||||
stripe.setText(node.subtitle);
|
||||
|
|
@ -696,7 +717,7 @@ export class BowtieView extends TextFileView {
|
|||
const chevron = wrap.createEl("button", {
|
||||
cls: `o-tie-stack-chevron${collapsed ? " is-collapsed" : ""}`,
|
||||
});
|
||||
chevron.style.top = `${headerH}px`;
|
||||
chevron.setCssStyles({ top: `${headerH}px` });
|
||||
chevron.setAttribute("aria-label", collapsed ? "Expand stack" : "Collapse stack");
|
||||
chevron.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
chevron.addEventListener("click", (e) => {
|
||||
|
|
@ -710,7 +731,7 @@ export class BowtieView extends TextFileView {
|
|||
if (stack.length === 0) {
|
||||
const placeholder = stackEl.createDiv({ cls: "o-tie-stack-empty" });
|
||||
placeholder.setText("Click + to add analysis row");
|
||||
placeholder.style.height = `${layout.barrierStackRowHeight}px`;
|
||||
placeholder.setCssStyles({ height: `${layout.barrierStackRowHeight}px` });
|
||||
placeholder.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.showAddStackRowMenu(e, node.ref);
|
||||
|
|
@ -739,19 +760,21 @@ export class BowtieView extends TextFileView {
|
|||
cls: "o-tie-stack-row",
|
||||
attr: { "data-stack-id": item.id },
|
||||
});
|
||||
row.style.height = `${layout.barrierStackRowHeight}px`;
|
||||
const rowStyles: Partial<CSSStyleDeclaration> = {
|
||||
height: `${layout.barrierStackRowHeight}px`,
|
||||
};
|
||||
if (item.color) {
|
||||
row.style.backgroundColor = item.color;
|
||||
if (item.color === "#ffffff" || item.color === "#f4ecf7" || item.color === "#eafaf1") {
|
||||
rowStyles.backgroundColor = item.color;
|
||||
if (isLightStackColor(item.color)) {
|
||||
row.addClass("o-tie-stack-row-light");
|
||||
}
|
||||
}
|
||||
row.setCssStyles(rowStyles);
|
||||
|
||||
const fieldDef = item.field
|
||||
? BARRIER_STACK_FIELDS.find((f) => f.key === item.field)
|
||||
: undefined;
|
||||
if (fieldDef) {
|
||||
row.style.borderLeftColor = "var(--o-tie-barrier)";
|
||||
row.addClass("o-tie-stack-row-preset");
|
||||
}
|
||||
|
||||
|
|
@ -872,9 +895,9 @@ export class BowtieView extends TextFileView {
|
|||
|
||||
if (!field) {
|
||||
window.setTimeout(() => {
|
||||
const rowEl = this.nodesEl.querySelector(
|
||||
const rowEl = this.nodesEl.querySelector<HTMLElement>(
|
||||
`[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${item.id}"] .o-tie-stack-row-label`
|
||||
) as HTMLElement | null;
|
||||
);
|
||||
if (rowEl) {
|
||||
this.startInlineEdit(rowEl, label, (v) => {
|
||||
this.updateStackRowLabel(ref, item.id, v);
|
||||
|
|
@ -944,9 +967,9 @@ export class BowtieView extends TextFileView {
|
|||
} else {
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Edit label").setIcon("pencil").onClick(() => {
|
||||
const rowEl = this.nodesEl.querySelector(
|
||||
const rowEl = this.nodesEl.querySelector<HTMLElement>(
|
||||
`[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${itemId}"] .o-tie-stack-row-label`
|
||||
) as HTMLElement | null;
|
||||
);
|
||||
if (rowEl) {
|
||||
this.startInlineEdit(rowEl, stackItem.label, (v) => {
|
||||
this.updateStackRowLabel(ref, itemId, v);
|
||||
|
|
@ -1039,7 +1062,7 @@ export class BowtieView extends TextFileView {
|
|||
initial: string,
|
||||
onCommit: (value: string) => void
|
||||
): void {
|
||||
const input = document.createElement("input");
|
||||
const input = activeDocument.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "o-tie-inline-edit";
|
||||
input.value = initial;
|
||||
|
|
@ -1154,7 +1177,7 @@ export class BowtieView extends TextFileView {
|
|||
this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => n.removeClass("o-tie-node-selected"));
|
||||
});
|
||||
|
||||
this.registerDomEvent(document, "keydown", (e) => {
|
||||
this.registerDomEvent(activeDocument, "keydown", (e) => {
|
||||
if (this.app.workspace.getActiveViewOfType(BowtieView) !== this) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("input, textarea, [contenteditable='true']")) return;
|
||||
|
|
@ -1208,23 +1231,26 @@ export class BowtieView extends TextFileView {
|
|||
const { panX, panY, zoom } = view;
|
||||
|
||||
// Pan and zoom on separate layers so zoom anchors correctly under the cursor.
|
||||
this.viewportEl.style.transform = `translate3d(${panX}px, ${panY}px, 0)`;
|
||||
this.viewportEl.style.transformOrigin = "0 0";
|
||||
this.viewportEl.setCssStyles({
|
||||
transform: `translate3d(${panX}px, ${panY}px, 0)`,
|
||||
transformOrigin: "0 0",
|
||||
});
|
||||
|
||||
if (BowtieView.useCssZoom) {
|
||||
this.stageEl.style.zoom = String(zoom);
|
||||
this.stageEl.style.transform = "";
|
||||
this.stageEl.setCssStyles({ zoom: String(zoom), transform: "" });
|
||||
} else {
|
||||
this.stageEl.style.zoom = "1";
|
||||
this.stageEl.style.transform = `scale(${zoom})`;
|
||||
this.stageEl.style.transformOrigin = "0 0";
|
||||
this.stageEl.setCssStyles({
|
||||
zoom: "1",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "0 0",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.containerEl_) {
|
||||
const gs = BowtieView.GRID_SIZE;
|
||||
const bgX = ((panX % gs) + gs) % gs;
|
||||
const bgY = ((panY % gs) + gs) % gs;
|
||||
this.containerEl_.style.backgroundPosition = `${bgX}px ${bgY}px`;
|
||||
this.containerEl_.setCssStyles({ backgroundPosition: `${bgX}px ${bgY}px` });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1420,25 +1446,16 @@ export class BowtieView extends TextFileView {
|
|||
);
|
||||
if (!threatNode) continue;
|
||||
|
||||
const lastBarrier = layout.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.kind === "preventionBarrier" && n.ref.threatId === threat.id
|
||||
)
|
||||
.sort((a, b) => b.x - a.x)[0];
|
||||
|
||||
const lastBarrier = this.findLastBarrierInLane(
|
||||
layout,
|
||||
(n) => n.kind === "preventionBarrier" && n.ref.threatId === threat.id
|
||||
);
|
||||
const fromNode = lastBarrier ?? threatNode;
|
||||
const fromX = fromNode.x + fromNode.width;
|
||||
const toX = topEvent.x;
|
||||
const fromY = fromNode.y + fromNode.height / 2;
|
||||
const toY = topEvent.y + topEvent.height / 2;
|
||||
const y = (fromY + toY) / 2;
|
||||
const pos = this.laneAddPosition(fromNode, topEvent, layout);
|
||||
if (!pos) continue;
|
||||
|
||||
this.createLaneAddButton(
|
||||
(fromX + toX) / 2 - 13,
|
||||
y - 13,
|
||||
"Add prevention barrier",
|
||||
() => this.addPreventionBarrier(threat.id)
|
||||
this.createLaneAddButton(pos.x, pos.y, "Add prevention barrier", () =>
|
||||
this.addPreventionBarrier(threat.id)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1448,31 +1465,76 @@ export class BowtieView extends TextFileView {
|
|||
);
|
||||
if (!consNode) continue;
|
||||
|
||||
const mitigationBarriers = layout.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.kind === "mitigationBarrier" &&
|
||||
n.ref.consequenceId === consequence.id
|
||||
)
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const lastMitigation = mitigationBarriers[mitigationBarriers.length - 1];
|
||||
const lastMitigation = this.findLastBarrierInLane(
|
||||
layout,
|
||||
(n) =>
|
||||
n.kind === "mitigationBarrier" &&
|
||||
n.ref.consequenceId === consequence.id
|
||||
);
|
||||
|
||||
const fromNode = lastMitigation ?? topEvent;
|
||||
const fromX = fromNode.x + fromNode.width;
|
||||
const toX = consNode.x;
|
||||
const fromY = fromNode.y + fromNode.height / 2;
|
||||
const toY = consNode.y + consNode.height / 2;
|
||||
const y = (fromY + toY) / 2;
|
||||
const pos = this.laneAddPosition(fromNode, consNode, layout);
|
||||
if (!pos) continue;
|
||||
|
||||
this.createLaneAddButton(
|
||||
(fromX + toX) / 2 - 13,
|
||||
y - 13,
|
||||
"Add mitigation barrier",
|
||||
() => this.addMitigationBarrier(consequence.id)
|
||||
this.createLaneAddButton(pos.x, pos.y, "Add mitigation barrier", () =>
|
||||
this.addMitigationBarrier(consequence.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private findLastBarrierInLane(
|
||||
layout: import("./layout").LayoutResult,
|
||||
match: (node: PositionedNode) => boolean
|
||||
): PositionedNode | undefined {
|
||||
return layout.nodes.filter(match).sort((a, b) => a.x - b.x).pop();
|
||||
}
|
||||
|
||||
private laneAddPosition(
|
||||
from: PositionedNode,
|
||||
to: PositionedNode,
|
||||
layout: import("./layout").LayoutResult
|
||||
): { x: number; y: number } | null {
|
||||
const LANE_ADD_SIZE = 26;
|
||||
const LANE_ADD_HALF = 13;
|
||||
const MIN_GAP = LANE_ADD_SIZE + 4;
|
||||
|
||||
const ports = connectionPorts(from, to);
|
||||
const gap = ports.to.x - ports.from.x;
|
||||
if (gap < MIN_GAP) return null;
|
||||
|
||||
const point = bezierPointAt(ports.from.x, ports.from.y, ports.to.x, ports.to.y, 0.5);
|
||||
const cx = point.x;
|
||||
const cy = point.y;
|
||||
|
||||
for (const node of layout.nodes) {
|
||||
if (node === from || node === to) continue;
|
||||
if (this.laneAddOverlapsButton(cx, cy, LANE_ADD_HALF, node)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { x: cx - LANE_ADD_HALF, y: cy - LANE_ADD_HALF };
|
||||
}
|
||||
|
||||
private laneAddOverlapsButton(
|
||||
cx: number,
|
||||
cy: number,
|
||||
half: number,
|
||||
node: PositionedNode
|
||||
): boolean {
|
||||
const margin = 2;
|
||||
const left = cx - half;
|
||||
const right = cx + half;
|
||||
const top = cy - half;
|
||||
const bottom = cy + half;
|
||||
return (
|
||||
right > node.x + margin &&
|
||||
left < node.x + node.width - margin &&
|
||||
bottom > node.y + margin &&
|
||||
top < node.y + node.height - margin
|
||||
);
|
||||
}
|
||||
|
||||
private createLaneAddButton(
|
||||
x: number,
|
||||
y: number,
|
||||
|
|
@ -1480,8 +1542,7 @@ export class BowtieView extends TextFileView {
|
|||
onClick: () => void
|
||||
): void {
|
||||
const btn = this.nodesEl.createEl("button", { cls: "o-tie-lane-add o-tie-plus-btn" });
|
||||
btn.style.left = `${x}px`;
|
||||
btn.style.top = `${y}px`;
|
||||
btn.setCssStyles({ left: `${x}px`, top: `${y}px` });
|
||||
btn.setAttribute("aria-label", label);
|
||||
btn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
btn.addEventListener("click", (e) => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export class NewBowtieNameModal extends Modal {
|
|||
text.inputEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") this.submit(text.getValue());
|
||||
});
|
||||
setTimeout(() => text.inputEl.focus(), 50);
|
||||
window.setTimeout(() => text.inputEl.focus(), 50);
|
||||
});
|
||||
|
||||
const actions = contentEl.createDiv({ cls: "o-tie-actions" });
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const EXPORT_CHROME_SELECTORS = [
|
|||
|
||||
function copyThemeVariables(from: HTMLElement, to: HTMLElement): void {
|
||||
const styles = getComputedStyle(from);
|
||||
const props: Record<string, string> = {};
|
||||
for (let i = 0; i < styles.length; i++) {
|
||||
const name = styles[i];
|
||||
if (
|
||||
|
|
@ -46,14 +47,15 @@ function copyThemeVariables(from: HTMLElement, to: HTMLElement): void {
|
|||
name.startsWith("--interactive") ||
|
||||
name.startsWith("--radius")
|
||||
) {
|
||||
to.style.setProperty(name, styles.getPropertyValue(name));
|
||||
props[name] = styles.getPropertyValue(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Node cards always use light pastel fills — keep export text readable in dark mode.
|
||||
to.style.setProperty("--text-normal", "#1a1a1a");
|
||||
to.style.setProperty("--text-muted", "#5d6d7e");
|
||||
to.style.setProperty("--background-primary", "#ffffff");
|
||||
props["--text-normal"] = "#1a1a1a";
|
||||
props["--text-muted"] = "#5d6d7e";
|
||||
props["--background-primary"] = "#ffffff";
|
||||
to.setCssProps(props);
|
||||
}
|
||||
|
||||
function prepareExportClone(clone: HTMLElement): void {
|
||||
|
|
@ -122,35 +124,32 @@ function buildDiagramLayer(
|
|||
nodesEl: HTMLElement,
|
||||
bounds: { width: number; height: number }
|
||||
): HTMLElement {
|
||||
const layer = document.createElement("div");
|
||||
const layer = activeDocument.createElement("div");
|
||||
layer.className = "o-tie-export-layer";
|
||||
layer.style.position = "relative";
|
||||
layer.style.width = `${bounds.width}px`;
|
||||
layer.style.height = `${bounds.height}px`;
|
||||
layer.style.overflow = "visible";
|
||||
layer.setCssStyles({
|
||||
width: `${bounds.width}px`,
|
||||
height: `${bounds.height}px`,
|
||||
});
|
||||
|
||||
const edgeImg = document.createElement("img");
|
||||
const edgeImg = activeDocument.createElement("img");
|
||||
edgeImg.className = "o-tie-export-edges";
|
||||
edgeImg.alt = "";
|
||||
edgeImg.src = svgToDataUrl(svgEl);
|
||||
edgeImg.width = bounds.width;
|
||||
edgeImg.height = bounds.height;
|
||||
edgeImg.style.position = "absolute";
|
||||
edgeImg.style.top = "0";
|
||||
edgeImg.style.left = "0";
|
||||
edgeImg.style.width = `${bounds.width}px`;
|
||||
edgeImg.style.height = `${bounds.height}px`;
|
||||
edgeImg.style.pointerEvents = "none";
|
||||
edgeImg.setCssStyles({
|
||||
width: `${bounds.width}px`,
|
||||
height: `${bounds.height}px`,
|
||||
});
|
||||
layer.appendChild(edgeImg);
|
||||
|
||||
const nodesClone = nodesEl.cloneNode(true) as HTMLElement;
|
||||
prepareExportClone(nodesClone);
|
||||
nodesClone.style.position = "absolute";
|
||||
nodesClone.style.top = "0";
|
||||
nodesClone.style.left = "0";
|
||||
nodesClone.style.width = `${bounds.width}px`;
|
||||
nodesClone.style.height = `${bounds.height}px`;
|
||||
nodesClone.style.overflow = "visible";
|
||||
nodesClone.classList.add("o-tie-export-nodes");
|
||||
nodesClone.setCssStyles({
|
||||
width: `${bounds.width}px`,
|
||||
height: `${bounds.height}px`,
|
||||
});
|
||||
layer.appendChild(nodesClone);
|
||||
|
||||
return layer;
|
||||
|
|
@ -208,48 +207,43 @@ export async function rasterizeBowtieForExport(options: BowtieExportOptions): Pr
|
|||
|
||||
// Offscreen wrapper carries the positioning offset so it is NOT inlined
|
||||
// onto the captured element (which would push content out of the frame).
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.cssText =
|
||||
"position:fixed;left:-20000px;top:0;pointer-events:none;z-index:-1;";
|
||||
const wrapper = activeDocument.createElement("div");
|
||||
wrapper.className = "o-tie-export-wrapper";
|
||||
|
||||
const root = document.createElement("div");
|
||||
const root = activeDocument.createElement("div");
|
||||
root.className = "o-tie-view-root o-tie-export-root";
|
||||
root.style.position = "relative";
|
||||
root.style.left = "0";
|
||||
root.style.top = "0";
|
||||
root.style.overflow = "hidden";
|
||||
root.style.opacity = "1";
|
||||
root.style.visibility = "visible";
|
||||
root.style.width = `${exportWidth}px`;
|
||||
root.style.height = `${exportHeight}px`;
|
||||
root.setCssStyles({
|
||||
width: `${exportWidth}px`,
|
||||
height: `${exportHeight}px`,
|
||||
});
|
||||
copyThemeVariables(viewRootEl, root);
|
||||
|
||||
const bgStyles = getComputedStyle(viewRootEl);
|
||||
const dotColor =
|
||||
bgStyles.getPropertyValue("--background-modifier-border").trim() || "#d0d0d0";
|
||||
root.style.backgroundColor = backgroundColor;
|
||||
const rootBg: Partial<CSSStyleDeclaration> = { backgroundColor };
|
||||
if (showGrid) {
|
||||
root.style.backgroundImage = buildGridBackground(backgroundColor, dotColor);
|
||||
root.style.backgroundSize = `${GRID_SIZE}px ${GRID_SIZE}px`;
|
||||
root.style.backgroundPosition = `${EXPORT_PADDING}px ${EXPORT_PADDING}px`;
|
||||
rootBg.backgroundImage = buildGridBackground(backgroundColor, dotColor);
|
||||
rootBg.backgroundSize = `${GRID_SIZE}px ${GRID_SIZE}px`;
|
||||
rootBg.backgroundPosition = `${EXPORT_PADDING}px ${EXPORT_PADDING}px`;
|
||||
}
|
||||
root.setCssStyles(rootBg);
|
||||
|
||||
const stage = document.createElement("div");
|
||||
const stage = activeDocument.createElement("div");
|
||||
stage.className = "o-tie-export-stage";
|
||||
stage.style.position = "absolute";
|
||||
stage.style.width = `${bounds.width}px`;
|
||||
stage.style.height = `${bounds.height}px`;
|
||||
stage.style.left = `${EXPORT_PADDING - (crop?.x ?? 0)}px`;
|
||||
stage.style.top = `${EXPORT_PADDING - (crop?.y ?? 0)}px`;
|
||||
stage.style.overflow = "visible";
|
||||
|
||||
stage.setCssStyles({
|
||||
width: `${bounds.width}px`,
|
||||
height: `${bounds.height}px`,
|
||||
left: `${EXPORT_PADDING - (crop?.x ?? 0)}px`,
|
||||
top: `${EXPORT_PADDING - (crop?.y ?? 0)}px`,
|
||||
});
|
||||
stage.appendChild(buildDiagramLayer(svgEl, nodesEl, bounds));
|
||||
root.appendChild(stage);
|
||||
wrapper.appendChild(root);
|
||||
document.body.appendChild(wrapper);
|
||||
activeDocument.body.appendChild(wrapper);
|
||||
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
await activeDocument.fonts.ready;
|
||||
await Promise.all(
|
||||
Array.from(root.querySelectorAll<HTMLImageElement>("img.o-tie-export-edges")).map(
|
||||
(img) => {
|
||||
|
|
@ -269,7 +263,7 @@ export async function rasterizeBowtieForExport(options: BowtieExportOptions): Pr
|
|||
|
||||
export function downloadPng(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
const anchor = activeDocument.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename.endsWith(".png") ? filename : `${filename}.png`;
|
||||
anchor.click();
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ export class ExportImageModal extends Modal {
|
|||
slider
|
||||
.setLimits(1, 4, 0.5)
|
||||
.setValue(this.scale)
|
||||
.setDynamicTooltip()
|
||||
.onChange((value) => {
|
||||
this.scale = value;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { App, Modal } from "obsidian";
|
||||
|
||||
const PLUGIN_VERSION = "1.0.0";
|
||||
import * as manifest from "../manifest.json";
|
||||
|
||||
export class HelpModal extends Modal {
|
||||
constructor(app: App) {
|
||||
|
|
@ -14,7 +13,7 @@ export class HelpModal extends Modal {
|
|||
|
||||
contentEl.createEl("p", {
|
||||
cls: "o-tie-help-version",
|
||||
text: `Version ${PLUGIN_VERSION}`,
|
||||
text: `Version ${manifest.version}`,
|
||||
});
|
||||
|
||||
const body = contentEl.createDiv({ cls: "o-tie-help-body" });
|
||||
|
|
@ -45,7 +44,24 @@ export class HelpModal extends Modal {
|
|||
"Top Event (red) — the central loss-of-control event",
|
||||
"Threats (gray) — left side; prevention barriers (green) sit on connectors to the top event",
|
||||
"Consequences (purple) — right side; mitigation barriers (green) sit on connectors from the top event",
|
||||
"Escalation factors (purple) — branch from barriers; can have escalation barriers",
|
||||
"Escalation factors (purple, dashed) — branch downward from a barrier when something could weaken that barrier",
|
||||
"Escalation barriers (purple, dashed) — sit on escalation factors and control those weakening influences",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Barriers", [
|
||||
"A barrier is a control that stops or reduces risk along the main bowtie path. In O-Tie, barriers are green nodes on the connectors between threats/consequences and the top event.",
|
||||
"Prevention barriers sit between a threat and the top event. They stop the threat from causing the top event — for example an alarm, an interlock, a procedure, or operator training.",
|
||||
"Mitigation barriers sit between the top event and a consequence. They limit harm if the top event happens — for example emergency shutdown, fire suppression, or evacuation.",
|
||||
"Add prevention barriers from a selected threat (+ Prevention Barrier in the inspector, the lane + button, or the barrier + on the threat node). Add mitigation barriers from a selected consequence the same way, or use + Barrier in the toolbar when a threat or consequence is selected.",
|
||||
"Each barrier can carry an analysis stack (type, effectiveness, criticality, and more) — see Barrier analysis stacks below.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Escalation factors and escalation barriers", [
|
||||
"Barriers are not always reliable on their own. An escalation factor describes a condition that could weaken, bypass, or defeat a barrier — such as missed maintenance, fatigue, corrosion, or conflicting procedures.",
|
||||
"In the diagram, escalation factors appear as purple dashed nodes branching below their parent barrier. Dashed connectors show that this is a secondary path: the factor does not replace the main threat-to-event or event-to-consequence flow, but explains how the barrier might fail.",
|
||||
"An escalation barrier is a control targeted at the escalation factor itself — the measure that keeps the weakening influence in check. For example, if an escalation factor is “sensor not calibrated”, an escalation barrier might be “annual calibration program”.",
|
||||
"To add an escalation factor, select a prevention or mitigation barrier, then click + Escalation factor in the inspector, the ⚡ button on the barrier, or use the context menu. To add an escalation barrier, select the escalation factor and use + Escalation barrier, the + button on the factor, or the context menu.",
|
||||
"Name each node to match your risk assessment. Use notes in the inspector for evidence, assumptions, or actions. A complete bowtie often chains several barriers, each with its own escalation factors and escalation barriers where relevant.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Editing", [
|
||||
|
|
@ -58,13 +74,6 @@ export class HelpModal extends Modal {
|
|||
"Use the + buttons in lanes between threats/consequences and the top event to add barriers quickly.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Barriers and escalation", [
|
||||
"Prevention barriers block threats from reaching the top event.",
|
||||
"Mitigation barriers reduce consequences after the top event.",
|
||||
"Select a barrier, then use + Barrier or the context menu to add escalation factors.",
|
||||
"Escalation factors can have their own escalation barriers.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Barrier analysis stacks", [
|
||||
"Each barrier can have an expandable stack of analysis rows.",
|
||||
"Preset fields include barrier type, effectiveness, criticality, responsible party, validation method, and status.",
|
||||
|
|
|
|||
|
|
@ -32,5 +32,5 @@ export function registerPluginIcon(): void {
|
|||
|
||||
/** Make the ribbon button icon slightly larger than default. */
|
||||
export function styleRibbonIcon(el: HTMLElement): void {
|
||||
el.style.setProperty("--icon-size", "var(--icon-size-l)");
|
||||
el.setCssProps({ "--icon-size": "var(--icon-size-l)" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
|
|||
barrierWidth: 140,
|
||||
barrierHeight: 52,
|
||||
barrierHeaderHeight: 52,
|
||||
barrierStackRowHeight: 22,
|
||||
barrierStackRowHeight: 24,
|
||||
escalationWidth: 130,
|
||||
escalationHeight: 44,
|
||||
columnGap: 100,
|
||||
|
|
@ -168,10 +168,6 @@ function placeBarriersInLane(
|
|||
);
|
||||
}
|
||||
|
||||
function nodeCenter(node: PositionedNode): { x: number; y: number } {
|
||||
return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
||||
}
|
||||
|
||||
function nodeRight(node: PositionedNode): { x: number; y: number } {
|
||||
return { x: node.x + node.width, y: node.y + node.height / 2 };
|
||||
}
|
||||
|
|
@ -180,6 +176,37 @@ function nodeLeft(node: PositionedNode): { x: number; y: number } {
|
|||
return { x: node.x, y: node.y + node.height / 2 };
|
||||
}
|
||||
|
||||
/** Port points used for main-flow bezier edges between two nodes. */
|
||||
export function connectionPorts(
|
||||
from: PositionedNode,
|
||||
to: PositionedNode
|
||||
): { from: { x: number; y: number }; to: { x: number; y: number } } {
|
||||
return { from: nodeRight(from), to: nodeLeft(to) };
|
||||
}
|
||||
|
||||
/** Point on the same cubic bezier as {@link makeBezierEdge} at parameter t (0–1). */
|
||||
export function bezierPointAt(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
t: number,
|
||||
curve = 0.35
|
||||
): { x: number; y: number } {
|
||||
const dx = (x2 - x1) * curve;
|
||||
const p1x = x1 + dx;
|
||||
const p1y = y1;
|
||||
const p2x = x2 - dx;
|
||||
const p2y = y2;
|
||||
const mt = 1 - t;
|
||||
const mt2 = mt * mt;
|
||||
const t2 = t * t;
|
||||
return {
|
||||
x: mt2 * mt * x1 + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t2 * t * x2,
|
||||
y: mt2 * mt * y1 + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t2 * t * y2,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeBottom(node: PositionedNode): { x: number; y: number } {
|
||||
return { x: node.x + node.width / 2, y: node.y + node.height };
|
||||
}
|
||||
|
|
|
|||
45
src/main.ts
45
src/main.ts
|
|
@ -63,7 +63,8 @@ export default class OTiePlugin extends Plugin {
|
|||
onunload(): void {}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
const loaded = (await this.loadData()) as Partial<OTieSettings> | null;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded ?? {});
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
|
|
@ -91,28 +92,32 @@ export default class OTiePlugin extends Plugin {
|
|||
}
|
||||
|
||||
private async createNewBowtie(): Promise<void> {
|
||||
new NewBowtieNameModal(this.app, async (name) => {
|
||||
const safeName = this.sanitizeFileName(name);
|
||||
const folder = normalizePath(this.settings.defaultFolder);
|
||||
await this.ensureFolder(folder);
|
||||
|
||||
const basePath = folder ? `${folder}/${safeName}` : safeName;
|
||||
const filePath = getBowtieFilePath(basePath);
|
||||
|
||||
if (this.app.vault.getAbstractFileByPath(filePath)) {
|
||||
new Notice(`A bowtie named "${safeName}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bowtie = createBowtie(name);
|
||||
bowtie.hazard = "Hazard";
|
||||
bowtie.topEvent = "Top Event";
|
||||
|
||||
const file = await this.app.vault.create(filePath, serializeBowtie(bowtie));
|
||||
await this.openBowtieFile(file);
|
||||
new NewBowtieNameModal(this.app, (name) => {
|
||||
void this.createBowtieFromName(name);
|
||||
}).open();
|
||||
}
|
||||
|
||||
private async createBowtieFromName(name: string): Promise<void> {
|
||||
const safeName = this.sanitizeFileName(name);
|
||||
const folder = normalizePath(this.settings.defaultFolder);
|
||||
await this.ensureFolder(folder);
|
||||
|
||||
const basePath = folder ? `${folder}/${safeName}` : safeName;
|
||||
const filePath = getBowtieFilePath(basePath);
|
||||
|
||||
if (this.app.vault.getAbstractFileByPath(filePath)) {
|
||||
new Notice(`A bowtie named "${safeName}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bowtie = createBowtie(name);
|
||||
bowtie.hazard = "Hazard";
|
||||
bowtie.topEvent = "Top Event";
|
||||
|
||||
const file = await this.app.vault.create(filePath, serializeBowtie(bowtie));
|
||||
await this.openBowtieFile(file);
|
||||
}
|
||||
|
||||
async openBowtieFile(file: TFile): Promise<void> {
|
||||
const leaf = this.app.workspace.getLeaf(true);
|
||||
await leaf.openFile(file);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export class OTieSettingTab extends PluginSettingTab {
|
|||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl("h2", { text: "O-Tie Settings" });
|
||||
new Setting(containerEl).setName("O-Tie Settings").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default folder")
|
||||
|
|
|
|||
60
styles.css
60
styles.css
|
|
@ -100,6 +100,48 @@
|
|||
--text-normal: #1a1a1a;
|
||||
--text-muted: #5d6d7e;
|
||||
--background-primary: #ffffff;
|
||||
position: relative;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.o-tie-export-wrapper {
|
||||
position: fixed;
|
||||
left: -20000px;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.o-tie-export-layer {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.o-tie-export-edges {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o-tie-export-nodes {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.o-tie-export-stage {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.o-tie-inspector-notes-resize {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Root view */
|
||||
|
|
@ -366,15 +408,16 @@
|
|||
position: absolute;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.o-tie-node-wrap:hover {
|
||||
z-index: 2;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.o-tie-node-wrap.o-tie-node-selected {
|
||||
z-index: 3;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.o-tie-node {
|
||||
|
|
@ -545,11 +588,12 @@
|
|||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.o-tie-stack-row-light {
|
||||
|
|
@ -570,6 +614,10 @@
|
|||
}
|
||||
|
||||
.o-tie-stack-row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -849,7 +897,7 @@
|
|||
background: var(--o-tie-barrier-bg);
|
||||
color: var(--o-tie-barrier);
|
||||
cursor: pointer;
|
||||
z-index: 15;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
opacity: 0.85;
|
||||
-webkit-appearance: none;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["DOM", "ES6"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "manifest.json"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"1.0.0": "1.4.0"
|
||||
"1.0.0": "1.4.0",
|
||||
"1.0.1": "1.4.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue