From dfba9f67eba81d8d8513af8ec0f80a3ae7023a00 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 26 Oct 2026 18:14:42 +0100 Subject: [PATCH 01/14] feat: web script editor at /editor Every script as a node graph, live on the proxy through the relay: commands, steps, arguments, aliases, defaults and servers as nodes, an editable YAML panel with linting and completion, groups, sorting, copy and paste, and saving through /cb editor confirm on the proxy. Includes YAML round-trip tests and browser tests for Chromium and Firefox. --- .eleventy.js | 3 + package.json | 5 +- src/editor.njk | 153 ++ src/lib/editor/editor.css | 504 +++++ src/lib/editor/editor.js | 2827 ++++++++++++++++++++++++++++ src/lib/editor/logos/fabric.png | Bin 0 -> 1555 bytes src/lib/editor/logos/folia.svg | 37 + src/lib/editor/logos/forge.svg | 1 + src/lib/editor/logos/paper.svg | 3 + src/lib/editor/logos/spigot.svg | 1 + src/lib/editor/logos/velocity.svg | 12 + src/lib/editor/script-yaml.js | 248 +++ tests/browser/editor-firefox.mjs | 105 ++ tests/browser/editor-ui.mjs | 437 +++++ tests/fixtures/docs-examples-0.yml | 38 + tests/fixtures/docs-examples-1.yml | 30 + tests/fixtures/docs-examples-2.yml | 39 + tests/fixtures/docs-examples-3.yml | 47 + tests/fixtures/docs-examples-4.yml | 36 + tests/fixtures/docs-syntax-0.yml | 43 + tests/fixtures/repo-concept.yml | 104 + tests/fixtures/repo-example.yml | 42 + tests/fixtures/repo-testcmd.yml | 44 + tests/script-fields.test.mjs | 185 ++ tests/script-yaml.test.mjs | 99 + 25 files changed, 5042 insertions(+), 1 deletion(-) create mode 100644 src/editor.njk create mode 100644 src/lib/editor/editor.css create mode 100644 src/lib/editor/editor.js create mode 100644 src/lib/editor/logos/fabric.png create mode 100644 src/lib/editor/logos/folia.svg create mode 100644 src/lib/editor/logos/forge.svg create mode 100644 src/lib/editor/logos/paper.svg create mode 100644 src/lib/editor/logos/spigot.svg create mode 100644 src/lib/editor/logos/velocity.svg create mode 100644 src/lib/editor/script-yaml.js create mode 100644 tests/browser/editor-firefox.mjs create mode 100644 tests/browser/editor-ui.mjs create mode 100644 tests/fixtures/docs-examples-0.yml create mode 100644 tests/fixtures/docs-examples-1.yml create mode 100644 tests/fixtures/docs-examples-2.yml create mode 100644 tests/fixtures/docs-examples-3.yml create mode 100644 tests/fixtures/docs-examples-4.yml create mode 100644 tests/fixtures/docs-syntax-0.yml create mode 100644 tests/fixtures/repo-concept.yml create mode 100644 tests/fixtures/repo-example.yml create mode 100644 tests/fixtures/repo-testcmd.yml create mode 100644 tests/script-fields.test.mjs create mode 100644 tests/script-yaml.test.mjs diff --git a/.eleventy.js b/.eleventy.js index a676dc6..78d3f41 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -115,6 +115,9 @@ module.exports = function(eleventyConfig) { "node_modules/d3-geo/dist/d3-geo.min.js": "js/d3-geo.js", "node_modules/topojson-client/dist/topojson-client.min.js": "js/topojson-client.js", "node_modules/world-atlas/land-110m.json": "land-110m.json", + "node_modules/yaml/browser/dist": "js/yaml", + "node_modules/drawflow/dist/drawflow.min.js": "js/drawflow.js", + "node_modules/drawflow/dist/drawflow.min.css": "js/drawflow.css", "src/lib": "/lib", }); diff --git a/package.json b/package.json index f8d1b45..5291746 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "clean": "rm -rf _site", + "test": "node --test tests/", "dev": "eleventy --serve & npx tailwindcss -i ./src/styles/main.css -o ./_site/styles.css --watch", "build": "ELEVENTY_PRODUCTION=true eleventy && NODE_ENV=production npx tailwindcss -i ./src/styles/main.css -o ./_site/styles.css --minify" }, @@ -34,6 +35,7 @@ "canvas-sketch-util": "^1.10.0", "d3-array": "^3.2.4", "d3-geo": "^3.1.1", + "drawflow": "0.0.60", "eleventy-plugin-toc": "^1.1.5", "glsl-noise": "^0.0.0", "lucide": "^0.507.0", @@ -42,6 +44,7 @@ "theatre": "^0.2.3", "three": "^0.176.0", "topojson-client": "^3.1.0", - "world-atlas": "^2.0.2" + "world-atlas": "^2.0.2", + "yaml": "2.6.1" } } diff --git a/src/editor.njk b/src/editor.njk new file mode 100644 index 0000000..b1e2044 --- /dev/null +++ b/src/editor.njk @@ -0,0 +1,153 @@ +--- +layout: layouts/base.njk +title: Script Editor +permalink: /editor/ +description: "Build CommandBridge scripts as a node graph, live on your proxy." +hideFooter: true +eleventyExcludeFromCollections: true +--- + + + + + + + +
+
+
+ + +
+ + +
+ + + + + +
+ + + +
+ + +
+ + + + +
+ +
+ +
+
+ +
+ +
+ +
+ + + + +
+
+ + {% macro menuItem(v) %} + + {% endmacro %} + {% macro menuEntry(v) %} + + + + + {% endmacro %} + + + +
+ Confirm on the server to save + + + +
+ +
+ + + + +
+ +
+ + + +
+

Run /cb editor on your proxy and open the link it gives you.

+
+
+ +
diff --git a/src/lib/editor/editor.css b/src/lib/editor/editor.css new file mode 100644 index 0000000..20fa0fb --- /dev/null +++ b/src/lib/editor/editor.css @@ -0,0 +1,504 @@ +/* CommandBridge script editor. The app chrome follows the site; the graph itself is colorful on purpose. */ + +.ed, .ph-ghost { + --pop-bg: rgb(24 24 27 / .97); --hover: rgb(255 255 255 / .07); --line-soft: rgb(255 255 255 / .09); + --piece-fg: #7dd3fc; --call-fg: #c4b5fd; --code-bg: #111113; --code-side: #0c0c0e; --code-fg: #d4d4d8; --switch-off: #52525b; + --y-str: #86efac; --y-num: #fcd34d; --y-bool: #f9a8d4; --y-punct: #52525b; --y-com: #71717a; +} +.ed { + --c-flow: #e5e7eb; --c-arg: #38bdf8; --c-srv: #f59e0b; --c-call: #a78bfa; --c-ok: #22c55e; --c-bad: #f87171; --c-focus: #60a5fa; + --h-command: #6d28d9; --h-run: #15803d; --h-wait: #475569; --h-arg: #0369a1; --h-server: #b45309; + --h-alias: #be185d; --h-defaults: #0f766e; --h-runas: #a16207; --h-cooldown: #475569; --h-rules: #c2410c; + --c-alias: #f472b6; --c-def: #2dd4bf; --c-runas: #facc15; --c-cool: #cbd5e1; --c-rules: #fb923c; + --node-bg: #18181b; --node-fg: #f4f4f5; --node-muted: #a1a1aa; --node-line: #3f3f46; --field-bg: #09090b; + display: flex; flex-direction: column; height: calc(100vh - 64px); + user-select: none; /* drags would otherwise select page text, which flickers in light mode */ +} + +/* ---------------- toolbar ---------------- */ +.ed-bar { display: flex; align-items: center; gap: 10px; padding: 0 12px; height: 52px; border-bottom: 1px solid hsl(var(--border)); font-size: 14px; } +.ed-seg { display: flex; align-items: center; gap: 2px; padding: 2px; border: 1px solid hsl(var(--border)); border-radius: 8px; } +.ed-tool { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 32px; height: 30px; padding: 0 8px; border: none; border-radius: 6px; + background: none; color: hsl(var(--muted-foreground)); cursor: pointer; } +.ed-tool:hover:not(:disabled) { color: hsl(var(--foreground)); background: hsl(var(--accent) / .6); } +/* switched on: filled and ringed, so it reads differently from a hover */ +.ed-tool[aria-pressed="true"] { color: hsl(var(--background)); background: hsl(var(--foreground)); } +.ed-tool[aria-pressed="true"]:hover { color: hsl(var(--background)); background: hsl(var(--foreground) / .85); } +.ed-tool:disabled { opacity: .35; cursor: default; } +.ed-btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 500; + border: 1px solid hsl(var(--border)); background: hsl(var(--background)); color: hsl(var(--foreground)); cursor: pointer; } +.ed-btn:hover:not(:disabled) { background: hsl(var(--accent)); } +.ed-btn:disabled { opacity: .4; cursor: default; } +.ed-spacer { flex: 1; } +.ed-status { color: hsl(var(--muted-foreground)); } +.ed-status-err { color: #ef4444; } +.ed-save { height: 34px; padding: 0 16px; border-radius: 8px; font-weight: 600; font-size: 13px; border: 1px solid transparent; + background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); cursor: pointer; } +.ed-save:disabled { background: none; border-color: hsl(var(--border)); color: hsl(var(--muted-foreground)); cursor: default; } + +/* ---------------- work area ---------------- */ +.ed-work { position: relative; flex: 1; display: flex; min-height: 0; } +.ed-landing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: hsl(var(--muted-foreground)); background: hsl(var(--background)); } +.ed-stage { position: relative; flex: 1; display: flex; min-width: 0; } +.ed-keys { position: absolute; left: 0; top: 0; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; resize: none; pointer-events: none; } +/* the YAML panel: files on the left with what saving does to each, the chosen file on the right */ +.ed-yaml { position: relative; display: flex; width: var(--yaml-w, 46%); min-width: 320px; max-width: 80%; border-left: 1px solid hsl(var(--border)); background: var(--code-bg); color: var(--node-fg); font-size: 13px; } +/* the panel's left edge is dragged to resize it */ +.ed-yaml-grip { position: absolute; left: -4px; top: 0; bottom: 0; z-index: 6; width: 8px; cursor: ew-resize; } +.ed-yaml-grip:hover, .ed-yaml-grip.on { background: linear-gradient(90deg, transparent 3px, var(--c-focus) 3px, var(--c-focus) 5px, transparent 5px); } +.ed-files { width: 180px; flex-shrink: 0; overflow: auto; padding: 8px 6px; border-right: 1px solid var(--hover); background: var(--code-side); } +.ed-files-h { display: flex; align-items: center; gap: 6px; padding: 4px 8px 8px; font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: #71717a; } +.ed-file { display: flex; align-items: center; gap: 7px; width: 100%; height: 28px; padding: 0 8px; border: none; border-radius: 6px; background: none; color: #a1a1aa; text-align: left; cursor: pointer; } +.ed-file:hover { background: var(--hover); color: var(--node-fg); } +.ed-file.on { background: var(--line-soft); color: var(--node-fg); } +.ed-file-i { display: flex; flex-shrink: 0; } +.ed-file-n { flex: 1; min-width: 0; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-file small { font-size: 10.5px; } +.ed-file-s { width: 14px; text-align: center; font: 600 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.ed-file.new { color: #86efac; } .ed-file.new .ed-file-s { color: #4ade80; } +.ed-file.changed { color: #fde68a; } .ed-file.changed .ed-file-s { color: #fbbf24; } +.ed-file.deleted { color: #fca5a5; } .ed-file.deleted .ed-file-s { color: #f87171; } .ed-file.deleted .ed-file-n { text-decoration: line-through; } +.ed-yaml-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.ed-yaml-main header { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 40px; padding: 0 14px; border-bottom: 1px solid var(--hover); + font: 600 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; } +.ed-yaml-main header small { font: 500 11.5px system-ui, sans-serif; color: #71717a; } +.ed-yaml-body { flex: 1; display: flex; overflow: auto; font: 12.5px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; } +.ed-yaml-n { margin: 0; padding: 12px 0; min-width: 46px; text-align: right; color: var(--node-line); user-select: none; position: sticky; left: 0; background: var(--code-bg); } +.ed-yaml-n span { display: inline-block; width: 100%; padding-right: 10px; border-right: 2px solid transparent; } +.ed-yaml-n span.chg { color: #fbbf24; border-right-color: #fbbf24; } +.ed-yaml-n span.add { color: #4ade80; border-right-color: #4ade80; } +.ed-yaml-n span.del { color: #f87171; border-right-color: #f87171; } +/* the YAML is typed into a see-through field over its own highlighting */ +.ed-yaml-edit { position: relative; flex: 1; min-width: max-content; } +.ed-yaml-code, .ed-yaml-ta { margin: 0; padding: 12px 16px; font: inherit; line-height: inherit; white-space: pre; tab-size: 2; } +.ed-yaml-code { color: var(--code-fg); } +/* problems underlined like in an editor: red for errors, yellow for keys the proxy doesn't know */ +.ed-yaml-lint { position: absolute; inset: 0; margin: 0; padding: 12px 16px; font: inherit; line-height: inherit; white-space: pre; color: transparent; pointer-events: none; } +.lint-e, .lint-w { text-decoration: underline wavy #f87171; text-decoration-thickness: 1.5px; text-underline-offset: 3px; text-decoration-skip-ink: none; } +.lint-w { text-decoration-color: #fbbf24; } +.ed-yaml-n span.le { color: #f87171; cursor: help; } .ed-yaml-n span.lw { color: #fbbf24; cursor: help; } +/* the completion list at the caret */ +.ed-ycmp { position: absolute; z-index: 5; min-width: 200px; max-height: 240px; overflow: auto; padding: 4px; border-radius: 8px; border: 1px solid var(--line-soft); + background: var(--pop-bg); box-shadow: 0 12px 30px rgb(0 0 0 / .4); animation: ed-pop-in .12s cubic-bezier(.2, .9, .3, 1.1); transform-origin: 0 0; } +.ed-ycmp button { display: flex; justify-content: space-between; gap: 18px; width: 100%; padding: 3px 8px; border: none; border-radius: 5px; background: none; color: var(--node-fg); font: inherit; text-align: left; cursor: pointer; } +.ed-ycmp button.on, .ed-ycmp button:hover { background: rgb(96 165 250 / .2); } +.ed-ycmp small { font: 11px system-ui, sans-serif; color: var(--node-muted); } +.ed-yaml-ta { position: absolute; inset: 0; width: 100%; height: 100%; resize: none; overflow: hidden; border: none; outline: none; background: none; + color: transparent; caret-color: var(--node-fg); user-select: text; -moz-user-select: text; } +.ed-yaml-ta::selection { background: rgb(96 165 250 / .35); color: transparent; } +.ed-yaml-ta[readonly] { caret-color: transparent; } +.ed-yaml-err { margin: 0; padding: 7px 14px; font-size: 12px; color: #fca5a5; background: rgb(248 113 113 / .1); border-bottom: 1px solid rgb(248 113 113 / .25); } +.y-key { color: var(--call-fg); } .y-punct { color: var(--y-punct); } .y-str { color: var(--y-str); } .y-num { color: var(--y-num); } .y-bool { color: var(--y-bool); } .y-enum { color: var(--piece-fg); } +.y-com { color: var(--y-com); font-style: italic; } +.y-arg { color: var(--piece-fg); } + +.ed-corner { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; z-index: 5; } +#ed-minimap { width: 240px; height: 150px; border: 1px solid hsl(var(--border)); border-radius: 8px; background: hsl(var(--background) / .85); color: hsl(var(--foreground)); cursor: pointer; } +.ed-zoom { display: flex; align-items: center; gap: 2px; padding: 2px; font-size: 12px; border: 1px solid hsl(var(--border)); border-radius: 8px; + background: hsl(var(--background)); color: hsl(var(--muted-foreground)); } +.ed-zoom button { display: inline-flex; align-items: center; padding: 4px 9px; border: none; background: none; color: inherit; cursor: pointer; border-radius: 6px; } +.ed-zoom button:hover { color: hsl(var(--foreground)); background: hsl(var(--accent)); } +.ed-zoom span { min-width: 40px; text-align: center; } +.ed-notes { position: absolute; left: 16px; bottom: 16px; z-index: 5; max-width: min(600px, calc(100% - 320px)); max-height: 30%; overflow: auto; padding: 10px 12px; + font-size: 13px; line-height: 1.6; border: 1px solid hsl(var(--border)); border-radius: 8px; background: hsl(var(--background)); } +.ed-more { padding: 0; border: none; background: none; font: inherit; color: hsl(var(--muted-foreground)); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } +.ed-ok { color: var(--c-ok); } .ed-err { color: #ef4444; } .ed-warn { color: var(--c-srv); } .ed-muted { color: hsl(var(--muted-foreground)); } + +/* port and context menus */ +.ed-pop { position: absolute; z-index: 20; width: 220px; padding: 5px; font-size: 13px; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--pop-bg); color: var(--node-fg); + box-shadow: 0 1px 0 var(--hover) inset, 0 18px 40px rgb(0 0 0 / .55), 0 2px 6px rgb(0 0 0 / .3); backdrop-filter: blur(12px); } +.ed-pop-t { padding: 6px 10px 7px; margin-bottom: 4px; font-size: 12px; font-weight: 600; color: var(--node-fg); border-bottom: 1px solid var(--hover); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-pop-s { padding: 8px 10px 4px; font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--node-muted); } +.ed-pop hr { margin: 4px 6px; border: none; border-top: 1px solid var(--hover); } +.ed-mw { position: relative; } +.ed-mi { display: flex; width: 100%; align-items: center; gap: 10px; height: 32px; padding: 0 8px; text-align: left; + border: none; border-radius: 7px; background: none; color: inherit; cursor: pointer; } +/* the top level highlights the item aim() opened, not every item the pointer crosses on its way to a submenu */ +.ed-sub .ed-mi:hover:not(:disabled), .ed-pop > .ed-mw.open > .ed-mi:not(:disabled) { background: var(--hover); } +.ed-pop.scroll { max-height: min(460px, calc(100% - 24px)); overflow: auto; } +.ed-mi-h { max-width: 45%; font-size: 12px; color: var(--node-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-mi-ok { display: flex; color: var(--c-focus); } +.ed-mi.checked:disabled { opacity: 1; } +.ed-mi:disabled { opacity: .45; cursor: default; } +.logo { width: 16px; height: 16px; object-fit: contain; flex-shrink: 0; } +.ed-mi.muted .logo { filter: grayscale(1); opacity: .6; } +.ed-mi-i { display: flex; flex-shrink: 0; width: 16px; color: var(--c, var(--node-muted)); } +.ed-mi-i:empty { display: none; } +.ed-mi-l { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-mi.muted .ed-mi-l { color: var(--node-muted); } +.ed-mi.muted .ed-mi-i { color: var(--node-muted); opacity: .6; } +.ed-mi kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--node-muted); } +.ed-mi-more { color: var(--node-muted); display: flex; } +.ed-mi.danger { color: #fca5a5; } +.ed-mi.danger:hover:not(:disabled) { background: rgb(220 38 38 / .18); } +.ed-mi.danger .ed-mi-i { color: #f87171; } +/* submenus fly out to the side; which one is open is decided by the safe triangle in aim() */ +.ed-sub { display: none; position: absolute; top: -6px; left: calc(100% + 4px); width: 220px; max-height: 60vh; overflow: auto; padding: 5px; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--pop-bg); box-shadow: 0 18px 40px rgb(0 0 0 / .55); } +.ed-mw.open > .ed-sub { display: block; } +.ed-pop.flip .ed-sub { left: auto; right: calc(100% + 4px); } +/* a server id that isn't listed: type it, then add it as a backend or a proxy */ +.ed-custom { display: flex; gap: 4px; padding: 3px; } +.ed-custom input { flex: 1; min-width: 0; height: 28px; padding: 0 8px; font-size: 13px; border-radius: 6px; border: 1px solid var(--line-soft); background: rgb(0 0 0 / .3); color: var(--node-fg); } +.ed-custom input:focus { outline: none; border-color: var(--c-focus); } +.ed-custom input.bad { border-color: var(--c-bad); } +.ed-custom button { display: grid; place-items: center; width: 28px; height: 28px; border: none; border-radius: 6px; background: none; color: var(--c); cursor: pointer; } +.ed-custom button:hover { background: var(--hover); } + +/* hover reasons for marked fields, and for the save button */ +.ed { position: relative; } +.ed-tip { position: absolute; z-index: 40; max-width: 340px; padding: 7px 10px; font-size: 12.5px; line-height: 1.5; white-space: pre-line; pointer-events: none; + border-radius: 8px; border: 1px solid var(--line-soft); background: var(--pop-bg); color: var(--node-fg); box-shadow: 0 10px 28px rgb(0 0 0 / .5); } +.ed-tip.err { border-color: rgb(248 113 113 / .5); } +.ed-savewrap { display: inline-flex; } +.ed-save:disabled { pointer-events: none; } +.ed-save.blocked { border-color: rgb(248 113 113 / .6); color: #fca5a5; } +.ed-savewrap:has(.blocked) { cursor: pointer; } +.ed-wire-x { position: absolute; z-index: 20; display: grid; place-items: center; width: 30px; height: 30px; transform: translate(-50%, -50%); border-radius: 999px; + border: 1px solid var(--node-line); background: #dc2626; color: #fff; cursor: pointer; box-shadow: 0 4px 12px rgb(0 0 0 / .4); } +.ed-wire-x:hover { background: #b91c1c; } + +/* ---------------- canvas and cursors ---------------- */ +#ed-canvas { flex: 1; position: relative; overflow: hidden; cursor: default; + background-color: hsl(var(--background)); + background-image: radial-gradient(hsl(var(--border)) 1px, transparent 1px); background-size: 24px 24px; } +#ed-canvas.ed-space, #ed-canvas.ed-grab { cursor: grab; } +#ed-canvas.ed-panning, #ed-canvas.ed-panning * { cursor: grabbing !important; } +#ed-canvas.ed-moving, #ed-canvas.ed-moving * { cursor: grabbing !important; } +#ed-canvas.ed-selecting, #ed-canvas.ed-wiring, #ed-canvas.ed-wiring * { cursor: crosshair !important; } +#ed-canvas.ed-resizing, #ed-canvas.ed-resizing * { cursor: nwse-resize !important; } +.ed-marquee { position: absolute; z-index: 10; pointer-events: none; border: 1px solid var(--c-focus); background: rgb(96 165 250 / .12); border-radius: 2px; } + +/* ---------------- nodes ---------------- */ +#ed-canvas .drawflow-node { + width: 380px; padding: 0; align-items: flex-start; font-size: 13px; cursor: default; + border-radius: 10px; border: 1px solid var(--node-line); background: var(--node-bg); color: var(--node-fg); + box-shadow: 0 6px 18px rgb(0 0 0 / .35); +} +#ed-canvas .drawflow-node.selected { border-color: var(--node-line); background: var(--node-bg); } +/* the node being worked on is drawn on top, so a section opening up is never hidden behind a neighbour */ +#ed-canvas .drawflow-node.is-sel { z-index: 4; border-color: var(--c-focus); box-shadow: 0 0 0 2px rgb(96 165 250 / .45), 0 6px 18px rgb(0 0 0 / .35); } +#ed-canvas .drawflow-node .drawflow_content_node { width: 100%; } +/* fixed widths like in Unreal: content wraps downwards instead of pushing into neighbours */ +#ed-canvas .cb-command { width: 420px; } #ed-canvas .cb-run { width: 440px; } #ed-canvas .cb-wait { width: 240px; } +#ed-canvas .cb-arg { width: 380px; } #ed-canvas .cb-server { width: 240px; } +#ed-canvas .cb-alias, #ed-canvas .cb-cooldown { width: 280px; } #ed-canvas .cb-defaults, #ed-canvas .cb-rules { width: 380px; } #ed-canvas .cb-runas { width: 330px; } + +/* the script's enabled switch sits in the title bar; a disabled script fades with everything only it uses */ +#ed-canvas .nh-on { margin-left: auto; display: flex; cursor: default; } +#ed-canvas .nh-on .sw input:checked + span { background: rgb(255 255 255 / .35); } +#ed-canvas .drawflow-node.muted { opacity: .38; filter: saturate(.25); } +#ed-canvas .connection.muted .main-path { opacity: .2; } +#ed-canvas .req { margin-left: 2px; font-style: normal; color: var(--c-bad); } +#ed-canvas .pr .missing { color: var(--c-bad); } + +/* run as: a segmented switch */ +#ed-canvas .seg { display: flex; width: 100%; padding: 3px; gap: 3px; border-radius: 8px; background: var(--field-bg); border: 1px solid var(--node-line); } +#ed-canvas .seg label { flex: 1; position: relative; cursor: pointer; } +#ed-canvas .seg input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; } +#ed-canvas .seg span { display: block; padding: 5px 0; text-align: center; font-size: 12px; font-weight: 600; border-radius: 6px; color: var(--node-muted); } +/* the chosen option's pill slides over, like iOS segmented controls */ +#ed-canvas .seg { position: relative; } +#ed-canvas .seg-thumb { position: absolute; top: 3px; bottom: 3px; left: 3px; width: calc((100% - 6px - (var(--n) - 1) * 3px) / var(--n)); border-radius: 6px; + background: var(--seg, var(--h-runas)); box-shadow: 0 1px 3px rgb(0 0 0 / .3); transition: transform .32s cubic-bezier(.3, 1.25, .4, 1); } +#ed-canvas .seg:has(label:nth-of-type(2) input:checked) .seg-thumb { transform: translateX(calc(100% + 3px)); } +#ed-canvas .seg:has(label:nth-of-type(3) input:checked) .seg-thumb { transform: translateX(calc(200% + 6px)); } +#ed-canvas .seg span { position: relative; transition: color .2s; } +#ed-canvas .seg input:checked + span { color: #fff; } +#ed-canvas .cb-defaults .seg { --seg: var(--h-defaults); } + +/* server: the id is the title; a proxy is blue, like in the menus */ +#ed-canvas .srv { display: none; } +#ed-canvas .cb-server.proxy .nh { background: #1d4ed8; } +#ed-canvas .srv-k { font-size: 12px; color: var(--node-muted); } + +/* aliases: one row per alias, then a field to add one */ +/* aliases: a grouped list, each row editable in place, the last row adds one */ +#ed-canvas .al-list { border-radius: 8px; border: 1px solid var(--node-line); background: var(--field-bg); overflow: hidden; } +#ed-canvas .tag, #ed-canvas .al-new { display: flex; align-items: center; height: 34px; padding: 0 4px 0 10px; border-bottom: 1px solid var(--node-line); } +#ed-canvas .al-new { border-bottom: none; } +#ed-canvas .tag i, #ed-canvas .al-new i { width: 12px; font-style: normal; color: var(--node-muted); } +#ed-canvas .tag input, #ed-canvas .al-new input { flex: 1; min-width: 0; height: 100%; padding: 0 4px; font-size: 13.5px; border: none; background: none; color: var(--node-fg); } +#ed-canvas .tag input { font-weight: 600; } +#ed-canvas .tag input:focus, #ed-canvas .al-new input:focus { outline: none; } +#ed-canvas .tag:focus-within, #ed-canvas .al-new:focus-within { box-shadow: inset 0 0 0 1px var(--c-focus); } +#ed-canvas .al-new:focus-within { border-radius: 0 0 7px 7px; } +#ed-canvas .als .tag:first-child { border-radius: 7px 7px 0 0; } +#ed-canvas .als:empty + .al-new:focus-within { border-radius: 7px; } +#ed-canvas .tag-x { display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 6px; background: none; color: var(--node-muted); cursor: pointer; opacity: 0; transition: opacity .12s; } +#ed-canvas .tag-x svg { position: static; } +#ed-canvas .tag:hover .tag-x, #ed-canvas .tag-x:focus-visible { opacity: 1; } +#ed-canvas .tag-x:hover { color: #fca5a5; background: rgb(248 113 113 / .12); } +#ed-canvas .tag.bad { box-shadow: inset 0 0 0 1px var(--c-bad); } +#ed-canvas .tag input.bad { color: #fca5a5; box-shadow: none !important; } + +/* dropdowns in the menu style */ +#ed-canvas .pick { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; height: 32px; padding: 0 8px 0 10px; font-size: 13px; text-align: left; + border-radius: 7px; border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); cursor: pointer; } +#ed-canvas .pick:hover { border-color: #52525b; } +#ed-canvas .pick svg { position: static; flex-shrink: 0; color: var(--node-muted); } +#ed-canvas .pick .pi { display: flex; color: var(--node-muted); } +#ed-canvas .pick span { flex: 1; } +#ed-canvas .pick.bad { border-color: var(--c-bad); } +#ed-canvas .fr.idle { opacity: .4; } +#ed-canvas .fr.idle .pick { pointer-events: none; } +#ed-canvas .fl [data-tip] { text-decoration: underline dotted rgb(255 255 255 / .3); text-underline-offset: 3px; cursor: help; } + +/* an unwired step setting shows what it gets from the Defaults */ +#ed-canvas .pr .inh { margin-left: 8px; font-style: normal; font-size: 11.5px; color: #71717a; } +#ed-canvas .nk em { margin-left: 8px; font-style: normal; font-weight: 500; opacity: .75; } + +/* the step's command: text with argument pieces, wrapping downwards */ +#ed-canvas .cmd { min-height: 34px; padding: 5px 9px; font: 13px/24px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; + border-radius: 7px; border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); cursor: text; } +#ed-canvas .cmd:focus { outline: none; border-color: var(--c-focus); } +#ed-canvas .cmd:empty::before { content: attr(data-placeholder); color: var(--node-muted); } +#ed-canvas .ph { display: inline-block; vertical-align: top; height: 22px; margin: 1px 1px; line-height: 22px; padding: 0 2px 0 7px; border-radius: 5px; font-family: inherit; cursor: default; + background: rgb(56 189 248 / .18); color: var(--piece-fg); box-shadow: 0 0 0 1px rgb(56 189 248 / .35) inset; user-select: none; } +#ed-canvas .ph.bad { background: rgb(248 113 113 / .18); color: #fca5a5; box-shadow: 0 0 0 1px var(--c-bad) inset; } +#ed-canvas .args { display: flex; flex-wrap: wrap; gap: 5px; } +#ed-canvas .args:empty { display: none; } +#ed-canvas .pill { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 9px 0 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; + border-radius: 5px; background: rgb(56 189 248 / .1); color: var(--piece-fg); cursor: grab; } +#ed-canvas .pill::before { content: ""; width: 6px; height: 12px; opacity: .6; + background: radial-gradient(currentColor 1px, transparent 1.2px) 0 0 / 3px 4px; } +#ed-canvas .pill:active { cursor: grabbing; } +#ed-canvas .ph { cursor: grab; padding-right: 7px; } +#ed-canvas .ph:active { cursor: grabbing; } +/* dragging: the text makes room where the piece will go, the piece pops in when dropped */ +#ed-canvas .cmd.drop-here { border-color: rgb(56 189 248 / .7); box-shadow: 0 0 0 3px rgb(56 189 248 / .15); } +#ed-canvas .ph.preview { pointer-events: none; color: transparent; background: rgb(56 189 248 / .12); box-shadow: 0 0 0 1px rgb(56 189 248 / .6) inset; + animation: ph-room .16s cubic-bezier(.2, .8, .2, 1); } +@keyframes ph-room { from { max-width: 0; padding: 0; margin: 0; opacity: 0; } to { max-width: 240px; opacity: 1; } } +#ed-canvas .pill.lifted { opacity: .35; } +/* the dragged piece: the same size and look as in the text, it only follows the pointer */ +.ph-ghost { position: fixed; z-index: 100; height: 22px; padding: 0 7px; line-height: 22px; border-radius: 5px; pointer-events: none; transform-origin: 0 0; + font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; background: #102c3b; color: var(--piece-fg); box-shadow: 0 0 0 1px rgb(56 189 248 / .35) inset, 0 8px 22px rgb(0 0 0 / .4); + transition: background .15s, color .15s, opacity .15s; } +.ph-ghost.doomed { background: rgb(248 113 113 / .22); color: #fca5a5; box-shadow: 0 0 0 1px rgb(248 113 113 / .6) inset, 0 8px 22px rgb(0 0 0 / .4); } +.ph-ghost.settle { transition: left .18s cubic-bezier(.2, .8, .2, 1), top .18s cubic-bezier(.2, .8, .2, 1); box-shadow: 0 0 0 1px rgb(56 189 248 / .35) inset; } +.ph-ghost.gone { opacity: 0; } +/* the remove zone a piece is dragged into */ +.ed-trash { position: absolute; left: 50%; bottom: 20px; z-index: 30; display: flex; align-items: center; gap: 10px; padding: 12px 18px; font-size: 13px; border-radius: 14px; + border: 1px dashed rgb(248 113 113 / .5); background: var(--pop-bg); color: #fca5a5; backdrop-filter: blur(10px); + transform: translate(-50%, 24px); opacity: 0; transition: transform .2s cubic-bezier(.2, .8, .2, 1), opacity .2s, background .15s, border-color .15s; pointer-events: none; } +.ed-trash.on { transform: translate(-50%, 0); opacity: 1; } +.ed-trash.hot { transform: translate(-50%, 0) scale(1.08); border-style: solid; border-color: #f87171; background: rgb(127 29 29 / .9); color: #fff; } +.ed-trash svg { transition: transform .2s; } +.ed-trash.hot svg { transform: rotate(-12deg) scale(1.15); } +/* a step that calls another script: the called name is fixed, the rest are its arguments */ +#ed-canvas .callw { display: inline-flex; vertical-align: top; align-items: center; gap: 4px; height: 22px; margin: 1px 1px 1px 0; padding: 0 7px 0 5px; border-radius: 5px; font-weight: 600; user-select: none; + background: rgb(167 139 250 / .16); color: var(--call-fg); box-shadow: 0 0 0 1px rgb(167 139 250 / .4) inset; } +#ed-canvas .callw svg { position: static; } +/* Drawflow turns text selection off for the whole canvas; Firefox then hides the caret in fields */ +#ed-canvas input, #ed-canvas textarea, #ed-canvas .cmd { user-select: text; -moz-user-select: text; } + +/* fields that wrap instead of scrolling sideways */ +#ed-canvas .fc > textarea { width: 100%; min-width: 0; min-height: 32px; padding: 6px 9px; font: inherit; font-size: 13px; line-height: 1.45; resize: none; overflow: hidden; + border-radius: 7px; border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); } +#ed-canvas textarea:focus { outline: none; border-color: var(--c-focus); } + +/* problems sit on what needs a change; the reason shows on hover */ +#ed-canvas .fr.bad .fl { color: var(--c-bad); } +#ed-canvas .al-new.bad, #ed-canvas .cmd.bad { border-color: var(--c-bad) !important; box-shadow: 0 0 0 3px rgb(248 113 113 / .15); } +#ed-canvas .pr .missing { color: var(--c-bad); cursor: help; } +#ed-canvas .drawflow-node:has(> .drawflow_content_node > .nh.has-err) { border-color: var(--c-bad); box-shadow: 0 0 0 2px rgb(248 113 113 / .35), 0 6px 18px rgb(0 0 0 / .35); } +#ed-canvas .nh.has-err { cursor: help; } + +#ed-canvas .nh { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 12px 0 6px; border-radius: 9px 9px 0 0; color: #fff; cursor: grab; user-select: none; } +#ed-canvas .cb-command .nh { background: var(--h-command); } +#ed-canvas .cb-run .nh { background: var(--h-run); } +#ed-canvas .cb-wait .nh { background: var(--h-wait); } +#ed-canvas .cb-arg .nh { background: var(--h-arg); } +#ed-canvas .cb-server .nh { background: var(--h-server); } +#ed-canvas .cb-alias .nh { background: var(--h-alias); } +#ed-canvas .cb-defaults .nh { background: var(--h-defaults); } +#ed-canvas .cb-runas .nh { background: var(--h-runas); } +#ed-canvas .cb-cooldown .nh { background: var(--h-cooldown); } +#ed-canvas .cb-rules .nh { background: var(--h-rules); } +/* Drawflow positions every svg absolutely (for its wires); header icons flow with the title */ +#ed-canvas .nh svg { position: static; flex-shrink: 0; } +#ed-canvas .grip { display: grid; place-items: center; width: 22px; height: 28px; border-radius: 5px; color: rgb(255 255 255 / .7); } +#ed-canvas .nh:hover .grip { color: #fff; } +#ed-canvas .nk { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#ed-canvas .nk b { font-weight: 600; } +#ed-canvas .offline .nh::after { content: "not connected"; margin-left: auto; padding: 2px 8px; font-size: 11px; border-radius: 999px; background: rgb(0 0 0 / .25); } + +/* port rows: header 38px, then rows of 26px from 6px below it; the port shapes line up with them */ +#ed-canvas .np { padding: 6px 0 4px; } +#ed-canvas .pr { display: flex; justify-content: space-between; gap: 24px; height: 26px; line-height: 26px; padding: 0 16px; font-size: 12.5px; color: var(--node-muted); } +#ed-canvas .drawflow-node .inputs, #ed-canvas .drawflow-node .outputs { position: absolute; top: 51px; width: 0; display: flex; flex-direction: column; gap: 10px; } +#ed-canvas .drawflow-node .inputs { left: 0; } +#ed-canvas .drawflow-node .outputs { right: 0; } +#ed-canvas .drawflow-node .input, #ed-canvas .drawflow-node .output { + width: 16px; height: 16px; margin: 0; top: -1px; left: -8px; right: auto; border: none; border-radius: 0; background: var(--c-flow); + cursor: crosshair; transition: transform .1s, opacity .1s, filter .1s; +} +#ed-canvas .drawflow-node .input { cursor: pointer; } +#ed-canvas .pt-flow { clip-path: polygon(10% 0, 100% 50%, 10% 100%); background: var(--c-flow) !important; color: var(--c-flow); } +#ed-canvas .pt-arg { border-radius: 50% !important; background: var(--c-arg) !important; color: var(--c-arg); } +#ed-canvas .pt-srv { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); background: var(--c-srv) !important; color: var(--c-srv); } +#ed-canvas .pt-call { border-radius: 3px !important; transform: scale(.85); background: var(--c-call) !important; color: var(--c-call); } +#ed-canvas .pt-alias { border-radius: 50% !important; background: var(--c-alias) !important; color: var(--c-alias); } +#ed-canvas .pt-def { border-radius: 3px !important; background: var(--c-def) !important; color: var(--c-def); } +#ed-canvas .pt-runas { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); background: var(--c-runas) !important; color: var(--c-runas); } +#ed-canvas .pt-cool { border-radius: 50% !important; background: var(--c-cool) !important; color: var(--c-cool); } +#ed-canvas .pt-rules { border-radius: 3px !important; background: var(--c-rules) !important; color: var(--c-rules); } +#ed-canvas .drawflow-node .input:hover, #ed-canvas .drawflow-node .output:hover { transform: scale(1.4); } +/* while wiring: the ports a wire can join grow and glow in their own color, the rest fades */ +#ed-canvas .drawflow-node .input.can { transform: scale(1.6); filter: drop-shadow(0 0 5px currentColor) drop-shadow(0 0 8px rgb(255 255 255 / .45)); } +#ed-canvas .drawflow-node .input.cannot { opacity: .12; } +#ed-canvas .drawflow-node.no-target { opacity: .4; } + +/* fields: always there, no "add setting" */ +#ed-canvas .sec { display: flex; flex-direction: column; gap: 8px; padding: 10px 14px 12px; border-top: 1px solid var(--node-line); } +#ed-canvas .fr { display: grid; grid-template-columns: 116px 1fr; align-items: center; gap: 10px; min-height: 30px; } +#ed-canvas .fl { font-size: 12.5px; color: var(--node-muted); } +#ed-canvas .fc { display: flex; align-items: center; min-width: 0; } +#ed-canvas .fc > input[type=text], #ed-canvas .fc > select, #ed-canvas .slashed { + width: 100%; min-width: 0; height: 32px; padding: 0 9px; font-size: 13px; border-radius: 7px; + border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); cursor: text; +} +#ed-canvas .fc > select { cursor: pointer; } +#ed-canvas select option { background: var(--field-bg); color: var(--node-fg); } +#ed-canvas .slashed { display: flex; align-items: center; padding: 0 0 0 9px; } +#ed-canvas .slashed span { color: var(--node-muted); } +#ed-canvas .slashed input { flex: 1; min-width: 0; height: 100%; padding: 0 9px 0 2px; border: none; background: none; color: inherit; font-weight: 600; } +#ed-canvas input:focus, #ed-canvas select:focus, #ed-canvas .slashed:focus-within { outline: none; border-color: var(--c-focus); } +#ed-canvas .slashed input:focus { border: none; } +#ed-canvas .bad, #ed-canvas .slashed:has(.bad) { border-color: var(--c-bad) !important; box-shadow: 0 0 0 3px rgb(248 113 113 / .15); } + +/* iOS-style switches */ +#ed-canvas .sw { position: relative; display: inline-block; width: 38px; height: 22px; flex-shrink: 0; cursor: pointer; } +#ed-canvas .sw input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; } +#ed-canvas .sw span { position: absolute; inset: 0; border-radius: 999px; background: var(--switch-off); transition: background .2s; pointer-events: none; } +#ed-canvas .sw span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / .4); transition: transform .15s; } +#ed-canvas .sw input:checked + span { background: var(--c-ok); } +#ed-canvas .sw input:checked + span::after { transform: translateX(16px); } +#ed-canvas .sw input:focus-visible + span { box-shadow: 0 0 0 3px rgb(96 165 250 / .5); } + +/* ---------------- wires ---------------- */ +#ed-canvas .connection .main-path { stroke: var(--c-flow); stroke-width: 3px; opacity: .85; cursor: pointer; } +#ed-canvas .connection.t-arg .main-path { stroke: var(--c-arg); } +#ed-canvas .connection.t-srv .main-path { stroke: var(--c-srv); } +#ed-canvas .connection.t-call .main-path { stroke: var(--c-call); stroke-dasharray: 8 6; } +#ed-canvas .connection.t-alias .main-path { stroke: var(--c-alias); } +#ed-canvas .connection.t-def .main-path { stroke: var(--c-def); } +#ed-canvas .connection.t-runas .main-path { stroke: var(--c-runas); } +#ed-canvas .connection.t-cool .main-path { stroke: var(--c-cool); } +#ed-canvas .connection.t-rules .main-path { stroke: var(--c-rules); } +#ed-canvas .connection .main-path { pointer-events: none; } +#ed-canvas .connection .hit-path { fill: none; stroke: transparent; stroke-width: 16px; stroke-dasharray: none; pointer-events: stroke; cursor: pointer; } +#ed-canvas .connection:hover .main-path { opacity: 1; stroke-width: 6px; } +#ed-canvas .connection .main-path.selected { opacity: 1; stroke-width: 6px; stroke: #fca5a5 !important; } +#ed-canvas.ed-wiring .connection .hit-path { pointer-events: none; } +#ed-canvas[data-drag="arg"] svg.connection:last-of-type .main-path { stroke: var(--c-arg); } +#ed-canvas[data-drag="srv"] svg.connection:last-of-type .main-path { stroke: var(--c-srv); } +#ed-canvas[data-drag="call"] svg.connection:last-of-type .main-path { stroke: var(--c-call); stroke-dasharray: 8 6; } +#ed-canvas[data-drag="alias"] svg.connection:last-of-type .main-path { stroke: var(--c-alias); } +#ed-canvas[data-drag="def"] svg.connection:last-of-type .main-path { stroke: var(--c-def); } +#ed-canvas[data-drag="runas"] svg.connection:last-of-type .main-path { stroke: var(--c-runas); } +#ed-canvas[data-drag="cool"] svg.connection:last-of-type .main-path { stroke: var(--c-cool); } +#ed-canvas[data-drag="rules"] svg.connection:last-of-type .main-path { stroke: var(--c-rules); } +#ed-canvas .drawflow-delete { display: none; } + +/* ---------------- groups ---------------- */ +#ed-canvas .grp { position: absolute; z-index: 0; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--g) 70%, transparent); background: color-mix(in srgb, var(--g) 10%, transparent); } +#ed-canvas .grp-h { display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 10px 0 6px; border-radius: 11px 11px 0 0; background: color-mix(in srgb, var(--g) 35%, transparent); cursor: grab; } +#ed-canvas .grp-h .grip svg { position: static; } +#ed-canvas .grp-fill { flex: 1; align-self: stretch; } +#ed-canvas .grp-n { font-size: 12px; color: rgb(255 255 255 / .65); white-space: nowrap; } +#ed-canvas .grp-t { min-width: 0; max-width: 60%; height: 28px; padding: 0 6px; font-size: 15px; font-weight: 600; border: 1px solid transparent; border-radius: 6px; background: none; color: #fff; cursor: text; } +#ed-canvas .grp-t:focus { outline: none; border-color: rgb(255 255 255 / .5); background: rgb(0 0 0 / .2); } +#ed-canvas .grp-colors { display: flex; gap: 4px; } +#ed-canvas .grp-c { width: 14px; height: 14px; padding: 0; border-radius: 50%; border: 2px solid rgb(255 255 255 / .3); background: var(--c); cursor: pointer; } +#ed-canvas .grp .sw { margin-right: 2px; } +#ed-canvas .grp .sw input:disabled + span { opacity: .4; } +#ed-canvas .grp.off { border-style: dashed; background: color-mix(in srgb, var(--g) 4%, transparent); } +#ed-canvas .grp.off .grp-h { background: color-mix(in srgb, var(--g) 14%, transparent); } +#ed-canvas .grp.off .grp-t { color: rgb(255 255 255 / .55); } +#ed-canvas .grp-x { width: 26px; height: 26px; border: none; border-radius: 6px; background: none; color: #fff; font-size: 16px; cursor: pointer; } +#ed-canvas .grp-x:hover { background: rgb(0 0 0 / .3); } +#ed-canvas .grp-body { position: absolute; inset: 40px 0 0 0; } +#ed-canvas .grp.solo .grp-x { display: none; } +#ed-canvas .grp-sort { display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 6px; background: none; color: rgb(255 255 255 / .8); cursor: pointer; } +#ed-canvas .grp-sort:hover { color: #fff; background: rgb(0 0 0 / .3); } +#ed-canvas .grp-sort svg { position: static; } + +@media (max-width: 900px) { + .ed-bar { overflow-x: auto; } + .ed-btn { padding: 0 8px; } + .ed-yaml { position: absolute; inset: 0; width: auto; max-width: none; } + .ed-notes { max-width: calc(100% - 32px); bottom: 220px; } +} + +/* a step dragged onto a wire goes in between: the wire lights up, the node pops in */ +#ed-canvas .connection.insert-at .main-path { stroke: #60a5fa !important; stroke-width: 6px; opacity: 1; stroke-dasharray: 10 6; animation: wire-march .5s linear infinite; } +@keyframes wire-march { to { stroke-dashoffset: -16; } } +#ed-canvas .drawflow-node.inserting { box-shadow: 0 0 0 2px #60a5fa, 0 18px 40px rgb(0 0 0 / .55); } +#ed-canvas .drawflow-node.dropped-in { animation: node-in .35s cubic-bezier(.3, 1.5, .5, 1); } +@keyframes node-in { from { transform: scale(.94); } to { transform: scale(1); } } + +/* light mode: the canvas is light, nodes stay dark; group headers need dark text */ +html:not(.dark) #ed-canvas .grp-h { background: color-mix(in srgb, var(--g) 22%, white); } +html:not(.dark) #ed-canvas .grp-t { color: color-mix(in srgb, var(--g) 55%, black); } +html:not(.dark) #ed-canvas .grp-t:focus { border-color: color-mix(in srgb, var(--g) 50%, transparent); background: rgb(255 255 255 / .6); } +html:not(.dark) #ed-canvas .grp-n, html:not(.dark) #ed-canvas .grp-h .grip, html:not(.dark) #ed-canvas .grp-sort, html:not(.dark) #ed-canvas .grp-x { color: color-mix(in srgb, var(--g) 45%, #3f3f46); } +html:not(.dark) #ed-canvas .grp.off .grp-t { color: color-mix(in srgb, var(--g) 30%, #71717a); } +html:not(.dark) #ed-canvas .connection.t-flow .main-path { stroke: #71717a; } + +/* light mode: nodes, menus and the YAML panel turn light too; node headers keep their colors */ +html:not(.dark) .ed, html:not(.dark) .ph-ghost { + --node-bg: #ffffff; --node-fg: #18181b; --node-muted: #71717a; --node-line: #e4e4e7; --field-bg: #f4f4f5; + --pop-bg: rgb(255 255 255 / .97); --hover: rgb(0 0 0 / .05); --line-soft: rgb(0 0 0 / .08); + --piece-fg: #0369a1; --call-fg: #6d28d9; --code-bg: #ffffff; --code-side: #fafafa; --code-fg: #3f3f46; --switch-off: #d4d4d8; + --y-str: #15803d; --y-num: #b45309; --y-bool: #be185d; --y-punct: #a1a1aa; --y-com: #a1a1aa; +} +html:not(.dark) #ed-canvas .drawflow-node { box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 8px 24px rgb(0 0 0 / .08); } +html:not(.dark) .ed-pop, html:not(.dark) .ed-sub { box-shadow: 0 18px 40px rgb(0 0 0 / .14), 0 2px 6px rgb(0 0 0 / .06); } +html:not(.dark) .ed-tip { box-shadow: 0 10px 28px rgb(0 0 0 / .12); } +html:not(.dark) .ph-ghost { background: #e0f2fe; box-shadow: 0 0 0 1px rgb(14 165 233 / .4) inset, 0 8px 22px rgb(0 0 0 / .15); } +html:not(.dark) .ed-trash { color: #b91c1c; } +html:not(.dark) .ed-trash.hot { color: #fff; } +html:not(.dark) .ed-custom input { background: #f4f4f5; } +html:not(.dark) .ed-yaml-main header, html:not(.dark) .ed-files { border-color: rgb(0 0 0 / .08); } +html:not(.dark) .ed-file { color: #52525b; } +html:not(.dark) .ed-file.new { color: #15803d; } html:not(.dark) .ed-file.changed { color: #a16207; } html:not(.dark) .ed-file.deleted { color: #b91c1c; } + +/* motion: quick, springy, and only where something appears or moves */ +@keyframes ed-pop-in { from { opacity: 0; transform: scale(.96) translateY(-4px); } } +@keyframes ed-sub-in { from { opacity: 0; transform: translateX(-6px); } } +@keyframes ed-sub-in-flip { from { opacity: 0; transform: translateX(6px); } } +@keyframes ed-fade-in { from { opacity: 0; } } +@keyframes ed-rise { from { opacity: 0; transform: translateY(8px); } } +@keyframes ed-slide-in { from { opacity: .4; transform: translateX(24px); } } +@keyframes ed-born { from { opacity: 0; transform: scale(.94); } } +.ed-pop { transform-origin: 0 0; animation: ed-pop-in .18s cubic-bezier(.2, .9, .3, 1.15); } +.ed-mw.open > .ed-sub { animation: ed-sub-in .16s cubic-bezier(.2, .8, .2, 1); } +.ed-pop.flip .ed-mw.open > .ed-sub { animation-name: ed-sub-in-flip; } +.ed-tip { animation: ed-fade-in .14s .12s both; } +.ed-notes { animation: ed-rise .22s cubic-bezier(.2, .8, .2, 1); } +.ed-yaml { animation: ed-slide-in .24s cubic-bezier(.2, .8, .2, 1); } +.ed-wire-x { animation: ed-born .16s cubic-bezier(.3, 1.4, .5, 1); } +#ed-canvas .drawflow-node.born { animation: ed-born .26s cubic-bezier(.3, 1.3, .5, 1); } +.ed-tool, .ed-btn, .ed-save, .ed-mi, .ed-file, .ed-zoom button, #ed-canvas .pick, #ed-canvas .tag-x, #ed-canvas .grp-sort, #ed-canvas .grp-x { transition: background-color .14s, color .14s, border-color .14s, opacity .14s; } +.ed-tool:active:not(:disabled), .ed-btn:active:not(:disabled), .ed-save:active:not(:disabled) { transform: scale(.96); } +#ed-canvas .sw span::after { transition: transform .28s cubic-bezier(.3, 1.4, .5, 1); } +#ed-canvas .sw input:active + span::after { width: 22px; } +#ed-canvas .sw input:checked:active + span::after { transform: translateX(12px); } +#ed-canvas .grp { transition: background-color .2s, border-color .2s; } +#ed-canvas .drawflow-node.muted { transition: opacity .2s, filter .2s; } +@media (prefers-reduced-motion: reduce) { .ed *, .ed *::after, .ph-ghost { animation: none !important; transition: none !important; } } + +/* after Save: the command that writes the changes, to run on the proxy */ +.ed-confirm { position: absolute; left: 50%; top: 16px; z-index: 25; display: flex; align-items: center; gap: 12px; padding: 8px 8px 8px 16px; transform: translateX(-50%); + border-radius: 12px; border: 1px solid var(--line-soft); background: var(--pop-bg); color: var(--node-fg); box-shadow: 0 18px 40px rgb(0 0 0 / .35); backdrop-filter: blur(12px); + animation: ed-drop-in .24s cubic-bezier(.2, .9, .3, 1.15); white-space: nowrap; } +@keyframes ed-drop-in { from { opacity: 0; transform: translate(-50%, -10px); } } +.ed-confirm-t { font-size: 13px; color: var(--node-muted); } +.ed-confirm code { padding: 5px 10px; border-radius: 7px; font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--field-bg); color: var(--node-fg); user-select: all; } +.ed-confirm .ed-btn { height: 30px; } +.ed-confirm-x { display: grid; place-items: center; width: 30px; height: 30px; border: none; border-radius: 7px; background: none; color: var(--node-muted); cursor: pointer; } +.ed-confirm-x:hover { background: var(--hover); color: var(--node-fg); } diff --git a/src/lib/editor/editor.js b/src/lib/editor/editor.js new file mode 100644 index 0000000..8596c6f --- /dev/null +++ b/src/lib/editor/editor.js @@ -0,0 +1,2827 @@ +// CommandBridge script editor: every script as a node graph, live on the proxy through the relay. +// The YAML side lives in script-yaml.js; positions and groups go to the proxy's editor-layout.json. + +const RELAY = location.hostname === "localhost" + ? "ws://localhost:8787/api/editor/" + : "wss://cb.objz.dev/api/editor/"; + +const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const unb64url = (t) => Uint8Array.from(atob(t.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); +const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const splitList = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean); +const isZero = (v) => v != null && /^\s*0+(\.0+)?\s*(ms|s|m|h)?\s*$/i.test(String(v)); + +// the same rules the proxy validates with, checked while typing +const RULES = { + name: [/^[a-z][a-z0-9-]{2,32}$/, "3 to 33 lowercase letters, digits or -, starting with a letter"], + argname: [/^[A-Za-z][A-Za-z0-9_-]*$/, "letters, digits, _ or -, starting with a letter"], + duration: [/^\s*\d+(\.\d+)?\s*(ms|s|m|h)?\s*$/i, "a duration like 500ms, 5s, 1m or 1h"], + // CB itself doesn't limit alias length (and short ones like /ft work), so only the characters are checked + alias: [/^[a-z][a-z0-9-]{0,32}$/, "lowercase letters, digits or -, starting with a letter"], + suggestions: [/^\s*([a-z0-9._+\-]+\s*(,\s*[a-z0-9._+\-]+\s*)*)?$/, "words of lowercase letters, digits and . _ + -, separated by commas"], + serverid: [/^\S+$/, "a server id without spaces"], +}; +const PLAYER_TYPES = ["STRING", "PLAYERS", "OFFLINE_PLAYER"]; // what CB can resolve to a player +const RUN_AS = ["CONSOLE", "PLAYER", "OPERATOR"]; +const PLACEHOLDER = /\$\{([^}]*)\}/g; + +// Ports per node type: [label, type]. A wire only joins ports of the same type, shape and color. +// Only text and switches live inside nodes; everything else is a node of its own, wired in. +const PORTS = { + command: { in: [["aliases", "alias"], ["arguments", "arg"], ["registered on", "srv"], ["defaults", "def"], ["called by", "call"]], out: [["then", "flow"]] }, + run: { in: [["after", "flow"], ["targets", "srv"], ["run as", "runas"], ["cooldown", "cool"], ["server rules", "rules"]], out: [["then", "flow"], ["calls", "call"]] }, + wait: { in: [["after", "flow"]], out: [["then", "flow"]] }, + arg: { in: [], out: [["argument", "arg"]] }, + server: { in: [], out: [["server", "srv"]] }, + alias: { in: [], out: [["aliases", "alias"]] }, + defaults: { in: [["targets", "srv"]], out: [["defaults", "def"]] }, + runas: { in: [], out: [["run as", "runas"]] }, + cooldown: { in: [], out: [["cooldown", "cool"]] }, + rules: { in: [], out: [["server rules", "rules"]] }, +}; +// port classes by name, so the code says what it wires +const P = { + cmd: { aliases: "input_1", args: "input_2", reg: "input_3", defaults: "input_4", calledBy: "input_5", then: "output_1" }, + run: { after: "input_1", targets: "input_2", runas: "input_3", cooldown: "input_4", rules: "input_5", then: "output_1", calls: "output_2" }, + def: { targets: "input_1" }, +}; +const REQUIRED_PORTS = { command: ["registered on", "defaults", "then"] }; +const TITLES = { command: "Command", run: "Execute", wait: "Wait", arg: "Argument", server: "Server", alias: "Aliases", defaults: "Defaults", runas: "Run as", cooldown: "Cooldown", rules: "Server rules" }; +const svg = (inner, size = 15) => ``; +const ICONS = { + command: svg(''), + run: svg(''), + wait: svg(''), + arg: svg(''), + server: svg(''), + alias: svg(''), + defaults: svg(''), + runas: svg(''), + cooldown: svg(''), + rules: svg(''), +}; +const GRIP = ``; +// a proxy and a backend look different wherever servers are listed +const SRV_ICONS = { + BACKEND: svg(''), + VELOCITY: svg(''), +}; +const RUN_AS_ICONS = { + CONSOLE: svg(''), + PLAYER: svg(''), + OPERATOR: svg(''), +}; +// Lucide icons used beyond the node icons (lucide.dev, ISC licence) +const LUCIDE = { + "type": '', + "text": '', + "wrap-text": '', + "hash": '', + "sigma": '', + "move-horizontal": '', + "toggle-left": '', + "clock-3": '', + "users": '', + "user-round-x": '', + "paw-print": '', + "shapes": '', + "globe": '', + "map-pin": '', + "map": '', + "compass": '', + "rotate-3d": '', + "trees": '', + "package": '', + "sparkles": '', + "flask-conical": '', + "volume-2": '', + "server": '', + "power-off": '', + "trash-2": '', + "file": '', + "folder-open": '' +}; +const TYPE_ICONS = { STRING: "type", TEXT: "text", GREEDY_STRING: "wrap-text", INTEGER: "hash", DOUBLE: "sigma", RANGE: "move-horizontal", BOOLEAN: "toggle-left", + TIME: "clock-3", PLAYERS: "users", OFFLINE_PLAYER: "user-round-x", ENTITIES: "paw-print", ENTITY_TYPE: "shapes", WORLD: "globe", LOCATION: "map-pin", + LOCATION_2D: "map", ANGLE: "compass", ROTATION: "rotate-3d", BIOME: "trees", ITEM_STACK: "package", ENCHANTMENT: "sparkles", POTION_EFFECT: "flask-conical", + SOUND: "volume-2", SERVER: "server" }; +const typeIcon = (t) => svg(LUCIDE[TYPE_ICONS[t]] || LUCIDE.type); +// what a script file may contain, for linting and completing the YAML panel (mirrors the proxy's Script records) +const Y = { + bool: { type: "bool" }, str: { type: "str" }, dur: { type: "dur" }, int: { type: "int" }, server: { type: "server" }, + enum: (values) => ({ type: "enum", values }), + map: (keys, required = []) => ({ type: "map", keys, required }), + list: (of) => ({ type: "list", of }), +}; +const Y_TARGETS = Y.list(Y.map({ id: Y.server, location: Y.enum(["VELOCITY", "BACKEND"]) }, ["id", "location"])); +const Y_RULES = Y.map({ "target-required": Y.bool, "schedule-online": Y.bool, "player-arg": { type: "arg" } }); +const Y_SCRIPT = Y.map({ + version: Y.int, name: { type: "name" }, enabled: Y.bool, description: Y.str, aliases: Y.list(Y.str), + permissions: Y.map({ enabled: Y.bool, silent: Y.bool }), + register: Y_TARGETS, + defaults: Y.map({ "run-as": Y.enum(RUN_AS), execute: Y_TARGETS, server: Y_RULES, delay: Y.dur, cooldown: Y.dur }), + args: Y.list(Y.map({ name: Y.str, required: Y.bool, type: { type: "enum", values: null }, suggestions: Y.list(Y.str) }, ["name"])), + commands: Y.list(Y.map({ command: Y.str, "run-as": Y.enum(RUN_AS), execute: Y_TARGETS, server: Y_RULES, delay: Y.dur, cooldown: Y.dur }, ["command"])), +}, ["version", "name", "permissions", "register", "defaults", "args", "commands"]); +const Y_HINTS = { bool: "true / false", enum: "one of", dur: "duration", int: "number", str: "text", server: "server id", arg: "argument", name: "name", map: "section", list: "list" }; + +// argument types by family, for the type menu +const ARG_FAMILIES = [ + ["Text", ["STRING", "TEXT", "GREEDY_STRING"]], + ["Numbers", ["INTEGER", "DOUBLE", "RANGE", "BOOLEAN", "TIME"]], + ["Players and entities", ["PLAYERS", "OFFLINE_PLAYER", "ENTITIES", "ENTITY_TYPE"]], + ["World", ["WORLD", "LOCATION", "LOCATION_2D", "ANGLE", "ROTATION", "BIOME"]], + ["Items and effects", ["ITEM_STACK", "ENCHANTMENT", "POTION_EFFECT", "SOUND"]], + ["Network", ["SERVER"]], +]; +// the software a server runs, by its real logo; Bukkit servers are shown as Spigot, which nearly all of them are +const logo = (file) => ``; +Object.assign(SRV_ICONS, { + VELOCITY: logo("velocity.svg"), PAPER: logo("paper.svg"), FOLIA: logo("folia.svg"), BUKKIT: logo("spigot.svg"), FORGE: logo("forge.svg"), FABRIC: logo("fabric.png"), +}); +const srvIcon = (loc, platform) => SRV_ICONS[platform] || SRV_ICONS[loc]; +const srvKind = (loc, platform) => (SRV_ICONS[platform] && platform !== "VELOCITY" ? platform.toLowerCase() : loc === "VELOCITY" ? "proxy" : "backend"); +const SRV_COLORS = { BACKEND: "#f59e0b", VELOCITY: "#60a5fa" }; +const MINIMAP_COLORS = { command: "#8b5cf6", run: "#22c55e", wait: "#64748b", arg: "#38bdf8", server: "#f59e0b", alias: "#ec4899", defaults: "#14b8a6", runas: "#eab308", cooldown: "#94a3b8", rules: "#f97316" }; +const GROUP_COLORS = ["#8b5cf6", "#22c55e", "#38bdf8", "#f59e0b", "#ef4444", "#94a3b8"]; +const kindOf = (node) => node.class.replace("cb-", ""); +const portType = (kind, side, port) => PORTS[kind][side][Number(port.split("_")[1]) - 1]?.[1]; +const portLabel = (kind, side, port) => PORTS[kind][side][Number(port.split("_")[1]) - 1]?.[0]; + +/* ---------------- fields inside nodes: text and switches only ---------------- */ + +const row = (label, control, req = false) => `
${label}${req ? '*' : ""}
${control}
`; +const text = (f, v, ph, rule = "", extra = "") => ``; +const toggle = (f, on, label) => ``; +const duration = (f, v, ph, req = false) => text(f, v, ph, "duration", req ? ' data-req="1"' : ""); +let segId = 0; +const seg = (f, options, current) => { const name = "seg" + ++segId; + return `
${options.map(([v, label]) => ``).join("")}
`; }; +const RUN_AS_SEG = RUN_AS.map((r) => [r, r.toLowerCase()]); +// a dropdown in the editor's own menu style; the value lives in data-v +const CHEVRON = svg('', 14); +const pick = (f, kind, v, label, icon) => ``; +// whose server target-required and schedule-online look at: the sender, or a player given as an argument +const playerPick = (f, v) => pick(f, "player", v, v || "who ran it", v ? svg(LUCIDE.users) : RUN_AS_ICONS.PLAYER); +const PLAYER_TIP = "Target required and schedule online look at this player's server. Normally that's the player who ran the command; pick an argument to use the player it names instead."; +// one alias: a row of the list, edited in place +const tag = (a) => `
/
`; +// a placeholder inside the command text: one piece that is moved or removed as a whole +// a placeholder inside the command text: one piece, dragged to move it or out to remove it +const ph = (name) => `${esc(name)}`; +const textHtml = (t) => String(t ?? "").split(/(\$\{[^}]*\})/).map((part) => (/^\$\{[^}]*\}$/.test(part) ? ph(part.slice(2, -1)) : esc(part))).join(""); +// a step that calls another script shows the called name as a fixed piece; the rest are its arguments +const CALL_ICON = svg('', 13); +const cmdHtml = (t, calling = false) => { + const m = calling && String(t ?? "").match(/^(\S+)(.*)$/s); + return m ? `${CALL_ICON}/${esc(m[1].replace(/^\//, ""))}${textHtml(m[2])}` : textHtml(t); +}; + +function nodeBody(kind, v) { + if (kind === "command") { + return `
+ ${row("name", `
/${text("name", v.name, "my-command", "name", ' data-req="1"')}
`, true)} + ${row("description", ``)} + ${row("needs permission", toggle("perm", v.perm !== "false", "needs permission"))} + ${row("silent denial", toggle("silent", v.silent === "true", "silent denial"))} +
`; + } + if (kind === "run") { + return `
${cmdHtml(v.command)}
`; + } + if (kind === "wait") return `
${row("for", duration("delay", v.delay ?? "1s", "1s", true), true)}
`; + if (kind === "arg") { + return `
+ ${row("name", text("name", v.name, "player", "argname", ' data-req="1"'), true)} + ${row("type", pick("type", "type", v.type || "STRING", v.type || "STRING", typeIcon(v.type || "STRING")))} + ${row("required", toggle("required", v.required === "true", "required"))} + ${row("suggestions", text("suggestions", v.suggestions, "steve, alex", "suggestions"))} +
`; + } + if (kind === "server") { + // fixed once added: a different server is a different node, picked from the menu + return `
`; + } + if (kind === "alias") { + // a list of names, then a field like the command's name to add one more + const list = splitList(v.aliases); + return `
${list.map(tag).join("")}
+
`; + } + if (kind === "defaults") { + return `
+ ${row("run as", seg("d-runas", RUN_AS_SEG, v["d-runas"] || "CONSOLE"))} + ${row("delay", duration("d-delay", v["d-delay"], "none"))} + ${row("cooldown", duration("d-cooldown", v["d-cooldown"], "none"))} + ${row("target required", toggle("d-target", v["d-target"] === "true", "target required"))} + ${row("schedule online", toggle("d-online", v["d-online"] === "true", "schedule online"))} + ${row(`checks player`, playerPick("d-player", v["d-player"]))} +
`; + } + if (kind === "runas") return `
${seg("value", RUN_AS_SEG, v.value || "CONSOLE")}
`; + if (kind === "cooldown") return `
${row("for", duration("cooldown", v.cooldown, "10s", true), true)}
`; + return `
+ ${row("target required", toggle("r-target", v["r-target"] === "true", "target required"))} + ${row("schedule online", toggle("r-online", v["r-online"] === "true", "schedule online"))} + ${row(`checks player`, playerPick("r-player", v["r-player"]))} +
`; +} + +const srvLoc = (v) => (v.location === "VELOCITY" ? "VELOCITY" : "BACKEND"); + +function nodeHtml(kind, v) { + const ports = PORTS[kind]; + let rows = ""; + const req = REQUIRED_PORTS[kind] || []; + for (let i = 0; i < Math.max(ports.in.length, ports.out.length); i++) { + const cell = (p) => (p ? `${p[0]}${req.includes(p[0]) ? '*' : ""}` : ""); + const left = kind === "server" ? `${srvLoc(v) === "VELOCITY" ? "proxy" : "backend"}` : cell(ports.in[i]); + rows += `
${left}${cell(ports.out[i])}
`; + } + const title = kind === "command" ? `/${esc(v.name || "")}` + : kind === "server" ? `${esc(v.id)}` : `${TITLES[kind]}`; + const enabled = kind === "command" ? `${toggle("enabled", v.enabled !== "false", "enabled")}` : ""; + const icon = kind === "server" ? SRV_ICONS[srvLoc(v)] : ICONS[kind]; + return `
${GRIP}${icon}${title}${enabled}
${rows ? `
${rows}
` : ""}${nodeBody(kind, v)}`; +} + +function cbEditor() { + // kept out of Alpine's reactive data: proxies around Drawflow and ES modules break them + let df, YAML, conv, building = false, syncingCalls = false, restoring = false; + const meta = new Map(); // node id -> { file, version } for commands, { src } for args and steps loaded from a file + let owner = new Map(); // node id -> the first command node that uses it, recomputed on every refresh + let owners = new Map(); // node id -> every command node that uses it + let groups = []; // { id, title, color, x, y, w, h, members: [script ids, see sidOf], auto } in canvas coordinates + let layout = { nodes: {}, groups: [] }; + const LAYOUT_VERSION = 3; // older layouts used other node types or one Server node per id; their nodes are laid out fresh + const selected = new Set(); + const history = { past: [], future: [], last: null, timer: null, pending: false }; + let layoutTimer = null, lastLayout = ""; + let wiring = null; // { from, port, x, y, made } while a wire is dragged + let wireShown = null; // the wire the delete button belongs to + const aim = { p0: null, p1: null, t: 0, timer: null }; // pointer trail for submenus' safe triangle + let clipboard = null; // { nodes, edges } copied with Ctrl+C + const bases = {}; // file -> YAML text edited in the panel, the base its YAML is written onto + let staged = null; // the change set waiting on the proxy for /cb editor confirm, as JSON + let draftTimer = null, lastDraft = ""; + let yamlTimer = null; + let lint = { text: null }, yamlCellSize = null; + let insertAt = null; // { from, to, out, inp, wire } a dragged step would be dropped into + let mouse = null; // last pointer position over the canvas, where Ctrl+V pastes + + return { + status: "connecting", + closeReason: "", + scripts: {}, + servers: {}, + loadedFiles: [], + out: {}, + deletions: [], + problems: [], + results: [], + showYaml: false, + showWarnings: false, + showMinimap: true, + selectedFile: null, + selectionSize: 0, + zoom: 100, + canUndo: false, + canRedo: false, + busy: false, + tip: null, // { text, x, y, right, err } for the hover tooltip + yamlPick: null, // the file chosen in the YAML panel + confirmCode: null, // the code to run /cb editor confirm with, once Save staged the changes + copied: false, + yamlDraft: null, // { file, text } being typed in the YAML panel + yamlError: "", + yamlSugg: null, // { items, sel, x, y, from, to } completion popup at the caret + yamlLine: -1, // the caret's line, for the message of a problem on it + pop: null, // { x, y, title, items: [{ label, hint, kind, keys, run, sub }] } for port and context menus + tool: "select", // left-drag on the canvas: "select" draws a selection box, "grab" pans + wireX: null, // { x, y } of the delete button on a selected wire + ws: null, key: null, sendSeq: 0, recvSeq: 0, + conn: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""), + queue: Promise.resolve(), + justSaved: [], + deferred: false, + removingNodes: false, + dragId: 0, + + // WebCrypto is async; chain sends and receives so sequence numbers stay in order + enqueue(fn) { this.queue = this.queue.then(fn).catch((e) => console.error(e)); }, + + async init() { + const [id, secret] = location.hash.slice(1).split(":"); + if (!/^[0-9a-f]{32}$/.test(id || "") || !secret) { this.status = "nolink"; return; } + [YAML, conv] = await Promise.all([import("/js/yaml/index.js"), import("/lib/editor/script-yaml.js")]); + window.cbArgTypes = conv.ARG_TYPES; + this.setupCanvas(); + this.key = await crypto.subtle.importKey("raw", unb64url(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); + this.ws = new WebSocket(RELAY + id + "/ws?role=editor"); + this.ws.onopen = () => this.send({ type: "hello" }); + this.ws.onmessage = (e) => this.enqueue(() => this.receive(e.data)); + this.ws.onclose = (e) => { + this.status = "closed"; + // 1006 without a reason means the relay refused the connection or was unreachable + this.closeReason = e.reason || (e.code === 1006 ? "could not join the session" : "code " + e.code); + }; + window.addEventListener("beforeunload", (e) => { if (this.changes()) e.preventDefault(); }); + }, + + statusText() { + return { connecting: "Connecting…", closed: "Session ended: " + this.closeReason }[this.status] ?? ""; + }, + + /* ================= canvas ================= */ + + setupCanvas() { + const el = document.getElementById("ed-canvas"); + df = new Drawflow(el); + df.reroute = false; + df.draggable_inputs = false; + df.zoom_enter = () => {}; // wheel zoom is ours, centred on the cursor + df.key = () => {}; // keys are ours too: Drawflow's Delete removes whatever it last saw clicked, even while typing + df.start(); + + df.on("connectionCreated", (c) => this.onConnect(c)); + df.on("connectionRemoved", (c) => this.onDisconnect(c)); + df.on("nodeRemoved", (id) => this.onNodeRemoved(id)); + // Drawflow stores a dragged node's position one mouse step behind where it draws it; trust the drawing + df.on("nodeMoved", (id) => { + if (insertAt) this.insertInto(String(id)); + const n = df.drawflow.drawflow.Home.data[id], node = document.getElementById("node-" + id); + if (n && node) { n.pos_x = parseFloat(node.style.left); n.pos_y = parseFloat(node.style.top); } + this.refresh(); + }); + df.on("translate", () => this.drawMinimap()); + // keeps each wire's click area on the wire while Drawflow redraws it + new MutationObserver((list) => { + for (const m of list) if (m.target.classList?.contains("main-path")) m.target.parentElement.querySelector(".hit-path")?.setAttribute("d", m.target.getAttribute("d")); + }).observe(df.precanvas, { subtree: true, attributes: true, attributeFilter: ["d"] }); + + el.addEventListener("wheel", (e) => { + e.preventDefault(); + const r = el.getBoundingClientRect(); + this.zoomAt(df.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), e.clientX - r.left, e.clientY - r.top); + }, { passive: false }); + + el.addEventListener("mousedown", (e) => this.onPointerDown(e), true); + // right-drag pans; keep Drawflow's right-click menu out of the way + el.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); }, true); + el.addEventListener("click", (e) => this.onClick(e)); + el.addEventListener("mousemove", (e) => { mouse = { x: e.clientX, y: e.clientY }; }); + el.addEventListener("mouseleave", () => { mouse = null; }); + el.addEventListener("input", (e) => this.onInput(e)); + el.addEventListener("change", (e) => this.onChange(e)); + el.addEventListener("keydown", (e) => this.onFieldKey(e)); + el.addEventListener("focusin", (e) => { if (e.target.dataset?.rule === "duration") this.durationMenu(e.target); }); + el.addEventListener("focusout", (e) => { + if (e.target === this.pop?.anchor && !e.relatedTarget?.closest?.(".ed-pop")) setTimeout(() => { if (this.pop?.anchor === e.target && document.activeElement !== e.target) this.pop = null; }, 150); + if (e.target.classList.contains("tag-in")) this.commitTag(e.target); + // an alias row emptied out goes away + if (e.target.classList.contains("al-in") && !e.target.value.trim()) { const box = e.target.closest(".sec"); e.target.closest(".tag").remove(); this.saveTags(box); } + if (e.target.classList.contains("cmd")) this.syncCalls(); // the first word may now name another script + }); + // nothing in the editor is dragged natively; Firefox would try to open a dropped piece as a link + el.addEventListener("dragstart", (e) => e.preventDefault()); + // only plain text is pasted into a command + el.addEventListener("paste", (e) => { + if (!e.target.closest?.(".cmd")) return; + e.preventDefault(); + document.execCommand("insertText", false, e.clipboardData.getData("text/plain").replace(/\s*\n\s*/g, " ")); + }); + // tooltips for fields with a problem, and for the save button + document.addEventListener("mouseover", (e) => this.showTip(e.target, e)); + document.addEventListener("mousedown", () => { this.tip = null; }, true); + document.addEventListener("keydown", (e) => this.onKey(e), true); + for (const type of ["copy", "cut", "paste"]) document.addEventListener(type, (e) => this.onClipboard(e)); + // Firefox drops keys like Ctrl+A and Ctrl+V while a plain element has focus; a hidden text field gets them all. + // Clicking anything in the editor that isn't a field hands focus back to it. + const keyBox = document.querySelector(".ed-keys"); + keyBox.addEventListener("beforeinput", (e) => e.preventDefault()); + // decided by what was clicked: Firefox may not have focused a clicked field yet when mouseup arrives. + // Click handlers that focus a field themselves run after this. + let down = null; + document.addEventListener("mousedown", (e) => { down = e.target; }, true); + document.addEventListener("mouseup", (e) => { + // text being selected (in the YAML panel) keeps its selection + if (this.status !== "online" || !e.target.closest?.(".ed") || e.target.closest(".ed-yaml") || !getSelection().isCollapsed) return; + if (!this.isTyping(down) && !this.isTyping(e.target)) keyBox.focus({ preventScroll: true }); + }, true); + document.addEventListener("keyup", (e) => { if (e.code === "Space") el.classList.remove("ed-space"); }); + window.addEventListener("resize", () => this.drawMinimap()); + }, + + toWorld(sx, sy) { + const r = df.container.getBoundingClientRect(); + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + return { x: (sx - r.left - df.canvas_x - ox) / df.zoom + ox, y: (sy - r.top - df.canvas_y - oy) / df.zoom + oy }; + }, + + // screen position inside the work area, for popups + toWork(sx, sy) { + const r = df.container.parentElement.getBoundingClientRect(); + return { x: sx - r.left, y: sy - r.top }; + }, + + /* ---------- pointer: pan, select, move, groups, wires, port menus ---------- */ + + onPointerDown(e) { + const el = df.container; + const t = e.target; + const nodeEl = t.closest(".drawflow-node"); + if (!t.closest(".ed-pop") && t !== this.pop?.anchor) this.pop = null; + if (!t.closest(".connection")) this.wireX = null; + const empty = t === el || t === df.precanvas || t.classList.contains("grp-body"); + + if (e.button === 2) { + // right-drag pans, a right-click without moving opens the context menu + e.stopPropagation(); e.preventDefault(); + const x0 = e.clientX, y0 = e.clientY; + let moved = false; + return this.drag(e, "ed-panning", (dx, dy, ev) => { + moved ||= Math.hypot(ev.clientX - x0, ev.clientY - y0) > 4; + if (moved) this.setView(df.zoom, df.canvas_x + dx, df.canvas_y + dy); + }, () => { if (!moved) this.contextMenu(t, x0, y0); }); + } + if (e.button === 1 || el.classList.contains("ed-space") || (this.tool === "grab" && empty)) { + e.stopPropagation(); e.preventDefault(); + return this.drag(e, "ed-panning", (dx, dy) => this.setView(df.zoom, df.canvas_x + dx, df.canvas_y + dy)); + } + + if (t.closest(".connection")) { + // a wire: select it and offer a delete button right there (Delete works too) + e.stopPropagation(); + return this.selectWire(t, e.clientX, e.clientY); + } + + const port = t.classList.contains("output") || t.classList.contains("input") ? t : null; + if (port && e.altKey) { + // Alt-click breaks every wire on a port, like in Unreal + e.stopPropagation(); e.preventDefault(); + const id = nodeEl.id.slice(5), cls = [...port.classList].find((c) => /^(in|out)put_\d+$/.test(c)); + const n = df.getNodeFromId(id), side = cls.startsWith("in") ? "inputs" : "outputs"; + for (const c of [...n[side][cls].connections]) { + side === "outputs" ? df.removeSingleConnection(id, c.node, cls, c.output) : df.removeSingleConnection(c.node, id, c.input, cls); + } + return; + } + if (port && t.classList.contains("input")) { + e.stopPropagation(); e.preventDefault(); + const x0 = e.clientX, y0 = e.clientY; + window.addEventListener("mouseup", (u) => { if (Math.hypot(u.clientX - x0, u.clientY - y0) < 5) this.portMenu(nodeEl.id.slice(5), "in", [...t.classList].find((c) => c.startsWith("input_")), u.clientX, u.clientY); }, { once: true }); + return; + } + if (port) return this.startWire(e, nodeEl, t); + + const grpHead = t.closest(".grp-h"); + if (grpHead && !t.closest("input, button, label")) { + e.stopPropagation(); e.preventDefault(); + const g = this.groupById(grpHead.parentElement.dataset.gid); + const inside = this.nodesIn(g); + return this.drag(e, "ed-moving", (dx, dy) => { + g.x += dx / df.zoom; g.y += dy / df.zoom; + for (const id of inside) this.moveNodeBy(id, dx / df.zoom, dy / df.zoom); + this.renderGroups(); + }, () => this.refresh()); + } + + if (nodeEl) { + const id = nodeEl.id.slice(5); + // an argument pill, or a piece already in a command, is dragged into place with a live preview + const piece = t.closest(".pill, .ph"); + if (piece) { this.pieceDrag(e, piece); return; } + const control = t.closest("input, select, button, textarea, label, .cmd, .args, .al-list"); + // the command box places its own caret; Drawflow must not move the node + if (t.closest(".cmd, .args")) e.stopPropagation(); + if ((e.shiftKey || e.ctrlKey || e.metaKey) && !control) { + e.stopPropagation(); + selected.has(id) ? selected.delete(id) : selected.add(id); + return this.paintSelection(); + } + if (!selected.has(id)) { selected.clear(); selected.add(id); this.paintSelection(); } + if (control) return; + // leaving a field for the node itself, so shortcuts act on the graph again + document.querySelector(".ed-keys").focus({ preventScroll: true }); + if (!t.closest(".nh")) { e.stopPropagation(); return; } // only the header moves a node + if (selected.size > 1) { + // move the whole selection; Drawflow itself only moves one node + e.stopPropagation(); e.preventDefault(); + return this.drag(e, "ed-moving", (dx, dy) => { + for (const s of selected) this.moveNodeBy(s, dx / df.zoom, dy / df.zoom); + }, () => this.refresh()); + } + el.classList.add("ed-moving"); + // a loose Execute or Wait dragged onto a step-to-step wire goes in between when dropped + const n0 = df.getNodeFromId(id); + const loose = ["run", "wait"].includes(kindOf(n0)) && !n0.inputs.input_1.connections.length && !n0.outputs.output_1.connections.length; + const watch = () => this.findInsert(id); + if (loose) window.addEventListener("mousemove", watch); + window.addEventListener("mouseup", () => { el.classList.remove("ed-moving"); window.removeEventListener("mousemove", watch); }, { once: true }); + return; + } + + if (empty && e.button === 0) { + // selection box; clicking the canvas also leaves any field, so shortcuts like Ctrl+Z act on the graph + e.stopPropagation(); e.preventDefault(); + document.querySelector(".ed-keys").focus({ preventScroll: true }); + if (!e.shiftKey) { selected.clear(); this.paintSelection(); } + const r = el.getBoundingClientRect(); + const box = document.createElement("div"); + box.className = "ed-marquee"; + el.appendChild(box); + const x0 = e.clientX, y0 = e.clientY; + let x1 = x0, y1 = y0; + return this.drag(e, "ed-selecting", (dx, dy, ev) => { + x1 = ev.clientX; y1 = ev.clientY; + Object.assign(box.style, { left: Math.min(x0, x1) - r.left + "px", top: Math.min(y0, y1) - r.top + "px", width: Math.abs(x1 - x0) + "px", height: Math.abs(y1 - y0) + "px" }); + }, () => { + box.remove(); + const [l, tp, rt, bt] = [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)]; + if (rt - l > 3 || bt - tp > 3) { + for (const n of el.querySelectorAll(".drawflow-node")) { + const b = n.getBoundingClientRect(); + if (b.right > l && b.left < rt && b.bottom > tp && b.top < bt) selected.add(n.id.slice(5)); + } + } + this.paintSelection(); + }); + } + }, + + selectWire(t, sx, sy) { + selected.clear(); this.paintSelection(); + const main = t.closest(".connection").querySelector(".main-path"); + df.connection_selected?.classList.remove("selected"); + df.connection_selected = main; + main.classList.add("selected"); + wireShown = main; + const p = this.toWork(sx, sy); + this.wireX = { x: p.x, y: p.y }; + }, + + // a wire starts: highlight the ports it may join in their own color, mute everything else + startWire(e, nodeEl, portEl) { + const el = df.container; + const from = df.getNodeFromId(nodeEl.id.slice(5)); + const port = [...portEl.classList].find((c) => c.startsWith("output_")); + const id = ++this.dragId; + wiring = { from: String(from.id), port, x: e.clientX, y: e.clientY, made: false }; + el.classList.add("ed-wiring"); + el.dataset.drag = portType(kindOf(from), "out", port); + el.querySelectorAll(".drawflow-node").forEach((node) => { + const to = df.getNodeFromId(node.id.slice(5)); + let any = false; + node.querySelectorAll(".input").forEach((inp) => { + const ok = this.allowed(from, port, to, [...inp.classList].find((c) => c.startsWith("input_")), true); + inp.classList.add(ok ? "can" : "cannot"); + any ||= ok; + }); + if (!any && node !== nodeEl) node.classList.add("no-target"); + }); + window.addEventListener("mouseup", (u) => setTimeout(() => { + if (id !== this.dragId) return; // a later drag already started + el.classList.remove("ed-wiring"); + delete el.dataset.drag; + el.querySelectorAll(".can, .cannot, .no-target").forEach((n) => n.classList.remove("can", "cannot", "no-target")); + const w = wiring; + wiring = null; + if (!w || w.made) return; + // a click on a port, or a wire dropped on empty space, opens the menu of what can go there + const onEmpty = u.target === el || u.target === df.precanvas || u.target.classList?.contains("grp-body"); + const clicked = Math.hypot(u.clientX - w.x, u.clientY - w.y) < 5; + if (clicked || onEmpty) this.portMenu(w.from, "out", w.port, u.clientX, u.clientY, !clicked); + }), { once: true }); + }, + + // the step-to-step wire running under a dragged node, lit up as the place it will go + findInsert(id) { + const node = document.getElementById("node-" + id), r = node.getBoundingClientRect(), from = df.getNodeFromId(id); + let hit = null; + for (const c of df.container.querySelectorAll("svg.connection.t-flow")) { + const path = c.querySelector(".main-path"), len = path.getTotalLength(), m = path.getScreenCTM(); + const cls = [...c.classList]; + const a = cls.find((k) => k.startsWith("node_out_"))?.slice(14), b = cls.find((k) => k.startsWith("node_in_"))?.slice(13); + if (!a || !b || a === id || b === id) continue; + for (let t = 0.1; t < 0.9 && !hit; t += 0.05) { + const p = path.getPointAtLength(len * t), x = p.x * m.a + m.e, y = p.y * m.d + m.f; + if (x > r.left && x < r.right && y > r.top && y < r.bottom) hit = { from: a, to: b, wire: c }; + } + if (hit) break; + } + // a Wait can't follow or lead into another Wait + if (hit && kindOf(from) === "wait" && [hit.from, hit.to].some((n) => kindOf(df.getNodeFromId(n)) === "wait")) hit = null; + if (insertAt?.wire !== hit?.wire) insertAt?.wire.classList.remove("insert-at"); + hit?.wire.classList.add("insert-at"); + node.classList.toggle("inserting", !!hit); + insertAt = hit; + }, + + insertInto(id) { + const { from, to, wire } = insertAt; + insertAt = null; + wire.classList.remove("insert-at"); + const node = document.getElementById("node-" + id); + node.classList.remove("inserting"); + df.removeSingleConnection(from, to, "output_1", "input_1"); + df.addConnection(from, id, "output_1", "input_1"); + df.addConnection(id, to, "output_1", "input_1"); + node.classList.add("dropped-in"); + setTimeout(() => node.classList.remove("dropped-in"), 400); + }, + + // generic drag helper: calls move(dx, dy) in screen pixels since the last event + drag(e, cls, move, done) { + const el = df.container; + let lx = e.clientX, ly = e.clientY; + el.classList.add(cls); + const onMove = (ev) => { move(ev.clientX - lx, ev.clientY - ly, ev); lx = ev.clientX; ly = ev.clientY; this.drawMinimap(); }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + el.classList.remove(cls); + done?.(); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp, { once: true }); + }, + + moveNodeTo(id, x, y) { + const n = df.drawflow.drawflow.Home.data[id]; + if (!n) return; + n.pos_x = x; n.pos_y = y; + const el = document.getElementById("node-" + id); + el.style.left = x + "px"; + el.style.top = y + "px"; + df.updateConnectionNodes("node-" + id); + }, + + moveNodeBy(id, dx, dy) { + const n = df.drawflow.drawflow.Home.data[id]; + if (n) this.moveNodeTo(id, n.pos_x + dx, n.pos_y + dy); + }, + + rectOf(id) { + const n = df.drawflow.drawflow.Home.data[id], el = document.getElementById("node-" + id); + return { x: n.pos_x, y: n.pos_y, w: el.offsetWidth, h: el.offsetHeight }; + }, + + // moves a new node to the nearest spot around (x, y) where it doesn't cover anything + settle(id, x, y) { + const me = this.rectOf(id), pad = 28; + const others = Object.keys(df.drawflow.drawflow.Home.data).filter((k) => k !== String(id)).map((k) => this.rectOf(k)); + const free = (px, py) => others.every((o) => px + me.w + pad <= o.x || o.x + o.w + pad <= px || py + me.h + pad <= o.y || o.y + o.h + pad <= py); + for (let ring = 0; ring < 40; ring++) { + const steps = Math.max(1, ring * 8); + for (let i = 0; i < steps; i++) { + const a = (i / steps) * Math.PI * 2; + const px = x + Math.cos(a) * ring * 60, py = y + Math.sin(a) * ring * 45; + if (free(px, py)) return this.moveNodeTo(id, px, py); + } + } + this.moveNodeTo(id, x, y); + }, + + paintSelection() { + document.querySelectorAll(".drawflow-node.is-sel").forEach((n) => n.classList.remove("is-sel")); + for (const id of [...selected]) { + const n = document.getElementById("node-" + id); + if (n) n.classList.add("is-sel"); else selected.delete(id); + } + this.selectionSize = selected.size; + const first = [...selected][0]; + if (first && owner.has(first)) this.selectedFile = this.fileOf(owner.get(first)); + }, + + isTyping(t) { + if (!t || t.classList?.contains("ed-keys")) return false; + return !!(t.matches?.('input:not([type="checkbox"]):not([type="radio"]), textarea, select') || t.closest?.('[contenteditable="true"]')); + }, + + // Ctrl+C, X and V go through the browser's clipboard events, so they also work between tabs; fields keep their own + onClipboard(e) { + if (this.status !== "online" || this.isTyping(e.target)) return; + if (e.type === "paste") { + let data = null; + try { data = JSON.parse(e.clipboardData.getData("text/plain")).cbEditor; } catch {} + if (data?.nodes) clipboard = data; + if (!clipboard) return; + e.preventDefault(); + this.paste(mouse && this.toWorld(mouse.x, mouse.y)); + return; + } + if (!selected.size) return; + e.preventDefault(); + this.copy(); + e.clipboardData.setData("text/plain", JSON.stringify({ cbEditor: clipboard })); + if (e.type === "cut") this.deleteSelection(); + }, + + // the usual node-editor keys; fields keep theirs, except Ctrl+S and Escape + onKey(e) { + if (this.status !== "online") return; + const t = e.target; + const typing = this.isTyping(t); + const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase(); + if (k === "escape") { + if (this.yamlSugg) return; // the YAML completion list closes first + if (this.pop) this.pop = null; + else if (typing) t.blur(); + else { selected.clear(); this.paintSelection(); this.wireX = null; } + return; + } + if (mod && k === "s") { e.preventDefault(); this.save(); return; } + if (typing) return; + if (mod) { + const run = { + z: () => (e.shiftKey ? this.redo() : this.undo()), y: () => this.redo(), a: () => this.selectAll(), + d: () => this.duplicate(), + g: () => (e.shiftKey ? this.ungroupSelection() : this.newGroup()), + 0: () => this.zoomAt(1, df.container.clientWidth / 2, df.container.clientHeight / 2), + "=": () => this.zoomBy(1.2), "+": () => this.zoomBy(1.2), "-": () => this.zoomBy(1 / 1.2), + }[k]; + if (run) { e.preventDefault(); run(); } + return; + } + if (k === "delete" || k === "backspace") { + // Drawflow would delete only the node it thinks is selected + e.stopPropagation(); e.preventDefault(); + if (selected.size) this.deleteSelection(); else this.deleteWire(); + return; + } + const step = e.shiftKey ? 50 : 10; + const nudge = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[k]; + if (nudge && selected.size) { + e.preventDefault(); + for (const id of selected) this.moveNodeBy(id, ...nudge); + this.refresh(); + return; + } + if (e.code === "Space") { df.container.classList.add("ed-space"); e.preventDefault(); return; } + if (k === "f") this.fit(selected.size ? [...selected] : null); + else if (k === "v") this.setTool("select"); + else if (k === "h") this.setTool("grab"); + else if (k === "a") { e.preventDefault(); this.addMenu(); } + }, + + selectAll() { + for (const id of Object.keys(df.drawflow.drawflow.Home.data)) selected.add(id); + this.paintSelection(); + }, + + setTool(t) { + this.tool = t; + df.container.classList.toggle("ed-grab", t === "grab"); + }, + + copy() { + if (!selected.size) return; + const snap = JSON.parse(this.serialize()); + clipboard = { nodes: snap.nodes.filter((n) => selected.has(n.id)), edges: snap.edges.filter(([a, , b]) => selected.has(a) && selected.has(b)) }; + }, + + // pastes at a canvas point, or next to the originals; a pasted Command is a new script (refresh gives it a group) + paste(at) { + if (!clipboard?.nodes.length) return; + const x0 = Math.min(...clipboard.nodes.map((n) => n.x)), y0 = Math.min(...clipboard.nodes.map((n) => n.y)); + const origin = at || { x: x0 + 60, y: y0 + 60 }; + const taken = new Set(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").map((n) => this.fields(n.id).name)); + const ids = new Map(); + selected.clear(); + for (const n of clipboard.nodes) { + const values = { ...n.values }; + if (n.kind === "command") { + let name = values.name + "-copy", i = 2; + while (taken.has(name) || this.scripts[name + ".yml"] !== undefined) name = `${values.name}-copy-${i++}`; + taken.add(values.name = name); + } + const id = this.place(n.kind, origin.x + n.x - x0, origin.y + n.y - y0, values); + ids.set(n.id, id); + selected.add(id); + } + for (const [a, out, b, inp] of clipboard.edges) df.addConnection(ids.get(a), ids.get(b), out, inp); + this.paintSelection(); + this.syncCalls(); + this.refresh(); + }, + + cut() { + this.copy(); + this.deleteSelection(); + }, + + duplicate() { + const keep = clipboard; + this.copy(); + this.paste(); + clipboard = keep; + }, + + deleteSelection() { + const ids = [...selected]; + selected.clear(); + this.removingNodes = true; // their wires go too, which must not rewrite other scripts' commands + for (const id of ids) if (df.drawflow.drawflow.Home.data[id]) df.removeNodeId("node-" + id); + this.removingNodes = false; + this.paintSelection(); + this.refresh(); + }, + + // deletes the wire the button was shown for; Drawflow may have dropped its own selection by now + deleteWire() { + if (wireShown?.isConnected) { + df.connection_selected = wireShown; + df.removeConnection(); + } + wireShown = null; + this.wireX = null; + }, + + /* ---------- port menus: what can be added or wired at a port ---------- */ + + portMenu(nodeId, side, port, sx, sy, dropped = false) { + const n = df.getNodeFromId(nodeId), kind = kindOf(n); + const label = portLabel(kind, side, port); + const type = portType(kind, side, port); + const at = dropped ? this.toWorld(sx, sy) : null; + const r = this.rectOf(nodeId); + const right = at || { x: r.x + r.w + 90, y: r.y }; + const left = (w) => at || { x: r.x - w - 90, y: r.y }; + const below = at || { x: r.x, y: r.y + r.h + 40 }; + const items = []; + const data = df.drawflow.drawflow.Home.data; + const script = owner.get(String(nodeId)); + const wireIn = (k, values, pos) => () => { const id = this.addNode(k, values, pos.x, pos.y); df.addConnection(id, nodeId, "output_1", port); this.refresh(); return id; }; + + if (side === "out" && type === "flow") { + const next = n.outputs[port].connections[0]?.node; + const insert = (k) => () => { + const id = this.addNode(k, {}, right.x, right.y); + if (next) df.removeSingleConnection(nodeId, next, port, "input_1"); + df.addConnection(nodeId, id, port, "input_1"); + if (next) df.addConnection(id, next, "output_1", "input_1"); + this.refresh(); + }; + items.push({ kind: "run", label: "Execute step", run: insert("run") }); + if (kind !== "wait") items.push({ kind: "wait", label: "Wait", run: insert("wait") }); + } else if (side === "out" && type === "call") { + for (const c of Object.values(data).filter((c) => kindOf(c) === "command" && String(c.id) !== script)) { + items.push({ kind: "command", label: "/" + this.fields(c.id).name, run: () => df.addConnection(nodeId, c.id, port, P.cmd.calledBy) }); + } + if (!items.length) items.push({ label: "No other scripts yet", disabled: true }); + } else if (side === "in" && type === "call") { + for (const c of Object.values(data).filter((c) => kindOf(c) === "run" && owner.get(String(c.id)) !== String(nodeId))) { + const s = owner.get(String(c.id)); + items.push({ kind: "run", label: `/${s ? this.fields(s).name : "?"}: ${this.fields(c.id).command || "(empty step)"}`, run: () => df.addConnection(c.id, nodeId, P.run.calls, port) }); + } + if (!items.length) items.push({ label: "No steps in other scripts yet", disabled: true }); + } else if (side === "in" && type === "arg") { + items.push({ kind: "arg", label: "New argument", run: () => { + // below the existing arguments, because their order on the canvas is their order in the command + const y = n.inputs[port].connections.map((c) => this.rectOf(c.node)).reduce((m, q) => Math.max(m, q.y + q.h + 30), r.y); + wireIn("arg", {}, at || { x: r.x - 380 - 90, y })(); + } }); + } else if (side === "in" && type === "alias") { + items.push({ kind: "alias", label: "New aliases", run: wireIn("alias", {}, left(300)) }); + } else if (side === "in" && type === "def") { + items.push({ kind: "defaults", label: "New defaults", run: wireIn("defaults", {}, below) }); + for (const d of Object.values(data).filter((d) => kindOf(d) === "defaults")) { + const users = d.outputs.output_1.connections.map((c) => "/" + this.fields(c.node).name).join(", "); + items.push({ kind: "defaults", label: "Reuse defaults", hint: users ? "used by " + users : "not used yet", run: () => { df.addConnection(d.id, nodeId, "output_1", port); this.refresh(); } }); + } + } else if (side === "in" && type === "runas") { + for (const v of RUN_AS) items.push({ icon: RUN_AS_ICONS[v], color: MINIMAP_COLORS.runas, label: v.toLowerCase(), run: wireIn("runas", { value: v }, below) }); + } else if (side === "in" && type === "cool") { + items.push({ kind: "cooldown", label: "New cooldown", run: wireIn("cooldown", { cooldown: "10s" }, below) }); + } else if (side === "in" && type === "rules") { + items.push({ kind: "rules", label: "New server rules", run: wireIn("rules", {}, below) }); + } else if (side === "in" && type === "srv") { + const wired = new Set(n.inputs[port].connections.map((c) => this.target(c.node)).map((t) => `${t.id}@${t.location}`)); + items.push(...this.serverItems((t) => this.wireServer(nodeId, port, t, left(240)), wired)); + } else if (side === "out" && type === "def") { + for (const c of Object.values(data).filter((c) => kindOf(c) === "command" && !c.inputs[P.cmd.defaults].connections.length)) { + items.push({ kind: "command", label: "/" + this.fields(c.id).name, run: () => { df.addConnection(nodeId, c.id, "output_1", P.cmd.defaults); this.refresh(); } }); + } + if (!items.length) items.push({ label: "Every script already has defaults", disabled: true }); + } else { + const hints = { flow: "Connect it from a step's 'then'", arg: "Drag it into a Command's 'arguments'", srv: "Drag it into 'registered on' or 'targets'", + alias: "Drag it into a Command's 'aliases'", runas: "Drag it into 'run as'", cool: "Drag it into a step's 'cooldown'", rules: "Drag it into a step's 'server rules'" }; + items.push({ label: hints[type] || "Drag from here to connect", disabled: true }); + } + this.openMenu(sx, sy, items, label); + }, + + // shows a menu at a screen point, kept inside the work area; submenus open to the left near the right edge + openMenu(sx, sy, items, title = "") { + const p = this.toWork(sx, sy); + this.pop = { x: p.x, y: p.y, title, items, flip: false, open: null }; + this.$nextTick(() => { + const m = document.querySelector(".ed-pop"), w = df.container.parentElement; + if (!m || !this.pop) return; + this.pop.x = Math.max(4, Math.min(p.x, w.clientWidth - m.offsetWidth - 16)); + this.pop.y = Math.max(4, Math.min(p.y, w.clientHeight - m.offsetHeight - 16)); + this.pop.flip = this.pop.x + m.offsetWidth * 2 > w.clientWidth; + }); + }, + + // safe triangle: while the pointer heads for the open submenu, crossing other items doesn't switch it + aimMove(e) { + aim.p0 = aim.p1; + aim.p1 = { x: e.clientX, y: e.clientY }; + aim.t = Date.now(); + }, + + aim(i) { + clearTimeout(aim.timer); + const sub = document.querySelector(".ed-pop > .ed-mw.open > .ed-sub"); + if (sub && this.pop.open !== i && this.headingFor(sub)) { aim.timer = setTimeout(() => this.aim(i), 120); return; } + this.pop.open = i; + }, + + headingFor(sub) { + const { p0: a, p1: p } = aim; + if (!a || !p || Date.now() - aim.t > 100) return false; // the pointer rests: it's where the user wants to be + const r = sub.getBoundingClientRect(), x = this.pop.flip ? r.right : r.left; + const b = { x, y: r.top - 8 }, c = { x, y: r.bottom + 8 }; + const side = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x); + const d1 = side(a, b, p), d2 = side(b, c, p), d3 = side(c, a, p); + return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0)); + }, + + icon(kind) { return ICONS[kind] || ""; }, + color(kind) { return MINIMAP_COLORS[kind]; }, + + // what can be added, at a canvas point or in the middle of the view + addItems(at) { + const node = (kind, label) => ({ kind, label, run: () => this.addAt(kind, {}, at) }); + return [ + { kind: "command", label: "Command", run: () => this.addScript(at) }, + { kind: "run", label: "Step", sub: [node("run", "Execute"), node("wait", "Wait")] }, + { kind: "arg", label: "Input", sub: [node("arg", "Argument"), node("alias", "Aliases")] }, + { kind: "defaults", label: "Setting", sub: [node("defaults", "Defaults"), node("runas", "Run as"), node("cooldown", "Cooldown"), node("rules", "Server rules")] }, + { kind: "server", label: "Server", sub: this.serverItems((t) => this.addAt("server", t, at)) }, + ]; + }, + + addAt(kind, values, at) { + const c = at || this.viewCenter(); + const id = this.addNode(kind, values, c.x, c.y); + selected.clear(); selected.add(id); this.paintSelection(); + this.refresh(); + return id; + }, + + // the toolbar's Add button and the A key + addMenu(e) { + const r = (e?.currentTarget || document.querySelector(".ed-addbtn")).getBoundingClientRect(); + this.openMenu(r.left, r.bottom - 4, this.addItems(null)); + }, + + contextMenu(t, sx, sy) { + const at = this.toWorld(sx, sy); + const nodeEl = t.closest(".drawflow-node"), wire = t.closest(".connection"), frame = t.closest(".grp"); + const ic = (d) => svg(d, 15); + const I = { + copy: ic(''), + cut: ic(''), + paste: ic(''), + dup: ic(''), + trash: ic(''), + power: ic(''), + group: ic(''), + ungroup: ic(''), + tidy: ic(''), + fit: ic(''), + all: ic(''), + dot: ic(''), + }; + // the icon shows what the item does: switch off (crossed out) or switch on (green) + const onOff = (on, what, run) => (on ? { icon: svg(LUCIDE["power-off"]), label: `Disable ${what}`, run } : { icon: I.power, label: `Enable ${what}`, run }); + const paste = { icon: I.paste, label: "Paste", keys: "Ctrl+V", disabled: !clipboard, run: () => this.paste(at) }; + let items, title = ""; + if (wire) { + this.selectWire(t, sx, sy); + items = [{ icon: I.trash, label: "Delete connection", keys: "Del", danger: true, run: () => this.deleteWire() }]; + } else if (nodeEl) { + const id = nodeEl.id.slice(5); + if (!selected.has(id)) { selected.clear(); selected.add(id); this.paintSelection(); } + const n = selected.size, kind = kindOf(df.getNodeFromId(id)); + title = n > 1 ? `${n} nodes` : kind === "command" ? "/" + this.fields(id).name : TITLES[kind]; + const sw = kind === "command" && n === 1 && nodeEl.querySelector('[data-f="enabled"]'); + const sids = this.selectedFiles(), inGroups = new Set(sids.map((f) => this.groupOf(f))); + const one = inGroups.size === 1 ? [...inGroups][0] : null, shared = [...inGroups].some((g) => g?.members.length > 1); + items = [ + { icon: I.copy, label: "Copy", keys: "Ctrl+C", run: () => this.copy() }, + { icon: I.cut, label: "Cut", keys: "Ctrl+X", run: () => this.cut() }, + { icon: I.dup, label: "Duplicate", keys: "Ctrl+D", run: () => this.duplicate() }, + paste, + { sep: true }, + // the group of the script(s) behind the selection: shown, changed, or left + ...(sids.length ? [{ icon: I.group, label: "Group", hint: one?.title || "", sub: [ + { section: sids.length > 1 ? `Move ${sids.length} scripts to` : "Move the script to" }, + ...groups.map((g) => ({ icon: I.dot, color: g.color, label: g.title || "Group", check: g === one, disabled: g === one, run: () => this.moveToGroup(sids, g) })), + { sep: true }, + { icon: I.group, label: "New group", keys: "Ctrl+G", run: () => this.newGroup() }, + ...(shared ? [{ icon: I.ungroup, label: "Remove from group", keys: "Ctrl+Shift+G", run: () => this.ungroupSelection() }] : []), + ] }] : []), + ...(shared ? [{ icon: I.ungroup, label: "Remove from group", keys: "Ctrl+Shift+G", run: () => this.ungroupSelection() }] : []), + ...(sw ? [onOff(sw.checked, "script", () => { sw.checked = !sw.checked; this.refresh(); })] : []), + { sep: true }, + { icon: I.trash, label: n > 1 ? `Delete ${n} nodes` : "Delete", keys: "Del", danger: true, run: () => this.deleteSelection() }, + ]; + } else { + const g = frame && this.groupById(frame.dataset.gid); + items = [ + ...this.addItems(at), + { sep: true }, + paste, + { icon: I.all, label: "Select all", keys: "Ctrl+A", run: () => this.selectAll() }, + { sep: true }, + ...(g ? [ + { section: g.title || "Group" }, + onOff(this.groupEnabled(g), "group", () => this.setGroupEnabled(g, !this.groupEnabled(g))), + { icon: I.tidy, label: "Sort group", run: () => this.sortGroup(g) }, + ...(g.members.length > 1 ? [{ icon: I.ungroup, label: "Ungroup", danger: true, run: () => this.ungroup(g.members) }] : []), + { sep: true }, + ] : []), + { icon: I.tidy, label: "Sort everything", run: () => this.tidy() }, + { icon: I.fit, label: "Fit view", keys: "F", run: () => this.fit() }, + ]; + } + this.openMenu(sx, sy, items, title); + }, + + runPop(item) { + if (item.disabled || item.sub) return; + this.pop = null; + item.run(); + }, + + // connected servers, then the ones scripts use that aren't connected, then a field for any other id + serverItems(pick, skip = new Set()) { + const items = []; + for (const online of [true, false]) { + const list = [...this.knownServers()].filter(([id, info]) => info.online === online && !skip.has(`${id}@${info.location}`)); + if (!list.length) continue; + items.push({ section: online ? "Connected" : "Offline" }); + for (const [id, info] of list) { + items.push({ icon: srvIcon(info.location, info.platform), color: online ? SRV_COLORS[info.location] : null, label: id, muted: !online, run: () => pick({ id, location: info.location }) }); + } + } + items.push({ sep: true }, { custom: true, run: pick }); + return items; + }, + + srvIcon(loc) { return srvIcon(loc); }, + + customServer(e, item) { + const input = e.target.elements.sid, id = input.value.trim(); + if (!RULES.serverid[0].test(id)) { input.classList.add("bad"); input.focus(); return; } + this.pop = null; + item.run({ id, location: e.submitter?.value || "BACKEND" }); + }, + + // one Server node per use, like a variable, so its wire stays short and crosses nothing + wireServer(nodeId, port, t, pos) { + df.addConnection(this.addNode("server", { id: t.id, location: t.location }, pos.x, pos.y), nodeId, "output_1", port); + this.refresh(); + }, + + // servers the proxy knows: itself and connected clients, plus every id the scripts already use + knownServers() { + const out = new Map(); + for (const [id, info] of Object.entries(this.servers)) out.set(id, { location: info.location, platform: info.platform, online: true }); + for (const text of Object.values(this.scripts)) { + let m; + try { m = conv.toModel(YAML, text); } catch { continue; } + for (const t of [...m.register, ...(m.defaults.execute || []), ...m.commands.flatMap((c) => c.execute || [])]) { + if (t?.id && !out.has(t.id)) out.set(String(t.id), { location: t.location || "BACKEND", online: false }); + } + } + return new Map([...out].sort((a, b) => Number(b[1].online) - Number(a[1].online) || a[0].localeCompare(b[0]))); + }, + + /* ---------- fields: lists, suggestions, validation ---------- */ + + onClick(e) { + const t = e.target; + // the switch shows whether any script in the group is on; a click turns them all the other way + if (t.classList.contains("grp-en")) { const g = this.groupById(t.closest(".grp").dataset.gid); this.setGroupEnabled(g, !this.groupEnabled(g)); return; } + if (t.classList.contains("grp-x")) { this.ungroup(this.groupById(t.closest(".grp").dataset.gid).members); return; } + if (t.closest(".grp-sort")) { this.sortGroup(this.groupById(t.closest(".grp").dataset.gid)); return; } + if (t.classList.contains("grp-c")) { this.groupById(t.closest(".grp").dataset.gid).color = t.dataset.color; this.renderGroups(); this.refresh(); return; } + if (t.closest(".tag-x")) { + const box = t.closest(".sec"); + t.closest(".tag").remove(); + this.saveTags(box); + return; + } + const pk = t.closest(".pick"); + if (pk) { this.pickMenu(pk); return; } + // a click on a field's frame, like the "/" before a name, goes into the field + const frame = t.closest(".slashed, .tag, .al-new"); + if (frame && t.tagName !== "INPUT" && !t.closest("button")) { + const input = frame.querySelector("input"); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + } + }, + + onInput(e) { + const t = e.target; + if (t.classList.contains("grp-t")) { + Object.assign(this.groupById(t.closest(".grp").dataset.gid), { title: t.value, named: true }); + t.size = Math.max(6, t.value.length + 1); + } + if (t.classList.contains("tag-in")) return; + if (t.classList.contains("al-in")) { t.closest(".tag").dataset.v = t.value.trim().replace(/^\//, ""); this.check(t); this.saveTags(t.closest(".sec")); return; } + if (t.dataset.f === "name" && t.closest(".cb-command")) t.closest(".drawflow-node").querySelector(".nh-name").textContent = t.value; + if (t.tagName === "TEXTAREA") this.grow(t); + if (t.classList.contains("cmd")) this.tokenize(t); + if (t.dataset.rule && RULES[t.dataset.rule]) this.check(t); + if (t.dataset.rule === "duration" && t === document.activeElement) this.durationMenu(t); + this.refresh(t.type === "text" || t.tagName === "TEXTAREA" || t.classList.contains("cmd")); + }, + + onChange(e) { + const t = e.target; + if (["name"].includes(t.dataset.f)) this.syncCalls(); + df.updateConnectionNodes(t.closest(".drawflow-node")?.id || ""); + this.refresh(); + }, + + onFieldKey(e) { + const t = e.target; + // single-line fields that wrap: Enter leaves them instead of breaking the line + if (e.key === "Enter" && (t.tagName === "TEXTAREA" || t.classList.contains("cmd"))) { e.preventDefault(); t.blur(); return; } + if (e.key === "Enter" && t.classList.contains("al-in")) { e.preventDefault(); t.closest(".sec").querySelector(".tag-in").focus(); return; } + if (!t.classList.contains("tag-in")) return; + if (e.key === "Enter" || e.key === "," || e.key === " " || e.key === "Tab") { + if (t.value.trim()) { e.preventDefault(); this.commitTag(t); } + } else if (e.key === "Backspace" && !t.value) { + const box = t.closest(".sec"), last = box.querySelector(".als").lastElementChild; + if (last) { last.remove(); this.saveTags(box); } + } + }, + + // an alias typed into the tag field becomes a tag + commitTag(input) { + const v = input.value.trim().replace(/^\//, ""); + input.value = ""; + const box = input.closest(".sec"); + if (!v || box.querySelector(`.tag[data-v="${CSS.escape(v)}"]`)) return; + box.querySelector(".als").insertAdjacentHTML("beforeend", tag(v)); + this.saveTags(box); + }, + + saveTags(box) { + box.querySelector('[data-f="aliases"]').value = [...box.querySelectorAll(".tag")].map((t) => t.dataset.v).filter(Boolean).join(","); + df.updateConnectionNodes(box.closest(".drawflow-node").id); + this.syncCalls(); + this.refresh(); + }, + + grow(area) { + area.style.height = "auto"; + area.style.height = area.scrollHeight + 2 + "px"; + df.updateConnectionNodes(area.closest(".drawflow-node").id); + }, + + // the command text without its pieces: placeholders back to ${name} + cmdText(box) { + let out = ""; + for (const n of box.childNodes) { + out += n.nodeType === 3 ? n.nodeValue : n.classList?.contains("ph") ? "${" + n.dataset.arg + "}" : n.classList?.contains("callw") ? n.dataset.word : this.cmdText(n); + } + return out.replace(/\u00a0/g, " "); + }, + + setCmd(box, text) { + box.innerHTML = cmdHtml(text, box.classList.contains("calling")); + }, + + // a typed ${name} becomes a placeholder piece; the caret stays where it was in the text + tokenize(box) { + if (![...box.childNodes].some((n) => n.nodeType === 3 && /\$\{[^}]*\}/.test(n.nodeValue)) && !box.querySelector(":scope > :not(.ph):not(.callw)")) return; + const sel = getSelection(), range = sel.rangeCount && sel.getRangeAt(0); + let caret = null; + if (range && box.contains(range.startContainer)) { + const pre = document.createRange(); + pre.selectNodeContents(box); + pre.setEnd(range.startContainer, range.startOffset); + const tmp = document.createElement("div"); + tmp.appendChild(pre.cloneContents()); + caret = this.cmdText(tmp).length; + } + this.setCmd(box, this.cmdText(box)); + if (caret === null) return; + let left = caret; + for (const n of box.childNodes) { + const len = n.nodeType === 3 ? n.nodeValue.length : this.cmdText({ childNodes: [n] }).length; + if (left <= len) { + const r = document.createRange(); + if (n.nodeType === 3) r.setStart(n, left); else r.setStartAfter(n); + r.collapse(true); + sel.removeAllRanges(); sel.addRange(r); + return; + } + left -= len; + } + }, + + // drags an argument (a pill, or a piece already in a command) between the words of a command; the text makes room + // as it moves. A piece dragged out lands in the remove zone, or anywhere else to go back where it was. + pieceDrag(e, el) { + e.stopPropagation(); e.preventDefault(); + this.tip = null; + const name = el.dataset.arg, from = el.classList.contains("ph") ? el : null, home = from?.closest(".cmd"); + const x0 = e.clientX, y0 = e.clientY, r0 = el.getBoundingClientRect(); + const PREVIEW = "\u0000"; + let ghost = null, trash = null, box = null, tokens = [], slots = [], slot = -1, homeText = "", overTrash = false; + const render = (b, list) => { b.innerHTML = list.map((t) => (t === PREVIEW ? `${esc(name)}` : `${textHtml(t)}`)).join(" "); }; + const leave = () => { if (box) { this.setCmd(box, tokens.join(" ")); box.classList.remove("drop-here"); } box = null; slot = -1; }; + const enter = (b) => { + box = b; + const text = this.cmdText(b).trim(); + tokens = text ? text.split(/\s+/) : []; // one space between words, however many there were + render(b, tokens); + b.classList.add("drop-here"); + const spans = [...b.querySelectorAll(".tk")].map((s) => s.getBoundingClientRect()); + // slot i goes before word i; a step that calls a script keeps the called name first + slots = spans.map((r) => ({ x: r.left - 3, y: r.top + r.height / 2 })); + const last = spans.at(-1), br = b.getBoundingClientRect(); + slots.push(last ? { x: last.right + 3, y: last.top + last.height / 2 } : { x: br.left + 10, y: br.top + br.height / 2 }); + if (b.classList.contains("calling") && slots.length > 1) slots[0] = null; + }; + const start = () => { + // the ghost is the piece as it looks in the text, at the canvas zoom, held where it was grabbed + ghost = document.createElement("div"); + ghost.className = "ph-ghost"; + ghost.textContent = name; + ghost.style.transform = `scale(${df.zoom})`; + document.body.append(ghost); + ghost.dataset.dx = x0 - r0.left; ghost.dataset.dy = y0 - r0.top; + if (from) { + homeText = this.cmdText(home); + from.remove(); + this.setCmd(home, this.cmdText(home).trim().replace(/\s+/g, " ")); + trash = document.createElement("div"); + trash.className = "ed-trash"; + trash.innerHTML = `${svg(LUCIDE["trash-2"], 18)}Drop here to remove`; + document.querySelector(".ed-stage").append(trash); + requestAnimationFrame(() => trash.classList.add("on")); + } else { + el.classList.add("lifted"); + } + df.container.classList.add("ed-moving"); + }; + const move = (ev) => { + if (!ghost) { if (Math.hypot(ev.clientX - x0, ev.clientY - y0) < 4) return; start(); } + ghost.style.left = ev.clientX - ghost.dataset.dx + "px"; + ghost.style.top = ev.clientY - ghost.dataset.dy + "px"; + const tr = trash?.getBoundingClientRect(); + overTrash = !!tr && ev.clientX > tr.left - 20 && ev.clientX < tr.right + 20 && ev.clientY > tr.top - 20 && ev.clientY < tr.bottom + 20; + trash?.classList.toggle("hot", overTrash); + ghost.classList.toggle("doomed", overTrash); + const under = overTrash ? null : document.elementFromPoint(ev.clientX, ev.clientY)?.closest("#ed-canvas .cmd"); + if (under !== box) { leave(); if (under) enter(under); } + if (!box) return; + let best = -1, dist = Infinity; + slots.forEach((p, i) => { if (!p) return; const d = Math.abs(p.x - ev.clientX) + Math.abs(p.y - ev.clientY) * 3; if (d < dist) { dist = d; best = i; } }); + if (best === slot) return; + slot = best; + const list = [...tokens]; + list.splice(slot, 0, PREVIEW); + render(box, list); + }; + const up = () => { + window.removeEventListener("mousemove", move); + df.container.classList.remove("ed-moving"); + el.classList.remove("lifted"); + if (!ghost) return; + let land = null; + if (box && slot >= 0) { + const target = box; + const list = [...tokens]; + list.splice(slot, 0, "${" + name + "}"); + box.classList.remove("drop-here"); + this.setCmd(target, list.join(" ")); + // the new piece is the one after as many same-named pieces as the words before it hold + const before = tokens.slice(0, slot).join(" ").split("${" + name + "}").length - 1; + land = target.querySelectorAll(`.ph[data-arg="${CSS.escape(name)}"]`)[before]; + box = null; + } else if (from && !overTrash) { + this.setCmd(home, homeText); // dropped nowhere: it goes back + land = [...home.querySelectorAll(".ph")].find((p) => p.dataset.arg === name); + } + // the ghost glides onto where the piece landed and hands over to it; in the bin or nowhere it fades + const to = land?.getBoundingClientRect(); + if (to) { + ghost.classList.add("settle"); + ghost.style.left = to.left + "px"; ghost.style.top = to.top + "px"; + ghost.addEventListener("transitionend", () => ghost.remove(), { once: true }); + setTimeout(() => ghost.remove(), 300); + } else { + ghost.classList.add("gone"); + setTimeout(() => ghost.remove(), 160); + } + if (trash) { trash.classList.remove("on"); setTimeout(() => trash.remove(), 220); } + this.syncCalls(); + this.refresh(); + df.container.querySelectorAll(".cb-run").forEach((n) => df.updateConnectionNodes(n.id)); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up, { once: true }); + }, + + // the player a server check is about has to be given, so the argument it comes from becomes required + requireArg(btn, name) { + const node = this.argNodesOf(btn.closest(".drawflow-node").id.slice(5)).find((n) => this.fields(n.id).name === name); + const sw = node && document.querySelector(`#node-${node.id} [data-f="required"]`); + if (sw) sw.checked = true; + }, + + // durations to pick from, narrowed down by what's typed; "none" where the field may stay empty + durationMenu(input) { + const v = input.value.trim().toLowerCase(); + const all = ["500ms", "1s", "3s", "5s", "10s", "30s", "1m", "5m", "10m", "30m", "1h", "6h", "12h", "24h"]; + const list = all.filter((d) => !v || (d.startsWith(v) && d !== v)); + const set = (d) => () => { input.value = d; input.dispatchEvent(new Event("input", { bubbles: true })); input.focus(); }; + const items = [...(!input.dataset.req && v ? [{ label: "none", muted: true, run: set("") }] : []), ...list.map((d) => ({ icon: svg(LUCIDE["clock-3"]), label: d, run: set(d) }))]; + if (!items.length) { this.pop = null; return; } + const r = input.getBoundingClientRect(); + this.openMenu(r.left, r.bottom + 2, items); + this.pop.scroll = true; + this.pop.width = Math.max(160, r.width); + this.pop.anchor = input; + }, + + // the editor's own dropdown: argument types by family, or the player the server checks are about + pickMenu(btn) { + if (btn.closest(".fr.idle")) return; + const r = btn.getBoundingClientRect(), cur = btn.dataset.v; + const set = (v, label, icon) => () => { btn.dataset.v = v; btn.querySelector("span").textContent = label; btn.querySelector(".pi").innerHTML = icon; this.refresh(); }; + let items; + if (btn.dataset.pick === "type") { + const types = window.cbArgTypes || []; + const used = new Set(); + const entry = (t) => ({ icon: typeIcon(t), label: t, check: t === cur, run: set(t, t, typeIcon(t)) }); + items = ARG_FAMILIES.flatMap(([section, list]) => { + const mine = list.filter((t) => types.includes(t)); + mine.forEach((t) => used.add(t)); + return mine.length ? [{ section }, ...mine.map(entry)] : []; + }); + const rest = types.filter((t) => !used.has(t)); + if (rest.length) items.push({ section: "Other" }, ...rest.map(entry)); + } else { + const args = this.argsOf(btn.closest(".drawflow-node").id.slice(5)); + items = [ + { icon: RUN_AS_ICONS.PLAYER, label: "who ran it", check: !cur, run: set("", "who ran it", RUN_AS_ICONS.PLAYER) }, + ...(args.length ? [{ section: "From an argument" }] : []), + ...args.map((a) => ({ icon: typeIcon(a.type), label: a.name, keys: a.type, check: a.name === cur, + disabled: !PLAYER_TYPES.includes(a.type), run: () => { this.requireArg(btn, a.name); set(a.name, a.name, svg(LUCIDE.users))(); } })), + ]; + } + this.openMenu(r.left, r.bottom + 2, items); + this.pop.scroll = true; + this.pop.width = Math.max(200, r.width); + }, + + + // a Server node's id and location + target(id) { + const f = this.fields(id); + return { id: f.id, location: f.location || "BACKEND" }; + }, + + check(input) { + const v = input.value.trim(); + const ok = v === "" ? !input.dataset.req : RULES[input.dataset.rule][0].test(v); + input.classList.toggle("bad", !ok); + if (ok) delete input.dataset.err; + else input.dataset.err = v === "" ? "Required" : "Use " + RULES[input.dataset.rule][1]; + }, + + // the argument nodes of the script a node belongs to, in their order + argNodesOf(nodeId) { + const cmd = owner.get(String(nodeId)); + if (!cmd) return []; + const data = df.drawflow.drawflow.Home.data; + return (data[cmd].inputs[P.cmd.args].connections || []).map((c) => data[c.node]).sort((a, b) => a.pos_y - b.pos_y); + }, + + argsOf(nodeId) { + return this.argNodesOf(nodeId).map((n) => this.fields(n.id)).filter((a) => a.name); + }, + + // options that depend on the graph: argument chips, player-arg choices, inherited values, server lists + // what depends on the whole graph: argument chips, unknown placeholders, required fields and ports, muted scripts + refreshDynamic(scripts) { + const byCmd = new Map(scripts.map((s) => [s.id, s])); + const data = df.drawflow.drawflow.Home.data; + // a step's arguments, ready to be dragged into its command + document.querySelectorAll("#ed-canvas .args").forEach((box) => { + const s = byCmd.get(owner.get(box.closest(".drawflow-node").id.slice(5))); + const html = (s?.model.args || []).filter((a) => a.name).map((a) => `${esc(a.name)}`).join(""); + if (box.innerHTML !== html) box.innerHTML = html; + }); + const known = this.knownServers(); + // a connected server shows the software it runs; the rest show proxy or backend + document.querySelectorAll('#ed-canvas .cb-server [data-f="id"]').forEach((i) => { + const node = i.closest(".drawflow-node"), info = known.get(i.value), loc = node.querySelector('[data-f="location"]').value; + const platform = info?.location === loc ? info.platform : undefined; + node.classList.toggle("offline", !info?.online); + if (node.dataset.platform !== String(platform)) { + node.dataset.platform = String(platform); + node.querySelector(".nh > svg, .nh > .logo").outerHTML = srvIcon(loc, platform); + node.querySelector(".srv-k").textContent = srvKind(loc, platform); + } + }); + // "checks player" only matters while target required or schedule online is on + document.querySelectorAll('#ed-canvas [data-pick="player"]').forEach((b) => { + const f = this.fields(b.closest(".drawflow-node").id.slice(5)), p = b.dataset.f[0]; + b.closest(".fr").classList.toggle("idle", f[p + "-target"] !== "true" && f[p + "-online"] !== "true"); + }); + // a step that calls another script shows the called name as a fixed piece (not while it's being typed in) + for (const n of Object.values(data)) { + if (kindOf(n) !== "run") continue; + const box = document.querySelector(`#node-${n.id} .cmd`), calling = n.outputs[P.run.calls].connections.length > 0; + if (box && box !== document.activeElement && box.classList.contains("calling") !== calling) { + const text = this.cmdText(box); + box.classList.toggle("calling", calling); + this.setCmd(box, text); + } + } + // a step's unwired settings come from the Defaults: show what they'll be + for (const [nodeId, cmdId] of owner) { + const node = document.getElementById("node-" + nodeId), s = byCmd.get(cmdId); + if (!s || !node?.classList.contains("cb-run")) continue; + const d = s.model.defaults, n = data[nodeId]; + const inherited = { + targets: (d.execute || []).map((t) => t.id).join(", ") || "none", + "run as": (d["run-as"] || "CONSOLE").toLowerCase(), + cooldown: d.cooldown || "none", + "server rules": "defaults", + }; + for (const [label, value] of Object.entries(inherited)) { + const span = node.querySelector(`[data-port="${label}"]`), port = { targets: P.run.targets, "run as": P.run.runas, cooldown: P.run.cooldown, "server rules": P.run.rules }[label]; + const text = n.inputs[port].connections.length ? "" : value; + let em = span.querySelector(".inh"); + if (!text) { em?.remove(); continue; } + if (!em) { em = document.createElement("em"); em.className = "inh"; em.dataset.tip = "Not wired, so it uses the Defaults"; span.append(em); } + if (em.textContent !== text) em.textContent = text; + } + } + // a disabled script is muted with everything that only it uses + const off = new Set(scripts.filter((s) => !s.model.enabled).map((s) => s.id)); + for (const n of Object.values(data)) { + const own = owners.get(String(n.id)); + document.getElementById("node-" + n.id).classList.toggle("muted", !!own?.size && [...own].every((c) => off.has(c))); + } + document.querySelectorAll("#ed-canvas svg.connection").forEach((c) => { + const ids = [...c.classList].filter((k) => k.startsWith("node_")).map((k) => k.split("-")[1]); + c.classList.toggle("muted", ids.length === 2 && ids.every((i) => document.getElementById("node-" + i)?.classList.contains("muted"))); + }); + }, + + // red marks right on what needs a change, with the reason on hover; what has no place goes to the notes + markProblems(problems) { + document.querySelectorAll("#ed-canvas .pmark").forEach((el) => { el.classList.remove("pmark", "bad", "missing", "has-err"); delete el.dataset.err; }); + document.querySelectorAll("#ed-canvas [data-rule]").forEach((i) => this.check(i)); + const pinned = new Set(); + for (const p of problems) { + const node = p.at && document.getElementById("node-" + p.at.node); + let el = node && (p.at.port ? node.querySelector(`[data-port="${p.at.port}"]`) : node.querySelector(p.at.sel || ".nh")); + if (el && ["checkbox", "radio", "hidden"].includes(el.type)) el = el.closest(".fr") || el.closest(".sec"); + if (!el) continue; + pinned.add(p); + el.dataset.err = el.classList.contains("pmark") ? el.dataset.err + "\n" + p.text : p.text; + el.classList.add("pmark", p.at.port ? "missing" : el.classList.contains("nh") ? "has-err" : "bad"); + } + return pinned; + }, + + showTip(target, e) { + if (e?.buttons) { this.tip = null; return; } // never while dragging + const el = target.closest?.("[data-err], [data-tip]"); + const text = el && (el.dataset.err || el.dataset.tip); + if (!text || !el.closest(".ed")) { this.tip = null; return; } + const r = el.getBoundingClientRect(), w = document.querySelector(".ed").getBoundingClientRect(); + const right = r.left - w.left > w.width / 2; + this.tip = { text, err: !!el.dataset.err, y: r.bottom - w.top + 6, x: right ? w.right - r.right : r.left - w.left, right }; + }, + + saveTip() { + const b = this.blocking(); + if (!b.length) return ""; + return `Fix ${b.length} ${b.length === 1 ? "problem" : "problems"} before saving:\n` + b.slice(0, 5).map((p) => (p.script ? `/${p.script}: ` : "") + p.text).join("\n") + (b.length > 5 ? `\nand ${b.length - 5} more` : ""); + }, + + // clicking the blocked save button shows the first problem + revealProblem() { + const p = this.blocking().find((x) => x.pinned); + if (!p) return; + const r = this.rectOf(p.at.node); + this.centerOn(r.x + r.w / 2, r.y + r.h / 2, Math.max(df.zoom, 0.8)); + selected.clear(); selected.add(String(p.at.node)); this.paintSelection(); + }, + + groupById(id) { return groups.find((g) => g.id === id); }, + + // a group holds whole scripts: every node they use + nodesIn(g) { + const cmds = new Set(this.groupCommands(g).map((n) => String(n.id))); + return [...owners].filter(([, own]) => [...own].some((c) => cmds.has(c))).map(([id]) => id); + }, + + groupOf(sid) { return groups.find((g) => g.members.includes(sid)); }, + + // a script's id for its group: its file once saved, a random one until then, so renaming never moves it + sidOf(cmdId) { + const m = meta.get(String(cmdId)) || {}; + if (!m.sid && !m.file) meta.set(String(cmdId), { ...m, sid: crypto.randomUUID() }); + return meta.get(String(cmdId)).sid || m.file; + }, + + // the scripts the selected nodes belong to + selectedFiles() { + return [...new Set([...selected].filter((id) => owner.has(id)).map((id) => this.sidOf(owner.get(id))))]; + }, + + // a script is in exactly one group; a group left without scripts goes away + moveToGroup(files, g) { + for (const x of groups) if (x !== g) x.members = x.members.filter((m) => !files.includes(m)); + g.members = [...new Set([...g.members, ...files])]; + groups = groups.filter((x) => x.members.length); + this.renderGroups(); + this.refresh(); + }, + + // the selected scripts form a new group (Ctrl+G) + newGroup() { + const files = this.selectedFiles(); + if (!files.length) return; + const g = { id: crypto.randomUUID().slice(0, 8), title: "Group", color: GROUP_COLORS[groups.length % GROUP_COLORS.length], x: 0, y: 0, w: 0, h: 0, members: [] }; + groups.push(g); + this.moveToGroup(files, g); + document.querySelector(`.grp[data-gid="${g.id}"] .grp-t`)?.select(); + }, + + // scripts leaving a group go back to a group of their own, like every script starts + ungroup(files) { + for (const g of groups) g.members = g.members.filter((m) => !files.includes(m)); + groups = groups.filter((g) => g.members.length); + for (const f of files) { + const cmd = Object.values(df.drawflow.drawflow.Home.data).find((n) => kindOf(n) === "command" && this.sidOf(n.id) === f); + groups.push(this.scriptGroup(f, "/" + (cmd ? this.fields(cmd.id).name : f))); + } + this.renderGroups(); + this.refresh(); + }, + + ungroupSelection() { + const files = this.selectedFiles().filter((f) => this.groupOf(f)?.members.length > 1); + if (files.length) this.ungroup(files); + }, + + groupCommands(g) { + return Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command" && g.members.includes(this.sidOf(n.id))); + }, + + groupEnabled(g) { + return this.groupCommands(g).some((n) => this.fields(n.id).enabled === "true"); + }, + + // a disabled group is every script in it disabled; nodes shared with an enabled script stay live + setGroupEnabled(g, on) { + for (const n of this.groupCommands(g)) document.querySelector(`#node-${n.id} [data-f="enabled"]`).checked = on; + this.refresh(); + }, + + scriptGroup(file, title) { + return { id: crypto.randomUUID().slice(0, 8), title, color: GROUP_COLORS[groups.length % GROUP_COLORS.length], x: 0, y: 0, w: 400, h: 200, members: [file], auto: true }; + }, + + // frames always wrap all of their scripts' nodes + fitGroups() { + const PAD = 40, HEAD = 44; + for (const g of groups) { + const ids = this.nodesIn(g); + if (!ids.length) continue; + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const id of ids) { const r = this.rectOf(id); x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h); } + Object.assign(g, { x: x0 - PAD, y: y0 - PAD - HEAD, w: x1 - x0 + PAD * 2, h: y1 - y0 + PAD * 2 + HEAD }); + } + }, + + // updates frames in place, so a title being typed keeps its focus + renderGroups() { + const seen = new Set(); + for (const g of groups) { + seen.add(g.id); + let el = df.precanvas.querySelector(`.grp[data-gid="${g.id}"]`); + if (!el) { + el = document.createElement("div"); + el.className = "grp"; + el.dataset.gid = g.id; + el.innerHTML = `
${GRIP} + ${GROUP_COLORS.map((c) => ``).join("")} + + +
`; + // behind the nodes, but above older frames + const frames = df.precanvas.querySelectorAll(".grp"); + frames.length ? frames[frames.length - 1].after(el) : df.precanvas.prepend(el); + } + Object.assign(el.style, { left: g.x + "px", top: g.y + "px", width: g.w + "px", height: g.h + "px" }); + el.style.setProperty("--g", g.color); + el.classList.toggle("solo", g.members.length === 1); + const on = this.groupEnabled(g); + el.querySelector(".grp-en").checked = on; + el.classList.toggle("off", !on); + el.querySelector(".grp-n").textContent = `${g.members.length} ${g.members.length === 1 ? "script" : "scripts"}`; + const title = el.querySelector(".grp-t"); + if (document.activeElement !== title && title.value !== g.title) { title.value = g.title; title.size = Math.max(6, g.title.length + 1); } + } + df.precanvas.querySelectorAll(".grp").forEach((n) => { if (!seen.has(n.dataset.gid)) n.remove(); }); + this.drawMinimap(); + }, + + // same-type ports only, and the flow stays a single chain + // same-type ports only; the flow stays a single chain; single-value inputs take one wire + allowed(from, outPort, to, inPort, preview = false) { + if (String(from.id) === String(to.id)) return false; + const type = portType(kindOf(from), "out", outPort); + if (!type || type !== portType(kindOf(to), "in", inPort)) return false; + const already = preview ? 0 : 1; // after creation the new wire is already counted + const outs = from.outputs[outPort].connections, ins = to.inputs[inPort].connections; + if (["flow", "call", "alias", "arg"].includes(type) && outs.length > already) return false; + if (type === "flow" && ins.length > already) return false; + if (["def", "runas", "cool", "rules"].includes(type) && ins.length > already) return false; + if (kindOf(from) === "wait" && kindOf(to) === "wait") return false; + return true; + }, + + onConnect({ output_id, input_id, output_class, input_class }) { + if (building) { this.tagWire(output_id, input_id, output_class, input_class); return; } + const from = df.getNodeFromId(output_id), to = df.getNodeFromId(input_id); + if (!this.allowed(from, output_class, to, input_class)) { + df.removeSingleConnection(output_id, input_id, output_class, input_class); + return; + } + if (wiring) wiring.made = true; + this.tagWire(output_id, input_id, output_class, input_class); + const type = portType(kindOf(from), "out", output_class); + if (!syncingCalls && type === "call") { + // wiring a call writes the called command's name into the step and runs the step where that script is registered + // the name replaces a script name already in front, otherwise it goes in front of what's there + const runField = document.querySelector(`#node-${output_id} [data-f="command"]`); + const words = this.cmdText(runField).trim().split(/\s+/).filter(Boolean); + const scripts = new Set(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").flatMap((n) => + [this.fields(n.id).name, ...n.inputs[P.cmd.aliases].connections.flatMap((c) => splitList(this.fields(c.node).aliases))]).map((x) => String(x).toLowerCase())); + if (words.length && scripts.has(words[0].replace(/^\//, "").toLowerCase())) words.shift(); + this.setCmd(runField, [this.fields(input_id).name, ...words].join(" ")); + const home = to.inputs[P.cmd.reg].connections[0]?.node; + if (home) { + const h = this.target(home); + const own = from.inputs[P.run.targets].connections.map((k) => String(k.node)); + if (!own.some((k) => { const t = this.target(k); return t.id === h.id && t.location === h.location; })) { + for (const k of own) df.removeSingleConnection(k, output_id, "output_1", P.run.targets); + const r = this.rectOf(output_id); + this.wireServer(output_id, P.run.targets, h, { x: r.x - 320 - 90, y: r.y + 40 }); + } + } + this.syncCalls(); + } else if (!syncingCalls && type === "srv") { + this.syncCalls(); // where a step runs decides whether it calls a script + } + this.refresh(); + }, + + // Drawflow removes a node's wires before it reports the node removed; deferring keeps the old + // ownership around so removing a Command can still take its own nodes with it + // Drawflow removes a node's wires before it reports the node removed; deferring keeps the old + // ownership around so removing a Command can still take its own nodes with it + onDisconnect(c) { + if (building || syncingCalls) return; + const data = df.drawflow.drawflow.Home.data; + const from = data[c?.output_id]; + if (!this.removingNodes && from && data[c.input_id] && kindOf(from) === "run" && c.output_class === P.run.calls) { + // a call is the command text; removing the wire removes the called name from it, or it would come right back + const field = document.querySelector(`#node-${c.output_id} [data-f="command"]`); + this.setCmd(field, this.cmdText(field).trim().split(/\s+/).slice(1).join(" ")); + } + if (this.deferred) return; + this.deferred = true; + queueMicrotask(() => { + this.deferred = false; + this.syncCalls(); + this.refresh(); + }); + }, + + onNodeRemoved(id) { + if (building) return; + // removing a Command removes its script: the nodes only it uses go with it, shared ones stay + this.removingNodes = true; + for (const [nodeId, own] of owners) { + if (nodeId !== String(id) && own.has(String(id)) && own.size === 1 && df.drawflow.drawflow.Home.data[nodeId]) df.removeNodeId("node-" + nodeId); + } + this.removingNodes = false; + meta.delete(String(id)); + selected.delete(String(id)); + this.refresh(); + }, + + tagWire(outId, inId, outPort, inPort) { + const type = portType(kindOf(df.getNodeFromId(outId)), "out", outPort); + const wire = document.querySelector(`.connection.node_out_node-${outId}.node_in_node-${inId}.${outPort}.${inPort}`); + if (!wire) return; + wire.classList.add("t-" + type); + // a wide invisible copy to click on: the drawn wire is thin, and a dashed one has gaps clicks fall through. + // It goes after the drawn path, because Drawflow updates children[0] when nodes move. + if (!wire.querySelector(".hit-path")) { + const hit = wire.querySelector(".main-path").cloneNode(); + hit.setAttribute("class", "hit-path"); + wire.appendChild(hit); + } + }, + + /* ---------- nodes ---------- */ + + place(kind, x, y, values = {}) { + const p = PORTS[kind]; + const id = String(df.addNode(kind, p.in.length, p.out.length, x, y, "cb-" + kind, {}, nodeHtml(kind, values))); + const el = document.getElementById("node-" + id); + if (kind === "server") el.classList.toggle("proxy", srvLoc(values) === "VELOCITY"); + // shape and color per port type; appended so Drawflow's positional classes stay where it expects them + el.querySelectorAll(".inputs .input").forEach((n, i) => { n.classList.add("pt-" + p.in[i][1]); n.title = `${p.in[i][0]}: click to add, or drop a wire here. Alt-click removes its wires`; }); + el.querySelectorAll("textarea").forEach((a) => this.grow(a)); + el.querySelectorAll(".outputs .output").forEach((n, i) => { n.classList.add("pt-" + p.out[i][1]); n.title = `${p.out[i][0]}: drag to connect, click for options. Alt-click removes its wires`; }); + return id; + }, + + // a node added by hand goes into free space near where it was asked for + addNode(kind, values, x, y) { + const id = this.place(kind, x, y, values); + const el = document.getElementById("node-" + id); + el.classList.add("born"); + setTimeout(() => el.classList.remove("born"), 400); + this.settle(id, x, y); + return id; + }, + + fields(id) { + const out = {}; + document.querySelectorAll(`#node-${id} [data-f]`).forEach((el) => { + if (el.type === "radio") { if (el.checked) out[el.dataset.f] = el.value; return; } + if (el.dataset.pick) { out[el.dataset.f] = el.dataset.v; return; } + if (el.classList.contains("cmd")) { out[el.dataset.f] = this.cmdText(el).trim(); return; } + out[el.dataset.f] = el.type === "checkbox" ? String(el.checked) : el.value.trim(); + }); + return out; + }, + + // keeps the canvas point under (mx, my) in place; Drawflow scales around the precanvas centre + zoomAt(z, mx, my) { + z = Math.min(1.6, Math.max(0.15, z)); + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + const px = (mx - df.canvas_x - ox) / df.zoom + ox, py = (my - df.canvas_y - oy) / df.zoom + oy; + this.setView(z, mx - ox - (px - ox) * z, my - oy - (py - oy) * z); + }, + + setView(z, x, y) { + df.zoom = df.zoom_last_value = z; + df.canvas_x = x; + df.canvas_y = y; + df.precanvas.style.transform = `translate(${x}px, ${y}px) scale(${z})`; + this.zoom = Math.round(z * 100); + this.pop = null; + this.wireX = null; + this.drawMinimap(); + }, + + zoomBy(f) { this.zoomAt(df.zoom * f, df.container.clientWidth / 2, df.container.clientHeight / 2); }, + + bounds(ids = null) { + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const id of ids || Object.keys(df.drawflow.drawflow.Home.data)) { + const r = this.rectOf(id); + x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h); + } + if (!ids) for (const g of groups) { x0 = Math.min(x0, g.x); y0 = Math.min(y0, g.y); x1 = Math.max(x1, g.x + g.w); y1 = Math.max(y1, g.y + g.h); } + return x0 === Infinity ? null : { x0, y0, x1, y1 }; + }, + + centerOn(wx, wy, z = df.zoom) { + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + this.setView(z, df.container.clientWidth / 2 - ox - (wx - ox) * z, df.container.clientHeight / 2 - oy - (wy - oy) * z); + }, + + // the whole graph, or only the given nodes + fit(ids = null) { + const b = this.bounds(ids); + if (!b) return; + const w = df.container.clientWidth, h = df.container.clientHeight; + const z = Math.min(1, Math.max(0.15, Math.min((w - 80) / (b.x1 - b.x0 + 120), (h - 80) / (b.y1 - b.y0 + 120)))); + this.centerOn((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, z); + }, + + viewCenter() { + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + return { + x: (df.container.clientWidth / 2 - df.canvas_x - ox) / df.zoom + ox - 160, + y: (df.container.clientHeight / 3 - df.canvas_y - oy) / df.zoom + oy, + }; + }, + + drawMinimap() { + if (this.minimapQueued) return; + this.minimapQueued = true; + requestAnimationFrame(() => { + this.minimapQueued = false; + const c = document.getElementById("ed-minimap"); + if (!c || !this.showMinimap || !df) return; + const ctx = c.getContext("2d"), W = c.width, H = c.height; + ctx.clearRect(0, 0, W, H); + const b = this.bounds(); + if (!b) return; + const r = df.container.getBoundingClientRect(); + const v0 = this.toWorld(r.left, r.top), v1 = this.toWorld(r.right, r.bottom); + const x0 = Math.min(b.x0, v0.x), y0 = Math.min(b.y0, v0.y), x1 = Math.max(b.x1, v1.x), y1 = Math.max(b.y1, v1.y); + const s = Math.min((W - 16) / (x1 - x0), (H - 16) / (y1 - y0)); + const ox = (W - (x1 - x0) * s) / 2, oy = (H - (y1 - y0) * s) / 2; + const mx = (x) => ox + (x - x0) * s, my = (y) => oy + (y - y0) * s; + this.minimapMap = { x0, y0, s, ox, oy }; + for (const g of groups) { ctx.fillStyle = g.color + "33"; ctx.fillRect(mx(g.x), my(g.y), g.w * s, g.h * s); } + for (const n of Object.values(df.drawflow.drawflow.Home.data)) { + const rr = this.rectOf(n.id); + ctx.fillStyle = MINIMAP_COLORS[kindOf(n)]; + ctx.fillRect(mx(rr.x), my(rr.y), Math.max(2, rr.w * s), Math.max(2, rr.h * s)); + } + ctx.strokeStyle = getComputedStyle(c).color; + ctx.lineWidth = 1.5; + ctx.strokeRect(mx(v0.x), my(v0.y), (v1.x - v0.x) * s, (v1.y - v0.y) * s); + }); + }, + + minimapJump(e) { + const m = this.minimapMap; + if (!m) return; + const c = e.currentTarget, r = c.getBoundingClientRect(); + const go = (ev) => { + const px = (ev.clientX - r.left) * (c.width / r.width), py = (ev.clientY - r.top) * (c.height / r.height); + this.centerOn((px - m.ox) / m.s + m.x0, (py - m.oy) / m.s + m.y0); + }; + go(e); + const move = (ev) => go(ev); + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", () => window.removeEventListener("mousemove", move), { once: true }); + }, + + /* ================= scripts -> graph ================= */ + + // one row per script: its servers, arguments, the command, then its steps left to right + // one row per script: servers, arguments and aliases, the command with its defaults, then its steps + // left to right with their own settings underneath + // texts: the scripts to lay out, by file; the saved ones unless the YAML panel hands in edited text + snapshotFromScripts(texts = this.scripts) { + const nodes = [], edges = []; + let next = 0; + for (const file of Object.keys(texts).sort()) { + let m; + try { m = conv.toModel(YAML, texts[file]); } catch { continue; } // listed as a problem in collect() + const count = {}; + const add = (kind, values, extra = {}) => { + const key = kind === "command" ? "cmd" : kind + (count[kind] = (count[kind] ?? -1) + 1); + const n = { id: String(++next), kind, values, band: file, key: `${file}#${key}`, ...extra }; + nodes.push(n); + return n.id; + }; + const d = m.defaults, s = d.server || {}; + const cmd = add("command", { name: m.name, description: m.description || "", enabled: String(m.enabled !== false), perm: String(m.permissions.enabled !== false), silent: String(!!m.permissions.silent) }, { meta: this.scripts[file] !== undefined ? { file, version: m.version } : { version: m.version } }); + if (m.aliases.length) edges.push([add("alias", { aliases: m.aliases.join(", ") }), "output_1", cmd, P.cmd.aliases]); + m.args.forEach((a) => { + const id = add("arg", { name: a.name, type: a.type, required: String(!!a.required), suggestions: (a.suggestions || []).join(", ") }, { meta: { src: a._src } }); + edges.push([id, "output_1", cmd, P.cmd.args]); + }); + // one Server node per use, like a variable: every wire stays short. Added in the order collect() meets them, + // which is what the layout keys count by + const server = (t) => add("server", { id: String(t.id), location: t.location === "VELOCITY" ? "VELOCITY" : "BACKEND" }); + m.register.forEach((t) => edges.push([server(t), "output_1", cmd, P.cmd.reg])); + // every script has its Defaults node, so where steps run is never left open + { + const def = add("defaults", { "d-runas": d["run-as"] || "CONSOLE", "d-delay": d.delay && !isZero(d.delay) ? String(d.delay) : "", "d-cooldown": d.cooldown && !isZero(d.cooldown) ? String(d.cooldown) : "", + "d-target": String(!!s["target-required"]), "d-online": String(!!s["schedule-online"]), "d-player": s["player-arg"] || "" }); + edges.push([def, "output_1", cmd, P.cmd.defaults]); + (d.execute || []).forEach((t) => edges.push([server(t), "output_1", def, P.def.targets])); + } + let prev = cmd; + m.commands.forEach((c) => { + if (c.delay != null) { + const w = add("wait", { delay: String(c.delay) }); + edges.push([prev, "output_1", w, "input_1"]); + prev = w; + } + const run = add("run", { command: c.command }, { meta: { src: c._src } }); + edges.push([prev, "output_1", run, P.run.after]); + (c.execute || []).forEach((t) => edges.push([server(t), "output_1", run, P.run.targets])); + if (c["run-as"]) edges.push([add("runas", { value: c["run-as"] }), "output_1", run, P.run.runas]); + if (c.cooldown != null) edges.push([add("cooldown", { cooldown: String(c.cooldown) }), "output_1", run, P.run.cooldown]); + if (c.server) { + const o = c.server; + const rules = add("rules", { "r-target": String(!!o["target-required"]), "r-online": String(!!o["schedule-online"]), "r-player": o["player-arg"] || "" }); + edges.push([rules, "output_1", run, P.run.rules]); + } + prev = run; + }); + } + // groups are by file name since version 2 and still fit + return { nodes, edges, groups: layout.version >= 2 ? layout.groups || [] : [] }; + }, + + // places nodes from a snapshot; nodes without a stored position get a tidy spot in their script's row + // places nodes from a snapshot; nodes without a stored position get a tidy spot in their script's row + buildFrom(snap, auto = false) { + building = true; + df.clear(); + meta.clear(); + selected.clear(); + groups = (snap.groups || []).map((g) => ({ ...g, members: [...(g.members || [])] })); + if (auto) { + // members whose script is gone (deleted, or never saved) leave; a script's own group goes with it + for (const g of groups) g.members = g.members.filter((m) => this.scripts[m] !== undefined); + groups = groups.filter((g) => g.members.length); + } + const ids = new Map(), unplaced = new Map(); // band -> [[snapshot node, node id]] + const keep = !auto || layout.version === LAYOUT_VERSION; + for (const n of snap.nodes) { + const stored = auto ? (keep ? layout.nodes?.[n.key] : null) : n; + const has = stored && stored.x != null; + const id = this.place(n.kind, has ? stored.x : 0, has ? stored.y : 0, n.values); + ids.set(n.id, id); + if (n.meta) meta.set(id, n.meta); + if (!has) (unplaced.get(n.band) || unplaced.set(n.band, []).get(n.band)).push([n, id]); + } + for (const [from, out, to, inp] of snap.edges) if (ids.has(from) && ids.has(to)) df.addConnection(ids.get(from), ids.get(to), out, inp); + if (unplaced.size) { + // measure nodes at their final size: argument pills only appear once the wires say whose arguments they are + this.refreshDynamic(this.collect().scripts); + const fresh = new Set([...unplaced.values()].flat().map(([, id]) => id)); + const data = df.drawflow.drawflow.Home.data; + const others = Object.keys(data).filter((id) => !fresh.has(id)); + const top = others.length ? Math.max(...others.map((id) => { const r = this.rectOf(id); return r.y + r.h; })) + 160 : 0; + // a whole new script gets the full layout below everything; a new node in a placed script goes next to what it feeds + const whole = []; + for (const items of unplaced.values()) { + const cmd = items.find(([n]) => n.kind === "command"); + if (cmd) { whole.push(cmd[1]); continue; } + for (const [, id] of items) { + const to = data[id].outputs.output_1?.connections[0]?.node, r = to && this.rectOf(to); + if (r) this.settle(id, r.x - this.rectOf(id).w - 90, r.y); else this.settle(id, 0, top); + } + } + this.arrange(whole, 0, top); + } + building = false; + this.renderGroups(); + this.syncCalls(); + this.refresh(); + }, + + // lays one script out so its wires run straight into their ports without crossing nodes: + // [Defaults' servers] [aliases, arguments, servers it's registered on, Defaults] [Command] [steps...], + // and each step's own settings below the step, ending just left of it. Returns where it ends. + layoutScript(cmdId, x, y) { + cmdId = String(cmdId); + const data = df.drawflow.drawflow.Home.data, cmd = data[cmdId]; + const GAP = 90, V = 28; + // nodes shared with another script stay where that script put them + const mine = (n, port) => (n?.inputs[port]?.connections || []).map((c) => String(c.node)).filter((id) => owner.get(id) === cmdId) + .sort((a, b) => data[a].pos_y - data[b].pos_y); + const size = (id) => this.rectOf(id); + const width = (ids) => Math.max(0, ...ids.map((id) => size(id).w)); + const defId = mine(cmd, P.cmd.defaults)[0]; + const colA = [...mine(cmd, P.cmd.aliases), ...mine(cmd, P.cmd.args), ...mine(cmd, P.cmd.reg), ...(defId ? [defId] : [])]; + const colB = defId ? mine(data[defId], P.def.targets) : []; + const wA = width(colA), wB = width(colB); + const xA = x + (wB ? wB + GAP : 0), xC = xA + (wA ? wA + GAP : 0); + let bottom = y, yA = y, defY = y; + // right-aligned, so every output sits at the same distance from the command + for (const id of colA) { if (id === defId) defY = yA; this.moveNodeTo(id, xA + wA - size(id).w, yA); yA += size(id).h + V; } + let yB = defY; + for (const id of colB) { this.moveNodeTo(id, x + wB - size(id).w, yB); yB += size(id).h + V; } + this.moveNodeTo(cmdId, xC, y); + bottom = Math.max(yA - V, yB - V, y + size(cmdId).h); + // the chain of steps in one row + const chain = []; + let sx = xC + size(cmdId).w + GAP, rowBottom = y + size(cmdId).h; + for (let n = data[cmd.outputs[P.cmd.then].connections[0]?.node]; n && owner.get(String(n.id)) === cmdId && !chain.includes(String(n.id)); n = data[n.outputs.output_1.connections[0]?.node]) { + const id = String(n.id); + chain.push(id); + this.moveNodeTo(id, sx, y); + sx += size(id).w + GAP; + rowBottom = Math.max(rowBottom, y + size(id).h); + } + const bandY = rowBottom + 50; + for (const id of chain) { + if (kindOf(data[id]) !== "run") continue; + const own = [P.run.targets, P.run.runas, P.run.cooldown, P.run.rules].flatMap((port) => mine(data[id], port)); + let sy = bandY; + for (const s of own) { this.moveNodeTo(s, data[id].pos_x - 30 - size(s).w, sy); sy += size(s).h + V; } + bottom = Math.max(bottom, sy - V); + } + return { bottom: Math.max(bottom, rowBottom), right: sx - GAP }; + }, + + // scripts one below the other, group by group, with room for the frames between groups + arrange(cmdIds, x, y) { + const rank = (id) => { const i = groups.indexOf(this.groupOf(this.sidOf(id))); return i < 0 ? groups.length : i; }; + const data = df.drawflow.drawflow.Home.data; + const list = [...cmdIds].sort((a, b) => rank(a) - rank(b) || data[a].pos_y - data[b].pos_y); + let last = null; + for (const id of list) { + const g = rank(id); + if (last !== null) y += g === last ? 120 : 220; + last = g; + y = this.layoutScript(id, x, y).bottom; + } + return y; + }, + + // the Sort button: every script laid out again (undoable) + tidy() { + this.arrange(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").map((n) => String(n.id)), 0, 0); + this.refresh(); + this.fit(); + }, + + // a group's sort button: only its scripts, where the group is now + sortGroup(g) { + this.arrange(this.groupCommands(g).map((n) => String(n.id)), g.x + 40, g.y + 84); + this.refresh(); + }, + + rebuild() { + for (const f of Object.keys(bases)) delete bases[f]; + this.results = []; + this.buildFrom(this.snapshotFromScripts(), true); + history.past = []; history.future = []; history.last = this.serialize(); + this.canUndo = this.canRedo = false; + this.fit(); + }, + + /* ---------- undo / redo: snapshots of the whole graph ---------- */ + + serialize() { + const data = df.drawflow.drawflow.Home.data; + const nodes = Object.values(data).map((n) => ({ id: String(n.id), kind: kindOf(n), x: n.pos_x, y: n.pos_y, values: this.fields(n.id), meta: meta.get(String(n.id)) })); + const edges = []; + for (const n of Object.values(data)) { + for (const [out, o] of Object.entries(n.outputs)) for (const c of o.connections) edges.push([String(n.id), out, String(c.node), c.output]); + } + return JSON.stringify({ nodes, edges, groups }); + }, + + // every action is its own undo step; only typing is grouped, so a word isn't one step per letter + remember(typing = false) { + if (building || restoring) return; + clearTimeout(history.timer); + if (typing) { + history.pending = true; + history.timer = setTimeout(() => this.commitHistory(), 400); + } else { + this.commitHistory(); + } + }, + + commitHistory() { + history.pending = false; + const now = this.serialize(); + if (now === history.last) return; + if (history.last) history.past.push(history.last); + if (history.past.length > 100) history.past.shift(); + history.last = now; + history.future = []; + this.canUndo = history.past.length > 0; + this.canRedo = false; + }, + + restore(snap) { + restoring = true; + const view = [df.zoom, df.canvas_x, df.canvas_y]; + this.buildFrom(JSON.parse(snap)); + this.setView(...view); + history.last = this.serialize(); // node ids change on every rebuild + restoring = false; + this.canUndo = history.past.length > 0; + this.canRedo = history.future.length > 0; + }, + + undo() { + // an edit still waiting for its snapshot becomes its own undo step first; a refresh without a change + // (like leaving a field) is not one. last is always serialized fresh, so comparing text is reliable here. + if (history.pending) { clearTimeout(history.timer); this.commitHistory(); } + if (!history.past.length) return; + history.future.push(history.last); + this.restore(history.past.pop()); + }, + + redo() { + if (!history.future.length) return; + history.past.push(history.last); + this.restore(history.future.pop()); + }, + + // A step calls a script when its command starts with that script's name or alias AND it runs where + // that script is registered. Elsewhere the same word is just that server's own command (e.g. /eco). + // A step calls a script when its command starts with that script's name or alias AND it runs where + // that script is registered. Elsewhere the same word is just that server's own command (e.g. /eco). + syncCalls() { + syncingCalls = true; + const data = df.drawflow.drawflow.Home.data; + const servers = (n, port) => (n?.inputs[port]?.connections || []).map((k) => { const t = this.target(k.node); return `${t.id}@${t.location}`; }); + const byName = new Map(); + for (const n of Object.values(data)) { + if (kindOf(n) !== "command") continue; + const reg = new Set(servers(n, P.cmd.reg)); + const names = [this.fields(n.id).name, ...n.inputs[P.cmd.aliases].connections.flatMap((c) => splitList(this.fields(c.node).aliases))]; + for (const name of names) if (name) (byName.get(name.toLowerCase()) || byName.set(name.toLowerCase(), []).get(name.toLowerCase())).push({ id: String(n.id), reg }); + } + for (const cmd of Object.values(data).filter((n) => kindOf(n) === "command")) { + const defaults = servers(data[cmd.inputs[P.cmd.defaults].connections[0]?.node], P.def.targets); + const seen = new Set(); + for (let n = data[cmd.outputs.output_1.connections[0]?.node]; n && !seen.has(n.id); n = data[n.outputs.output_1.connections[0]?.node]) { + seen.add(n.id); + if (kindOf(n) !== "run") continue; + const own = servers(n, P.run.targets), where = own.length ? own : defaults; + const first = (this.fields(n.id).command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase(); + const want = (byName.get(first) || []).find((c) => c.id !== String(cmd.id) && where.some((w) => c.reg.has(w)))?.id; + const have = n.outputs[P.run.calls].connections[0]?.node; + if (have && have !== want) df.removeSingleConnection(n.id, have, P.run.calls, P.cmd.calledBy); + if (want && have !== want) df.addConnection(n.id, want, P.run.calls, P.cmd.calledBy); + } + } + syncingCalls = false; + }, + + fileOf(cmdId) { + if (!cmdId) return null; + return meta.get(cmdId)?.file || (this.fields(cmdId).name || "new") + ".yml"; + }, + + collect() { + const data = df.drawflow.drawflow.Home.data; + const nodes = Object.values(data); + const problems = [], scripts = []; + owner = new Map(); + owners = new Map(); + // a node belongs to every script that reaches it; the first one keeps its layout + const own = (id, cmd) => { + id = String(id); + (owners.get(id) || owners.set(id, new Set()).get(id)).add(cmd); + if (!owner.has(id)) owner.set(id, cmd); + }; + const from = (n, port) => (n?.inputs[port]?.connections || []).map((k) => data[k.node]).filter(Boolean).sort((a, b) => a.pos_y - b.pos_y); + const durationOk = (v) => !v || RULES.duration[0].test(v); + + const field = (node, f) => ({ node: String(node.id), sel: `[data-f="${f}"]` }); + for (const cmd of nodes.filter((n) => kindOf(n) === "command")) { + const id = String(cmd.id), f = this.fields(id), file = this.fileOf(id), info = meta.get(id) || {}; + // at: where the problem is shown, { node, sel } for a field or { node, port } for a port + const bad = (text, at) => problems.push({ file, script: f.name, text, at, blocking: true }); + const target = (n) => { own(n.id, id); return this.target(n.id); }; + own(id, id); + if (!RULES.name[0].test(f.name)) bad("Use " + RULES.name[1], field(cmd, "name")); + + const aliases = from(cmd, P.cmd.aliases).flatMap((n) => { + own(n.id, id); + const list = splitList(this.fields(n.id).aliases); + if (!list.length) bad("Add an alias, or delete this node", { node: String(n.id), sel: ".al-new" }); + for (const a of list) if (!RULES.alias[0].test(a)) bad("Use " + RULES.alias[1], { node: String(n.id), sel: `.tag[data-v="${CSS.escape(a)}"]` }); + return list; + }); + + // arguments are positional: top to bottom on the canvas + const names = new Set(); + const args = from(cmd, P.cmd.args).map((n, i) => { + own(n.id, id); + const label = document.querySelector(`#node-${n.id} .nk`), title = `Argument ${i + 1}${esc(this.fields(n.id).name)}`; + if (label && label.innerHTML !== title) label.innerHTML = title; + const a = this.fields(n.id); + if (a.name && RULES.argname[0].test(a.name) && names.has(a.name)) bad(`Another argument is already named ${a.name}`, field(n, "name")); + if (!RULES.suggestions[0].test(a.suggestions)) bad("Use " + RULES.suggestions[1], field(n, "suggestions")); + if (!a.name || !RULES.argname[0].test(a.name)) bad(a.name ? "Use " + RULES.argname[1] : "Required", field(n, "name")); + names.add(a.name); + return { name: a.name, type: a.type, required: a.required === "true", suggestions: splitList(a.suggestions), _src: meta.get(String(n.id))?.src, node: n }; + }); + let optional = false; + args.forEach((a) => { if (!a.required) optional = true; else if (optional) bad("A required argument can't come after an optional one", field(a.node, "required")); }); + args.forEach((a, i) => { if (["TEXT", "GREEDY_STRING"].includes(a.type) && i < args.length - 1) bad(`${a.type} takes the rest of the line, so it has to be the last argument`, field(a.node, "type")); }); + args.forEach((a) => delete a.node); + // a player comes from one of this script's arguments that CB can resolve to a player + const player = (name, at) => { + if (!name) return undefined; + const a = args.find((x) => x.name === name); + if (!a) bad(`/${f.name} has no argument ${name}`, at); + else if (!PLAYER_TYPES.includes(a.type)) bad(`${a.name} is ${a.type}, which can't be a player`, at); + else if (!a.required) bad(`Make ${a.name} required: the server check needs a player`, at); + return name; + }; + + const register = from(cmd, P.cmd.reg).map(target); + const defNode = from(cmd, P.cmd.defaults)[0]; + let defaults = { "run-as": "CONSOLE", execute: [], server: {} }; + if (defNode) { + own(defNode.id, id); + const d = this.fields(defNode.id); + for (const k of ["d-delay", "d-cooldown"]) if (!durationOk(d[k])) bad("Use " + RULES.duration[1], field(defNode, k)); + defaults = { + "run-as": d["d-runas"] || "CONSOLE", execute: from(defNode, P.def.targets).map(target), + delay: d["d-delay"] || undefined, cooldown: d["d-cooldown"] || undefined, + server: { "target-required": d["d-target"] === "true", "schedule-online": d["d-online"] === "true", "player-arg": player(d["d-player"], field(defNode, "d-player")) }, + }; + } else { + bad("Wire a Defaults node here", { node: id, port: "defaults" }); + } + + const commands = []; + let cur = data[cmd.outputs[P.cmd.then].connections[0]?.node], delay, nowhere = false; + const seen = new Set(); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + own(cur.id, id); + const s = this.fields(cur.id); + if (kindOf(cur) === "wait") { + delay = s.delay; + if (!s.delay || !durationOk(s.delay)) bad(s.delay ? "Use " + RULES.duration[1] : "Required", field(cur, "delay")); + if (!cur.outputs.output_1.connections.length) bad("Nothing comes after this Wait", { node: String(cur.id), sel: ".nh" }); + } else { + if (!s.command) bad("Required", field(cur, "command")); + for (const p of new Set([...s.command.matchAll(PLACEHOLDER)].map((m) => m[1]).filter((p) => !names.has(p)))) { + bad(`/${f.name} has no argument ${p}`, { node: String(cur.id), sel: `.ph[data-arg="${CSS.escape(p)}"]` }); + } + const execute = from(cur, P.run.targets).map(target); + nowhere ||= !execute.length && !defaults.execute.length; + const ra = from(cur, P.run.runas)[0], cd = from(cur, P.run.cooldown)[0], rules = from(cur, P.run.rules)[0]; + [ra, cd, rules].forEach((v) => v && own(v.id, id)); + const r = rules && this.fields(rules.id); + if (cd && (!this.fields(cd.id).cooldown || !durationOk(this.fields(cd.id).cooldown))) bad(this.fields(cd.id).cooldown ? "Use " + RULES.duration[1] : "Required", field(cd, "cooldown")); + commands.push({ + command: s.command, "run-as": ra ? this.fields(ra.id).value : undefined, delay, cooldown: cd ? this.fields(cd.id).cooldown : undefined, execute, _src: meta.get(String(cur.id))?.src, + server: rules ? { "target-required": r["r-target"] === "true", "schedule-online": r["r-online"] === "true", "player-arg": player(r["r-player"], field(rules, "r-player")) } : undefined, + }); + delay = undefined; + } + cur = data[cur.outputs.output_1.connections[0]?.node]; + } + // the defaults' servers are where every step without its own runs + if (nowhere && defNode) bad("Wire the Server the steps run on", { node: String(defNode.id), port: "targets" }); + if (!commands.length) bad("Connect an Execute step", { node: id, port: "then" }); + if (!register.length) bad("Wire the Server this command is registered on", { node: id, port: "registered on" }); + // older schemas differ in structure; bumping the number alone would corrupt the script + if (info.version != null && String(info.version) !== "4") bad(`${file} uses schema version ${info.version}. Run /cb migrate on the proxy first.`); + + scripts.push({ + id, file, sid: this.sidOf(id), isNew: !info.file, + model: { + version: info.version ?? 4, name: f.name, description: f.description || undefined, enabled: f.enabled === "true", + aliases, permissions: { enabled: f.perm === "true", silent: f.silent === "true" }, register, defaults, args, commands, + }, + }); + } + + // anything not wired into a script would silently vanish + for (const n of nodes) { + if (!owner.has(String(n.id))) problems.push({ file: null, text: "Not connected to any script", at: { node: String(n.id), sel: ".nh" }, blocking: true }); + } + + const byName = new Map(); + for (const s of scripts) for (const name of [s.model.name, ...s.model.aliases]) byName.set(name, [...(byName.get(name) || []), s]); + for (const s of scripts) { + const err = (text, at) => problems.push({ file: s.file, script: s.model.name, text, at, blocking: true }); + for (const name of [s.model.name, ...s.model.aliases]) { + const others = [...new Set(byName.get(name).filter((o) => o !== s).map((o) => "/" + o.model.name))]; + if (!others.length) continue; + const text = `/${name} is also used by ${others.join(", ")}`; + if (name === s.model.name) err(text, { node: s.id, sel: '[data-f="name"]' }); + else err(text, { node: s.id, port: "aliases" }); + } + if (s.isNew && this.scripts[s.file] !== undefined) err(`${s.file} already exists on the proxy`, { node: s.id, sel: '[data-f="name"]' }); + } + for (const file of Object.keys(this.scripts)) { + try { conv.toModel(YAML, this.scripts[file]); } catch (e) { problems.push({ file, text: `${file} is not valid YAML. Fix it in a text editor: ${e.message}`, blocking: false }); } + } + return { scripts, problems }; + }, + + // positions and groups for editor-layout.json, keyed so they survive reloads + // positions and groups for editor-layout.json, keyed so they survive reloads + layoutNow(scripts) { + const data = df.drawflow.drawflow.Home.data; + const nodes = {}; + for (const s of scripts) { + const count = {}; + for (const [nodeId, cmd] of owner) { + const n = data[nodeId]; + if (cmd !== s.id || !n) continue; + const k = kindOf(n); + const key = k === "command" ? "cmd" : k + (count[k] = (count[k] ?? -1) + 1); + nodes[`${s.file}#${key}`] = { x: Math.round(n.pos_x), y: Math.round(n.pos_y) }; + } + } + // the file stores groups by file name; unsaved scripts have none yet and drop out on the next load + const fileOfSid = new Map(scripts.map((s) => [s.sid, s.file])); + return { version: LAYOUT_VERSION, nodes, groups: groups.map((g) => ({ ...g, members: g.members.map((m) => fileOfSid.get(m) || m) })) }; + }, + + refresh(typing = false) { + if (building || !conv) return; + const { scripts, problems } = this.collect(); + this.refreshDynamic(scripts); + const out = {}; + for (const s of scripts) { + try { + // text edited in the YAML panel is the base, so its comments and layout stay + out[s.file] = conv.fromModel(YAML, bases[s.file] ?? this.scripts[s.file] ?? this.newScript(s.model.name), s.model); + } catch (e) { + problems.push({ file: s.file, text: `${s.file} could not be turned into YAML: ${e.message}`, blocking: true }); + } + } + // every script is in one group; groups of scripts that are gone go too + const sids = new Set(scripts.map((s) => s.sid)); + for (const g of groups) g.members = g.members.filter((m) => sids.has(m)); + groups = groups.filter((g) => g.members.length); + for (const s of scripts) { + if (!this.groupOf(s.sid)) groups.push(this.scriptGroup(s.sid, "/" + s.model.name)); + // a script's own group is named after it, until someone names the group + const own = groups.find((g) => g.auto && !g.named && g.members.length === 1 && g.members[0] === s.sid); + if (own) own.title = "/" + s.model.name; + } + this.fitGroups(); + this.renderGroups(); + const present = new Set([...meta.values()].map((m) => m.file).filter(Boolean)); + this.deletions = this.loadedFiles.filter((f) => !present.has(f) && this.scripts[f] !== undefined); + this.out = out; + // only problems in scripts that would be saved, or loose nodes, block saving + const pinned = this.markProblems(problems); + this.problems = problems.map((p) => ({ ...p, pinned: pinned.has(p), blocking: p.blocking && (p.file === null || out[p.file] !== this.scripts[p.file]) })); + this.remember(typing); + this.saveLayoutSoon(scripts); + this.saveDraftSoon(); + // a change after Save makes the waiting code save something older: take it back + if (staged && this.confirmCode && JSON.stringify(this.changeSet()) !== staged) this.cancelStage(); + this.drawMinimap(); + }, + + saveLayoutSoon(scripts) { + if (restoring || this.status !== "online") return; + const next = this.layoutNow(scripts); + clearTimeout(layoutTimer); + layoutTimer = setTimeout(() => { + const text = JSON.stringify(next); + if (text === lastLayout) return; + lastLayout = text; + layout = next; + this.send({ type: "layout", layout: next }); + }, 800); + }, + + changes() { + return Object.keys(this.out).filter((f) => this.out[f] !== this.scripts[f]).length + this.deletions.length; + }, + + blocking() { return this.problems.filter((p) => p.blocking); }, + + // the YAML panel: every script file with what saving does to it, and the chosen one, highlighted + yamlFiles() { + const files = [...new Set([...Object.keys(this.out), ...Object.keys(this.scripts)])].sort(); + const state = (f) => this.deletions.includes(f) ? "deleted" : this.scripts[f] === undefined ? "new" + : this.out[f] === undefined ? "unread" : this.out[f] !== this.scripts[f] ? "changed" : "saved"; + const order = { new: 0, changed: 1, deleted: 2, unread: 3, saved: 4 }; + return files.map((file) => ({ file, state: state(file) })).sort((a, b) => order[a.state] - order[b.state] || a.file.localeCompare(b.file)); + }, + + fileIcon() { return svg(LUCIDE.file, 14); }, + + // the YAML panel's left edge drags its width + resizeYaml(e) { + const panel = e.currentTarget.parentElement, grip = e.currentTarget, right = panel.getBoundingClientRect().right; + grip.classList.add("on"); + const move = (ev) => panel.style.setProperty("--yaml-w", Math.max(320, right - ev.clientX) + "px"); + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", () => { window.removeEventListener("mousemove", move); grip.classList.remove("on"); this.drawMinimap(); }, { once: true }); + }, + + yamlFile() { + const files = this.yamlFiles().map((f) => f.file); + return [this.yamlPick, this.selectedFile].find((f) => f && files.includes(f)) || files[0]; + }, + + yamlState() { + return this.yamlFiles().find((f) => f.file === this.yamlFile())?.state || ""; + }, + + // what the file will be after saving; a deleted file shows what goes away + yamlText() { + const f = this.yamlFile(); + if (this.yamlDraft && this.yamlDraft.file === f) return this.yamlDraft.text; + return (this.deletions.includes(f) ? this.scripts[f] : this.out[f] ?? this.scripts[f]) ?? ""; + }, + + // problems in the YAML with where they are: parse errors, wrong value types, unknown or missing keys + yamlLint(text) { + if (lint.text === text) return lint; + const marks = []; + const at = (node, msg, warn = false) => { const r = node?.range; if (r) marks.push({ from: r[0], to: Math.max(r[1], r[0] + 1), msg, warn }); }; + let doc; + try { doc = YAML.parseDocument(text); } catch (e) { doc = null; } + for (const e of doc?.errors || []) marks.push({ from: e.pos[0], to: Math.max(e.pos[1], e.pos[0] + 1), msg: e.message.split("\n")[0] }); + const argNames = new Set((doc?.toJS?.()?.args || []).map((a) => a?.name).filter(Boolean)); + const types = window.cbArgTypes || []; + const walk = (node, sch) => { + if (!sch || node == null) return; + const scalar = YAML.isScalar(node), v = scalar ? node.value : undefined; + if (sch.type === "map") { + if (!YAML.isMap(node)) return at(node, "Should be a section of keys"); + const seen = new Set(); + for (const pair of node.items) { + const k = pair.key?.value; + seen.add(k); + if (!(k in sch.keys)) at(pair.key, `Unknown key ${k}`, true); + else walk(pair.value, sch.keys[k]); + } + for (const k of sch.required) if (!seen.has(k)) at({ range: [node.range[0], node.range[0] + 1] }, `Missing ${k}`); + } else if (sch.type === "list") { + if (!YAML.isSeq(node)) return at(node, "Should be a list"); + node.items.forEach((item) => walk(item, sch.of)); + } else if (!scalar) { + at(node, `Should be ${Y_HINTS[sch.type] === "text" ? "text" : "a single value"}`); + } else if (sch.type === "bool" && typeof v !== "boolean") at(node, "Should be true or false"); + else if (sch.type === "int" && !Number.isInteger(v)) at(node, "Should be a whole number"); + else if (sch.type === "dur" && !RULES.duration[0].test(String(v))) at(node, "Should be " + RULES.duration[1]); + else if (sch.type === "name" && !RULES.name[0].test(String(v))) at(node, "Should be " + RULES.name[1]); + else if (sch.type === "enum" && !(sch.values || types).includes(String(v))) at(node, `Should be one of ${(sch.values || types).join(", ")}`); + else if (sch.type === "arg" && v && !argNames.has(String(v))) at(node, `No argument named ${v}`); + }; + if (doc && !doc.errors.length) walk(doc.contents, Y_SCRIPT); + const lines = []; + const starts = [0]; + for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); + const lineOf = (pos) => { let l = 0; while (l + 1 < starts.length && starts[l + 1] <= pos) l++; return l; }; + for (const m of marks) (lines[lineOf(m.from)] ||= []).push(m); + lint = { text, marks, lines }; + return lint; + }, + + // the same text, see-through, with the problems underlined; it lies under the typing field + yamlLintHtml() { + const text = this.yamlText(), { marks } = this.yamlLint(text); + let html = "", pos = 0; + for (const m of [...marks].sort((a, b) => a.from - b.from)) { + if (m.from < pos) continue; + html += esc(text.slice(pos, m.from)) + `${esc(text.slice(m.from, m.to)) || " "}`; + pos = m.to; + } + return html + esc(text.slice(pos)) + "\n"; + }, + + // what the caret's line has wrong, shown above the code + yamlLineMsg() { + const ms = this.yamlLint(this.yamlText()).lines[this.yamlLine]; + return ms ? ms.map((m) => m.msg).join("; ") : ""; + }, + + yamlCaret(t) { + this.yamlLine = t.value.slice(0, t.selectionStart).split("\n").length - 1; + }, + + // the schema for a line, from the keys it sits under; list items count as one step down + yamlSchemaAt(lines, li, col) { + const keyCol = (l) => { const m = l.match(/^(\s*)(- )?([\w-]+):/); return m ? m[1].length + (m[2] ? 2 : 0) : -1; }; + const path = []; + let c = col; + for (let i = li - 1; i >= 0 && c > 0; i--) { + const kc = keyCol(lines[i]); + if (kc >= 0 && kc < c) { path.unshift(lines[i].match(/([\w-]+):/)[1]); c = kc; } + } + let sch = Y_SCRIPT; + for (const k of path) { + if (sch?.type === "list") sch = sch.of; + sch = sch?.type === "map" ? sch.keys[k] : null; + } + if (sch?.type === "list") sch = sch.of; + return sch; + }, + + // completion at the caret, like nvim-cmp: keys where a key goes, values after a key + yamlComplete(t, force = false) { + const text = t.value, caret = t.selectionStart; + const lineStart = text.lastIndexOf("\n", caret - 1) + 1, before = text.slice(lineStart, caret); + const lines = text.split("\n"), li = text.slice(0, caret).split("\n").length - 1; + let items = [], from = caret; + const val = before.match(/^(\s*)(- )?([\w-]+):\s*([^\s#]*)$/); + const key = before.match(/^(\s*)(- )?([\w-]*)$/); + if (val) { + const sch = this.yamlSchemaAt(lines, li, val[1].length + (val[2] ? 2 : 0)); + const s2 = sch?.type === "map" ? sch.keys[val[3]] : null, typed = val[4]; + from = caret - typed.length; + const opts = !s2 ? [] : s2.type === "bool" ? ["true", "false"] : s2.type === "enum" ? s2.values || window.cbArgTypes || [] + : s2.type === "dur" ? ["0s", "500ms", "1s", "5s", "10s", "30s", "1m", "5m", "1h", "24h"] : s2.type === "server" ? [...this.knownServers().keys()].map((id) => `"${id}"`) + : s2.type === "arg" ? (conv && (() => { try { return conv.toModel(YAML, text).args.map((a) => a.name); } catch { return []; } })()) || [] : []; + if (!before.endsWith(":") || force) items = opts.filter((o) => o.toLowerCase().startsWith(typed.toLowerCase()) && o !== typed).map((o) => ({ label: o, kind: Y_HINTS[s2.type] })); + } else if (key && (key[3] || force)) { + const col = key[1].length + (key[2] ? 2 : 0); + const sch = this.yamlSchemaAt(lines, li, col); + if (sch?.type === "map") { + // keys already in this block (this list item) aren't offered again + const kc = (l) => { const m = l.match(/^(\s*)(- )?([\w-]+):/); return m ? [m[1].length + (m[2] ? 2 : 0), m[3], !!m[2]] : null; }; + const have = new Set(); + for (let i = li - 1; i >= 0; i--) { const k = kc(lines[i]); if (!k) continue; if (k[0] < col) break; if (k[0] === col) { have.add(k[1]); if (k[2]) break; } } + for (let i = li + 1; i < lines.length; i++) { const k = kc(lines[i]); if (!k) continue; if (k[0] < col || (k[0] === col && k[2])) break; if (k[0] === col) have.add(k[1]); } + from = caret - key[3].length; + items = Object.entries(sch.keys).filter(([k]) => !have.has(k) && k.startsWith(key[3]) && k !== key[3]).map(([k, v]) => ({ label: k, kind: Y_HINTS[v.type] || "key", key: true, block: ["map", "list"].includes(v.type) })); + } + } + if (!items.length) { this.yamlSugg = null; return; } + // where the caret is, from the monospace cell size + const cell = this.yamlCell(t), col = caret - lineStart; + this.yamlSugg = { items: items.slice(0, 50), sel: 0, from, to: caret, x: 16 + (from - lineStart) * cell.w - t.scrollLeft, y: 12 + (li + 1) * cell.h }; + }, + + yamlCell(t) { + if (!yamlCellSize) { + const probe = document.createElement("span"); + probe.textContent = "M".repeat(10); + probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;font:inherit"; + t.parentElement.querySelector(".ed-yaml-code").append(probe); + yamlCellSize = { w: probe.getBoundingClientRect().width / 10, h: parseFloat(getComputedStyle(t).lineHeight) }; + probe.remove(); + } + return yamlCellSize; + }, + + yamlAccept(i) { + const sg = this.yamlSugg, t = document.querySelector(".ed-yaml-ta"), item = sg?.items[i ?? sg.sel]; + if (!item) return; + const indent = (t.value.slice(0, sg.from).match(/(^|\n)(\s*)(- )?[^\n]*$/)?.[2] || "") + (t.value.slice(0, sg.from).match(/(^|\n)\s*- [^\n]*$/) ? " " : ""); + const insert = item.key ? (item.block ? `${item.label}:\n${indent} ` : `${item.label}: `) : item.label; + t.focus(); + t.setRangeText(insert, sg.from, sg.to, "end"); + this.yamlSugg = null; + this.yamlInput({ target: t }); + if (item.key && !item.block) this.yamlComplete(t, true); + }, + + // keys while typing YAML: the completion list takes arrows, Tab, Enter and Escape when it's open + yamlKey(e) { + const sg = this.yamlSugg, t = e.target; + if (sg) { + const n = sg.items.length; + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n") || (e.key === "Tab" && !e.shiftKey)) { e.preventDefault(); sg.sel = (sg.sel + 1) % n; return; } + if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p") || (e.key === "Tab" && e.shiftKey)) { e.preventDefault(); sg.sel = (sg.sel - 1 + n) % n; return; } + if (e.key === "Enter") { e.preventDefault(); this.yamlAccept(); return; } + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); this.yamlSugg = null; return; } + } + if (e.ctrlKey && e.key === " ") { e.preventDefault(); this.yamlComplete(t, true); return; } + if (e.key === "Tab") { e.preventDefault(); this.yamlTab(e); return; } + if (e.key === "Escape") t.blur(); + }, + + yamlEditable() { + return ["new", "changed", "saved"].includes(this.yamlState()); + }, + + yamlInput(e) { + this.yamlError = ""; // the underlines show what's wrong while typing + this.yamlDraft = { file: this.yamlFile(), text: e.target.value }; + clearTimeout(yamlTimer); + yamlTimer = setTimeout(() => this.yamlApply(), 700); + }, + + // edited YAML that parses becomes the graph: the script's nodes are rebuilt, everything keeps its place + yamlApply() { + clearTimeout(yamlTimer); + const draft = this.yamlDraft; + if (!draft) return; + try { conv.toModel(YAML, draft.text); } catch (e) { this.yamlError = e.message.split("\n")[0]; return; } + this.yamlError = ""; + if (draft.text === this.out[draft.file]) { this.yamlDraft = null; return; } + const texts = { ...this.scripts, ...this.out, [draft.file]: draft.text }; + for (const f of this.deletions) delete texts[f]; + bases[draft.file] = draft.text; + layout = this.layoutNow(this.collect().scripts); + const view = [df.zoom, df.canvas_x, df.canvas_y]; + this.buildFrom(this.snapshotFromScripts(texts), true); + this.setView(...view); + this.yamlDraft = null; + }, + + // Tab indents by two spaces in the YAML, like an editor + yamlTab(e) { + const t = e.target, a = t.selectionStart; + t.setRangeText(" ", a, t.selectionEnd, "end"); + this.yamlInput(e); + }, + + // highlighting in the editor's own colors: keys, strings, numbers, switches, enums, comments and ${arguments} + yamlHtml() { + const value = (v) => { + const m = v.match(/^(\s*)(.*?)(\s+#.*)?$/); + let body = m[2]; + if (/^(true|false|null)$/.test(body)) body = `${body}`; + else if (/^-?\d+(\.\d+)?(ms|s|m|h)?$/.test(body)) body = `${body}`; + else if (/^[A-Z][A-Z0-9_]*$/.test(body)) body = `${body}`; + else if (/^\[.*\]$/.test(body)) body = `${esc(body)}`; + else if (body) body = `${esc(body).replace(/\$\{([^}]*)\}/g, '$1')}`; + return m[1] + body + (m[3] ? `${esc(m[3])}` : ""); + }; + return this.yamlText().split("\n").map((line) => { + if (/^\s*#/.test(line)) return `${esc(line)}`; + const m = line.match(/^(\s*)(- )?([A-Za-z0-9_-]+)(:)(.*)$/); + if (m) return `${m[1]}${m[2] ? '- ' : ""}${m[3]}:${value(m[5])}`; + const d = line.match(/^(\s*)- (.*)$/); + return d ? `${d[1]}- ${value(" " + d[2]).slice(1)}` : esc(line); + }).join("\n"); + }, + + yamlGutter() { + const f = this.yamlFile(), state = this.yamlState(); + const saved = new Set((this.scripts[f] ?? "").split("\n")); + const { lines } = this.yamlLint(this.yamlText()); + return this.yamlText().split("\n").map((l, i) => { + const ms = lines[i], cls = [state === "new" ? "add" : state === "deleted" ? "del" : state === "changed" && !saved.has(l) ? "chg" : "", ms ? (ms.every((m) => m.warn) ? "lw" : "le") : ""].join(" "); + return `${i + 1}`; + }).join("\n"); + }, + + newScript(name) { + return `version: 4\n\nname: ${name}\nenabled: true\n\npermissions:\n enabled: true\n silent: false\n\nregister: []\n\ndefaults:\n run-as: CONSOLE\n\nargs: []\n\ncommands: []\n`; + }, + + // a new script gets its own row below everything else, one step, and is registered on the proxy + // a new script gets its own row below everything else, one step, and is registered on the proxy + addScript(at) { + const taken = new Set(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").map((n) => this.fields(n.id).name)); + let name = "new-command", i = 2; + while (taken.has(name) || this.scripts[name + ".yml"] !== undefined) name = "new-command-" + i++; + const b = this.bounds(); + const x = at ? at.x : 0, y = at ? at.y : b ? b.y1 + 220 : 0; + const proxyId = Object.keys(this.servers).find((id) => this.servers[id].location === "VELOCITY"); + const cmd = this.place("command", x, y, { name }); + df.addConnection(cmd, this.place("run", x, y, { command: "say hello" }), "output_1", P.run.after); + const def = this.place("defaults", x, y, {}); + df.addConnection(def, cmd, "output_1", P.cmd.defaults); + // registered on the proxy and running there, until changed + if (proxyId) { + df.addConnection(this.place("server", x, y, { id: proxyId, location: "VELOCITY" }), cmd, "output_1", P.cmd.reg); + df.addConnection(this.place("server", x, y, { id: proxyId, location: "VELOCITY" }), def, "output_1", P.def.targets); + } + this.collect(); // who owns the new nodes, for the layout + const end = this.layoutScript(cmd, x, y); + if (!at) this.centerOn(x + (end.right - x) / 2, y + (end.bottom - y) / 2); + selected.clear(); selected.add(cmd); this.paintSelection(); + document.querySelector(`#node-${cmd} [data-f="name"]`).select(); + this.refresh(); + }, + + /* ================= save ================= */ + + // what saving would write: changed and new files, and the files to delete + changeSet() { + const files = {}; + for (const f of Object.keys(this.out)) if (this.out[f] !== this.scripts[f]) files[f] = this.out[f]; + return { files, deletions: [...this.deletions] }; + }, + + // Save stages the changes on the proxy; they are written once someone runs the confirm command there + save() { + this.refresh(); + if (this.blocking().length || !this.changes()) return; + this.results = []; + this.busy = true; + const set = this.changeSet(); + staged = JSON.stringify(set); + this.send({ type: "stage", ...set }); + }, + + cancelStage() { + staged = null; + this.confirmCode = null; + this.send({ type: "unstage" }); + }, + + copyConfirm() { + navigator.clipboard?.writeText(`/cb editor confirm ${this.confirmCode}`); + this.copied = true; + setTimeout(() => { this.copied = false; }, 1500); + }, + + // unsaved work is kept on the proxy, so closing the tab or a new link doesn't lose it + saveDraftSoon() { + if (restoring || this.status !== "online") return; + clearTimeout(draftTimer); + draftTimer = setTimeout(() => { + const set = this.changeSet(); + const text = JSON.stringify(set); + if (text === lastDraft) return; + lastDraft = text; + this.send({ type: "draft", draft: Object.keys(set.files).length || set.deletions.length ? { ...set, at: Date.now() } : {} }); + }, 1500); + }, + + // a draft from earlier is laid over the saved scripts, the same way an edit in the YAML panel is + restoreDraft(d) { + const texts = { ...this.scripts, ...d.files }; + for (const f of d.deletions || []) delete texts[f]; + for (const [f, t] of Object.entries(d.files)) bases[f] = t; + this.buildFrom(this.snapshotFromScripts(texts), true); + history.past = []; history.future = []; history.last = this.serialize(); + this.canUndo = this.canRedo = false; + this.fit(); + this.results.push({ ok: true, warning: true, restored: true, text: `Restored your unsaved changes from ${new Date(d.at || Date.now()).toLocaleString()}.` }); + }, + + discardDraft() { + this.results = []; + this.rebuild(); + }, + + /* ================= connection ================= */ + + send(msg) { + this.enqueue(async () => { + const body = JSON.stringify(msg); + const seq = ++this.sendSeq; + const mac = await crypto.subtle.sign("HMAC", this.key, new TextEncoder().encode(`editor\n${this.conn}\n${seq}\n${body}`)); + this.ws.send(JSON.stringify({ conn: this.conn, seq, body, mac: b64url(mac) })); + }); + }, + + async receive(frame) { + const { conn, seq, body, mac } = JSON.parse(frame); + // replies are bound to this page load, so old proxy messages can't be replayed into it + const ok = typeof body === "string" && conn === this.conn && seq > this.recvSeq && + await crypto.subtle.verify("HMAC", this.key, unb64url(mac), new TextEncoder().encode(`proxy\n${conn}\n${seq}\n${body}`)); + if (!ok) { console.warn("Dropped an unsigned or replayed message"); return; } + this.recvSeq = seq; + const msg = JSON.parse(body); + + if (msg.type === "state") { + const first = this.status !== "online"; + // files we just saved or deleted changed because of us; anything else changed on the proxy + const ours = new Set(this.justSaved); + const external = Object.keys({ ...this.scripts, ...msg.scripts }).some((f) => this.scripts[f] !== msg.scripts[f] && !ours.has(f)); + const hadChanges = this.changes() > 0; + this.scripts = msg.scripts; + this.servers = msg.servers || {}; + this.confirmCode = msg.pending || null; + if (first) { layout = msg.layout || layout; lastLayout = JSON.stringify(layout); } + this.status = "online"; + if (first) { + this.loadedFiles = Object.keys(msg.scripts); + this.rebuild(); + // keeps the session alive while the tab is open; the proxy closes it a few minutes after the tab is gone + setInterval(() => this.send({ type: "ping" }), 60_000); + const d = msg.draft; + if (d?.files && (Object.keys(d.files).some((f) => d.files[f] !== msg.scripts[f]) || d.deletions?.length)) this.restoreDraft(d); + lastDraft = JSON.stringify(this.changeSet()); + if (this.confirmCode) staged = lastDraft; + } else if (external && !hadChanges) { + this.loadedFiles = Object.keys(msg.scripts); + this.rebuild(); + } else { + this.loadedFiles = [...new Set([...this.loadedFiles, ...Object.keys(msg.scripts)])].filter((f) => msg.scripts[f] !== undefined); + if (external) this.results.push({ ok: true, warning: true, text: "Scripts changed on the proxy. Revert to load them." }); + this.refresh(); + } + } else if (msg.type === "staged") { + this.busy = false; + const bad = Object.entries(msg.errors || {}); + if (bad.length) { + staged = null; + for (const [file, errs] of bad) this.results.push({ ok: false, text: `${file} can't be saved: ${errs.join("; ")}` }); + } else { + this.confirmCode = msg.code; + } + } else if (msg.type === "applied") { + staged = null; + this.confirmCode = null; + this.justSaved = Object.keys(msg.results); + setTimeout(() => { this.justSaved = []; }, 2000); + for (const [file, errs] of Object.entries(msg.results)) { + if (!errs.length) { + // a new script now has a file; remember it so it isn't treated as new again + for (const [id, cmd] of owner) { + if (id === cmd && !meta.get(id)?.file && this.fileOf(id) === file) meta.set(id, { ...meta.get(id), file, version: 4 }); + } + delete bases[file]; + } + this.results.push({ ok: !errs.length, text: errs.length ? `${file}: not saved: ${errs.join("; ")}` : `${file}: ${this.deletions.includes(file) ? "deleted" : "saved"}` }); + } + if (msg.warning) this.results.push({ ok: true, warning: true, text: msg.warning }); + } else if (msg.type === "layout-error") { + this.results.push({ ok: false, text: "Layout not stored: " + msg.error }); + } + }, + }; +} + diff --git a/src/lib/editor/logos/fabric.png b/src/lib/editor/logos/fabric.png new file mode 100644 index 0000000000000000000000000000000000000000..12c4531de92fe0544a0e15347f84381c132a7feb GIT binary patch literal 1555 zcmbVMONbmr817YeS0`Bvm<_8$sdPkC($if%&o0utJLwrS1L<{`3}loX(o5`x6Ep1Z4+am|^rTyU-RHPnPe)^ld+*!=$4&3I z>W!eGA48bhNyDT~k_>H^p*imGQs^3Zl?0$k+Loj8KVQ7W1ItwT6B%97U5#|C^1vg< z2P<_vSjCFTFD-(@Az}nJ2@DY0UB^eE$`5%FTSvzt4~CFnRpkqjLeS8wK%*W3nPgVL zFfD_el7v}Fk<*8OEWw;8R1=sseC60TqKJ9em~hy zC8^gIp`s|FB#W{vFofW*JAn}jj(>2%P$WL~EH|*I10qJFN!J3EXO@m!u-%x}@yB6e z0TV;R6=70}Tp9vR9OK+IuRBz3Vv%%-O`O1ISQum74h^W^p?^aii~pp6g;v*N9S^m| zwqq53Q0g%^#sPUK+OMy>M63~?u6dZ0dd$p&kvA^VJYodYt5e#YJXCdJGSIZ>Ve;Um z6P9DrzW?%$JEUj?MCBv70A&GY>oAKlX#{hElt+>@g6hlcrFNcIG_C5bC_#`dML=rKZ5X)F-wenaX=`<9S6l@7;C1a*r zFpYEBk-O}Ek>a%|3nur?|9Ss4&tg?*bRU@~s?8{UP}%a?!>*63=Qw$Dyy{wDm@&w} zQ6;E6j#7Y_{P@^<utq@Kv^7o)Nrxg!46%b{#X ziBI6HZ$!(uVX;lz@`%IwoW~m4((?!2X3g;ZO0iH6ziV#JEKKiv@r~aK;fD%m+`qeP zqj!gUd>JIZzIXQGa=BdwQ+vS+TaVp2d4HjC{TKZ1T^OEP^h*yupZ((7eYf^3ZRqE( zWln<`?)nV)~_M| + + + + + diff --git a/src/lib/editor/logos/forge.svg b/src/lib/editor/logos/forge.svg new file mode 100644 index 0000000..ae77c10 --- /dev/null +++ b/src/lib/editor/logos/forge.svg @@ -0,0 +1 @@ + diff --git a/src/lib/editor/logos/paper.svg b/src/lib/editor/logos/paper.svg new file mode 100644 index 0000000..0b9a513 --- /dev/null +++ b/src/lib/editor/logos/paper.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/editor/logos/spigot.svg b/src/lib/editor/logos/spigot.svg new file mode 100644 index 0000000..05c14a6 --- /dev/null +++ b/src/lib/editor/logos/spigot.svg @@ -0,0 +1 @@ +SpigotMC \ No newline at end of file diff --git a/src/lib/editor/logos/velocity.svg b/src/lib/editor/logos/velocity.svg new file mode 100644 index 0000000..4ea0c13 --- /dev/null +++ b/src/lib/editor/logos/velocity.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/lib/editor/script-yaml.js b/src/lib/editor/script-yaml.js new file mode 100644 index 0000000..ee73915 --- /dev/null +++ b/src/lib/editor/script-yaml.js @@ -0,0 +1,248 @@ +// Converts CommandBridge script YAML to a plain model for the node editor and back. +// Saving patches the original document instead of re-serializing it, so comments, +// formatting and keys the editor doesn't know about survive an edit. +// `YAML` is the `yaml` package (eemeli/yaml v2), passed in so this runs in the browser and in node. + +export const ARG_TYPES = ["STRING", "INTEGER", "BOOLEAN", "DOUBLE", "TEXT", "GREEDY_STRING", "RANGE", + "PLAYERS", "OFFLINE_PLAYER", "ENTITIES", "ENTITY_TYPE", "WORLD", "SERVER", "LOCATION", "LOCATION_2D", + "ANGLE", "ROTATION", "ITEM_STACK", "ENCHANTMENT", "POTION_EFFECT", "SOUND", "BIOME", "TIME"]; +export const RUN_AS = ["CONSOLE", "PLAYER", "OPERATOR"]; +export const LOCATIONS = ["VELOCITY", "BACKEND"]; + +/** Parses script text into the editor model; list items remember their index in the file as `_src`. */ +export function toModel(YAML, text) { + const doc = YAML.parseDocument(text); + if (doc.errors.length) throw new Error(doc.errors[0].message); + const js = doc.toJS() || {}; + const tag = (list) => (Array.isArray(list) ? list : []).map((item, i) => ({ ...item, _src: i })); + const defaults = js.defaults ?? {}; + return { + version: js.version ?? 4, + name: js.name ?? "", + description: js.description, + enabled: js.enabled ?? true, + aliases: js.aliases ?? [], + // same defaults as the Java model: permission checks are on unless disabled + permissions: { enabled: js.permissions?.enabled ?? true, silent: js.permissions?.silent ?? false }, + register: tag(js.register), + defaults: { ...defaults, execute: defaults.execute && tag(defaults.execute) }, + args: tag(js.args).map((a) => ({ ...a, required: a.required ?? false, type: a.type ?? "STRING" })), + commands: tag(js.commands).map((c) => ({ ...c, execute: c.execute && tag(c.execute) })), + }; +} + +/** Writes the model back into `original` (or a fresh document when null) and returns the new text. */ +export function fromModel(YAML, original, model) { + const doc = original ? YAML.parseDocument(original) : new YAML.Document({}); + if (!YAML.isMap(doc.contents)) doc.contents = doc.createNode({}); + const root = doc.contents; + + setField(YAML, doc, root, "version", model.version ?? 4); + setField(YAML, doc, root, "name", model.name); + setField(YAML, doc, root, "description", model.description || undefined); + setField(YAML, doc, root, "enabled", model.enabled, true); + setField(YAML, doc, root, "aliases", model.aliases, []); + + // a block the file didn't have is written out in full, like a new item + const newPerms = !root.has("permissions"); + const perms = childMap(doc, root, "permissions"); + setField(YAML, doc, perms, "enabled", model.permissions.enabled, newPerms ? undefined : true); + setField(YAML, doc, perms, "silent", model.permissions.silent, newPerms ? undefined : false); + + syncTargets(YAML, doc, root, "register", model.register, true); + + const d = model.defaults; + const newDefaults = !root.has("defaults"); + const defaults = childMap(doc, root, "defaults"); + setField(YAML, doc, defaults, "run-as", d["run-as"], newDefaults ? undefined : "CONSOLE"); + syncTargets(YAML, doc, defaults, "execute", d.execute); + const s = d.server || {}; + if (defaults.has("server") || s["target-required"] || s["schedule-online"] || s["player-arg"]) { + patchServer(YAML, doc, childMap(doc, defaults, "server"), s); + } + setDefaultDuration(YAML, doc, defaults, "delay", d.delay); + setDefaultDuration(YAML, doc, defaults, "cooldown", d.cooldown); + + // new items spell out every field; existing ones keep omitted defaults omitted + syncItems(YAML, doc, root, "args", model.args, (node, a, fresh) => { + setField(YAML, doc, node, "name", a.name); + setField(YAML, doc, node, "required", a.required, fresh ? undefined : false); + setField(YAML, doc, node, "type", a.type, fresh ? undefined : "STRING"); + setField(YAML, doc, node, "suggestions", a.suggestions, fresh ? undefined : []); + }); + + // per-command keys left undefined inherit from defaults, so they are removed + syncItems(YAML, doc, root, "commands", model.commands, (node, c) => { + setField(YAML, doc, node, "command", c.command); + setField(YAML, doc, node, "run-as", c["run-as"] || undefined); + syncTargets(YAML, doc, node, "execute", c.execute); + setField(YAML, doc, node, "delay", c.delay || undefined); + setField(YAML, doc, node, "cooldown", c.cooldown || undefined); + // A step's server block replaces the defaults' block as a whole (CB merges top-level fields only), + // so keys missing inside it mean false/none. Rewrite it only when its effective values change. + const have = node.has("server") ? effectiveServer(plain(YAML, node.get("server", true))) : null; + const want = c.server ? effectiveServer(c.server) : null; + if (!want) { + if (have) node.delete("server"); + } else if (!have || !same(have, want)) { + const srv = childMap(doc, node, "server"); + setField(YAML, doc, srv, "target-required", !!c.server["target-required"]); + setField(YAML, doc, srv, "schedule-online", !!c.server["schedule-online"]); + setField(YAML, doc, srv, "player-arg", c.server["player-arg"] || undefined); + } + }); + + return keepFormatting(YAML, original, doc.toString({ flowCollectionPadding: false })); +} + +// The yaml library re-renders the whole file, normalizing indentation and spacing. Lines that only +// differ in whitespace get their original text back, unless that changes what the file means. +function keepFormatting(YAML, original, generated) { + if (!original) return generated; + const key = (l) => l.trim().replace(/\s+#/, " #"); + const a = original.split("\n"), b = generated.split("\n"); + // longest common subsequence of lines, compared without whitespace differences + const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1)); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + dp[i][j] = key(a[i]) === key(b[j]) ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const out = []; + let i = 0, j = 0; + while (j < b.length) { + if (i < a.length && key(a[i]) === key(b[j])) { out.push(a[i]); i++; j++; } + else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) { + if (!a[i].trim()) out.push(a[i]); // the library collapses blank lines + i++; + } + else { out.push(b[j]); j++; } + } + const merged = out.join("\n"); + try { + const same = JSON.stringify(YAML.parse(merged)) === JSON.stringify(YAML.parse(generated)); + return same ? merged : generated; + } catch { + return generated; + } +} + +function patchServer(YAML, doc, map, server) { + setField(YAML, doc, map, "target-required", server["target-required"], false); + setField(YAML, doc, map, "schedule-online", server["schedule-online"], false); + setField(YAML, doc, map, "player-arg", server["player-arg"] || undefined); +} + +// a default of 0s means the same as no default, so `delay: 0s` stays when the editor has none +const isZero = (v) => v != null && /^\s*0+(\.0+)?\s*(ms|s|m|h)?\s*$/i.test(String(v)); +function setDefaultDuration(YAML, doc, map, key, value) { + if (!value && isZero(plain(YAML, map.get(key, true)))) return; + setField(YAML, doc, map, key, value || undefined); +} + +const effectiveServer = (o) => ({ t: String(!!o?.["target-required"]), s: String(!!o?.["schedule-online"]), p: String(o?.["player-arg"] || "") }); +const plain = (YAML, node) => (YAML.isNode(node) ? node.toJSON() : node); + +function childMap(doc, parent, key) { + let node = parent.get(key, true); + if (!node || typeof node.get !== "function") { + const existed = parent.has(key); + node = doc.createNode({}); + parent.set(key, node); + if (!existed) moveToSchemaPosition(parent, key); + } + return node; +} + +// "10" from a text field equals 10 in the file, and "" equals a missing value +const same = (a, b) => { + if ((a === "" || a === null) && b === undefined) return true; + if (a !== null && b !== null && typeof a !== "object" && typeof b !== "object") return String(a) === String(b); + return JSON.stringify(a) === JSON.stringify(b); +}; + +// Only touches the file when the value really changed; an absent key equal to `def` stays absent. +function setField(YAML, doc, map, key, value, def) { + const has = map.has(key); + if (value === undefined) { + if (has && !same(plain(YAML, map.get(key, true)), undefined)) map.delete(key); + return; + } + if (has) { + const current = map.get(key, true); + if (same(plain(YAML, current), value)) return; + } else if (def !== undefined && same(value, def)) { + return; + } + const scalars = Array.isArray(value) && value.every((v) => v === null || typeof v !== "object"); + const current = has ? map.get(key, true) : undefined; + if (value === null || typeof value !== "object") { + map.set(key, value); // keeps the existing scalar node, and its comments, when there is one + } else if (scalars && YAML.isSeq(current)) { + current.items = value.map((v) => doc.createNode(v)); // keeps the list node and its comment + } else { + const node = doc.createNode(value); + if (scalars && value.length) node.flow = true; + map.set(key, node); + } + if (!has) moveToSchemaPosition(map, key); +} + +// The documented key order; a key the file didn't have goes before the first later key, not at the end +const KEY_ORDER = ["version", "name", "description", "enabled", "silent", "aliases", "permissions", "register", + "defaults", "args", "commands", "command", "required", "type", "suggestions", "id", "location", "run-as", + "execute", "server", "target-required", "schedule-online", "player-arg", "delay", "cooldown"]; + +function moveToSchemaPosition(map, key) { + const rank = KEY_ORDER.indexOf(key); + if (rank < 0) return; + const items = map.items; + const pair = items.pop(); + const at = items.findIndex((p) => KEY_ORDER.indexOf(String(p.key?.value ?? p.key)) > rank); + items.splice(at < 0 ? items.length : at, 0, pair); +} + +// Target lists ({id, location}); an empty optional list is removed so it inherits again +function syncTargets(YAML, doc, parent, key, targets, required = false) { + const list = targets || []; + const current = plain(YAML, parent.get(key, true)); + if (same(norm(current), norm(list))) return; + if (!list.length && !required) { + parent.delete(key); + return; + } + // shared server nodes don't know their line in the file, so match entries by id and location + const original = norm(current), used = new Set(); + const matched = list.map((t) => { + if (t._src != null) return t; + const i = original.findIndex((o, k) => !used.has(k) && o.id === String(t.id) && o.location === String(t.location)); + if (i >= 0) used.add(i); + return { ...t, _src: i >= 0 ? i : undefined }; + }); + syncItems(YAML, doc, parent, key, matched, (node, t) => { + setField(YAML, doc, node, "id", t.id); + setField(YAML, doc, node, "location", t.location); + }); +} + +const norm = (list) => (Array.isArray(list) ? list.map((t) => ({ id: String(t?.id ?? ""), location: String(t?.location ?? "") })) : []); + +// Rebuilds a list from model items, reusing each item's original node so its comments move with it. +function syncItems(YAML, doc, parent, key, items, patch) { + const seq = parent.get(key, true); + const old = YAML.isSeq(seq) ? seq.items : []; + const next = items.map((item) => { + const reuse = item._src != null && YAML.isMap(old[item._src]); + const node = reuse ? old[item._src] : doc.createNode({}); + patch(node, item, !reuse); + return node; + }); + if (YAML.isSeq(seq)) { + seq.items = next; + if (next.length && seq.flow) seq.flow = false; // `args: []` grows into a normal block list + } else { + const fresh = doc.createNode([]); + fresh.items = next; + parent.set(key, fresh); + } +} diff --git a/tests/browser/editor-firefox.mjs b/tests/browser/editor-firefox.mjs new file mode 100644 index 0000000..8e474e3 --- /dev/null +++ b/tests/browser/editor-firefox.mjs @@ -0,0 +1,105 @@ +// The editor in the installed Firefox, driven over WebDriver BiDi (no extra dependencies). Same setup as editor-ui.mjs. +// Run: node tests/browser/editor-firefox.mjs +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import { readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const D = new URL("../fixtures/", import.meta.url); +const scripts = Object.fromEntries(["docs-examples-0.yml", "docs-examples-2.yml"].map((f) => [f, readFileSync(new URL(f, D), "utf8")])); +const id = crypto.randomBytes(16).toString("hex"), secret = crypto.randomBytes(32); +const sign = (r, c, s, b) => crypto.createHmac("sha256", secret).update(`${r}\n${c}\n${s}\n${b}`).digest("base64url"); +const proxy = new WebSocket(`ws://localhost:8787/api/editor/${id}/ws?role=proxy`, { headers: { Authorization: "Bearer cb-dump-upload-v1" } }); +let seq = 0, conn = ""; +const sendP = (m) => { const b = JSON.stringify(m); seq++; proxy.send(JSON.stringify({ conn, seq, body: b, mac: sign("proxy", conn, seq, b) })); }; +proxy.onmessage = (e) => { const env = JSON.parse(e.data); conn = env.conn; if (JSON.parse(env.body).type === "hello") sendP({ type: "state", scripts, servers: { "proxy-1": { location: "VELOCITY", platform: "VELOCITY" }, lobby: { location: "BACKEND", platform: "PAPER" } } }); }; +await new Promise((r) => (proxy.onopen = r)); + +const profile = mkdtempSync(join(tmpdir(), "cb-ff-")); +const port = 9300 + Math.floor(Math.random() * 500); +const ff = spawn("firefox", ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "--window-size=1600,1000"], { stdio: "ignore" }); +let ws; +for (let i = 0; i < 60 && !ws; i++) { + await new Promise((r) => setTimeout(r, 500)); + try { ws = new WebSocket(`ws://127.0.0.1:${port}/session`); await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = no; }); } catch { ws = null; } +} +let msgId = 0; +const waiting = new Map(); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && waiting.has(m.id)) { waiting.get(m.id)(m); waiting.delete(m.id); } }; +const cmd = (method, params) => new Promise((ok, no) => { const i = ++msgId; waiting.set(i, (m) => (m.type === "error" ? no(new Error(method + ": " + m.message)) : ok(m.result))); ws.send(JSON.stringify({ id: i, method, params })); }); +await cmd("session.new", { capabilities: {} }); +const ctx = (await cmd("browsingContext.getTree", {})).contexts[0].context; +// results come back as JSON text, since BiDi's own serialization of objects is verbose +const js = async (expr) => { + const r = await cmd("script.evaluate", { expression: `(async () => JSON.stringify(await (${expr})))()`, target: { context: ctx }, awaitPromise: true }); + if (r.type === "exception") throw new Error(r.exceptionDetails.text); + return r.result.value === undefined ? undefined : JSON.parse(r.result.value); +}; +const act = (actions) => cmd("input.performActions", { context: ctx, actions }); +const click = async (x, y, button = 0) => act([{ type: "pointer", id: "m", actions: [{ type: "pointerMove", x: Math.round(x), y: Math.round(y) }, { type: "pointerDown", button }, { type: "pointerUp", button }] }]); +const keys = async (...seq) => act([{ type: "key", id: "k", actions: seq.flatMap((k) => (Array.isArray(k) ? [...k.map((v) => ({ type: "keyDown", value: v })), ...k.slice().reverse().map((v) => ({ type: "keyUp", value: v }))] : [{ type: "keyDown", value: k }, { type: "keyUp", value: k }])) }]); +const CTRL = ""; +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); + +let fails = 0; +const check = (n, c, x = "") => { console.log((c ? "PASS " : "FAIL ") + n + (c ? "" : " " + x)); if (!c) fails++; }; +try { + await cmd("browsingContext.navigate", { context: ctx, url: `http://localhost:8080/editor/#${id}:${secret.toString("base64url")}`, wait: "complete" }); + for (let i = 0; i < 40 && !(await js(`document.querySelectorAll(".drawflow-node").length > 5`)); i++) await pause(250); + await js(`(document.querySelector("[x-data*=cookie], .cookie, #cookie-banner")?.remove(), 1)`); + const A = `Alpine.$data(document.querySelector(".ed"))`; + const count = () => js(`document.querySelectorAll(".drawflow-node").length`); + + // a node, then Ctrl+A, Ctrl+C, Ctrl+V + const cmdHead = await js(`(() => { const n = [...document.querySelectorAll(".cb-command")].find((n) => n.querySelector('[data-f="name"]').value === "alert"); const a = ${A}, r = a.rectOf(n.id.slice(5)); a.centerOn(r.x + 200, r.y + 100, 1); const b = n.querySelector(".nh").getBoundingClientRect(); return { x: b.left + 120, y: b.top + 15 }; })()`); + await click(cmdHead.x, cmdHead.y); + check("click selects a node", (await js(`${A}.selectionSize`)) === 1); + check("keys go to the editor's key field", (await js(`document.activeElement.className`)) === "ed-keys", await js(`document.activeElement.tagName + "." + document.activeElement.className`)); + await keys([CTRL, "a"]); + const all = await count(); + check("Ctrl+A selects everything", (await js(`${A}.selectionSize`)) === all, `${await js(`${A}.selectionSize`)} of ${all}`); + const empty = await js(`(() => { const r = document.getElementById("ed-canvas").getBoundingClientRect(); return { x: r.left + 8, y: r.top + 8 }; })()`); + await click(empty.x, empty.y); // empty canvas: nothing selected + await click(cmdHead.x, cmdHead.y); + await keys([CTRL, "c"]); + await keys([CTRL, "v"]); + await pause(200); + check("Ctrl+C, Ctrl+V paste a copy", (await count()) === all + 1, `${await count()} vs ${all + 1}`); + + // typing into a field after clicking it + // a description field nothing else covers (the pasted copy lands on top of the original) + const desc = await js(`(() => { for (const t of document.querySelectorAll('.cb-command [data-f="description"]')) { const b = t.getBoundingClientRect(), x = b.left + 20, y = b.top + 10; if (document.elementFromPoint(x, y) === t) return { x, y }; } })()`); + await click(desc.x, desc.y); + await keys("x", "y"); + check("a clicked field takes typing", /xy/.test(await js(`document.activeElement.value`)) && (await js(`document.activeElement.dataset.f`)) === "description"); + // every kind of field takes typing after a click, also a click on its frame + for (const [label, sel, frame] of [["command name", '.cb-command .slashed', true], ["suggestions", '.cb-arg [data-f="suggestions"]'], ["delay", '.cb-defaults [data-f="d-delay"]'], + ["alias row", '.cb-alias .tag input'], ["new alias", '.cb-alias .al-new', true], ["step command", '.cb-run .cmd']]) { + const pt = await js(`(() => { for (const el of document.querySelectorAll(${JSON.stringify(sel)})) { const n = el.closest(".drawflow-node"), a = ${A}, r = a.rectOf(n.id.slice(5)); a.centerOn(r.x + r.w / 2, r.y + r.h / 2, 1); + const b = el.getBoundingClientRect(), x = b.left + (${!!frame} ? 4 : Math.min(30, b.width / 2)), y = b.top + b.height / 2; if (el.contains(document.elementFromPoint(x, y)) || document.elementFromPoint(x, y) === el) return { x, y }; } })()`); + if (!pt) { check(label + " is reachable", false); continue; } + await click(pt.x, pt.y); + await keys("q", "z"); + const got = await js(`(() => { const a = document.activeElement; return a.isContentEditable ? a.textContent : a.value; })()`); + check(`typing goes into the ${label}`, /qz/.test(got || "") && (frame ? /qz$/.test(got) : true), String(got)); + } + check("fields allow text selection (caret shows in Firefox)", (await js(`getComputedStyle(document.querySelector('.cb-command [data-f="name"]')).userSelect`)) !== "none"); + + // dragging an argument pill into a command, with a preview while dragging + const drag = await js(`(() => { const run = [...document.querySelectorAll(".cb-run")].find((n) => n.querySelector('.ph[data-arg="amount"]')); const a = ${A}, r = a.rectOf(run.id.slice(5)); a.centerOn(r.x + 200, r.y + 150, 1); + const p = run.querySelector(".pill").getBoundingClientRect(), c = run.querySelector(".cmd").getBoundingClientRect(); window.__run = run.id; return { px: p.left + 10, py: p.top + 8, cx: c.left + 8, cy: c.top + c.height / 2, before: run.querySelectorAll(".cmd .ph").length }; })()`); + await act([{ type: "pointer", id: "m", actions: [{ type: "pointerMove", x: Math.round(drag.px), y: Math.round(drag.py) }, { type: "pointerDown", button: 0 }, + { type: "pointerMove", x: Math.round(drag.cx), y: Math.round(drag.cy), duration: 200 }] }]); + check("dragging shows a preview in the text", (await js(`document.querySelectorAll("#" + window.__run + " .cmd .ph.preview").length`)) === 1); + await act([{ type: "pointer", id: "m", actions: [{ type: "pointerUp", button: 0 }] }]); + check("dropping inserts the argument", (await js(`document.querySelectorAll("#" + window.__run + " .cmd .ph").length`)) === drag.before + 1); + check("still on the editor page", (await js(`location.pathname`)) === "/editor/"); +} finally { + ff.kill(); + proxy.close(); + ws?.close(); + rmSync(profile, { recursive: true, force: true }); +} +console.log(fails ? `${fails} failed` : "all passed"); +process.exit(fails ? 1 : 0); diff --git a/tests/browser/editor-ui.mjs b/tests/browser/editor-ui.mjs new file mode 100644 index 0000000..74dd29d --- /dev/null +++ b/tests/browser/editor-ui.mjs @@ -0,0 +1,437 @@ +// Drives the editor in Chromium against a fake proxy. Needs the wiki dev server (8080) and the relay (wrangler dev, 8787). +// Run: node tests/browser/editor-ui.mjs [screenshot dir] +import { chromium } from "/usr/lib/node_modules/omniroute/node_modules/playwright-core/index.mjs"; +import crypto from "node:crypto"; +import { readFileSync } from "node:fs"; + +const SHOTS = process.argv[2]; +const D = new URL("../fixtures/", import.meta.url); +const scripts = Object.fromEntries(["docs-examples-0.yml", "docs-examples-1.yml", "docs-examples-2.yml", "docs-examples-4.yml"].map((f) => [f, readFileSync(new URL(f, D), "utf8")])); +scripts["docs-examples-2.yml"] = scripts["docs-examples-2.yml"].replace("aliases: [eco]", "aliases: [eco, money]"); +const id = crypto.randomBytes(16).toString("hex"), secret = crypto.randomBytes(32); +const sign = (r, c, s, b) => crypto.createHmac("sha256", secret).update(`${r}\n${c}\n${s}\n${b}`).digest("base64url"); +const proxy = new WebSocket(`ws://localhost:8787/api/editor/${id}/ws?role=proxy`, { headers: { Authorization: "Bearer cb-dump-upload-v1" } }); +let seq = 0, conn = ""; +const sendP = (m) => { const b = JSON.stringify(m); seq++; proxy.send(JSON.stringify({ conn, seq, body: b, mac: sign("proxy", conn, seq, b) })); }; +// a stand-in for the proxy: it keeps the draft, holds staged changes until confirm() is called, and counts pings +const servers = { "proxy-1": { location: "VELOCITY", platform: "VELOCITY" }, lobby: { location: "BACKEND", platform: "PAPER" } }; +let draft = {}, staged = null, pings = 0; +const state = () => sendP({ type: "state", scripts, servers, draft, pending: staged?.code ?? null }); +const confirm = () => { + const results = {}; + for (const [f, y] of Object.entries(staged.files)) { scripts[f] = y; results[f] = []; } + for (const f of staged.deletions) { delete scripts[f]; results[f] = []; } + staged = null; draft = {}; + sendP({ type: "applied", results, warning: null }); + state(); +}; +proxy.onmessage = (e) => { + const env = JSON.parse(e.data); conn = env.conn; const m = JSON.parse(env.body); + if (m.type === "hello") state(); + if (m.type === "draft") draft = m.draft; + if (m.type === "ping") pings++; + if (m.type === "stage") { staged = { code: "k3m9x2", files: m.files, deletions: m.deletions }; sendP({ type: "staged", errors: {}, code: staged.code }); } + if (m.type === "unstage") { staged = null; state(); } +}; +await new Promise((r) => (proxy.onopen = r)); + +let fails = 0; +const check = (n, c, x = "") => { console.log((c ? "PASS " : "FAIL ") + n + (c ? "" : " " + x)); if (!c) fails++; }; +const browser = await chromium.launch({ executablePath: process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome" }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); +const errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +page.on("console", (m) => { if (process.env.DEBUG && /error|warn/i.test(m.type())) console.log("CONSOLE", m.text()); }); +await page.goto(`http://localhost:8080/editor/#${id}:${secret.toString("base64url")}`); +await page.waitForFunction(() => document.querySelectorAll(".drawflow-node").length > 5, null, { timeout: 15000 }); +await page.waitForTimeout(500); +const app = () => page.evaluate(() => { const a = Alpine.$data(document.querySelector(".ed")); return { changes: a.changes(), out: a.out, scripts: a.scripts, blocking: a.blocking().map((p) => ({ script: p.script, text: p.text })) }; }); +const shot = (n) => SHOTS && page.screenshot({ path: `${SHOTS}/${n}.png` }); + +const view = (loc, dx = 200, dy = 150) => loc.evaluate((n, [dx, dy]) => { const a = Alpine.$data(document.querySelector(".ed")), r = a.rectOf(n.id.slice(5)); a.centerOn(r.x + dx, r.y + dy, 1); }, [dx, dy]); +const blank = async () => { await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).centerOn(-5000, -5000)); }; +await page.evaluate(() => document.querySelector("[x-data*=cookie], .cookie, #cookie-banner")?.remove()); + +let s = await app(); +check("loads without changes", s.changes === 0, JSON.stringify(s.blocking) + " " + Object.keys(s.out).filter((f) => s.out[f] !== s.scripts?.[f])); +check("every script has a Defaults node", await page.locator(".cb-defaults").count() === 4); +check("Defaults show run as PLAYER", await page.locator('.cb-defaults [data-f="d-runas"][value="PLAYER"]:checked').count() === 1); +check("checks player is a picker", await page.locator('.cb-defaults [data-f="d-player"]').evaluateAll((els) => els.map((e) => e.dataset.v)).then((v) => v.includes("player"))); +check("a connected backend shows the software it runs", await page.locator('.cb-server:has(.nk:text-is("lobby")) .srv-k').first().textContent() === "paper"); +check("server node shows its id as the title", await page.locator('.cb-server .nk', { hasText: "proxy-1" }).count() > 0); +// wires that run through a node they don't belong to, and nodes on top of each other +const crossings = () => page.evaluate(() => { + const nodes = [...document.querySelectorAll("#ed-canvas .drawflow-node")].map((n) => ({ id: n.id, r: n.getBoundingClientRect() })); + let wires = 0, overlaps = 0; + const bad = []; + for (const c of document.querySelectorAll("#ed-canvas svg.connection")) { + const ends = [...c.classList].filter((k) => k.startsWith("node_")).map((k) => "node-" + k.split("-")[1]); + const path = c.querySelector(".main-path"), len = path.getTotalLength(), m = path.getScreenCTM(); + const hit = new Set(); + for (let t = 0.08; t < 0.92; t += 0.04) { + const p = path.getPointAtLength(len * t), x = p.x * m.a + m.e, y = p.y * m.d + m.f; + for (const n of nodes) if (!ends.includes(n.id) && x > n.r.left + 2 && x < n.r.right - 2 && y > n.r.top + 2 && y < n.r.bottom - 2) hit.add(n.id); + } + wires += hit.size; + if (hit.size) bad.push(ends.join(">") + " through " + [...hit].join(",")); + } + for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i].r, b = nodes[j].r; + if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) { overlaps++; bad.push(nodes[i].id + " on " + nodes[j].id); } + } + return { wires, overlaps, bad: bad.slice(0, 6) }; +}); +const cx = await crossings(); +check("first layout: no overlapping nodes, no wire through a node", cx.overlaps === 0 && cx.wires === 0, JSON.stringify(cx)); +check("server node is fixed", await page.locator('.cb-server input[type="text"]').count() === 0); + +const def = page.locator(".cb-defaults").filter({ has: page.locator('[data-f="d-runas"][value="PLAYER"]:checked') }); +await view(def); +await def.locator('label:has([value="OPERATOR"])').click(); +s = await app(); +check("run-as change reaches YAML", /run-as: OPERATOR/.test(s.out["docs-examples-1.yml"])); + +// saving: staged on the proxy, written only after the confirm command +await page.locator(".ed-save").click(); +await page.locator(".ed-confirm").waitFor({ state: "visible" }); +check("Save shows the confirm command", (await page.locator(".ed-confirm code").textContent()) === "/cb editor confirm k3m9x2" && staged !== null); +check("nothing is written before the confirm", !scripts["docs-examples-1.yml"].includes("OPERATOR")); +await page.waitForTimeout(300); +await shot("confirm"); +const before = Object.keys(staged.files).length; +confirm(); +await page.locator(".ed-confirm").waitFor({ state: "hidden" }); +s = await app(); +check("after the confirm the changes are saved", s.changes === 0 && scripts["docs-examples-1.yml"].includes("OPERATOR") && before > 0, `${s.changes} left`); +// a change after Save takes the staged set back +const lobbyDesc = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="lobby"]') }).locator('[data-f="description"]'); +await view(lobbyDesc.locator("xpath=ancestor::div[contains(@class,'drawflow-node')]"), 200, 200); +await lobbyDesc.fill("Draft text"); +await page.locator(".ed-save").click(); +await page.locator(".ed-confirm").waitFor({ state: "visible" }); +await lobbyDesc.fill("Draft text, changed after Save"); +await page.waitForTimeout(300); +check("changing something after Save cancels the waiting code", staged === null && !(await page.locator(".ed-confirm").isVisible())); +// the unsaved change comes back after reopening the editor +await page.waitForTimeout(2000); +check("unsaved changes are kept on the proxy", JSON.stringify(draft).includes("Draft text, changed after Save")); +await page.reload(); +await page.waitForFunction(() => document.querySelectorAll(".drawflow-node").length > 5, null, { timeout: 15000 }); +await page.waitForTimeout(500); +await page.evaluate(() => document.querySelector("[x-data*=cookie], .cookie, #cookie-banner")?.remove()); +check("reopening the editor restores them", await page.locator('.cb-command [data-f="description"]').evaluateAll((e) => e.some((x) => x.value === "Draft text, changed after Save")) && (await app()).changes === 1); +await page.locator(".ed-notes button", { hasText: "Discard" }).click(); +check("Discard drops them", (await app()).changes === 0); + + +// aliases: a list, each row edited in place +const al = page.locator("#" + await page.locator(".cb-alias").filter({ has: page.locator('.tag[data-v="money"]') }).getAttribute("id")); +await view(al); +check("one Aliases node holds both aliases", await al.locator(".tag").count() === 2); +await al.locator(".tag-in").click(); +await page.keyboard.type("cash "); +s = await app(); +check("typed alias becomes a row in YAML", /aliases:.*cash/.test(s.out["docs-examples-2.yml"])); +await page.keyboard.type("Bad!"); +await page.keyboard.press("Enter"); +s = await app(); +check("invalid alias is marked on its row", await al.locator('.tag.bad').count() === 1 && s.blocking.length > 0); +await al.locator('.tag[data-v="Bad!"] input').fill(""); +await al.locator(".tag-in").focus(); +await al.locator('.tag[data-v="money"] input').fill("coins"); +s = await app(); +check("an alias row is edited in place", /aliases: \[eco, coins, cash\]/.test(s.out["docs-examples-2.yml"])); +await al.locator('.tag[data-v="coins"] input').fill("money"); +await al.locator('.tag[data-v="cash"]').hover(); +await al.locator('.tag[data-v="cash"] .tag-x').click(); +s = await app(); +check("emptying and removing rows leaves YAML", /aliases: \[eco, money\]/.test(s.out["docs-examples-2.yml"]) && !s.blocking.length, JSON.stringify(s.blocking)); +await shot("nodes"); + +// command text with argument pieces, dragged with the mouse +const at = async (loc) => { const b = await loc.boundingBox(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; }; +const runId = await page.locator(".cb-run").filter({ has: page.locator('.ph[data-arg="amount"]') }).getAttribute("id"); +const run = page.locator("#" + runId); +await view(run); +check("placeholders render as pieces without a remove button", await run.locator(".ph").count() === 2 && await run.locator(".ph button").count() === 0); +// out to the remove zone +let p0 = await at(run.locator('.ph[data-arg="amount"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(p0.x + 30, p0.y + 40, { steps: 4 }); +const bin = await at(page.locator(".ed-trash")); +await page.mouse.move(bin.x, bin.y, { steps: 8 }); +check("the remove zone lights up under the piece", await page.locator(".ed-trash.hot").count() === 1); +await shot("trash"); +await page.mouse.up(); +s = await app(); +check("dropping into the remove zone removes the piece", /command: "?eco give \$\{player\}"?\s*$/m.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +// back in, aimed at the middle of a word: it goes between words and the text makes room while dragging +p0 = await at(run.locator('.pill[data-arg="amount"]')); +const word = await run.locator(".cmd").boundingBox(); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); +await page.mouse.move(word.x + 14, word.y + word.height / 2, { steps: 10 }); +check("dragging shows where the piece goes", await run.locator(".cmd .ph.preview").count() === 1); +await shot("drag-preview"); +await page.mouse.up(); +s = await app(); +check("a piece never splits a word", /command: "?(\$\{amount\} eco|eco \$\{amount\}) give \$\{player\}"?\s*$/m.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +// a piece dragged out and dropped on nothing goes back +p0 = await at(run.locator('.ph[data-arg="amount"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(p0.x + 200, p0.y - 150, { steps: 6 }); await page.mouse.up(); +check("a piece dropped on nothing goes back", await run.locator('.ph[data-arg="amount"]').count() === 1); +// moved to the end +p0 = await at(run.locator('.ph[data-arg="amount"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(word.x + word.width - 10, word.y + word.height / 2, { steps: 10 }); +await page.mouse.up(); +s = await app(); +check("a piece moves within the command", /eco give \$\{player\} \$\{amount\}/.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +await run.locator(".cmd").click({ position: { x: 12, y: 14 } }); +await page.keyboard.press("End"); +await page.keyboard.type(" ${nope}"); +await page.locator("#ed-canvas").click({ position: { x: 5, y: 5 } }); +check("typed placeholder becomes a marked piece", await run.locator('.ph.bad[data-arg="nope"]').count() === 1); +await run.locator('.ph[data-arg="nope"]').hover(); +check("hover shows the reason", /no argument nope/.test(await page.locator(".ed-tip").textContent())); +await page.locator(".ed-savewrap").hover(); +check("save explains why it's blocked", /Fix 1 problem/.test(await page.locator(".ed-tip").textContent()), await page.locator(".ed-tip").textContent()); +await shot("errors"); +p0 = await at(run.locator('.ph[data-arg="nope"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(p0.x + 30, p0.y + 40, { steps: 4 }); +await page.mouse.move(...Object.values(await at(page.locator(".ed-trash"))), { steps: 6 }); await page.mouse.up(); +check("notes stay empty for pinned problems", !(await page.locator(".ed-notes").isVisible())); + +// a step that calls another script shows the called name as a fixed piece +await view(run, 300, 60); +await run.locator(".output.output_2").click({ force: true }); +await page.locator(".ed-pop .ed-mi", { hasText: "/lobby" }).click(); +check("a calling step shows the called script as a piece", await run.locator(".cmd .callw").textContent() === "/lobby"); +await shot("calling"); +s = await app(); +// "eco" was an alias of the script itself, so the called name takes its place +check("the call keeps its arguments in the text", /command: "?lobby give/.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +await page.keyboard.press("Control+z"); +await page.keyboard.press("Control+z"); + +// description wraps downwards +const cmdNode = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="eco-give"]') }); +const h0 = (await cmdNode.locator('[data-f="description"]').boundingBox()).height; +await cmdNode.locator('[data-f="description"]').click(); +await page.keyboard.type(" and a lot more words so the description needs a second line to show all of it"); +check("description grows downwards", (await cmdNode.locator('[data-f="description"]').boundingBox()).height > h0 + 10); +await page.keyboard.press("Escape"); + +// group toggle +const grp = page.locator(".grp").filter({ has: page.locator('.grp-t[value="/eco-give"]') }); +await grp.locator(".grp-h .sw").click(); +s = await app(); +check("group off disables its script", /enabled: false/.test(s.out["docs-examples-2.yml"])); +await grp.locator(".grp-h .sw").click(); + +// a loose Wait dropped onto the wire between a command and its step goes in between +const ecoCmd = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="eco-give"]') }); +await view(ecoCmd, 600, 60); +const cb = await ecoCmd.boundingBox(); +await page.mouse.click(cb.x + cb.width + 40, cb.y - 120, { button: "right" }); +await page.locator(".ed-pop > .ed-mw > .ed-mi", { hasText: "Step" }).hover(); +await page.locator(".ed-sub .ed-mi", { hasText: "Wait" }).click(); +const wait = page.locator(".cb-wait.is-sel"); +const wh = await wait.locator(".nh").boundingBox(); +const then = await ecoCmd.locator(".output.output_1").boundingBox(); +await page.mouse.move(wh.x + 60, wh.y + 15); await page.mouse.down(); +await page.mouse.move(then.x + 60, then.y - 20, { steps: 12 }); +check("the wire under a dragged Wait lights up", await page.locator("svg.connection.insert-at").count() === 1); +await shot("insert"); +await page.mouse.up(); +s = await app(); +check("dropping it there puts the Wait between them", /command: .*eco give.*\n\s+delay: 1s/.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].split("commands:")[1]); +await page.keyboard.press("Control+z"); +await page.keyboard.press("Control+z"); +await page.keyboard.press("Control+z"); + +// durations: suggestions in the editor's menu +const def2 = page.locator(".cb-defaults").filter({ has: page.locator('[data-f="d-runas"][value="OPERATOR"]:checked') }); +await view(def2, 200, 150); +await def2.locator('[data-f="d-delay"]').click(); +check("a duration field offers durations", await page.locator(".ed-pop .ed-mi", { hasText: "500ms" }).count() === 1); +await page.keyboard.type("1"); +check("typing narrows them down", await page.locator(".ed-pop .ed-mi", { hasText: "500ms" }).count() === 0 && await page.locator(".ed-pop .ed-mi", { hasText: "1m" }).count() === 1); +await page.locator(".ed-pop .ed-mi", { hasText: "1m" }).click(); +check("picking one fills the field", await def2.locator('[data-f="d-delay"]').inputValue() === "1m"); +await def2.locator('[data-f="d-delay"]').fill(""); +await page.keyboard.press("Escape"); +// checks player: closed while unused, and picking an argument makes it required +await def2.locator('[data-f="d-player"]').click({ force: true }); +check("checks player can't be opened while it's unused", await page.locator(".ed-pop .ed-mi", { hasText: "who ran it" }).count() === 0); + +// groups from a selection +const alert = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="alert"]') }); +const lobby = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="lobby"]') }); +const groupsBefore = await page.locator(".grp").count(); +await view(alert, 100, 10); +await alert.locator(".nh").click({ position: { x: 120, y: 15 } }); +await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).fit()); +await lobby.locator(".nh .nk").click({ modifiers: ["Control"], force: true }); +await page.keyboard.press("Control+g"); +check("Ctrl+G groups two selected scripts", await page.locator(".grp").count() === groupsBefore - 1 && await page.locator(".grp:not(.solo) .grp-n").first().textContent() === "2 scripts"); +await page.keyboard.press("Escape"); +await page.keyboard.press("Control+z"); +check("undo restores the groups", await page.locator(".grp").count() === groupsBefore); + +// menus +const box = await page.locator("#ed-canvas").boundingBox(); +await blank(); +await page.mouse.click(box.x + 600, box.y + 400, { button: "right" }); +check("context menu opens", await page.locator(".ed-pop").isVisible()); +await page.locator(".ed-pop > .ed-mw > .ed-mi", { hasText: "Step" }).hover(); +await shot("ctx-add"); +await page.locator(".ed-sub .ed-mi", { hasText: "Execute" }).click(); +check("Execute added from context menu", await page.locator(".cb-run .nh.has-err").count() === 1); +await page.mouse.click(box.x + 900, box.y + 300, { button: "right" }); +await page.locator(".ed-pop .ed-mi-l").getByText("Server", { exact: true }).hover(); +await shot("ctx-servers"); +// safe triangle: heading diagonally into the submenu across other items keeps it open +const srvItem = await page.locator(".ed-pop .ed-mi-l").getByText("Server", { exact: true }).boundingBox(); +const sub = await page.locator(".ed-mw.open > .ed-sub").boundingBox(); +await page.mouse.move(srvItem.x + 20, srvItem.y + 8); +await page.mouse.move(sub.x + 10, sub.y + sub.height - 10, { steps: 12 }); +check("safe triangle keeps the submenu", await page.locator(".ed-mw.open .ed-mi-l", { hasText: "lobby" }).count() === 1); +await page.locator(".ed-sub .ed-mi", { hasText: "lobby" }).click(); +check("server added from the menu", await page.locator('.cb-server [data-f="id"][value="lobby"]').count() > 0); + +await page.locator(".ed-addbtn").click(); +await page.locator(".ed-pop .ed-mi-l").getByText("Server", { exact: true }).hover(); +await shot("add-servers"); +await page.locator('.ed-custom input').fill("hub-2"); +await page.locator('.ed-custom button[value="VELOCITY"]').click(); +check("custom id adds a proxy server", await page.locator('.cb-server:has([data-f="id"][value="hub-2"]):has([data-f="location"][value="VELOCITY"])').count() === 1); + +// a new command comes with its Defaults wired +await page.locator(".ed-addbtn").click(); +await page.locator(".ed-pop .ed-mi", { hasText: "Command" }).click(); +s = await app(); +const fresh = s.out["new-command.yml"] || ""; +check("new command has Defaults with a target", /defaults:[\s\S]*execute:[\s\S]*proxy-1/.test(fresh) && !s.blocking.some((b) => b.script === "new-command"), JSON.stringify(s.blocking)); +check("a new command gets exactly one group", await page.locator(".grp .grp-t").evaluateAll((e) => e.filter((x) => x.value === "/new-command").length) === 1); + +// copy / paste +await view(alert, 100, 10); +await alert.locator(".nh").click({ position: { x: 120, y: 15 } }); +await page.keyboard.press("Control+c"); +await page.keyboard.press("Control+v"); +s = await app(); +check("pasted Command is a new script", Object.keys(s.out).includes("alert-copy.yml")); +const hb = await alert.locator(".nh").boundingBox(); +await page.mouse.click(hb.x + 120, hb.y + 15, { button: "right" }); +await page.locator(".ed-pop .ed-mi-l").getByText("Group", { exact: true }).hover(); +await shot("ctx-node"); +check("node menu shows the group, with the current one checked", await page.locator(".ed-mw.open .ed-sub .ed-mi.checked").count() === 1 && await page.locator(".ed-mw.open .ed-sub .ed-mi", { hasText: "New group" }).count() === 1); +await page.keyboard.press("Escape"); + +// renaming a command keeps its single group +const nc = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="new-command"]') }); +await view(nc, 100, 100); +await nc.locator('[data-f="name"]').fill(""); +await nc.locator('[data-f="name"]').type("alert"); +await nc.locator('[data-f="name"]').fill(""); +await nc.locator('[data-f="name"]').type("renamed"); +check("renaming through a taken name keeps one group each", await page.evaluate(() => { + const t = [...document.querySelectorAll(".grp .grp-t")].map((x) => x.value); + return t.filter((v) => v === "/renamed").length === 1 && t.filter((v) => v === "/alert").length === 1; +}), await page.evaluate(() => [...document.querySelectorAll(".grp .grp-t")].map((x) => x.value).join(","))); +await page.keyboard.press("Escape"); + +// pickers +const arg = page.locator(".cb-arg").filter({ has: page.locator('[data-f="name"][value="message"]') }); +await view(arg, 100, 100); +await arg.locator('[data-f="type"]').click(); +await shot("type-menu"); +check("type picker lists types by family", await page.locator(".ed-pop .ed-pop-s", { hasText: "Numbers" }).count() === 1); +await page.locator(".ed-pop .ed-mi", { hasText: "GREEDY_STRING" }).click(); +check("picking a type reaches YAML", /type: GREEDY_STRING/.test((await app()).out["docs-examples-0.yml"])); +const alertRun = page.locator(".cb-run").filter({ has: page.locator('.ph[data-arg="message"]') }).first(); +check("unwired step settings show what the Defaults give", await alertRun.locator('[data-port="run as"] .inh').textContent() === "console"); + +// sort +await page.locator(".ed-tool", { hasText: "Sort" }).click(); +const cs = await crossings(); +check("Sort: no overlapping nodes, no wire through a node", cs.overlaps === 0 && cs.wires === 0, JSON.stringify(cs)); +await shot("sorted"); + +// yaml panel +await page.locator(".ed-tool", { hasText: "YAML" }).click(); +await page.locator(".ed-yaml").waitFor({ state: "visible" }); +await page.waitForTimeout(350); // the panel slides in +const grip = await page.locator(".ed-yaml-grip").boundingBox(), w0 = (await page.locator(".ed-yaml").boundingBox()).width; +await page.mouse.move(grip.x + 4, grip.y + 300); await page.mouse.down(); await page.mouse.move(grip.x - 150, grip.y + 300, { steps: 5 }); await page.mouse.up(); +check("the YAML panel is dragged wider", (await page.locator(".ed-yaml").boundingBox()).width > w0 + 100); +const code = await page.locator(".ed-yaml-code").boundingBox(); +await page.mouse.move(code.x + 20, code.y + 20); await page.mouse.down(); await page.mouse.move(code.x + 200, code.y + 80, { steps: 5 }); await page.mouse.up(); +check("text selected in the YAML stays selected", await page.evaluate(() => { const t = document.querySelector(".ed-yaml-ta"); return t.selectionEnd - t.selectionStart > 5; })); +// editing the YAML changes the nodes +await page.locator(".ed-file", { hasText: "docs-examples-2.yml" }).click(); +const ta = page.locator(".ed-yaml-ta"); +const yaml0 = await ta.inputValue(); +await ta.fill(yaml0.replace("silent: false", "silent: true")); +await page.locator(".ed-yaml-main header").click(); +await page.waitForTimeout(100); +const ecoSilent = () => page.locator('.cb-command').filter({ has: page.locator('[data-f="name"][value="eco-give"]') }).locator('[data-f="silent"]').isChecked(); +check("editing the YAML updates the node", await ecoSilent()); +await ta.fill(yaml0.replace("version: 4", "version: [4")); +await page.waitForTimeout(900); +check("broken YAML shows an error and leaves the nodes", await page.locator(".ed-yaml-err").isVisible() && await ecoSilent()); +// linting: a wrong value is underlined and explained on its line +await ta.fill(yaml0.replace("silent: false", "silent: maybe")); +check("a wrong value is underlined", await page.locator(".ed-yaml-lint .lint-e", { hasText: "maybe" }).count() === 1); +check("its line number says why", /true or false/.test(await page.locator(".ed-yaml-n span.le").first().getAttribute("data-err"))); +await page.waitForTimeout(300); +await shot("yaml-lint"); +// completion: values after a key, keys where a key goes +await ta.fill(yaml0); +await ta.evaluate((t) => { const i = t.value.indexOf("silent: false") + "silent: ".length; t.focus(); t.setSelectionRange(i, i + 5); }); +await page.keyboard.press("Backspace"); +await page.keyboard.press("Control+Space"); +check("after a key the values are offered", (await page.locator(".ed-ycmp button > span").allTextContents()).join(",") === "true,false"); +await page.waitForTimeout(300); +await shot("yaml-complete"); +await page.keyboard.press("Enter"); +check("Enter takes the first suggestion", (await ta.inputValue()).includes("silent: true")); +await ta.evaluate((t) => { const i = t.value.indexOf(" enabled: ", t.value.indexOf("permissions:")); t.focus(); t.setSelectionRange(i, i); }); +await page.keyboard.type(" si\n".slice(0, 4)); +check("keys already there aren't offered again", !(await page.locator(".ed-ycmp button > span").allTextContents()).includes("silent")); +await page.keyboard.press("Escape"); +await ta.fill(yaml0); +await page.locator(".ed-yaml-main header").click(); +await page.waitForTimeout(300); +check("the header names the file picked in the list", (await page.locator(".ed-yaml-main header span").textContent()) === "scripts/" + (await page.locator(".ed-file.on").getAttribute("title")).split(" (")[0], + (await page.locator(".ed-yaml-main header span").textContent()) + " vs " + await page.locator(".ed-file.on").getAttribute("title")); +await shot("yaml"); +await page.locator(".ed-tool", { hasText: "YAML" }).click(); +await page.locator(".ed-tool", { hasText: "YAML" }).click(); +check("YAML panel is highlighted", await page.locator(".ed-yaml .y-key").count() > 3); +check("YAML panel lists the files with what saving does", await page.locator(".ed-file.changed").count() > 0 && await page.locator(".ed-file.new").count() > 0, await page.locator(".ed-files").innerText()); +await page.locator(".ed-file.new").first().click(); +check("picking a file shows it", (await page.locator(".ed-yaml-main header span").textContent()).includes((await page.locator(".ed-file.new .ed-file-n").first().textContent()))); +await shot("yaml"); +await page.locator(".ed-tool", { hasText: "YAML" }).click(); + +// shortcuts +await page.keyboard.press("h"); +check("H picks the grab tool", await page.evaluate(() => document.getElementById("ed-canvas").classList.contains("ed-grab"))); +await page.keyboard.press("v"); +await alert.locator('[data-f="enabled"]').evaluate((i) => i.focus()); +const z0 = await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).zoom); +await page.keyboard.press("Control+0"); +check("shortcuts work while a switch has focus", await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).zoom) === 100 && z0 !== undefined); +await view(alert, 100, 10); +await alert.locator(".nh").click({ position: { x: 120, y: 15 } }); +const x0 = (await alert.boundingBox()).x; +await page.keyboard.press("ArrowRight"); +check("arrow keys nudge", Math.round((await alert.boundingBox()).x - x0) === 10); + +await page.evaluate(() => document.documentElement.classList.remove("dark")); +await view(ecoCmd, 300, 200); +await shot("light"); +check("no page errors", !errors.length, errors.join(" | ")); +await browser.close(); +proxy.close(); +console.log(fails ? `${fails} failed` : "all passed"); +process.exit(fails ? 1 : 0); diff --git a/tests/fixtures/docs-examples-0.yml b/tests/fixtures/docs-examples-0.yml new file mode 100644 index 0000000..a8f9b68 --- /dev/null +++ b/tests/fixtures/docs-examples-0.yml @@ -0,0 +1,38 @@ +version: 4 +name: alert +description: Broadcast an alert to all servers +enabled: true +aliases: [] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "lobby" + location: BACKEND + - id: "survival" + location: BACKEND + - id: "minigames" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: message + required: true + type: GREEDY_STRING + suggestions: [] + +commands: + - command: "say [Alert]: ${message}" diff --git a/tests/fixtures/docs-examples-1.yml b/tests/fixtures/docs-examples-1.yml new file mode 100644 index 0000000..abc35ed --- /dev/null +++ b/tests/fixtures/docs-examples-1.yml @@ -0,0 +1,30 @@ +version: 4 +name: lobby +description: Send the player to the lobby +enabled: true +aliases: [hub] + +permissions: + enabled: true + silent: false + +register: + - id: "survival-1" + location: BACKEND + +defaults: + run-as: PLAYER + execute: + - id: "proxy-1" + location: VELOCITY + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: [] + +commands: + - command: "server lobby" diff --git a/tests/fixtures/docs-examples-2.yml b/tests/fixtures/docs-examples-2.yml new file mode 100644 index 0000000..395d70c --- /dev/null +++ b/tests/fixtures/docs-examples-2.yml @@ -0,0 +1,39 @@ +version: 4 +name: eco-give +description: Give money to a player on a backend +enabled: true +aliases: [eco] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: PLAYERS + suggestions: [] + + - name: amount + required: true + type: INTEGER + suggestions: [] + +commands: + - command: "eco give ${player} ${amount}" diff --git a/tests/fixtures/docs-examples-3.yml b/tests/fixtures/docs-examples-3.yml new file mode 100644 index 0000000..ff26505 --- /dev/null +++ b/tests/fixtures/docs-examples-3.yml @@ -0,0 +1,47 @@ +version: 4 +name: netban +description: Ban a player on all servers +enabled: true +aliases: [nban] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + - id: "lobby-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: target + required: true + type: PLAYERS + suggestions: [] + + - name: reason + required: false + type: GREEDY_STRING + suggestions: [] + +commands: + - command: "ban ${target} ${reason}" + + - command: "say ${target} has been banned: ${reason}" + execute: + - id: "admin-1" + location: BACKEND + run-as: CONSOLE diff --git a/tests/fixtures/docs-examples-4.yml b/tests/fixtures/docs-examples-4.yml new file mode 100644 index 0000000..b08ad74 --- /dev/null +++ b/tests/fixtures/docs-examples-4.yml @@ -0,0 +1,36 @@ +version: 4 +name: claim-reward +description: Claim your daily reward +enabled: true +aliases: [reward] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: true + player-arg: "player" + delay: 0s + cooldown: 24h + +args: + - name: player + required: true + type: OFFLINE_PLAYER + suggestions: [] + +commands: + - command: "say ${player} is claiming their daily reward..." + - command: "give ${player} diamond 5" + delay: 30s diff --git a/tests/fixtures/docs-syntax-0.yml b/tests/fixtures/docs-syntax-0.yml new file mode 100644 index 0000000..5825a8f --- /dev/null +++ b/tests/fixtures/docs-syntax-0.yml @@ -0,0 +1,43 @@ +version: 4 + +name: example +description: An example command script - edit or replace this with your own +enabled: false +aliases: [ex] + +permissions: + enabled: true + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: STRING + suggestions: [] + + - name: message + required: true + type: TEXT + suggestions: [] + +commands: + - command: "msg ${player} ${message}" + server: + target-required: true + delay: 2s diff --git a/tests/fixtures/repo-concept.yml b/tests/fixtures/repo-concept.yml new file mode 100644 index 0000000..9f7a51f --- /dev/null +++ b/tests/fixtures/repo-concept.yml @@ -0,0 +1,104 @@ +version: 4 # The configuration version. Must be 4 for the current schema. + +name: eco-give # The canonical name of the script and the command. This will be the primary label on the proxy. +description: Simple demo script that proxies an /eco command to a backend server # A short description shown in /help. +enabled: true # When false, the script is ignored and nothing is registered. +aliases: [eco] # Aliases for the command; must be a list even if you only have one. + +permissions: + enabled: true # If true, the proxy will check commandbridge.command. before executing the command. + silent: false # When true, lack of permission fails silently instead of sending an error message. + +# The register section tells CB on which clients and in which context to register this command. +# Each entry consists of: +# id: the client identifier from your clients config +# location: VELOCITY or BACKEND, specifying whether to register the command on the proxy or on a backend. +# If you have multiple proxies or backends you can register on more than one. +register: + - id: "client-1" + location: VELOCITY # Register on the Velocity proxy. + +defaults: + # These defaults are inherited by every command in the `commands` list unless overridden locally. + # Fields annotated with @Merge in the commands block (run-as, execute, server, delay, cooldown) + # are inherited from defaults when the command does not specify them. + run-as: CONSOLE # Options are CONSOLE, PLAYER or OPERATOR. Determines the executor context on the backend. + # Where to execute the command by default. Each entry has an `id` and a `location`. Multiple targets are allowed. + execute: + - id: "client-1" + location: BACKEND # Forward the command to this backend server. + # Settings that control player-presence checks and scheduling behaviour. + server: + target-required: true # If true, abort if the target player is not online on the execution target. Defaults to false. + schedule-online: true # If true, queue the command until the target comes online on the execution target. Defaults to false. + player-arg: player # References an arg name (must be a player-resolvable type: STRING, PLAYERS, or OFFLINE_PLAYER) to identify the player for target-required and schedule-online checks. + delay: 0s # Delay before forwarding the command. Zero disables the delay. + cooldown: 0s # Cooldown per player between uses. Zero disables rate limiting. + +# Argument definitions. +# Each argument must have: +# name: the placeholder used in the command template (for example ${player}) +# type: one of the values from the ArgType enum (see below). Defaults to STRING. +# required: whether the user must supply this argument. Defaults to false. +# suggestions: optional list of tab-complete suggestions for this argument +# +# Valid types as of this version: +# +# Cross-platform (VELOCITY + BACKEND): +# STRING # A single token. Player-resolvable. +# INTEGER # Whole numbers. +# BOOLEAN # true or false. +# DOUBLE # Floating-point numbers. +# TEXT # The rest of the input line as a single string, preserving spaces and quotes. +# GREEDY_STRING # Consumes all remaining input as a raw string. +# OFFLINE_PLAYER # An offline player name. Player-resolvable. Suggests known players on both platforms. +# +# Backend only: +# RANGE # A range like 1..10 (uses DoubleRangeArgument, no min/max support yet). +# ENTITIES # Select one or more entities (entity selector syntax). +# ENTITY_TYPE # The type of an entity (e.g. zombie). +# WORLD # A Minecraft world name. +# LOCATION # A 3D location (x, y, z). +# LOCATION_2D # A 2D location (x, z). +# ANGLE # A rotation angle. +# ROTATION # A full rotation (yaw + pitch). +# ITEM_STACK # An item stack. +# ENCHANTMENT # An enchantment. +# POTION_EFFECT # A potion effect. +# SOUND # A sound identifier. +# BIOME # A biome identifier. +# +# Backend (with optional Velocity support via PacketEvents): +# PLAYERS # Select one or more players (entity selector syntax). Player-resolvable. +# TIME # A time value (ticks or duration). +# +# Proxy only: +# SERVER # A Velocity server name. +# +# Types marked "Player-resolvable" can be referenced by the server.player-arg setting (STRING, PLAYERS, OFFLINE_PLAYER). +# Types with "optional Velocity support" work on the proxy only when the PacketEvents integration is available. +# The PlatformProcessor ensures you do not use a backend-only type for a proxy-registered command. +args: + - name: player + required: true + type: PLAYERS # Use the PLAYERS selector to pick a player or group of players. + + - name: amount + required: true + type: RANGE # A numeric range argument. + +commands: + # The list of commands to run. The proxy will process them in order. + # Each command can override any of the defaults (run-as, execute, server, delay, cooldown). + # When a field is not specified, it is inherited from defaults via @Merge. + - command: "eco give ${player} ${amount}" + # Overrides for this command only: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + server: + target-required: false # Override defaults: do not abort if the target player is offline. + schedule-online: false # Do not schedule; run immediately if possible. + # player-arg not set here, so inherited from defaults via merge. + delay: 2s # Wait two seconds before sending the command to the backend. diff --git a/tests/fixtures/repo-example.yml b/tests/fixtures/repo-example.yml new file mode 100644 index 0000000..922b125 --- /dev/null +++ b/tests/fixtures/repo-example.yml @@ -0,0 +1,42 @@ +# CommandBridge - Example Script +# For a full reference of all options, visit https://cb.objz.dev/docs + +version: 4 + +name: example +description: An example command script - edit or replace this with your own +enabled: false # Set to true to activate this script +aliases: [ex] + +permissions: + enabled: true # Requires commandbridge.command.example to use + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + server: + target-required: false + schedule-online: false + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: PLAYERS + suggestions: [] + + - name: message + required: true + type: TEXT + suggestions: [] + +commands: + - command: "msg ${player} ${message}" diff --git a/tests/fixtures/repo-testcmd.yml b/tests/fixtures/repo-testcmd.yml new file mode 100644 index 0000000..309b35a --- /dev/null +++ b/tests/fixtures/repo-testcmd.yml @@ -0,0 +1,44 @@ +version: 2 + +name: testcmd +description: Simple demo script +enabled: true +aliases: [tc] + +permissions: + enabled: true + silent: false + +register: + - id: "survival-1" + location: BACKEND + +defaults: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + server: + target-required: true + schedule-online: true + timeout: 1s + delay: 0s + cooldown: 0s + + +args: + - name: player + required: true + type: PLAYERS + + - name: amount + required: true + type: RANGE + +commands: + - command: "eco give ${player} ${amount}" + run-as: CONSOLE + server: + target-required: false + schedule-online: false + delay: 2s diff --git a/tests/script-fields.test.mjs b/tests/script-fields.test.mjs new file mode 100644 index 0000000..457a669 --- /dev/null +++ b/tests/script-fields.test.mjs @@ -0,0 +1,185 @@ +// Every field a script can have, changed one at a time through the editor model. +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import * as YAML from "yaml"; +import { toModel, fromModel, ARG_TYPES } from "../src/lib/editor/script-yaml.js"; + +const dir = new URL("./fixtures/", import.meta.url); +const fixtures = readdirSync(dir).map((f) => [f, readFileSync(new URL(f, dir), "utf8")]); +const concept = readFileSync(new URL("repo-concept.yml", dir), "utf8"); + +// drops what the file can't distinguish: _src, undefined, "", empty lists and empty objects +function norm(v) { + if (Array.isArray(v)) { + const a = v.map(norm).filter((x) => x !== undefined); + return a.length ? a : undefined; + } + if (v && typeof v === "object") { + const o = {}; + for (const [k, x] of Object.entries(v)) { + if (k === "_src") continue; + const n = norm(x); + if (n !== undefined) o[k] = n; + } + return Object.keys(o).length ? o : undefined; + } + return v === "" || v === null || v === undefined ? undefined : String(v); +} +const comments = (t) => t.split("\n").map((l) => l.match(/#.*$/)?.[0]).filter(Boolean); + +for (const [name, text] of fixtures) { + test(`${name}: unchanged round trip is byte for byte`, () => { + assert.equal(fromModel(YAML, text, toModel(YAML, text)), text); + }); +} + +const t = (m) => ({ id: m, location: "BACKEND" }); +const mutations = { + "name": (m) => { m.name = "renamed-cmd"; }, + "description set": (m) => { m.description = "new description"; }, + "description cleared": (m) => { m.description = undefined; }, + "enabled off": (m) => { m.enabled = false; }, + "aliases changed": (m) => { m.aliases = ["a1", "a2"]; }, + "aliases cleared": (m) => { m.aliases = []; }, + "permissions.enabled off": (m) => { m.permissions.enabled = false; }, + "permissions.silent on": (m) => { m.permissions.silent = true; }, + "register target added": (m) => { m.register.push(t("hub-1")); }, + "register location changed": (m) => { m.register[0].location = "BACKEND"; }, + "defaults.run-as": (m) => { m.defaults["run-as"] = "OPERATOR"; }, + "defaults.execute added": (m) => { m.defaults.execute.push(t("survival-2")); }, + "defaults.execute cleared": (m) => { m.defaults.execute = []; }, + "defaults.server.target-required": (m) => { m.defaults.server["target-required"] = false; }, + "defaults.server.schedule-online": (m) => { m.defaults.server["schedule-online"] = false; }, + "defaults.server.player-arg": (m) => { m.defaults.server["player-arg"] = "target"; }, + "defaults.delay": (m) => { m.defaults.delay = "5s"; }, + "defaults.cooldown": (m) => { m.defaults.cooldown = "1m"; }, + "defaults.delay zero": (m) => { m.defaults.delay = "0s"; }, + "arg name": (m) => { m.args[0].name = "who"; }, + "arg required": (m) => { m.args[0].required = !m.args[0].required; }, + "arg type": (m) => { m.args[0].type = "OFFLINE_PLAYER"; }, + "arg suggestions": (m) => { m.args[0].suggestions = ["steve", "alex"]; }, + "arg added": (m) => { m.args.push({ name: "extra", required: false, type: "INTEGER", suggestions: ["1"] }); }, + "arg removed": (m) => { m.args.pop(); }, + "args reordered": (m) => { m.args.reverse(); }, + "command text": (m) => { m.commands[0].command = "eco take ${player} ${amount}"; }, + "command run-as": (m) => { m.commands[0]["run-as"] = "PLAYER"; }, + "command execute override": (m) => { m.commands[0].execute = [t("lobby"), { id: "proxy-2", location: "VELOCITY" }]; }, + "command delay": (m) => { m.commands[0].delay = "500ms"; }, + "command cooldown": (m) => { m.commands[0].cooldown = "10s"; }, + "command server override": (m) => { m.commands[0].server = { "target-required": true, "schedule-online": false, "player-arg": "player" }; }, + "command server override cleared": (m) => { m.commands[0].server = undefined; }, + "command added": (m) => { m.commands.push({ command: "say added", delay: "1s", server: { "target-required": false, "schedule-online": true } }); }, + "command removed": (m) => { m.commands.pop(); }, + "commands reordered": (m) => { m.commands.reverse(); }, +}; + +for (const [label, mutate] of Object.entries(mutations)) { + test(`field: ${label}`, () => { + const m = toModel(YAML, concept); + m.defaults.execute ??= []; + m.defaults.server ??= {}; + if (m.commands.length < 2) m.commands.push({ command: "say second" }); + mutate(m); + const out = fromModel(YAML, concept, m); + assert.deepEqual(norm(toModel(YAML, out)), norm(m), "model after save differs"); + if (!/removed|cleared/.test(label)) { + const kept = comments(out); + for (const c of comments(concept)) assert.ok(kept.includes(c), "lost comment: " + c); + } + }); +} + +test("a script without permissions keeps checks on and writes nothing extra", () => { + const src = "version: 4\nname: bare\nregister: []\ndefaults: {}\nargs: []\ncommands:\n - command: say hi\n"; + const m = toModel(YAML, src); + assert.equal(m.permissions.enabled, true); + assert.equal(fromModel(YAML, src, m).includes("enabled: false"), false); +}); + +test("every argument type survives a round trip", () => { + const m = toModel(YAML, concept); + m.args = ARG_TYPES.map((type, i) => ({ name: "a" + i, required: false, type, suggestions: [] })); + assert.deepEqual(toModel(YAML, fromModel(YAML, concept, m)).args.map((a) => a.type), ARG_TYPES); +}); + +for (const [name, text] of fixtures) { + test(`${name}: editing one command changes exactly one line`, () => { + const m = toModel(YAML, text); + m.commands[0].command = "say edited"; + const a = text.split("\n"), b = fromModel(YAML, text, m).split("\n"); + assert.equal(b.length, a.length); + const changed = a.filter((l, i) => l !== b[i]); + assert.equal(changed.length, 1, "changed lines: " + JSON.stringify(changed)); + }); +} + +test("command server overrides keep their type", () => { + const m = toModel(YAML, concept); + m.commands[0].server = { "target-required": false, "schedule-online": true, "player-arg": "player" }; + const back = toModel(YAML, fromModel(YAML, concept, m)).commands[0].server; + assert.deepEqual(back, { "target-required": false, "schedule-online": true, "player-arg": "player" }); +}); + +test("a new argument spells out every field", () => { + const m = toModel(YAML, concept); + m.args.push({ name: "extra", required: false, type: "STRING", suggestions: [] }); + const added = YAML.parse(fromModel(YAML, concept, m)).args.at(-1); + assert.deepEqual(added, { name: "extra", required: false, type: "STRING", suggestions: [] }); +}); + +test("keys the file didn't have are inserted in the documented order", () => { + const src = "version: 4\nname: bare\nenabled: true\nregister: []\ndefaults:\n run-as: CONSOLE\nargs: []\ncommands:\n - command: say hi\n"; + const m = toModel(YAML, src); + m.description = "added"; + m.aliases = ["b"]; + m.defaults.cooldown = "1s"; + m.commands[0].delay = "2s"; + m.commands[0]["run-as"] = "PLAYER"; + const out = YAML.parse(fromModel(YAML, src, m)); + assert.deepEqual(Object.keys(out), ["version", "name", "description", "enabled", "aliases", "permissions", "register", "defaults", "args", "commands"]); + assert.deepEqual(Object.keys(out.commands[0]), ["command", "run-as", "delay"]); + assert.deepEqual(out.permissions, { enabled: true, silent: false }, "a missing required block is written out in full"); +}); + +test("items added to an empty inline list are written as a block list", () => { + const src = "version: 4\nname: bare\npermissions:\n enabled: false\nregister: []\ndefaults: {}\nargs: []\ncommands:\n - command: say hi\n"; + const m = toModel(YAML, src); + m.args.push({ name: "who", required: true, type: "STRING", suggestions: [] }); + m.register.push({ id: "proxy-1", location: "VELOCITY" }); + const out = fromModel(YAML, src, m); + assert.match(out, /\nargs:\n - name: who\n/); + assert.match(out, /\nregister:\n - id: proxy-1\n/); +}); + +test("targets without a source index keep their comments by matching id and location", () => { + const m = toModel(YAML, concept); + m.defaults.execute = [{ id: "client-1", location: "BACKEND" }, { id: "added", location: "BACKEND" }]; + const out = fromModel(YAML, concept, m); + assert.ok(out.includes("# Forward the command to this backend server.")); + assert.deepEqual(toModel(YAML, out).defaults.execute.map((t) => t.id), ["client-1", "added"]); +}); + +test("a zero default duration counts as no default", () => { + const m = toModel(YAML, concept); + m.defaults.delay = undefined; + m.defaults.cooldown = ""; + const out = fromModel(YAML, concept, m); + assert.ok(out.includes("delay: 0s") && out.includes("cooldown: 0s")); + m.defaults.delay = "3s"; + assert.equal(toModel(YAML, fromModel(YAML, concept, m)).defaults.delay, "3s"); +}); + +test("a step's server block is written whole, because it replaces the defaults' block", () => { + const m = toModel(YAML, concept); + m.commands[0].server = { "schedule-online": true }; + const back = YAML.parse(fromModel(YAML, concept, m)).commands[0].server; + assert.deepEqual(back, { "target-required": false, "schedule-online": true }); +}); + +test("a partial step server block is kept as is while its effective values don't change", () => { + const src = "version: 4\nname: part\npermissions:\n enabled: true\nregister: []\ndefaults: {}\nargs: []\ncommands:\n - command: say hi\n server:\n schedule-online: false\n"; + const m = toModel(YAML, src); + m.commands[0].server = { "target-required": false, "schedule-online": false }; + assert.equal(fromModel(YAML, src, m), src); +}); diff --git a/tests/script-yaml.test.mjs b/tests/script-yaml.test.mjs new file mode 100644 index 0000000..04bac20 --- /dev/null +++ b/tests/script-yaml.test.mjs @@ -0,0 +1,99 @@ +// node --test tests/ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as YAML from "yaml"; +import { toModel, fromModel } from "../src/lib/editor/script-yaml.js"; + +const EXAMPLE = `# header comment +version: 4 + +name: example +description: An example +enabled: false # Set to true to activate +aliases: [ex] + +permissions: + enabled: true # needs a node + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + delay: 0s + +args: + - name: player + required: true + type: TEXT + +# what runs +commands: + # first step + - command: "msg \${player} hi" + # second step + - command: "say done" + delay: 2s +`; + +const strip = (m) => JSON.parse(JSON.stringify(m, (k, v) => (k === "_src" ? undefined : v))); + +test("unchanged model writes the file back byte for byte", () => { + assert.equal(fromModel(YAML, EXAMPLE, toModel(YAML, EXAMPLE)), EXAMPLE); +}); + +test("editing one command keeps every comment and other line", () => { + const m = toModel(YAML, EXAMPLE); + m.commands[0].command = "msg ${player} hello"; + m.enabled = true; + const out = fromModel(YAML, EXAMPLE, m); + for (const c of ["# header comment", "# Set to true to activate", "# needs a node", "# what runs", "# first step", "# second step"]) { + assert.ok(out.includes(c), "lost comment: " + c); + } + assert.ok(out.includes('msg ${player} hello')); + assert.ok(out.includes("enabled: true # Set to true to activate")); + assert.equal(out.split("\n").length, EXAMPLE.split("\n").length); +}); + +test("reorder, remove and add items; comments travel with their item", () => { + const m = toModel(YAML, EXAMPLE); + m.commands.reverse(); + m.commands[0].delay = undefined; + m.args.push({ name: "reason", required: false, type: "GREEDY_STRING", suggestions: [] }); + const out = fromModel(YAML, EXAMPLE, m); + const back = toModel(YAML, out); + assert.deepEqual(back.commands.map((c) => c.command), ["say done", "msg ${player} hi"]); + assert.equal(back.commands[0].delay, undefined); + assert.deepEqual(back.args.map((a) => a.name), ["player", "reason"]); + // a comment on a later item moves with it (the first item's comment belongs to the list) + assert.ok(out.indexOf("# second step") < out.indexOf("say done")); + assert.ok(out.indexOf("say done") < out.indexOf("msg ${player} hi")); +}); + +test("new script from a model parses back to the same model", () => { + const m = { + version: 4, name: "fresh", enabled: true, aliases: [], permissions: { enabled: false, silent: false }, + register: [{ id: "proxy-1", location: "VELOCITY" }], + defaults: { "run-as": "CONSOLE", execute: [{ id: "lobby", location: "BACKEND" }] }, + args: [{ name: "msg", required: true, type: "TEXT", suggestions: [] }], + commands: [{ command: "say ${msg}" }], + }; + const out = fromModel(YAML, null, m); + assert.ok(!out.includes("server:"), "empty server block written"); + const back = strip(toModel(YAML, out)); + assert.deepEqual(back.commands, m.commands); + assert.deepEqual(back.register, m.register); + assert.deepEqual(back.defaults.execute, m.defaults.execute); + assert.equal(back.name, "fresh"); +}); + +test("the shipped example script round-trips unchanged", () => { + const text = readFileSync(new URL("../../CommandBridge/velocity/src/main/resources/example.yml", import.meta.url), "utf8"); + assert.equal(fromModel(YAML, text, toModel(YAML, text)), text); +}); From 9ac87e79697b79a02eacff10313bfcebafdd5963 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 26 Oct 2026 18:39:10 +0100 Subject: [PATCH 02/14] feat: network setup page at /setup --- src/editor.njk | 1 + src/lib/editor/editor.js | 47 ++---- src/lib/editor/relay.js | 48 ++++++ src/lib/setup/setup.css | 88 +++++++++++ src/lib/setup/setup.js | 295 +++++++++++++++++++++++++++++++++++++ src/setup.njk | 270 +++++++++++++++++++++++++++++++++ tests/browser/setup-ui.mjs | 148 +++++++++++++++++++ 7 files changed, 859 insertions(+), 38 deletions(-) create mode 100644 src/lib/editor/relay.js create mode 100644 src/lib/setup/setup.css create mode 100644 src/lib/setup/setup.js create mode 100644 src/setup.njk create mode 100644 tests/browser/setup-ui.mjs diff --git a/src/editor.njk b/src/editor.njk index b1e2044..372359a 100644 --- a/src/editor.njk +++ b/src/editor.njk @@ -11,6 +11,7 @@ eleventyExcludeFromCollections: true +
diff --git a/src/lib/editor/editor.js b/src/lib/editor/editor.js index 8596c6f..cad055b 100644 --- a/src/lib/editor/editor.js +++ b/src/lib/editor/editor.js @@ -1,12 +1,6 @@ // CommandBridge script editor: every script as a node graph, live on the proxy through the relay. // The YAML side lives in script-yaml.js; positions and groups go to the proxy's editor-layout.json. -const RELAY = location.hostname === "localhost" - ? "ws://localhost:8787/api/editor/" - : "wss://cb.objz.dev/api/editor/"; - -const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -const unb64url = (t) => Uint8Array.from(atob(t.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const splitList = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean); const isZero = (v) => v != null && /^\s*0+(\.0+)?\s*(ms|s|m|h)?\s*$/i.test(String(v)); @@ -298,32 +292,22 @@ function cbEditor() { pop: null, // { x, y, title, items: [{ label, hint, kind, keys, run, sub }] } for port and context menus tool: "select", // left-drag on the canvas: "select" draws a selection box, "grab" pans wireX: null, // { x, y } of the delete button on a selected wire - ws: null, key: null, sendSeq: 0, recvSeq: 0, - conn: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""), - queue: Promise.resolve(), + relay: null, justSaved: [], deferred: false, removingNodes: false, dragId: 0, - // WebCrypto is async; chain sends and receives so sequence numbers stay in order - enqueue(fn) { this.queue = this.queue.then(fn).catch((e) => console.error(e)); }, - async init() { - const [id, secret] = location.hash.slice(1).split(":"); - if (!/^[0-9a-f]{32}$/.test(id || "") || !secret) { this.status = "nolink"; return; } + const relay = window.cbRelay(location.hash); + if (!relay) { this.status = "nolink"; return; } [YAML, conv] = await Promise.all([import("/js/yaml/index.js"), import("/lib/editor/script-yaml.js")]); window.cbArgTypes = conv.ARG_TYPES; this.setupCanvas(); - this.key = await crypto.subtle.importKey("raw", unb64url(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); - this.ws = new WebSocket(RELAY + id + "/ws?role=editor"); - this.ws.onopen = () => this.send({ type: "hello" }); - this.ws.onmessage = (e) => this.enqueue(() => this.receive(e.data)); - this.ws.onclose = (e) => { - this.status = "closed"; - // 1006 without a reason means the relay refused the connection or was unreachable - this.closeReason = e.reason || (e.code === 1006 ? "could not join the session" : "code " + e.code); - }; + relay.onOpen = () => this.send({ type: "hello" }); + relay.onMessage = (msg) => this.receive(msg); + relay.onClose = (reason) => { this.status = "closed"; this.closeReason = reason; }; + this.relay = relay.connect(); window.addEventListener("beforeunload", (e) => { if (this.changes()) e.preventDefault(); }); }, @@ -2748,23 +2732,10 @@ function cbEditor() { /* ================= connection ================= */ send(msg) { - this.enqueue(async () => { - const body = JSON.stringify(msg); - const seq = ++this.sendSeq; - const mac = await crypto.subtle.sign("HMAC", this.key, new TextEncoder().encode(`editor\n${this.conn}\n${seq}\n${body}`)); - this.ws.send(JSON.stringify({ conn: this.conn, seq, body, mac: b64url(mac) })); - }); + this.relay.send(msg); }, - async receive(frame) { - const { conn, seq, body, mac } = JSON.parse(frame); - // replies are bound to this page load, so old proxy messages can't be replayed into it - const ok = typeof body === "string" && conn === this.conn && seq > this.recvSeq && - await crypto.subtle.verify("HMAC", this.key, unb64url(mac), new TextEncoder().encode(`proxy\n${conn}\n${seq}\n${body}`)); - if (!ok) { console.warn("Dropped an unsigned or replayed message"); return; } - this.recvSeq = seq; - const msg = JSON.parse(body); - + receive(msg) { if (msg.type === "state") { const first = this.status !== "online"; // files we just saved or deleted changed because of us; anything else changed on the proxy diff --git a/src/lib/editor/relay.js b/src/lib/editor/relay.js new file mode 100644 index 0000000..2882088 --- /dev/null +++ b/src/lib/editor/relay.js @@ -0,0 +1,48 @@ +// The page's end of a proxy session on cb.objz.dev: the link's #id:secret names the relay channel and signs +// every message, so the relay only forwards. Used by the editor and the setup page. + +window.cbRelay = (hash) => { + const [id, secret] = String(hash).replace(/^#/, "").split(":"); + if (!/^[0-9a-f]{32}$/.test(id || "") || !secret) return null; + const base = location.hostname === "localhost" ? "ws://localhost:8787/api/editor/" : "wss://cb.objz.dev/api/editor/"; + const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const unb64url = (t) => Uint8Array.from(atob(t.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); + const enc = (t) => new TextEncoder().encode(t); + // replies are bound to this page load, so old proxy messages can't be replayed into it + const conn = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""); + let ws, sendSeq = 0, recvSeq = 0; + // WebCrypto is async; chain sends and receives so sequence numbers stay in order + let queue = Promise.resolve(); + const enqueue = (fn) => { queue = queue.then(fn).catch((e) => console.error(e)); }; + const keyP = crypto.subtle.importKey("raw", unb64url(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); + + const relay = { + onOpen: () => {}, + onMessage: () => {}, + onClose: () => {}, + send(msg) { + enqueue(async () => { + const body = JSON.stringify(msg); + const seq = ++sendSeq; + const mac = await crypto.subtle.sign("HMAC", await keyP, enc(`editor\n${conn}\n${seq}\n${body}`)); + ws.send(JSON.stringify({ conn, seq, body, mac: b64url(mac) })); + }); + }, + connect() { + ws = new WebSocket(base + id + "/ws?role=editor"); + ws.onopen = () => relay.onOpen(); + ws.onmessage = (e) => enqueue(async () => { + const { conn: c, seq, body, mac } = JSON.parse(e.data); + const ok = typeof body === "string" && c === conn && seq > recvSeq && + await crypto.subtle.verify("HMAC", await keyP, unb64url(mac), enc(`proxy\n${c}\n${seq}\n${body}`)); + if (!ok) { console.warn("Dropped an unsigned or replayed message"); return; } + recvSeq = seq; + await relay.onMessage(JSON.parse(body)); + }); + // 1006 without a reason means the relay refused the connection or was unreachable + ws.onclose = (e) => relay.onClose(e.reason || (e.code === 1006 ? "could not join the session" : "code " + e.code)); + return relay; + }, + }; + return relay; +}; diff --git a/src/lib/setup/setup.css b/src/lib/setup/setup.css new file mode 100644 index 0000000..ad88d09 --- /dev/null +++ b/src/lib/setup/setup.css @@ -0,0 +1,88 @@ +/* Network setup: the editor's frame and nodes (editor.css), laid out one question at a time. */ + +.setup-who { display: flex; align-items: center; gap: 9px; font-weight: 600; } +.setup-who .logo { width: 18px; height: 18px; } +.setup-nav { display: flex; align-items: center; gap: 8px; } + +/* the steps on the left, styled like the YAML panel's file list */ +.setup-steps { width: 220px; padding: 10px 8px; } +.setup-steps .ed-file { height: auto; min-height: 30px; padding: 6px 8px; align-items: flex-start; } +.setup-steps .ed-file:disabled { cursor: default; } +.setup-steps .ed-file:disabled:hover { background: none; color: #a1a1aa; } +.setup-steps .ed-file.on:disabled:hover { background: var(--line-soft); color: var(--node-fg); } +.setup-steps .ed-file-n { font-family: inherit; font-size: 13px; white-space: normal; } +.setup-steps .ed-file small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--node-muted); } +.setup-mark { flex-shrink: 0; display: grid; place-items: center; width: 18px; height: 18px; margin-top: 1px; border-radius: 50%; + font-size: 10.5px; font-weight: 600; border: 1.5px solid var(--node-line); color: var(--node-muted); } +.setup-steps .done .setup-mark { border: none; background: #166534; color: #bbf7d0; } +.setup-steps .on .setup-mark { border-color: var(--node-fg); color: var(--node-fg); } + +/* one question in the middle of the canvas */ +.setup #ed-canvas { overflow: auto; } +.setup-q, .setup-head { position: relative; width: min(520px, calc(100% - 32px)); margin: 44px auto 0; } +.setup-q .drawflow-node, .setup-sum .drawflow-node { position: relative; width: 100% !important; } +.ask { margin: 0 0 4px; font-size: 18px; font-weight: 600; letter-spacing: -.01em; color: hsl(var(--foreground)); } +.ask-why { margin: 0 0 18px; font-size: 13.5px; line-height: 1.55; color: hsl(var(--muted-foreground)); } +.setup .hint { margin: 12px 0 0; font-size: 12.5px; color: hsl(var(--muted-foreground)); } +.setup .hint b { margin-right: 6px; padding: 1px 6px; border: 1px solid hsl(var(--border)); border-radius: 5px; font-weight: 500; color: hsl(var(--foreground)); } +.setup .drawflow-node .hint { margin: 0; color: var(--node-muted); } +.setup-err { margin: 10px 0 0; font-size: 13px; color: var(--c-bad); } +.setup-def { flex-shrink: 0; margin-left: 8px; padding: 2px 8px; font-size: 11px; border-radius: 999px; background: var(--hover); color: var(--node-muted); } +.setup-ro { font-size: 13px; color: var(--node-muted); } +#ed-canvas .fc > input[type=password] { width: 100%; min-width: 0; height: 32px; padding: 0 9px; font-size: 13px; border-radius: 7px; + border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); } +.setup-badge { margin-left: auto; padding: 2px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; background: rgb(0 0 0 / .25); white-space: nowrap; } +.setup .logo.unknown { filter: grayscale(1); opacity: .7; } + +/* the servers list */ +.srow { display: grid; grid-template-columns: 38px 16px minmax(0, 1fr) minmax(0, 1fr) 22px; align-items: center; gap: 10px; padding: 8px 14px; border-top: 1px solid var(--node-line); font-size: 13px; } +.srow:first-child { border-top: none; } +.srow-id { font-weight: 600; overflow: hidden; text-overflow: ellipsis; } +.srow-addr { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--node-muted); overflow: hidden; text-overflow: ellipsis; } +.srow.off { color: var(--node-muted); } +.srow.off .logo { filter: grayscale(1); opacity: .6; } +.srow-new { grid-template-columns: 38px 16px minmax(0, 1fr) minmax(0, 1fr); } +#ed-canvas .srow-new .fc > input { height: 28px; } +.srow-x, .srow-add { display: flex; align-items: center; border: none; background: none; color: var(--node-muted); cursor: pointer; } +.srow-x { justify-content: center; width: 22px; height: 22px; border-radius: 6px; } +.srow-x:hover, .srow-add:hover { color: var(--node-fg); background: var(--hover); } +.srow-add { gap: 6px; width: 100%; padding: 9px 14px; border-top: 1px solid var(--node-line); border-radius: 0 0 9px 9px; font-size: 13px; } + +/* pairing: the servers wired to the proxy, a wire dashed until its server paired */ +.setup-net { position: relative; min-height: 100%; padding-bottom: 40px; } +.setup-wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; } +.pair-wire { fill: none; stroke: var(--c-srv); stroke-width: 3; transition: opacity .3s; } +.pair-wire.dashed { stroke-dasharray: 7 6; opacity: .7; } +.setup-cols { display: flex; align-items: center; justify-content: center; gap: 120px; width: max-content; min-width: 100%; margin-top: 28px; } +.setup-col { display: flex; flex-direction: column; gap: 22px; } +.setup-col .drawflow-node { position: relative; width: 330px !important; } +.setup-port { position: absolute; right: -8px; top: 49px; width: 16px; height: 16px; background: var(--c-srv); clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); } +.setup-port.in { right: auto; left: -8px; } +.badge.live::before, .badge.wait::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; vertical-align: 1px; } +.badge.live::before { background: #4ade80; } +.badge.wait::before { background: #fbbf24; animation: setup-pulse 1.4s ease-in-out infinite; } +@keyframes setup-pulse { 50% { opacity: .3; } } +.setup-code { display: flex; align-items: center; gap: 8px; } +.setup-code code { flex: 1; padding: 6px 9px; border-radius: 7px; border: 1px solid var(--node-line); background: var(--field-bg); + font: 600 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; user-select: all; } +.setup-code .ed-btn { height: 30px; } + +/* apply: a summary and the confirm card */ +.setup-apply { position: relative; padding-top: 40px; } +.setup-apply .ed-confirm { top: 12px; } +.setup-apply .setup-head { width: min(680px, calc(100% - 32px)); } +.setup-sum { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; width: min(680px, calc(100% - 32px)); margin: 0 auto; } +.setup-kv { display: grid; grid-template-columns: 116px minmax(0, 1fr); row-gap: 8px; padding: 12px 14px; font-size: 13px; } +.setup-kv > span:nth-child(odd), .setup-kv > span > span:first-child { color: var(--node-muted); } +.setup-result { width: min(680px, calc(100% - 32px)); margin: 16px auto 0; font-size: 13.5px; } +.setup-result p { margin: 0 0 8px; } +.setup-restart { color: var(--c-srv); font-weight: 500; } +.setup-download { margin-left: 4px; } + +@media (max-width: 900px) { + .setup-steps { display: none; } + .setup-sum { grid-template-columns: 1fr; } + .setup-cols { flex-direction: column; gap: 40px; } + .setup-wires { display: none; } +} +@media (prefers-reduced-motion: reduce) { .badge.wait::before { animation: none; } } diff --git a/src/lib/setup/setup.js b/src/lib/setup/setup.js new file mode 100644 index 0000000..fdf7645 --- /dev/null +++ b/src/lib/setup/setup.js @@ -0,0 +1,295 @@ +// CommandBridge network setup: one question per screen, then pairing and apply, live on the proxy through the relay. +// Nothing here writes anything: the proxy keeps the plan and applies it after /cb setup confirm in its console. + +const LOGOS = { PAPER: "paper.svg", FOLIA: "folia.svg", VELOCITY: "velocity.svg", FORGE: "forge.svg", SPIGOT: "spigot.svg", BUKKIT: "spigot.svg", FABRIC: "fabric.png" }; +const TLS = { TOFU: "first use", PLAIN: "off", STRICT: "strict" }; +const PLATFORM = { PAPER: "Paper", FOLIA: "Folia", BUKKIT: "Bukkit", SPIGOT: "Spigot", FORGE: "Forge", FABRIC: "Fabric", VELOCITY: "Velocity" }; + +function cbSetup() { + return { + status: "connecting", // connecting | online | closed | nolink + closeReason: "", + relay: null, + current: null, + known: [], // servers from velocity.toml: { id, address, connected, platform } + a: null, // the answers + step: "name", + err: "", // what's wrong with the answer on this screen + problems: [], // what the proxy said about the plan + codes: {}, + paired: {}, + staged: null, + applied: null, // { restartNeeded, errors, configs } + copied: "", + newRow: null, // { id, address } while adding a server + waiting: false, // a plan was sent, the codes aren't back yet + wires: [], + + init() { + const relay = window.cbRelay(location.hash); + if (!relay) { this.status = "nolink"; return; } + relay.onOpen = () => relay.send({ type: "hello" }); + relay.onMessage = (msg) => this.receive(msg); + relay.onClose = (reason) => { this.status = "closed"; this.closeReason = reason; }; + this.relay = relay.connect(); + window.addEventListener("resize", () => this.layoutWires()); + }, + + statusText() { + return { connecting: "Connecting…", closed: "Session ended: " + this.closeReason }[this.status] ?? ""; + }, + + /* ================= steps ================= */ + + steps() { + if (!this.a) return []; + const ws = this.a.endpointType === "WEBSOCKET"; + return [ + { k: "name", t: "Name", s: this.a.serverId }, + { k: "type", t: "Connection", s: ws ? "WebSocket" : "Redis" }, + ws && { k: "port", t: "Port", s: String(this.a.port) }, + { k: "address", t: ws ? "Address" : "Redis", s: ws ? this.a.address : `${this.a.redis.host}:${this.a.redis.port}` }, + { k: "tls", t: "Encryption", s: TLS[this.a.tlsMode] }, + this.a.tlsMode === "STRICT" && { k: "keystore", t: "Keystore", s: this.a.keystore.path }, + { k: "servers", t: "Servers", s: this.picked().map((s) => s.id).join(", ") || "none" }, + { k: "pairing", t: "Pairing", s: `${this.pairedCount()} of ${this.picked().length} paired` }, + { k: "apply", t: "Apply", s: this.applied ? "applied" : "" }, + ].filter(Boolean); + }, + index() { return this.steps().findIndex((s) => s.k === this.step); }, + isDone(k) { return this.steps().findIndex((s) => s.k === k) < this.index(); }, + + go(k) { + if (!this.isDone(k) || this.step === "apply" && this.applied) return; + if (this.step === "apply" && this.staged) this.relay.send({ type: "unstage" }); + this.err = ""; + this.step = k; + this.focus(); + }, + + back() { + const st = this.steps(), i = this.index(); + if (i > 0) this.go(st[i - 1].k); + }, + + next() { + if (this.status !== "online" || this.nextDisabled()) return; + this.err = this.check(); + if (this.err) return; + if (this.step === "servers") { this.sendPlan(); return; } + const st = this.steps(), i = this.index(); + this.step = st[i + 1].k; + if (this.step === "apply") this.relay.send({ type: "stage" }); + this.focus(); + }, + + nextDisabled() { + if (this.step === "pairing") return this.pairedCount() < this.picked().length; + if (this.step === "servers") return this.waiting || !this.picked().length; + return this.step === "apply"; + }, + + nextLabel() { + if (this.step === "servers") return this.waiting ? "Making codes…" : `Continue with ${this.picked().length} ${this.picked().length === 1 ? "server" : "servers"}`; + if (this.step === "pairing") { + const left = this.picked().length - this.pairedCount(); + return left ? `Waiting for ${left} ${left === 1 ? "server" : "servers"}` : "Continue"; + } + if (this.step === "apply") return this.applied ? "Applied" : "Waiting for confirm"; + return "Continue"; + }, + + // the same rules the proxy checks, so most mistakes show before the plan is sent + check() { + const a = this.a; + if (this.step === "name" && !/^[a-z0-9-]{1,32}$/.test(a.serverId)) return "Use a to z, 0 to 9 and dashes, up to 32 characters."; + if (this.step === "port" && !(a.port >= 1 && a.port <= 65535)) return "The port must be between 1 and 65535."; + if (this.step === "address" && a.endpointType === "WEBSOCKET") { + if (!a.address.trim()) return "Enter the address servers use to reach the proxy."; + if (a.address.trim() === "0.0.0.0") return "Servers can't reach 0.0.0.0. Use the address they connect to."; + } + if (this.step === "address" && a.endpointType === "REDIS" && (!a.redis.host.trim() || !(a.redis.port >= 1 && a.redis.port <= 65535))) + return "Enter the Redis host and a port between 1 and 65535."; + if (this.step === "keystore" && !a.keystore.path.trim()) return "Enter the path of the keystore file."; + if (this.step === "servers" && this.newRow && (this.newRow.id || this.newRow.address)) return this.addRow() || ""; + return ""; + }, + + focus() { + this.$nextTick(() => { + const q = document.querySelector(`#ed-canvas [data-step="${this.step}"]`); + q?.querySelector("input[type=text], input:checked")?.focus(); + this.layoutWires(); + }); + }, + + keydown(e) { + if (e.key !== "Enter" || e.isComposing) return; + if (e.target.matches("[data-f=new-id], [data-f=new-address]")) { e.preventDefault(); this.err = this.addRow(); return; } + if (e.target.closest("button")) return; + e.preventDefault(); + this.next(); + }, + + /* ================= servers ================= */ + + picked() { return this.a ? this.a.servers.filter((s) => s.on) : []; }, + pairedCount() { return this.picked().filter((s) => this.paired[s.id]).length; }, + logo(s) { + const p = this.paired[s.id]?.platform || s.platform; + return "/lib/editor/logos/" + (LOGOS[p] || "paper.svg"); + }, + hasLogo(s) { return !!LOGOS[this.paired[s.id]?.platform || s.platform]; }, + software(s) { + const p = this.paired[s.id]; + if (!p) return "run in its console"; + return [PLATFORM[p.platform] || p.platform, p.version].filter(Boolean).join(" ") || "paired"; + }, + added() { return this.picked().filter((s) => s.isNew); }, + + startRow() { this.newRow = { id: "", address: "" }; this.$nextTick(() => document.querySelector("[data-f=new-id]")?.focus()); }, + addRow() { + const r = this.newRow; + if (!r) return ""; + const id = r.id.trim(), address = r.address.trim(); + if (!id || !address) return "A new server needs a name and an address."; + if (this.a.servers.some((s) => s.id === id)) return `There is already a server called ${id}.`; + if (!/^[^\s:]+:\d{1,5}$/.test(address)) return "Write the address as host:port, like 10.0.0.13:25565."; + this.a.servers.push({ id, address, on: true, isNew: true, platform: null }); + this.newRow = null; + return ""; + }, + removeRow(s) { this.a.servers = this.a.servers.filter((x) => x !== s); }, + + // a different connection means every server pairs again + connectionChanged() { + const a = this.a, c = this.current; + return a.endpointType !== c.endpointType || a.tlsMode !== c.tlsMode || (a.endpointType === "WEBSOCKET" && a.port !== c.port); + }, + + /* ================= plan, pairing, apply ================= */ + + planJson() { + const a = this.a, ws = a.endpointType === "WEBSOCKET"; + return { + serverId: a.serverId.trim(), + endpointType: a.endpointType, + port: Number(a.port), + address: ws ? a.address.trim() : "", + redis: ws ? null : { host: a.redis.host.trim(), port: Number(a.redis.port), username: a.redis.username, password: a.redis.password || null }, + tlsMode: a.tlsMode, + keystore: a.tlsMode === "STRICT" ? { path: a.keystore.path.trim(), type: a.keystore.type, password: a.keystore.password || null } : null, + servers: this.picked().map((s) => ({ id: s.id, address: s.address, addToVelocity: !!s.isNew })), + }; + }, + + sendPlan() { + this.problems = []; + this.waiting = true; + this.relay.send({ type: "plan", plan: this.planJson() }); + }, + + later() { + this.step = "apply"; + this.relay.send({ type: "stage" }); + }, + + async copy(text) { + await navigator.clipboard?.writeText(text).catch(() => {}); + this.copied = text; + setTimeout(() => { if (this.copied === text) this.copied = ""; }, 1500); + }, + + download() { + for (const [id, yml] of Object.entries(this.applied?.configs || {})) { + const link = document.createElement("a"); + link.href = URL.createObjectURL(new Blob([yml], { type: "text/yaml" })); + link.download = `${id}-config.yml`; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + } + }, + + /* ================= connection ================= */ + + receive(msg) { + if (msg.type === "state") { + const first = !this.a; + this.current = msg.current; + this.known = msg.servers || []; + this.codes = msg.codes || {}; + this.paired = msg.paired || {}; + this.staged = msg.staged || null; + if (first) { + this.a = this.answersFrom(msg); + // a reload in the middle of the setup lands where it was + this.step = msg.plan ? (msg.staged ? "apply" : "pairing") : "name"; + setInterval(() => this.relay.send({ type: "ping" }), 60_000); + } else if (this.waiting) { + this.waiting = false; + this.step = "pairing"; + } + this.status = "online"; + this.focus(); + } else if (msg.type === "plan-error") { + this.waiting = false; + if (this.step === "apply") this.step = "servers"; + this.problems = msg.problems || []; + } else if (msg.type === "paired") { + this.paired = { ...this.paired, [msg.id]: { platform: msg.platform, version: msg.version } }; + delete this.codes[msg.id]; + this.$nextTick(() => this.layoutWires()); + } else if (msg.type === "staged") { + this.staged = msg.code; + } else if (msg.type === "applied") { + this.staged = null; + this.applied = { restartNeeded: !!msg.restartNeeded, errors: msg.errors || [], configs: msg.configs || {} }; + } + }, + + answersFrom(msg) { + const c = msg.current, p = msg.plan; + const fromToml = (msg.servers || []).map((s) => ({ id: s.id, address: s.address, on: true, isNew: false, platform: s.platform })); + const servers = p + ? [...fromToml.map((s) => ({ ...s, on: p.servers.some((x) => x.id === s.id) })), + ...p.servers.filter((x) => x.addToVelocity).map((x) => ({ id: x.id, address: x.address, on: true, isNew: true, platform: null }))] + : fromToml; + // a guess for the address: the bind host when it's one servers can reach + const guess = msg.saved?.address || (/^(0\.0\.0\.0|127\.|localhost|::)/.test(c.bindHost || "") ? "" : c.bindHost || ""); + return { + serverId: p?.serverId ?? c.serverId, + endpointType: p?.endpointType ?? c.endpointType, + port: p?.port || c.port, + address: p?.address ?? guess, + redis: { host: p?.redis?.host ?? c.redis.host, port: p?.redis?.port ?? c.redis.port, username: p?.redis?.username ?? c.redis.username, password: "" }, + tlsMode: p?.tlsMode ?? c.tlsMode, + keystore: { path: p?.keystore?.path ?? c.keystore.path, type: p?.keystore?.type ?? (c.keystore.type || "PKCS12"), password: "" }, + servers, + }; + }, + + /* ================= the network drawing on the pairing screen ================= */ + + //