From b0aac503db1f023af6ddfe7f8b9c8e070ecf22f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 03:06:45 +0000 Subject: [PATCH] Rewrite the local web UI with the gd5 kit. Serve the vendored gd5 engine and orange UI components from /webui, and keep the same run/stop API for the command line and node runtime pages. Co-authored-by: Fan Feilong --- MANIFEST.in | 2 + README.md | 6 +- setup.cfg | 6 + setup.py | 2 +- src/pyouter/node.py | 10 + src/pyouter/web.py | 10 + src/pyouter/web_page.py | 1018 +------------- src/pyouter/webui/HomePage.css | 238 ++++ src/pyouter/webui/HomePage.js | 677 +++++++++ src/pyouter/webui/__init__.py | 63 + src/pyouter/webui/app.js | 24 + src/pyouter/webui/gd5/ORIGIN.txt | 2 + .../webui/gd5/engine/component-render.js | 274 ++++ src/pyouter/webui/gd5/engine/component.js | 346 +++++ src/pyouter/webui/gd5/engine/element.js | 418 ++++++ src/pyouter/webui/gd5/engine/index.js | 35 + src/pyouter/webui/gd5/engine/log.js | 450 ++++++ src/pyouter/webui/gd5/engine/styles.js | 418 ++++++ src/pyouter/webui/gd5/ui/CommandPalette.js | 128 ++ src/pyouter/webui/gd5/ui/CoverFlow.js | 685 +++++++++ src/pyouter/webui/gd5/ui/Drawer.js | 116 ++ src/pyouter/webui/gd5/ui/EChart.js | 160 +++ src/pyouter/webui/gd5/ui/JsonTree.js | 531 +++++++ src/pyouter/webui/gd5/ui/Lightbox.js | 87 ++ src/pyouter/webui/gd5/ui/LoadingButton.js | 139 ++ src/pyouter/webui/gd5/ui/Modal.js | 169 +++ src/pyouter/webui/gd5/ui/Notification.js | 123 ++ src/pyouter/webui/gd5/ui/PeleView.js | 249 ++++ src/pyouter/webui/gd5/ui/README.md | 5 + src/pyouter/webui/gd5/ui/SparkView.js | 372 +++++ src/pyouter/webui/gd5/ui/ThreeView.js | 242 ++++ src/pyouter/webui/gd5/ui/Toast.js | 89 ++ src/pyouter/webui/gd5/ui/WorkflowDAG.js | 438 ++++++ src/pyouter/webui/gd5/ui/accordion.js | 51 + src/pyouter/webui/gd5/ui/affix.js | 60 + src/pyouter/webui/gd5/ui/avatar.js | 81 ++ src/pyouter/webui/gd5/ui/banner.js | 48 + src/pyouter/webui/gd5/ui/breadcrumbs.js | 57 + src/pyouter/webui/gd5/ui/button.js | 136 ++ src/pyouter/webui/gd5/ui/calendar.js | 93 ++ src/pyouter/webui/gd5/ui/card.js | 31 + src/pyouter/webui/gd5/ui/carousel.js | 65 + src/pyouter/webui/gd5/ui/cascader.js | 86 ++ src/pyouter/webui/gd5/ui/chart.js | 84 ++ src/pyouter/webui/gd5/ui/chat.js | 31 + src/pyouter/webui/gd5/ui/chip.js | 57 + src/pyouter/webui/gd5/ui/code.js | 615 +++++++++ src/pyouter/webui/gd5/ui/config.js | 41 + src/pyouter/webui/gd5/ui/control.js | 147 ++ src/pyouter/webui/gd5/ui/css/antd-gap.css | 528 +++++++ src/pyouter/webui/gd5/ui/css/button.css | 251 ++++ src/pyouter/webui/gd5/ui/css/card.css | 33 + src/pyouter/webui/gd5/ui/css/code-block.css | 207 +++ src/pyouter/webui/gd5/ui/css/control.css | 210 +++ src/pyouter/webui/gd5/ui/css/coverflow.css | 213 +++ src/pyouter/webui/gd5/ui/css/embed.css | 82 ++ src/pyouter/webui/gd5/ui/css/extra.css | 1221 +++++++++++++++++ src/pyouter/webui/gd5/ui/css/feedback.css | 99 ++ src/pyouter/webui/gd5/ui/css/input.css | 81 ++ src/pyouter/webui/gd5/ui/css/jelly.css | 73 + src/pyouter/webui/gd5/ui/css/json-tree.css | 170 +++ src/pyouter/webui/gd5/ui/css/layout.css | 59 + .../webui/gd5/ui/css/markdown-editor.css | 237 ++++ src/pyouter/webui/gd5/ui/css/modal.css | 74 + src/pyouter/webui/gd5/ui/css/todo.css | 174 +++ src/pyouter/webui/gd5/ui/css/tokens.css | 81 ++ src/pyouter/webui/gd5/ui/css/typography.css | 69 + src/pyouter/webui/gd5/ui/css/ui.css | 19 + src/pyouter/webui/gd5/ui/css/workflow.css | 188 +++ src/pyouter/webui/gd5/ui/drag.js | 26 + src/pyouter/webui/gd5/ui/feedback.js | 67 + src/pyouter/webui/gd5/ui/flex.js | 95 ++ src/pyouter/webui/gd5/ui/float.js | 46 + src/pyouter/webui/gd5/ui/form-extra.js | 331 +++++ src/pyouter/webui/gd5/ui/form.js | 58 + src/pyouter/webui/gd5/ui/icon.js | 57 + src/pyouter/webui/gd5/ui/image.js | 48 + src/pyouter/webui/gd5/ui/index.js | 226 +++ src/pyouter/webui/gd5/ui/input-number.js | 61 + src/pyouter/webui/gd5/ui/input.js | 72 + src/pyouter/webui/gd5/ui/jelly-body.js | 669 +++++++++ src/pyouter/webui/gd5/ui/jelly-review.js | 116 ++ src/pyouter/webui/gd5/ui/jelly.js | 519 +++++++ src/pyouter/webui/gd5/ui/layout-extra.js | 149 ++ src/pyouter/webui/gd5/ui/layout.js | 72 + src/pyouter/webui/gd5/ui/list.js | 156 +++ src/pyouter/webui/gd5/ui/markdown.js | 545 ++++++++ src/pyouter/webui/gd5/ui/menu.js | 103 ++ src/pyouter/webui/gd5/ui/navigation.js | 195 +++ src/pyouter/webui/gd5/ui/overlay.js | 176 +++ src/pyouter/webui/gd5/ui/pagination.js | 70 + src/pyouter/webui/gd5/ui/progress.js | 121 ++ src/pyouter/webui/gd5/ui/radio.js | 66 + src/pyouter/webui/gd5/ui/rate.js | 48 + src/pyouter/webui/gd5/ui/result.js | 111 ++ src/pyouter/webui/gd5/ui/skeleton.js | 41 + src/pyouter/webui/gd5/ui/space.js | 44 + src/pyouter/webui/gd5/ui/steps.js | 83 ++ src/pyouter/webui/gd5/ui/table.js | 95 ++ src/pyouter/webui/gd5/ui/tabs.js | 78 ++ src/pyouter/webui/gd5/ui/text-extra.js | 127 ++ src/pyouter/webui/gd5/ui/theme.js | 92 ++ src/pyouter/webui/gd5/ui/timeline.js | 38 + src/pyouter/webui/gd5/ui/todo.js | 274 ++++ src/pyouter/webui/gd5/ui/tree-select.js | 78 ++ src/pyouter/webui/gd5/ui/tree.js | 222 +++ src/pyouter/webui/gd5/ui/typography.js | 47 + src/pyouter/webui/gd5/ui/upload.js | 173 +++ src/pyouter/webui/gd5/ui/util.js | 66 + src/pyouter/webui/gd5/ui/watermark.js | 45 + src/pyouter/webui/labels.js | 42 + src/pyouter/webui/labels.py | 22 + src/pyouter/webui/store.js | 351 +++++ src/tests/test_web.py | 8 + 114 files changed, 18410 insertions(+), 1003 deletions(-) create mode 100644 MANIFEST.in create mode 100644 src/pyouter/webui/HomePage.css create mode 100644 src/pyouter/webui/HomePage.js create mode 100644 src/pyouter/webui/__init__.py create mode 100644 src/pyouter/webui/app.js create mode 100644 src/pyouter/webui/gd5/ORIGIN.txt create mode 100644 src/pyouter/webui/gd5/engine/component-render.js create mode 100644 src/pyouter/webui/gd5/engine/component.js create mode 100644 src/pyouter/webui/gd5/engine/element.js create mode 100644 src/pyouter/webui/gd5/engine/index.js create mode 100644 src/pyouter/webui/gd5/engine/log.js create mode 100644 src/pyouter/webui/gd5/engine/styles.js create mode 100644 src/pyouter/webui/gd5/ui/CommandPalette.js create mode 100644 src/pyouter/webui/gd5/ui/CoverFlow.js create mode 100644 src/pyouter/webui/gd5/ui/Drawer.js create mode 100644 src/pyouter/webui/gd5/ui/EChart.js create mode 100644 src/pyouter/webui/gd5/ui/JsonTree.js create mode 100644 src/pyouter/webui/gd5/ui/Lightbox.js create mode 100644 src/pyouter/webui/gd5/ui/LoadingButton.js create mode 100644 src/pyouter/webui/gd5/ui/Modal.js create mode 100644 src/pyouter/webui/gd5/ui/Notification.js create mode 100644 src/pyouter/webui/gd5/ui/PeleView.js create mode 100644 src/pyouter/webui/gd5/ui/README.md create mode 100644 src/pyouter/webui/gd5/ui/SparkView.js create mode 100644 src/pyouter/webui/gd5/ui/ThreeView.js create mode 100644 src/pyouter/webui/gd5/ui/Toast.js create mode 100644 src/pyouter/webui/gd5/ui/WorkflowDAG.js create mode 100644 src/pyouter/webui/gd5/ui/accordion.js create mode 100644 src/pyouter/webui/gd5/ui/affix.js create mode 100644 src/pyouter/webui/gd5/ui/avatar.js create mode 100644 src/pyouter/webui/gd5/ui/banner.js create mode 100644 src/pyouter/webui/gd5/ui/breadcrumbs.js create mode 100644 src/pyouter/webui/gd5/ui/button.js create mode 100644 src/pyouter/webui/gd5/ui/calendar.js create mode 100644 src/pyouter/webui/gd5/ui/card.js create mode 100644 src/pyouter/webui/gd5/ui/carousel.js create mode 100644 src/pyouter/webui/gd5/ui/cascader.js create mode 100644 src/pyouter/webui/gd5/ui/chart.js create mode 100644 src/pyouter/webui/gd5/ui/chat.js create mode 100644 src/pyouter/webui/gd5/ui/chip.js create mode 100644 src/pyouter/webui/gd5/ui/code.js create mode 100644 src/pyouter/webui/gd5/ui/config.js create mode 100644 src/pyouter/webui/gd5/ui/control.js create mode 100644 src/pyouter/webui/gd5/ui/css/antd-gap.css create mode 100644 src/pyouter/webui/gd5/ui/css/button.css create mode 100644 src/pyouter/webui/gd5/ui/css/card.css create mode 100644 src/pyouter/webui/gd5/ui/css/code-block.css create mode 100644 src/pyouter/webui/gd5/ui/css/control.css create mode 100644 src/pyouter/webui/gd5/ui/css/coverflow.css create mode 100644 src/pyouter/webui/gd5/ui/css/embed.css create mode 100644 src/pyouter/webui/gd5/ui/css/extra.css create mode 100644 src/pyouter/webui/gd5/ui/css/feedback.css create mode 100644 src/pyouter/webui/gd5/ui/css/input.css create mode 100644 src/pyouter/webui/gd5/ui/css/jelly.css create mode 100644 src/pyouter/webui/gd5/ui/css/json-tree.css create mode 100644 src/pyouter/webui/gd5/ui/css/layout.css create mode 100644 src/pyouter/webui/gd5/ui/css/markdown-editor.css create mode 100644 src/pyouter/webui/gd5/ui/css/modal.css create mode 100644 src/pyouter/webui/gd5/ui/css/todo.css create mode 100644 src/pyouter/webui/gd5/ui/css/tokens.css create mode 100644 src/pyouter/webui/gd5/ui/css/typography.css create mode 100644 src/pyouter/webui/gd5/ui/css/ui.css create mode 100644 src/pyouter/webui/gd5/ui/css/workflow.css create mode 100644 src/pyouter/webui/gd5/ui/drag.js create mode 100644 src/pyouter/webui/gd5/ui/feedback.js create mode 100644 src/pyouter/webui/gd5/ui/flex.js create mode 100644 src/pyouter/webui/gd5/ui/float.js create mode 100644 src/pyouter/webui/gd5/ui/form-extra.js create mode 100644 src/pyouter/webui/gd5/ui/form.js create mode 100644 src/pyouter/webui/gd5/ui/icon.js create mode 100644 src/pyouter/webui/gd5/ui/image.js create mode 100644 src/pyouter/webui/gd5/ui/index.js create mode 100644 src/pyouter/webui/gd5/ui/input-number.js create mode 100644 src/pyouter/webui/gd5/ui/input.js create mode 100644 src/pyouter/webui/gd5/ui/jelly-body.js create mode 100644 src/pyouter/webui/gd5/ui/jelly-review.js create mode 100644 src/pyouter/webui/gd5/ui/jelly.js create mode 100644 src/pyouter/webui/gd5/ui/layout-extra.js create mode 100644 src/pyouter/webui/gd5/ui/layout.js create mode 100644 src/pyouter/webui/gd5/ui/list.js create mode 100644 src/pyouter/webui/gd5/ui/markdown.js create mode 100644 src/pyouter/webui/gd5/ui/menu.js create mode 100644 src/pyouter/webui/gd5/ui/navigation.js create mode 100644 src/pyouter/webui/gd5/ui/overlay.js create mode 100644 src/pyouter/webui/gd5/ui/pagination.js create mode 100644 src/pyouter/webui/gd5/ui/progress.js create mode 100644 src/pyouter/webui/gd5/ui/radio.js create mode 100644 src/pyouter/webui/gd5/ui/rate.js create mode 100644 src/pyouter/webui/gd5/ui/result.js create mode 100644 src/pyouter/webui/gd5/ui/skeleton.js create mode 100644 src/pyouter/webui/gd5/ui/space.js create mode 100644 src/pyouter/webui/gd5/ui/steps.js create mode 100644 src/pyouter/webui/gd5/ui/table.js create mode 100644 src/pyouter/webui/gd5/ui/tabs.js create mode 100644 src/pyouter/webui/gd5/ui/text-extra.js create mode 100644 src/pyouter/webui/gd5/ui/theme.js create mode 100644 src/pyouter/webui/gd5/ui/timeline.js create mode 100644 src/pyouter/webui/gd5/ui/todo.js create mode 100644 src/pyouter/webui/gd5/ui/tree-select.js create mode 100644 src/pyouter/webui/gd5/ui/tree.js create mode 100644 src/pyouter/webui/gd5/ui/typography.js create mode 100644 src/pyouter/webui/gd5/ui/upload.js create mode 100644 src/pyouter/webui/gd5/ui/util.js create mode 100644 src/pyouter/webui/gd5/ui/watermark.js create mode 100644 src/pyouter/webui/labels.js create mode 100644 src/pyouter/webui/labels.py create mode 100644 src/pyouter/webui/store.js diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..458be94 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include src/pyouter/webui * +global-exclude __pycache__ *.py[cod] .DS_Store diff --git a/README.md b/README.md index caa9bbf..4059b3a 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Router nodes run their children in parallel. `policy="sequence"` runs them in or ## Local web -Open the current router in a browser. The server binds to `127.0.0.1:8765` and uses only the Python standard library. +Open the current router in a browser. The server binds to `127.0.0.1:8765` and uses only the Python standard library. The page is a [gd5](https://github.com/zigslice/gd5) app: `Layout`, `Tree`, `Button`, `Card`, `Tag`, and the orange theme from that UI kit, served from `pyouter/webui`. ```shell python test.py --web @@ -654,6 +654,10 @@ PYTHONPATH=src python3 -m unittest src.tests.test_cluster -v ## What's new +### 0.13.0 + +* rewrite the local web ui with the [gd5](https://github.com/zigslice/gd5) engine and orange UI kit + ### 0.12.0 * a super-node network requires a shared `--net` secret; a peer without it is not relayed and its streams are refused diff --git a/setup.cfg b/setup.cfg index 4fda48b..c3656be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,9 +5,15 @@ release = register sdist bdist_egg upload package_dir = =src packages = find_namespace: +include_package_data = True [options.packages.find] where = src +[options.package_data] +pyouter = + webui/* + webui/**/* + [options.entry_points] console_scripts = pyouter = pyouter:runner \ No newline at end of file diff --git a/setup.py b/setup.py index 83d18ff..0d12a39 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ long_description = (this_directory / "README.md").read_text() setup(name="pyouter", - version="0.12.0", + version="0.13.0", description="Python command line tasks router", install_requires=["libp2p>=0.8.0"], long_description=long_description, diff --git a/src/pyouter/node.py b/src/pyouter/node.py index ba697c3..6a03d6f 100644 --- a/src/pyouter/node.py +++ b/src/pyouter/node.py @@ -111,6 +111,16 @@ def do_GET(self): if parsed.path == "/": self._page() return + if parsed.path.startswith("/webui/"): + from .webui import read_static + + asset = read_static(parsed.path) + if asset is None: + self._send(404, {"error": "not found"}) + return + body, content_type = asset + self._raw(200, body, content_type) + return if parsed.path == "/favicon.ico": self._raw(204, b"", "image/x-icon") return diff --git a/src/pyouter/web.py b/src/pyouter/web.py index 2e0c273..cb84a0f 100644 --- a/src/pyouter/web.py +++ b/src/pyouter/web.py @@ -731,6 +731,16 @@ def do_GET(self): body = self.bridge.page().encode("utf-8") self._send(200, body, "text/html; charset=utf-8") return + if parsed.path.startswith("/webui/"): + from pyouter.webui import read_static + + asset = read_static(parsed.path) + if asset is None: + self._send(404, b"not found", "text/plain; charset=utf-8") + return + body, content_type = asset + self._send(200, body, content_type) + return if parsed.path == "/favicon.ico": self._send(204, b"", "image/x-icon") return diff --git a/src/pyouter/web_page.py b/src/pyouter/web_page.py index fdbce28..8109d9b 100644 --- a/src/pyouter/web_page.py +++ b/src/pyouter/web_page.py @@ -1,1018 +1,34 @@ -"""Self-contained HTML for the local pyouter web ui.""" +"""HTML shell for the local pyouter web ui (gd5).""" import json +from pyouter.webui.labels import BOOT_LABELS + PAGE = r""" - + pyouter - - - -
-
-
- - pyouter - 本机 -
-
- - 空闲 -
-
-
- -
-
-
-
-
-
+ + + + +
+ """ def render_page(boot): - payload = json.dumps(boot or {}, ensure_ascii=False).replace("<", "\\u003c") - return PAGE.replace("/*__BOOT__*/null", payload) + payload = dict(boot or {}) + labels = dict(BOOT_LABELS) + labels.update(payload.get("labels") or {}) + payload["labels"] = labels + encoded = json.dumps(payload, ensure_ascii=False).replace("<", "\\u003c") + return PAGE.replace("/*__BOOT__*/null", encoded) diff --git a/src/pyouter/webui/HomePage.css b/src/pyouter/webui/HomePage.css new file mode 100644 index 0000000..9be2a82 --- /dev/null +++ b/src/pyouter/webui/HomePage.css @@ -0,0 +1,238 @@ +.po-shell { + min-height: 100vh; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--gd-bg, #fff); + color: var(--gd-text, #1f1f1f); +} + +.po-shell .gd-layout { + flex: 1; + min-height: 0; +} + +.po-shell .gd-layout__sider { + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--gd-border, #ebe6e0); + background: var(--gd-bg-elevated, #fff); + padding: 12px 12px 8px; + gap: 10px; +} + +.po-shell .gd-layout__content { + min-width: 0; + min-height: 0; + overflow: auto; + padding: 16px 20px 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.po-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 18px; + border-bottom: 1px solid var(--gd-border, #ebe6e0); + background: #fff; +} + +.po-brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.po-brand__name { + font-size: 20px; + font-weight: 650; + letter-spacing: -0.03em; +} + +.po-meta { + display: flex; + align-items: center; + gap: 12px; + color: var(--gd-text-secondary, #6e6e73); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.po-sider__bar, +.po-section__bar, +.po-log__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.po-sider__title, +.po-section__bar h3, +.po-log__bar h3 { + margin: 0; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--gd-text-secondary, #6e6e73); + font-weight: 700; +} + +.po-sider__title { + display: block; +} + +.po-sider__actions { + display: flex; + gap: 4px; +} + +.po-tree-host { + flex: 1; + min-height: 0; + overflow: auto; + margin: 0 -4px; + padding: 0 4px 4px; +} + +.po-sider__foot, +.po-sider__hint { + color: var(--gd-text-secondary, #6e6e73); + font-size: 12px; +} + +.po-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.po-title-row h1 { + margin: 0; + font-size: 28px; + font-weight: 650; + letter-spacing: -0.03em; +} + +.po-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.po-path { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + color: var(--gd-text-secondary, #6e6e73); + word-break: break-all; +} + +.po-doc, +.po-signature { + color: var(--gd-text-secondary, #6e6e73); + font-size: 13px; + white-space: pre-wrap; +} + +.po-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.po-command { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + padding: 10px 12px; + background: var(--gd-fill-secondary, #f6f5f3); + border: 1px solid var(--gd-border, #ebe6e0); + border-radius: 8px; +} + +.po-command code { + flex: 1; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + word-break: break-all; +} + +.po-map { + display: flex; + flex-direction: column; + gap: 8px; +} + +.po-map__kids { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; + margin-top: 8px; +} + +.po-map-card { + cursor: pointer; +} + +.po-map-card.is-selected { + outline: 2px solid var(--gd-primary, #e86a17); + outline-offset: 1px; +} + +.po-log { + flex: 1; + min-height: 220px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.po-log__box { + flex: 1; + min-height: 200px; + max-height: min(48vh, 520px); + overflow: auto; + background: #111214; + color: #e8e6e3; + border-radius: 8px; + padding: 10px 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; +} + +.po-log__line { + display: grid; + grid-template-columns: 28px 64px minmax(0, 1fr); + gap: 8px; +} + +.po-log__line.is-stderr .po-log__msg { + color: #ff8e8e; +} + +.po-log__line.is-meta .po-log__msg { + color: #f0b36a; +} + +.po-log__gutter, +.po-log__ts { + color: #8b8680; +} + +.po-empty { + color: var(--gd-text-secondary, #6e6e73); + padding: 24px 0; +} diff --git a/src/pyouter/webui/HomePage.js b/src/pyouter/webui/HomePage.js new file mode 100644 index 0000000..fb644ef --- /dev/null +++ b/src/pyouter/webui/HomePage.js @@ -0,0 +1,677 @@ +import { Component } from "./gd5/engine/index.js"; +import { + Alert, + Breadcrumbs, + Button, + Card, + Empty, + Layout, + LayoutContent, + LayoutHeader, + LayoutSider, + SearchInput, + Space, + StatusDot, + Tag, + Toast, + Tree, + loadUi, + setTheme, +} from "./gd5/ui/index.js"; +import { el } from "./gd5/ui/util.js"; +import { + BOOT, + STATUS_LABEL, + UI_LABEL, + activeOwner, + childStats, + commandFor, + expandAll, + fetchSnapshot, + findNode, + formatDuration, + formatTime, + kindName, + layerKind, + nodeState, + pollEvents, + runPath, + runtimeMode, + selectPath, + state, + statusDot, + stopPath, + subscribe, + tagColor, + toTreeData, +} from "./store.js"; + +function pathKey(path) { + return path === "" ? "__root__" : path; +} + +function fromKey(key) { + return key === "__root__" ? "" : key; +} + +export class HomePage extends Component { + static styles = new URL("./HomePage.css", import.meta.url); + + constructor(deps = {}) { + super(deps); + this._unsub = null; + this._pollTimer = 0; + this._mapTimer = 0; + this._durationTimer = 0; + this._toast = null; + this._lastClick = { path: "", at: 0 }; + this.state.data.booted = false; + } + + async componentDidMount() { + setTheme("orange"); + await loadUi(); + this._toast = new Toast({}); + await this._toast.mount(document.body); + this._unsub = subscribe(() => this.refresh()); + try { + const snap = await fetchSnapshot(); + expandAll(state.tree); + const focus = snap.here || BOOT.focus || snap.focus || ""; + if (focus && !findNode(state.tree, focus)) { + this.toast("未找到路径 " + focus); + selectPath(""); + } else { + selectPath(focus); + } + this.setData({ booted: true }); + } catch (_error) { + this.toast("无法加载路由"); + } + const tick = async () => { + await pollEvents(); + this._pollTimer = window.setTimeout(tick, 400); + }; + this._pollTimer = window.setTimeout(tick, 400); + this._mapTimer = window.setInterval(() => { + fetchSnapshot().catch(() => {}); + }, 3000); + this._durationTimer = window.setInterval(() => { + const info = nodeState(state.selected); + if (info.status !== "running" && info.status !== "pending") return; + const el = this.host()?.querySelector("[data-duration]"); + if (el) { + el.textContent = formatDuration(info.started_at, null); + } + }, 500); + this.on(window, "keydown", (event) => { + if ( + event.key === "/" && + document.activeElement && + document.activeElement.tagName !== "INPUT" && + document.activeElement.tagName !== "TEXTAREA" + ) { + event.preventDefault(); + const input = this.host()?.querySelector(".gd-search input, input[type='search']"); + if (input) input.focus(); + } + }); + } + + componentDidUnMount() { + if (this._unsub) this._unsub(); + this._unsub = null; + window.clearTimeout(this._pollTimer); + window.clearInterval(this._mapTimer); + window.clearInterval(this._durationTimer); + if (this._toast) { + this._toast.unmount(); + this._toast = null; + } + } + + toast(message, type = "info") { + if (this._toast) this._toast.show({ message, type, duration: 2400 }); + } + + async runSelected(path) { + try { + await runPath(path); + } catch (error) { + this.toast(error.message || "执行失败", "error"); + } + } + + async stopSelected(path) { + try { + await stopPath(path); + } catch (error) { + this.toast(error.message || "无法停止", "error"); + } + } + + copyCommand(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(text) + .then(() => this.toast(UI_LABEL.copied, "success")) + .catch(() => this.toast(text)); + return; + } + this.toast(text); + } + + onTreeSelect(key, item) { + const path = item?.path != null ? item.path : fromKey(key); + const now = Date.now(); + const double = + !runtimeMode() && + this._lastClick.path === path && + now - this._lastClick.at < 350; + this._lastClick = { path, at: now }; + selectPath(path); + if (double) this.runSelected(path); + } + + renderMapCard(node, depth) { + const info = nodeState(node.path); + const selected = node.path === state.selected; + const kids = node.children || []; + const body = [ + Space( + { size: "sm" }, + StatusDot({ + status: statusDot(info.status), + title: STATUS_LABEL[info.status] || info.status, + }), + Tag({ + text: STATUS_LABEL[info.status] || info.status, + color: tagColor(info.status), + }), + node.here ? Tag({ text: UI_LABEL.here, color: "primary" }) : null, + node.reach === "up" ? Tag({ text: "可达", color: "primary" }) : null, + node.reach === "down" ? Tag({ text: "不可达", color: "danger" }) : null + ), + ]; + const card = Card( + { + className: "po-map-card" + (selected ? " is-selected" : ""), + title: node.name || "(root)", + extra: runtimeMode() + ? null + : Button({ + type: "text", + size: "sm", + text: "▶", + title: "执行", + onClick: (event) => { + event.stopPropagation(); + selectPath(node.path); + this.runSelected(node.path); + }, + }), + onClick: () => selectPath(node.path), + }, + Space( + { direction: "vertical", size: "sm" }, + Tag({ text: kindName(node) || node.type || "" }), + ...body.filter(Boolean) + ) + ); + if (kids.length && depth < 2) { + const wrap = document.createElement("div"); + wrap.className = "po-map__kids"; + kids.forEach((child) => wrap.appendChild(this.renderMapCard(child, depth + 1).dom())); + card.dom().querySelector(".gd-card__body")?.appendChild(wrap); + } else if (kids.length) { + const note = document.createElement("div"); + note.className = "po-signature"; + note.textContent = "还有 " + kids.length + " 个子节点,可在左侧目录继续查看"; + card.dom().querySelector(".gd-card__body")?.appendChild(note); + } + return card; + } + + render() { + const active = Object.values(state.nodes).filter( + (node) => node.status === "running" || node.status === "pending" + ).length; + const node = findNode(state.tree, state.selected || ""); + const info = node ? nodeState(node.path) : null; + const status = info?.status || "idle"; + const owner = node ? activeOwner(node.path) : null; + const stats = node ? childStats(node.path) : {}; + const statBits = []; + if (stats.running) statBits.push("运行 " + stats.running); + if (stats.pending) statBits.push("等待 " + stats.pending); + if (stats.success) statBits.push("成功 " + stats.success); + if (stats.error) statBits.push("失败 " + stats.error); + if (stats.cancelled) statBits.push("已取消 " + stats.cancelled); + + let routers = 0; + let actions = 0; + const visit = (item) => { + if (!item) return; + if (item.type === "router") routers += 1; + else actions += 1; + (item.children || []).forEach(visit); + }; + visit(state.tree); + + const crumbs = []; + if (state.tree) { + crumbs.push({ + label: state.tree.name || "(root)", + onClick: () => selectPath(""), + }); + if (node && node.path) { + const parts = node.path.split("."); + let current = ""; + parts.forEach((part) => { + current = current ? current + "." + part : part; + const found = findNode(state.tree, current); + if (!found) return; + crumbs.push({ + label: found.name || part, + onClick: + found.path === node.path ? undefined : () => selectPath(found.path), + }); + }); + } + } + + let mapNode = node; + let mapNote = ""; + if (node) { + if (node.type === "router") { + mapNote = "当前路由及其子节点"; + } else { + const parentPath = node.path.includes(".") + ? node.path.slice(0, node.path.lastIndexOf(".")) + : ""; + mapNode = findNode(state.tree, parentPath) || node; + mapNote = "叶子节点挂在上一级路由下,高亮的是当前节点"; + } + } + + const command = node ? commandFor(node.path) : ""; + const logs = info?.logs || []; + const detailTags = []; + if (node?.here) detailTags.push(Tag({ text: UI_LABEL.here, color: "primary" })); + const layer = node ? layerKind(node) : ""; + if (layer) detailTags.push(Tag({ text: kindName(node), color: "primary" })); + if (node?.reach === "up") detailTags.push(Tag({ text: "可达", color: "primary" })); + if (node?.reach === "down") detailTags.push(Tag({ text: "不可达", color: "danger" })); + if (node?.runtime && node.runtime !== node.peer_id) { + detailTags.push(Tag({ text: "runtime " + node.runtime })); + } + if (node?.peer_id) detailTags.push(Tag({ text: "peer " + node.peer_id })); + (node?.sn || []).forEach((item) => detailTags.push(Tag({ text: "sn " + item }))); + if (node?.ip) { + const address = node.host_port ? node.ip + ":" + node.host_port : node.ip; + detailTags.push(Tag({ text: "IP " + address })); + } + + // Layout* / Tag / Button return Elements from their own D. Nest them with + // el() so bind()'s single root stays #root (bind div children would steal root). + this.ensureShell((ids) => ids.root, ({ div }) => { + div( + { id: "root", className: "po-shell" }, + LayoutHeader( + { className: "po-header" }, + el( + "div", + { className: "po-brand" }, + el("span", { className: "po-brand__name", text: "pyouter" }), + Tag({ + text: runtimeMode() ? UI_LABEL.here : UI_LABEL.local, + color: "primary", + }) + ), + el( + "div", + { className: "po-meta" }, + el("span", { + text: active ? active + " 个节点执行中" : "空闲", + style: active ? { color: "var(--gd-primary)" } : undefined, + }), + el("span", { + text: typeof location !== "undefined" ? location.host : "", + }) + ) + ), + Layout( + { hasSider: true, className: "po-layout" }, + LayoutSider( + { width: 360, className: "po-sider" }, + el( + "div", + { className: "po-sider__bar" }, + el("div", { + className: "po-sider__title", + text: UI_LABEL.directory, + }), + el( + "div", + { className: "po-sider__actions" }, + Button({ + type: "text", + size: "sm", + text: UI_LABEL.expand, + onClick: () => { + expandAll(state.tree); + this.refresh(); + }, + }), + Button({ + type: "text", + size: "sm", + text: UI_LABEL.collapse, + onClick: () => { + state.expanded = new Set(); + this.refresh(); + }, + }) + ) + ), + SearchInput({ + placeholder: UI_LABEL.search, + value: state.filter, + onInput: (event) => { + state.filter = event?.target?.value || ""; + this.refresh(); + }, + onClear: () => { + state.filter = ""; + this.refresh(); + }, + }), + el("div", { + className: "po-sider__foot", + text: + "路由 " + Math.max(routers - 1, 0) + " · 动作 " + actions, + }), + el("div", { id: "treeHost", className: "po-tree-host" }), + el("div", { + className: "po-sider__hint", + text: runtimeMode() + ? "单击查看这个节点" + : "单击查看,双击或详情里执行", + }) + ), + LayoutContent( + { className: "po-main", id: "main" }, + el("div", { id: "detailHost" }) + ) + ) + ); + }); + + const meta = this.host()?.querySelector(".po-meta"); + if (meta && meta.children[0]) { + meta.children[0].textContent = active + ? active + " 个节点执行中" + : "空闲"; + meta.children[0].style.color = active ? "var(--gd-primary)" : ""; + } + const whereTag = this.host()?.querySelector(".po-brand .gd-tag"); + if (whereTag) { + whereTag.textContent = runtimeMode() ? UI_LABEL.here : UI_LABEL.local; + } + const foot = this.host()?.querySelector(".po-sider__foot"); + if (foot) { + foot.textContent = + "路由 " + Math.max(routers - 1, 0) + " · 动作 " + actions; + } + const hint = this.host()?.querySelector(".po-sider__hint"); + if (hint) { + hint.textContent = runtimeMode() + ? "单击查看这个节点" + : "单击查看,双击或详情里执行"; + } + const searchInput = this.host()?.querySelector(".gd-search__input, .gd-search input"); + if (searchInput && searchInput.value !== state.filter) { + searchInput.value = state.filter; + } + + const root = this.host(); + const treeHost = root?.querySelector("#treeHost"); + if (treeHost) { + treeHost.textContent = ""; + if (!state.tree) { + treeHost.appendChild( + Empty({ title: "加载中", description: "正在加载路由…" }).dom() + ); + } else { + const treeData = toTreeData(state.tree, state.filter); + if (!treeData.length) { + treeHost.appendChild( + Empty({ title: "无匹配", description: "没有匹配的路由" }).dom() + ); + } else { + const expandedKeys = state.filter + ? collectAllKeys(treeData) + : [...state.expanded].map(pathKey); + const tree = Tree({ + treeData, + expandedKeys, + selectedKey: pathKey(state.selected || ""), + onExpand: (keys) => { + state.expanded = new Set(keys.map(fromKey)); + }, + onSelect: (key, item) => this.onTreeSelect(key, item), + }); + treeHost.appendChild(tree.dom()); + } + } + } + + const detailHost = root?.querySelector("#detailHost"); + if (!detailHost) return; + detailHost.textContent = ""; + + if (!state.tree) { + detailHost.appendChild( + Empty({ title: "加载中", description: "正在加载路由…" }).dom() + ); + return; + } + if (!node) { + detailHost.appendChild( + Empty({ title: "未找到", description: "未找到这个节点" }).dom() + ); + return; + } + + const fragment = document.createDocumentFragment(); + fragment.appendChild(Breadcrumbs({ items: crumbs }).dom()); + + const titleRow = document.createElement("div"); + titleRow.className = "po-title-row"; + const h1 = document.createElement("h1"); + h1.textContent = node.name || "(root)"; + const badges = document.createElement("div"); + badges.className = "po-badges"; + badges.appendChild(Tag({ text: kindName(node) || node.type || "" }).dom()); + badges.appendChild( + Tag({ + text: STATUS_LABEL[status] || status, + color: tagColor(status), + }).dom() + ); + badges.appendChild( + StatusDot({ + status: statusDot(status), + title: STATUS_LABEL[status] || status, + }).dom() + ); + detailTags.forEach((tag) => badges.appendChild(tag.dom())); + titleRow.append(h1, badges); + fragment.appendChild(titleRow); + + const pathEl = document.createElement("div"); + pathEl.className = "po-path"; + pathEl.textContent = node.path || "(root)"; + fragment.appendChild(pathEl); + + if (node.signature || node.target) { + const sig = document.createElement("div"); + sig.className = "po-signature"; + sig.textContent = (node.target || "") + (node.signature || ""); + fragment.appendChild(sig); + } + if (node.doc) { + const doc = document.createElement("div"); + doc.className = "po-doc"; + doc.textContent = node.doc; + fragment.appendChild(doc); + } + + const statusRow = document.createElement("div"); + statusRow.className = "po-actions"; + const duration = document.createElement("strong"); + duration.dataset.duration = "1"; + duration.textContent = formatDuration( + info.started_at, + status === "running" || status === "pending" ? null : info.finished_at + ); + const childNote = document.createElement("span"); + childNote.className = "po-signature"; + childNote.textContent = statBits.length + ? "子节点 " + statBits.join(" · ") + : node.type === "router" + ? "还没有子节点执行记录" + : ""; + statusRow.append(duration, childNote); + fragment.appendChild(statusRow); + + if (info.error) { + fragment.appendChild( + Alert({ type: "error", message: info.error }).dom() + ); + } + + if (!runtimeMode()) { + const actions = document.createElement("div"); + actions.className = "po-actions"; + if (owner === null) { + actions.appendChild( + Button({ + type: "primary", + text: UI_LABEL.run, + onClick: () => this.runSelected(node.path), + }).dom() + ); + } else { + actions.appendChild( + Button({ + type: "danger", + text: UI_LABEL.stop, + onClick: () => this.stopSelected(node.path), + }).dom() + ); + const note = document.createElement("span"); + note.className = "po-signature"; + note.textContent = + owner === node.path + ? "此节点正在执行" + : "相关节点正在执行:" + (owner || "(root)"); + actions.appendChild(note); + } + fragment.appendChild(actions); + + const commandRow = document.createElement("div"); + commandRow.className = "po-command"; + const code = document.createElement("code"); + code.textContent = command; + commandRow.appendChild(code); + commandRow.appendChild( + Button({ + type: "default", + text: UI_LABEL.copy, + onClick: () => this.copyCommand(command), + }).dom() + ); + fragment.appendChild(commandRow); + } + + const mapSection = document.createElement("section"); + mapSection.className = "po-section"; + const mapBar = document.createElement("div"); + mapBar.className = "po-section__bar"; + const mapTitle = document.createElement("h3"); + mapTitle.textContent = UI_LABEL.map; + mapBar.appendChild(mapTitle); + mapSection.appendChild(mapBar); + if (mapNote) { + const note = document.createElement("div"); + note.className = "po-signature"; + note.textContent = mapNote; + mapSection.appendChild(note); + } + const mapHost = document.createElement("div"); + mapHost.className = "po-map"; + if (mapNode) mapHost.appendChild(this.renderMapCard(mapNode, 0).dom()); + mapSection.appendChild(mapHost); + fragment.appendChild(mapSection); + + const logSection = document.createElement("section"); + logSection.className = "po-log"; + const logBar = document.createElement("div"); + logBar.className = "po-log__bar"; + const logTitle = document.createElement("h3"); + logTitle.textContent = UI_LABEL.logs; + const logCount = document.createElement("span"); + logCount.className = "po-signature"; + logCount.textContent = logs.length ? logs.length + " 行" : ""; + logBar.append(logTitle, logCount); + logSection.appendChild(logBar); + const logBox = document.createElement("div"); + logBox.className = "po-log__box"; + logBox.dataset.log = "1"; + if (!logs.length) { + const empty = document.createElement("div"); + empty.className = "po-empty"; + empty.style.color = "#8b8680"; + empty.textContent = runtimeMode() + ? "这个节点正在提供服务。绿色标签是可达,红色标签是不可达。" + : "还没有日志。执行此节点后,标准输出和标准错误会显示在这里。"; + logBox.appendChild(empty); + } else { + logs.forEach((line) => { + const row = document.createElement("div"); + row.className = + "po-log__line" + + (line.stream === "stderr" + ? " is-stderr" + : line.stream === "meta" + ? " is-meta" + : ""); + const gutter = document.createElement("span"); + gutter.className = "po-log__gutter"; + gutter.textContent = + line.stream === "stderr" ? "err" : line.stream === "meta" ? "·" : "out"; + const ts = document.createElement("span"); + ts.className = "po-log__ts"; + ts.textContent = formatTime(line.ts); + const msg = document.createElement("span"); + msg.className = "po-log__msg"; + msg.textContent = line.message; + row.append(gutter, ts, msg); + logBox.appendChild(row); + }); + } + logSection.appendChild(logBox); + fragment.appendChild(logSection); + detailHost.appendChild(fragment); + logBox.scrollTop = logBox.scrollHeight; + } +} + +function collectAllKeys(nodes, into = []) { + for (const node of nodes || []) { + into.push(node.key); + if (node.children?.length) collectAllKeys(node.children, into); + } + return into; +} diff --git a/src/pyouter/webui/__init__.py b/src/pyouter/webui/__init__.py new file mode 100644 index 0000000..d0c69c7 --- /dev/null +++ b/src/pyouter/webui/__init__.py @@ -0,0 +1,63 @@ +"""Static gd5-backed assets for the local web ui.""" + +from __future__ import annotations + +import mimetypes +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +_CONTENT_TYPES = { + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".txt": "text/plain; charset=utf-8", +} + + +def resolve_static(url_path: str): + """Map ``/webui/...`` to a file under this package, or return None.""" + raw = "" if url_path is None else str(url_path) + if raw.startswith("/webui/"): + relative = raw[len("/webui/") :] + elif raw.startswith("webui/"): + relative = raw[len("webui/") :] + else: + return None + if not relative or relative.endswith("/"): + return None + parts = relative.split("/") + if any(part in ("", ".", "..") for part in parts): + return None + path = ROOT.joinpath(*parts).resolve() + try: + path.relative_to(ROOT) + except ValueError: + return None + if not path.is_file(): + return None + return path + + +def content_type_for(path: Path) -> str: + suffix = path.suffix.lower() + if suffix in _CONTENT_TYPES: + return _CONTENT_TYPES[suffix] + guessed, _encoding = mimetypes.guess_type(str(path)) + return guessed or "application/octet-stream" + + +def read_static(url_path: str): + """Return ``(bytes, content_type)`` for a webui URL, or None.""" + path = resolve_static(url_path) + if path is None: + return None + return path.read_bytes(), content_type_for(path) diff --git a/src/pyouter/webui/app.js b/src/pyouter/webui/app.js new file mode 100644 index 0000000..58d0f30 --- /dev/null +++ b/src/pyouter/webui/app.js @@ -0,0 +1,24 @@ +import { ComponentRender, LogProvider } from "./gd5/engine/index.js"; +import { HomePage } from "./HomePage.js"; + +const app = new ComponentRender({ + logProvider: new LogProvider({ + level: "info", + sink: "console", + context: { machId: "pyouter", userId: "local" }, + }), +}); + +app.init( + { loading: "home", home: "home" }, + { + home: HomePage, + } +); + +const boot = () => app.render(); +if (document.readyState === "loading") { + window.addEventListener("DOMContentLoaded", boot); +} else { + boot(); +} diff --git a/src/pyouter/webui/gd5/ORIGIN.txt b/src/pyouter/webui/gd5/ORIGIN.txt new file mode 100644 index 0000000..f6cbda8 --- /dev/null +++ b/src/pyouter/webui/gd5/ORIGIN.txt @@ -0,0 +1,2 @@ +Vendored from https://github.com/zigslice/gd5 (MIT) +d6732b5b994d73eb824e7849cee70c5828bc1a6a diff --git a/src/pyouter/webui/gd5/engine/component-render.js b/src/pyouter/webui/gd5/engine/component-render.js new file mode 100644 index 0000000..46aaa93 --- /dev/null +++ b/src/pyouter/webui/gd5/engine/component-render.js @@ -0,0 +1,274 @@ +import { LogEnv, LogProvider } from './log.js'; + +/** + * 默认用户信息(浏览器 demo / 本地原型) + * 产品可在构造 ComponentRender 时注入自己的 getUserInfo。 + */ +export function defaultGetUserInfo() { + return { + machId: 'local', + userId: 'demo', + token: '', + }; +} + +/** + * 组件渲染器,支持在页面之间导航(引擎): + * 1. 通过 this.m_navigator 导航,提供 navigate / goBack 方法 + * 2. navigate 到下一个页面 + * * 如果下一个页面是模态组件,则保留当前页面作为透视背景,否则当前页面不显示 + * * 依次执行组件的 componentDidMount / render / componentDidAttachEvents 方法 + * 3. goBack 到前一个页面 + * * 执行当前页面的 componentDidUnMount 方法 + * * 不显示当前页面 + * * 如果当前页面是模态组件,则什么都不做,否则显示上一个页面 + * 4. pop(n) 直接弹出 n 个页面 + * + * 备注:React 之类的 Virtual Dom 有一定的好处,但是也有很多不必要的工作, + * 本引擎使用迷你简单实模式 Dom 操作。 + * 参考:https://svelte.dev/blog/virtual-dom-is-pure-overhead + */ +export class ComponentRender { + /** + * @param {object} [options] + * @param {() => {machId:string,userId:string,token:string}} [options.getUserInfo] + * @param {import('./log.js').LogProvider} [options.logProvider] preferred logging config + * @param {string} [options.logEnv] legacy {@link LogEnv}; used when logProvider/logger omitted + * @param {import('./log.js').Logger} [options.logger] inject an existing logger instance + */ + constructor(options = {}) { + const getUserInfo = options.getUserInfo || defaultGetUserInfo; + const { machId, userId, token } = getUserInfo(); + const userContext = { machId, userId, token }; + + if (options.logger) { + this.m_logProvider = options.logger.provider ?? null; + this.m_logger = options.logger; + } else if (options.logProvider) { + this.m_logProvider = options.logProvider; + this.m_logger = options.logProvider.logger; + } else { + this.m_logProvider = LogProvider.fromEnv(options.logEnv || LogEnv.chrome, userContext); + this.m_logger = this.m_logProvider.logger; + } + + this.m_navigator = { + navigate: (componentName, ...args) => { + this._navigate(componentName, ...args); + }, + goBack: () => { + this._goBack(); + }, + pop: (n) => { + this._pop(n); + }, + /** + * 刷新已挂载页面(默认当前页)。 + * 走 Component.refresh(),不重新 mount / 不重绑 AttachEvents。 + */ + refresh: (componentName) => { + this.refresh(componentName); + }, + }; + this.state = null; + } + + /** + * 注册页面。 + * + * 简写:`home: HomePage` 或 `home: { type: HomePage, isModal: true }` + * 默认:`id = name`,`isMount = false`,`isModal = false` + * 构造时自动注入 `{ navigator, logger }`,组件内直接 `this.navigator`。 + * + * @param {{ loading: string, home: string }} roots + * @param {Record} componentsDef + */ + init({ loading, home }, componentsDef) { + this.state = { + componentState: { + stack: [], + loading, + home, + current: null, + }, + components: {}, + }; + + const runtime = { + navigator: this.m_navigator, + logger: this.m_logger, + }; + + for (const componentName in componentsDef) { + const raw = componentsDef[componentName]; + const componentDef = + typeof raw === 'function' ? { type: raw } : raw && typeof raw === 'object' ? raw : null; + if (!componentDef?.type) { + this.m_logger.e(`Component:'${componentName}' type is missing`); + continue; + } + + this.state.components[componentName] = { + componentName, + isMount: componentDef.isMount ?? false, + isModal: componentDef.isModal ?? false, + id: componentDef.id ?? componentName, + component: new componentDef.type(runtime), + }; + } + } + + dump() { + const o = []; + for (const name in this.state.components) { + const i = this.state.components[name]; + if (i.isMount) o.push(name); + } + console.log(o); + } + + render() { + const { componentState } = this.state; + this._navigate(componentState.home, null, () => { + this._renderPage(componentState.loading, 'none'); + }); + } + + /** + * 刷新已挂载组件。实现原先「isMount 时不重复渲染,添加刷新机制」的缺口。 + * @param {string} [componentName] 默认当前页 + */ + refresh(componentName) { + const { componentState, components } = this.state; + const name = componentName || componentState.current; + if (name == null) { + this.m_logger.w('refresh skipped: no current component'); + return; + } + const instance = components[name]; + if (instance == null) { + this.m_logger.e(`refresh failed: Component:'${name}' is not exist!`); + return; + } + if (!instance.isMount) { + this.m_logger.w(`refresh skipped: '${name}' is not mounted`); + return; + } + instance.component.refresh(); + } + + _pop(n) { + if (n == null) { + n = 1; + } + + while (n > 0) { + n--; + this._goBack(); + } + } + + _navigate(nextComponentName, arg, onComplete) { + const { componentState, components } = this.state; + + if (componentState.current === nextComponentName) { + this.m_logger.w( + `componentInstance not change, current:${componentState.current}, nextPage:${nextComponentName}` + ); + return; + } + + const instance = components[nextComponentName]; + if (instance == null) { + this.m_logger.e(`Component:'${nextComponentName}' is not exist!`); + return; + } + + // hide current and push into stack + componentState.stack.push(instance); + + if (instance.isModal) { + // 模态对话框下,上一个页面不需要隐藏,做为透明背景 + } else if (componentState.current != null) { + this._renderPage(componentState.current, 'none'); + } + + // reset current componentInstance and show nextPage + componentState.current = nextComponentName; + this._renderPage(nextComponentName, 'block', arg, onComplete); + } + + _goBack() { + const { componentState } = this.state; + + // pop previous and show + const previousIndex = componentState.stack.length - 2; + if (previousIndex >= 0) { + const previousInstance = componentState.stack[previousIndex]; + const currentInstance = componentState.stack.pop(); + + // 与页内子组件同一套 unmount(DidUnMount + 清宿主 + 卸监听) + currentInstance.component.unmount(); + this._renderPage(currentInstance.componentName, 'none'); + currentInstance.isMount = false; + + componentState.current = previousInstance.componentName; + + if (!currentInstance.isModal) { + this._renderPage(componentState.current, 'block'); + } else { + // 模态情况下,上一个页面不会被隐藏,不需要重置 display + } + } else { + this.m_logger.w(`componentState.stack: ${JSON.stringify(componentState.stack)}`); + } + + this.dump(); + } + + _renderPage(componentName, display, arg, onComplete) { + const { componentState, components } = this.state; + const instance = components[componentName]; + if (instance == null) { + this.m_logger.w(`componentInstance is null, componentName:${componentName}`); + return; + } + + const hostEl = document.getElementById(instance.id); + const _display = () => { + if (onComplete) { + onComplete(); + } + if (hostEl) hostEl.style.display = display; + }; + + const component = instance.component; + if (instance.isMount) { + // 已挂载:只切换 display。数据刷新请用 component.setData / navigator.refresh + console.log(`display:${componentName}, isMount:${instance.isMount}, ${display}`); + _display(); + this.dump(); + return; + } + + // prepare params + let params; + if (arg == null) { + params = {}; + } else { + params = arg; + } + const previousIndex = componentState.stack.length - 2; + if (previousIndex >= 0) { + const previousInstance = componentState.stack[previousIndex]; + params.source = previousInstance.componentName; + } + + // 页面也走 Component.mount,host() / on() / ensureShell 与子组件一致 + instance.isMount = true; + component.mount(hostEl, params).then(() => { + _display(); + this.dump(); + }); + } +} diff --git a/src/pyouter/webui/gd5/engine/component.js b/src/pyouter/webui/gd5/engine/component.js new file mode 100644 index 0000000..c553ea3 --- /dev/null +++ b/src/pyouter/webui/gd5/engine/component.js @@ -0,0 +1,346 @@ +import { D, bind } from './element.js'; +import { adoptStyles, adoptStylesFor, applyStyleScope } from './styles.js'; + +/** + * paint / ensureShell 建树期间的组合父组件。 + * Component.dom() 用它登记子组件,便于父 unmount 时卸掉。 + * @type {Component|null} + */ +let composeParent = null; + +/** + * 迷你实模式 HTML 组件规范(引擎) + * + * this.state = { + * data: {}, // 视图展示所依赖的数据 + * ref: {} // 视图 HTML Element 的索引 + * }; + * + * 两种挂载方式(同一套生命周期): + * 1. 页面:ComponentRender.navigate → mount(页槽) + * 2. 组合:像 div 一样写进 ensureShell / bind 树(Component 有 .dom(),与 Element 同形) + * + * 样式与组件同目录: + * static styles = new URL('./FooComponent.css', import.meta.url); + * mount 时按 key 注入一次(见 adoptStyles)。 + * + * 刷新约定(Mount 之后): + * - setData(patch) 合并 this.state.data,并默认调用 refresh() + * - refresh() 默认再跑 render();AttachEvents 只在首次 mount + * - render 用 ensureShell 建壳,refresh 路径不要 D.clear 整棵树(否则事件丢失) + * - ensureShell 只建一次 → 子组件只挂一次 + * + * 易错点由引擎收掉: + * - paint / ensureShell:不必手写 clear → bind → get → append + * - 子组件组合:`div({ id: 'root' }, this.child)`,与 React 把 写进树同形 + * - DOM 事件写在 props(onClick…);window 等用 on(),unmount 自动卸 + * - unmount 默认卸组合子组件 + 清宿主 DOM + 重置 ref(DidUnMount 只做业务清理) + * - 样式旁挂 + mount 注入,不必在 index.html 堆全量 CSS + * - `static styles` 默认自动 scope:CSS / className 仍写 `.toolbar`,引擎改写选择器并在 host 打 `data-gd-c`;`stylesScoped = false` 关闭 + * - navigator / logger:页面由 ComponentRender 注入;子组件由父写入 deps,用法同为 this.navigator + * + * API 一览:docs/api.md + */ +export class Component { + /** + * 可选:旁挂样式。URL | CSS 文本 | 数组。 + * @type {string|URL|Array|null} + */ + static styles = null; + + /** + * 是否对 `static styles` 自动 scope(默认 true)。 + * 作者不写前缀;注入时改写成 `[data-gd-c="Name"] …`。 + * @type {boolean} + */ + static stylesScoped = true; + + /** + * 覆盖 scope id(默认用 class name)。 + * @type {string|null} + */ + static styleScope = null; + + /** @see {adoptStyles} */ + static adoptStyles = adoptStyles; + + /** @see {adoptStylesFor} */ + static adoptStylesFor = adoptStylesFor; + + /** + * @param {object} [deps] + * @param {object} [deps.navigator] 页栈遥控器(页面由引擎注入;子组件通常不需要) + * @param {import('./log.js').Logger} [deps.logger] + * @param {(self: Component) => void} [deps.onMount] mount 完成后回调(组合后初始化数据等) + */ + constructor(deps = {}) { + this._host = null; + this._mounted = false; + this._listeners = []; + /** @type {Component[]} */ + this._slotted = []; + this.state = { data: {}, ref: {} }; + this.navigator = deps.navigator ?? null; + this.logger = deps.logger ?? null; + this._onMount = typeof deps.onMount === 'function' ? deps.onMount : null; + } + + /** + * 当前宿主 DOM(页槽或父组件给出的 mount 点) + * @returns {HTMLElement|null} + */ + host() { + return this._host; + } + + /** + * 是否已 mount(含页内子组件) + */ + isMounted() { + return this._mounted; + } + + /** + * `this.state.ref.id` 快捷访问(AttachEvents / sync 常用) + * @returns {Record} + */ + ids() { + return this.state.ref?.id || {}; + } + + /** + * `this.state.ref.key` 快捷访问 + * @returns {Record} + */ + keys() { + return this.state.ref?.key || {}; + } + + /** + * 与 Element.dom 同形:作为 bind / el() 树的子节点时被调用。 + * 像 React 里把 `` 写进 `
…
` —— 直接 `div({}, this.todoList)`。 + * + * 插入 `display: contents` 宿主,入树后自动 mount;布局由子组件自己的根节点承担。 + * @returns {HTMLElement} + */ + dom() { + if (typeof document === 'undefined') { + throw new Error('Component.dom: no document'); + } + const host = document.createElement('div'); + host.style.display = 'contents'; + host.setAttribute('data-gd-slot', this.constructor?.name || 'Component'); + + const parent = composeParent; + if (parent && !parent._slotted.includes(this)) { + parent._slotted.push(this); + } + + queueMicrotask(() => { + void this.mount(host); + }); + return host; + } + + /** + * 挂到宿主并走完整生命周期(页面与页内组合共用)。 + * @param {string|HTMLElement} host id 或元素 + * @param {object} [params] + */ + async mount(host, params = {}) { + if (this._mounted) { + this.refresh(); + return this; + } + this._host = typeof host === 'string' ? document.getElementById(host) : host; + if (!this._host) { + throw new Error('Component.mount: host not found'); + } + await adoptStylesFor(this.constructor); + applyStyleScope(this._host, this.constructor); + await this.componentDidMount(params); + this.render(); + this.componentDidAttachEvents(); + this._mounted = true; + this._onMount?.(this); + return this; + } + + /** + * 卸载:先卸组合子组件,再业务 DidUnMount,再卸监听、清宿主、重置 ref。 + */ + unmount() { + if (!this._mounted) return; + this._unmountSlots(); + this.componentDidUnMount(); + this._teardown(); + this._mounted = false; + this._host = null; + } + + /** + * 可选:需要包一层真实宿主(className / style / onMount)时用。 + * 普通组合请直接把子组件当 children:`div({}, this.child)`。 + * + * @param {Component} child + * @param {object} [hostProps] + * @returns {import('./element.js').Element|Component} + */ + slot(child, hostProps = {}) { + if (!child || typeof child.mount !== 'function') { + throw new Error('Component.slot: expected a Component'); + } + const { onMount, ...domProps } = hostProps; + if (typeof onMount === 'function') { + child._onMount = onMount; + } + if (Object.keys(domProps).length === 0) { + return child; + } + const d = new D(); + const host = d.el('div', domProps); + const hostEl = host.dom(); + if (!this._slotted.includes(child)) { + this._slotted.push(child); + } + queueMicrotask(() => { + void child.mount(hostEl); + }); + return host; + } + + /** + * 合并视图数据并刷新。 + * @param {object} patch + * @param {{ refresh?: boolean }} [options] refresh 默认 true + */ + setData(patch = {}, options = {}) { + if (!this.state) { + this.state = { data: {}, ref: {} }; + } + if (!this.state.data || typeof this.state.data !== 'object') { + this.state.data = {}; + } + Object.assign(this.state.data, patch); + if (options.refresh !== false) { + this.refresh(); + } + return this.state.data; + } + + /** + * Mount 之后的刷新入口:默认再次 render()。 + * 不重新 AttachEvents / 不走 UnMount。 + */ + refresh() { + this.render(); + } + + /** + * 清宿主 → bind 建树 → 写入 state.ref → append。 + * 只用于「整棵重建」;可刷新壳请用 ensureShell。 + * 建树期间子 Component 可直接作 children(走 .dom())。 + * @param {(api: ReturnType) => void} build + * @param {HTMLElement} [host] + */ + paint(build, host = this.host()) { + if (!host) { + throw new Error('Component.paint: no host'); + } + this._unmountSlots(); + D.clear(host); + const api = bind(); + api.slot = (child, hostProps) => this.slot(child, hostProps); + const prev = composeParent; + composeParent = this; + try { + build(api); + } finally { + composeParent = prev; + } + const [ref, root] = api.get(); + this.state.ref = ref; + if (root) host.appendChild(root); + return ref; + } + + /** + * 壳只建一次:probe(ids) 已有则跳过,否则 paint。 + * @param {(ids: Record) => any} probe + * @param {(api: ReturnType) => void} build + * @returns {boolean} true = 本次新建了壳 + */ + ensureShell(probe, build) { + if (probe(this.ids())) return false; + this.paint(build); + return true; + } + + /** + * 绑事件并登记,unmount 时自动 removeEventListener。 + * @param {EventTarget|null|undefined} target + * @param {string} type + * @param {EventListener} handler + * @param {boolean|AddEventListenerOptions} [options] + */ + on(target, type, handler, options) { + if (!target) return this; + target.addEventListener(type, handler, options); + this._listeners.push({ target, type, handler, options }); + return this; + } + + /** + * this.ids()[id] 上绑事件(同上,自动清理)。 + * @param {string} id + * @param {string} type + * @param {EventListener} handler + * @param {boolean|AddEventListenerOptions} [options] + */ + onId(id, type, handler, options) { + return this.on(this.ids()[id], type, handler, options); + } + + /** @private */ + _unmountSlots() { + const kids = this._slotted; + this._slotted = []; + for (const child of kids) { + try { + child.unmount(); + } catch { + /* ignore */ + } + } + } + + /** @private */ + _teardown() { + for (const { target, type, handler, options } of this._listeners) { + try { + target.removeEventListener(type, handler, options); + } catch { + /* ignore */ + } + } + this._listeners = []; + const host = this._host; + if (host) D.clear(host); + this.state.ref = {}; + } + + /** virtual method — 业务挂载(取数、订阅前准备) */ + async componentDidMount() {} + + /** + * virtual method — 业务卸载(卸订阅、停动画等)。 + * 不必再 D.clear(host) / 重置 ref:unmount 会统一做。 + */ + componentDidUnMount() {} + + /** virtual method — 首次 render 之后绑事件(用 on / onId) */ + componentDidAttachEvents() {} + + /** virtual method — 绑定 state.data → DOM;壳用 ensureShell */ + render() {} +} diff --git a/src/pyouter/webui/gd5/engine/element.js b/src/pyouter/webui/gd5/engine/element.js new file mode 100644 index 0000000..8e89c23 --- /dev/null +++ b/src/pyouter/webui/gd5/engine/element.js @@ -0,0 +1,418 @@ +/** + * 创建一个 HTML Element(引擎) + * + * 属性约定: + * - className:真实 CSS class,可多个 + * - id → ref.id;key → ref.key + * - text / dataset / style / attrs + * - onClick / onChange / …:声明式事件(→ addEventListener) + * - events: { click: fn }:补充非标准名 + * - 其它字段:DOM 属性 + * + * 组合(降噪): + * - 尾参 children:`d.div(props, child1, child2)`(仍支持 `.childList`) + * - children 可以是 Element、文本,或 **Component**(与 Element 同有 `.dom()`,像 React 里写 ``) + * - `bind(d)` / `D.tags()`:解构短名 + `h` + * - `d.map(list, render, keyOf)`:列表自动带 key + * + * 事件写在 props 里,与 DOM 同处;壳用 ensureShell 只建一次,监听随节点生命周期。 + * window / document 等非树内目标仍用 Component.on。 + */ + +/** onDoubleClick → dblclick 等特例 */ +const EVENT_NAME_SPECIAL = { + doubleclick: 'dblclick', +}; + +/** + * `onClick` → `click`;`onPointerDown` → `pointerdown`。 + * @param {string} prop + * @returns {string|null} + */ +export function domEventType(prop) { + if (typeof prop !== 'string' || prop.length < 3 || !prop.startsWith('on')) return null; + const third = prop.charCodeAt(2); + if (third < 65 || third > 90) return null; // 必须 on + 大写 + const raw = prop.slice(2).toLowerCase(); + return EVENT_NAME_SPECIAL[raw] || raw; +} + +/** + * 从 props 拆出事件并绑到 el,返回其余属性。 + * @param {HTMLElement} el + * @param {Record} props + */ +export function applyDomEvents(el, props) { + const rest = {}; + for (const name in props) { + const value = props[name]; + if (name === 'events' && value && typeof value === 'object') { + for (const type in value) { + if (typeof value[type] === 'function') { + el.addEventListener(type, value[type]); + } + } + continue; + } + const type = domEventType(name); + if (type && typeof value === 'function') { + el.addEventListener(type, value); + continue; + } + rest[name] = value; + } + return rest; +} + +function isPlainProps(value) { + return ( + value != null && + typeof value === 'object' && + !Array.isArray(value) && + typeof value.dom !== 'function' + ); +} + +function flattenChildren(children) { + const out = []; + const walk = (list) => { + for (const item of list) { + if (item == null || item === false) continue; + if (Array.isArray(item)) walk(item); + else out.push(item); + } + }; + walk(children); + return out; +} + +export class Element { + constructor(ref, tag, attributes = {}) { + this.ref = ref; + + const e = document.createElement(tag); + const { + text, + className, + id, + key, + dataset, + style, + attrs, + events, + ...rawProps + } = attributes; + + const props = applyDomEvents(e, { ...rawProps, events }); + + if (className != null) { + e.className = className; + this.ref.class[className] = e; + } + + if (id != null) { + e.id = id; + if (this.ref.id[id]) { + console.warn(`[D] duplicated id: ${id}`); + } + this.ref.id[id] = e; + } + + if (key != null) { + if (!this.ref.key) this.ref.key = {}; + if (this.ref.key[key]) { + console.warn(`[D] duplicated key: ${key}`); + } + this.ref.key[key] = e; + } + + if (text != null) { + e.innerText = text; + } + + if (dataset && typeof dataset === 'object') { + Object.assign(e.dataset, dataset); + } + + if (style != null) { + if (typeof style === 'string') { + e.style.cssText = style; + } else if (typeof style === 'object') { + Object.assign(e.style, style); + } + } + + if (attrs && typeof attrs === 'object') { + for (const name in attrs) { + if (attrs[name] != null) e.setAttribute(name, attrs[name]); + } + } + + for (const name in props) { + const value = props[name]; + if (value != null) { + e[name] = value; + } + } + + this.e = e; + } + + childList(list) { + appendKids(this, list); + return this; + } + + dom() { + return this.e; + } +} + +function appendKids(element, children) { + for (const kid of flattenChildren(children)) { + if (typeof kid === 'string' || typeof kid === 'number') { + element.e.appendChild(document.createTextNode(String(kid))); + } else if (kid && typeof kid.dom === 'function') { + element.e.appendChild(kid.dom()); + } + } +} + +const TAGS = [ + 'div', + 'img', + 'b', + 's', + 'strong', + 'section', + 'li', + 'ul', + 'button', + 'input', + 'iframe', + 'header', + 'aside', + 'main', + 'h1', + 'h2', + 'p', + 'span', + 'label', + 'textarea', +]; + +/** + * @example + * const d = new D(); + * d.div({ className: 'box', id: 'home' }, + * d.div({ className: 'title', text: 'Hi' }), + * d.p({ text: 'body' }), + * ); + * + * const { div, button, get, map, h } = bind(); + * div({ className: 'row' }, + * ...map(items, (item, i, key) => button({ key, text: item.label }), (item) => item.id), + * ); + */ +export class D { + constructor() { + this.ref = { class: {}, id: {}, key: {} }; + this.root = null; + } + + /** 解构短名:`const { div, h, map, get } = D.tags()` */ + static tags() { + return bind(new D()); + } + + div(props, ...children) { + return this._create('div', props, ...children); + } + + img(props, ...children) { + return this._create('img', props, ...children); + } + + b(props, ...children) { + return this._create('b', props, ...children); + } + + s(props, ...children) { + return this._create('s', props, ...children); + } + + strong(props, ...children) { + return this._create('strong', props, ...children); + } + + section(props, ...children) { + return this._create('section', props, ...children); + } + + li(props, ...children) { + return this._create('li', props, ...children); + } + + ul(props, ...children) { + return this._create('ul', props, ...children); + } + + button(props, ...children) { + return this._create('button', props, ...children); + } + + input(props, ...children) { + return this._create('input', props, ...children); + } + + iframe(props, ...children) { + return this._create('iframe', props, ...children); + } + + header(props, ...children) { + return this._create('header', props, ...children); + } + + aside(props, ...children) { + return this._create('aside', props, ...children); + } + + main(props, ...children) { + return this._create('main', props, ...children); + } + + h1(props, ...children) { + return this._create('h1', props, ...children); + } + + h2(props, ...children) { + return this._create('h2', props, ...children); + } + + p(props, ...children) { + return this._create('p', props, ...children); + } + + span(props, ...children) { + return this._create('span', props, ...children); + } + + label(props, ...children) { + return this._create('label', props, ...children); + } + + textarea(props, ...children) { + return this._create('textarea', props, ...children); + } + + /** 任意标签:d.el('nav', props, ...children) */ + el(tag, props, ...children) { + return this._create(tag, props, ...children); + } + + /** + * 列表渲染:自动把 keyOf(item) 传给 render,并写入 ref.key。 + * @param {Array} list + * @param {(item, index, key) => Element} render + * @param {(item, index) => string|number} [keyOf] + */ + map(list, render, keyOf = (_item, index) => String(index)) { + return (list || []).map((item, index) => { + const key = keyOf(item, index); + const node = render(item, index, key); + if (node && node.e && key != null) { + this.ref.key[key] = node.e; + } + return node; + }); + } + + _create(tag, props, ...children) { + let attrs = {}; + let kids = children; + + if (!isPlainProps(props)) { + kids = props === undefined ? children : [props, ...children]; + attrs = {}; + } else { + attrs = props || {}; + } + + const e = new Element(this.ref, tag, attrs); + const flat = flattenChildren(kids); + if (flat.length) appendKids(e, flat); + + // 尾参求值时子节点先创建,会暂时占住 root;若本节点包住了当前 root,提升为自己 + const wrapsRoot = flat.some( + (k) => k && typeof k.dom === 'function' && (k === this.root || k.e === this.root?.e) + ); + if (this.root == null || wrapsRoot) { + this.root = e; + } + return e; + } + + get() { + return [this.ref, this.root.dom()]; + } + + static clear(e) { + if (e == null) return; + while (e.firstChild) { + e.removeChild(e.firstChild); + } + } +} + +/** + * Component class?: has mount + render on prototype (avoids importing Component here). + * @param {unknown} type + */ +function isComponentClass(type) { + return ( + typeof type === 'function' && + type.prototype != null && + type.prototype.constructor === type && + typeof type.prototype.mount === 'function' && + typeof type.prototype.render === 'function' + ); +} + +/** + * 绑定到同一个 D 实例,便于解构短名。 + * @param {D} [d] + */ +export function bind(d = new D()) { + const api = { + d, + /** + * Hyperscript: DOM tag, UI primitive, or Component class / factory. + * `h(TodoList, { items })` / `h(CoverFlow, { items })` — React createElement 同形。 + * @param {string|Function} type + * @param {object} [props] + * @param {...any} children + */ + h(type, props, ...children) { + if (isComponentClass(type)) { + return new type(props || {}); + } + if (typeof type === 'function') { + return type(props || {}, ...children); + } + return d.el(type, props, ...children); + }, + map(list, render, keyOf) { + return d.map(list, render, keyOf); + }, + get() { + return d.get(); + }, + clear: D.clear, + }; + + for (const tag of TAGS) { + api[tag] = (props, ...children) => d[tag](props, ...children); + } + + return api; +} diff --git a/src/pyouter/webui/gd5/engine/index.js b/src/pyouter/webui/gd5/engine/index.js new file mode 100644 index 0000000..f561566 --- /dev/null +++ b/src/pyouter/webui/gd5/engine/index.js @@ -0,0 +1,35 @@ +/** + * ProtoLive Engine — 迷你实模式 DOM 组件运行时 + * + * 产品(ProtoLive 编辑器)应依赖本引擎实现页面导航与视图构建, + * 而不是绕过 Component / ComponentRender / D。 + */ + +export { Component } from './component.js'; +export { ComponentRender, defaultGetUserInfo } from './component-render.js'; +export { Element, D, bind, domEventType, applyDomEvents } from './element.js'; +export { + Log, + LogEnv, + LogLevel, + LogProvider, + Logger, + createAlertSink, + createConsoleSink, + createNoopSink, + formatLogRecord, + normalizeLogContext, + resolveLogLevel, + resolveLogSink, +} from './log.js'; +export { + adoptStyles, + adoptStylesFor, + adoptedStyleKeys, + resetAdoptedStyles, + scopeCssText, + styleScopeId, + styleScopeSelector, + applyStyleScope, + STYLE_SCOPE_ATTR, +} from './styles.js'; diff --git a/src/pyouter/webui/gd5/engine/log.js b/src/pyouter/webui/gd5/engine/log.js new file mode 100644 index 0000000..836d1a3 --- /dev/null +++ b/src/pyouter/webui/gd5/engine/log.js @@ -0,0 +1,450 @@ +/** + * LogProvider — structured logging for the engine. + * + * Configure once at the app root, then inject the logger into components: + * + * const logs = new LogProvider({ + * level: 'info', + * sink: 'console', + * context: { machId: 'local', userId: 'demo' }, + * }); + * const app = new ComponentRender({ logProvider: logs }); + * + * this.logger.info('mounted'); + * this.logger.child({ page: 'editor' }).warn('slow paint', { ms: 42 }); + * + * Levels: debug < info < warn < error < silent + * Sinks: 'console' | 'noop' | 'alert' | (record) => void + */ + +/** @typedef {'debug'|'info'|'warn'|'error'} LogLevelName */ +/** @typedef {'console'|'noop'|'alert'|((record: LogRecord) => void)} LogSinkOption */ + +/** + * @typedef {object} LogRecord + * @property {LogLevelName} level + * @property {number} levelValue + * @property {number} time + * @property {string} message + * @property {Record} context + */ + +export const LogLevel = Object.freeze({ + debug: 10, + info: 20, + warn: 30, + error: 40, + silent: 100, +}); + +const LEVEL_NAME = Object.freeze({ + [LogLevel.debug]: 'debug', + [LogLevel.info]: 'info', + [LogLevel.warn]: 'warn', + [LogLevel.error]: 'error', +}); + +const NAME_TO_LEVEL = Object.freeze({ + debug: LogLevel.debug, + info: LogLevel.info, + warn: LogLevel.warn, + error: LogLevel.error, + silent: LogLevel.silent, +}); + +/** + * Legacy environment names. Prefer `LogProvider` `{ level, sink }`. + * @deprecated + */ +export const LogEnv = Object.freeze({ + disable: 'disable', + chrome: 'chrome', + wechat: 'wechat', +}); + +/** + * @param {number|string|undefined} level + * @returns {number} + */ +export function resolveLogLevel(level) { + if (typeof level === 'number' && Number.isFinite(level)) return level; + if (typeof level === 'string' && level in NAME_TO_LEVEL) return NAME_TO_LEVEL[level]; + return LogLevel.info; +} + +/** + * @param {Record|null|undefined} ctx + * @returns {string} + */ +function formatContext(ctx) { + if (!ctx || typeof ctx !== 'object') return ''; + return Object.entries(ctx) + .filter(([, v]) => v != null && v !== '') + .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`) + .join(' '); +} + +/** + * @param {LogRecord} record + * @returns {string} + */ +export function formatLogRecord(record) { + const ctx = formatContext(record.context); + const tag = String(record.level || 'info').toUpperCase(); + return ctx ? `[${tag}] ${ctx} | ${record.message}` : `[${tag}] ${record.message}`; +} + +/** @returns {(record: LogRecord) => void} */ +export function createConsoleSink() { + return (record) => { + const line = formatLogRecord(record); + if (record.level === 'error') { + console.error(line); + console.assert(false, record.message); + } else if (record.level === 'warn') { + console.warn(line); + } else if (record.level === 'debug') { + (console.debug || console.log).call(console, line); + } else { + console.log(line); + } + }; +} + +/** @returns {(record: LogRecord) => void} */ +export function createNoopSink() { + return () => {}; +} + +/** @returns {(record: LogRecord) => void} */ +export function createAlertSink() { + return (record) => { + if (typeof globalThis.alert === 'function') { + globalThis.alert(formatLogRecord(record)); + } + }; +} + +/** + * @param {LogSinkOption|string|undefined} sink + * @returns {(record: LogRecord) => void} + */ +export function resolveLogSink(sink) { + if (typeof sink === 'function') return sink; + if (sink === 'noop' || sink === LogEnv.disable) return createNoopSink(); + if (sink === 'alert' || sink === LogEnv.wechat) return createAlertSink(); + return createConsoleSink(); +} + +/** + * Normalize legacy context lists and plain objects into a context map. + * @param {Record|string[]|string|null|undefined} contexts + * @returns {Record} + */ +export function normalizeLogContext(contexts) { + if (contexts == null) return {}; + if (typeof contexts === 'object' && !Array.isArray(contexts)) { + return { ...contexts }; + } + if (!Array.isArray(contexts)) { + return { ctx: String(contexts) }; + } + + const out = {}; + contexts.forEach((item, i) => { + if (item != null && typeof item === 'object' && !Array.isArray(item)) { + Object.assign(out, item); + return; + } + const s = String(item ?? ''); + if (!s) return; + + // "machId:local, userId:demo, token:" (legacy ComponentRender prefix) + if (s.includes(':') && s.includes(',')) { + s.split(',').forEach((part) => { + const trimmed = part.trim(); + const idx = trimmed.indexOf(':'); + if (idx > 0) { + out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim(); + } else if (trimmed) { + out[`ctx${i}`] = trimmed; + } + }); + return; + } + + if (s.includes('=') && !s.includes(' ')) { + const idx = s.indexOf('='); + out[s.slice(0, idx)] = s.slice(idx + 1); + return; + } + + const colon = s.indexOf(':'); + if (colon > 0 && !s.includes(' ')) { + out[s.slice(0, colon)] = s.slice(colon + 1); + return; + } + + out[`ctx${i}`] = s; + }); + return out; +} + +/** + * Scoped logger bound to a {@link LogProvider}. + */ +export class Logger { + /** + * @param {LogProvider} provider + * @param {Record} [context] + */ + constructor(provider, context = {}) { + this._provider = provider; + this._context = { ...context }; + } + + /** @returns {Record} */ + get context() { + return { ...this._context }; + } + + /** @returns {LogProvider} */ + get provider() { + return this._provider; + } + + /** + * Child logger with merged context. Parent context is not mutated. + * @param {Record} [extra] + * @returns {Logger} + */ + child(extra = {}) { + return new Logger(this._provider, { ...this._context, ...extra }); + } + + /** Alias of {@link Logger#child} with no extra fields (copy). */ + fork() { + return this.child(); + } + + /** + * Mutate this logger's context (prefer {@link Logger#child} in new code). + * @param {Record|string} value + */ + pushContext(value) { + if (value != null && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(this._context, value); + return; + } + if (value != null) { + this._context[`ctx${Object.keys(this._context).length}`] = value; + } + } + + /** Remove the last context key (pairs with legacy {@link Logger#pushContext}). */ + popContext() { + const keys = Object.keys(this._context); + if (keys.length) delete this._context[keys[keys.length - 1]]; + } + + /** Legacy env name from the owning provider. */ + env() { + return this._provider.env; + } + + /** + * @param {unknown} message + * @param {Record} [fields] + */ + debug(message, fields) { + this._emit(LogLevel.debug, message, fields); + } + + /** + * @param {unknown} message + * @param {Record} [fields] + */ + info(message, fields) { + this._emit(LogLevel.info, message, fields); + } + + /** + * @param {unknown} message + * @param {Record} [fields] + */ + warn(message, fields) { + this._emit(LogLevel.warn, message, fields); + } + + /** + * @param {unknown} message + * @param {Record} [fields] + */ + error(message, fields) { + this._emit(LogLevel.error, message, fields); + } + + /** @param {unknown} message @param {Record} [fields] */ + i(message, fields) { + this.info(message, fields); + } + + /** @param {unknown} message @param {Record} [fields] */ + w(message, fields) { + this.warn(message, fields); + } + + /** @param {unknown} message @param {Record} [fields] */ + e(message, fields) { + this.error(message, fields); + } + + /** + * @param {number} level + * @param {unknown} message + * @param {Record} [fields] + */ + _emit(level, message, fields) { + this._provider.emit(level, message, this._context, fields); + } +} + +/** + * App-level logging configuration. Create one per runtime and pass it to + * {@link import('./component-render.js').ComponentRender}. + */ +export class LogProvider { + /** + * @param {object} [options] + * @param {number|string} [options.level='info'] + * @param {LogSinkOption|string} [options.sink='console'] + * @param {Record} [options.context] + * @param {string} [options.env] legacy {@link LogEnv} name exposed via `env` + */ + constructor(options = {}) { + this._level = resolveLogLevel(options.level ?? LogLevel.info); + this._sink = resolveLogSink(options.sink ?? 'console'); + this._context = { ...(options.context || {}) }; + this._env = options.env || inferEnvName(options); + this._root = new Logger(this, this._context); + } + + /** + * Build a provider from a legacy {@link LogEnv} value. + * @param {string} [env] + * @param {Record|string[]} [contexts] + * @returns {LogProvider} + */ + static fromEnv(env = LogEnv.chrome, contexts) { + const context = normalizeLogContext(contexts); + if (env === LogEnv.disable) { + return new LogProvider({ + level: LogLevel.silent, + sink: 'noop', + context, + env: LogEnv.disable, + }); + } + if (env === LogEnv.wechat) { + return new LogProvider({ + level: LogLevel.info, + sink: 'alert', + context, + env: LogEnv.wechat, + }); + } + return new LogProvider({ + level: LogLevel.info, + sink: 'console', + context, + env: LogEnv.chrome, + }); + } + + /** @returns {number} */ + get level() { + return this._level; + } + + /** @returns {string} */ + get env() { + return this._env; + } + + /** Root logger for this provider. */ + get logger() { + return this._root; + } + + /** + * @param {Record} [extra] + * @returns {Logger} + */ + child(extra) { + return this._root.child(extra || {}); + } + + /** + * @param {number|string} level + */ + setLevel(level) { + this._level = resolveLogLevel(level); + } + + /** + * @param {LogSinkOption|string} sink + */ + setSink(sink) { + this._sink = resolveLogSink(sink); + } + + /** + * @param {number} level + * @param {unknown} message + * @param {Record} [baseContext] + * @param {Record} [fields] + */ + emit(level, message, baseContext, fields) { + if (level < this._level) return; + const name = /** @type {LogLevelName} */ (LEVEL_NAME[level] || 'info'); + const context = { ...(baseContext || {}) }; + if (fields != null && typeof fields === 'object' && !Array.isArray(fields)) { + Object.assign(context, fields); + } + this._sink({ + level: name, + levelValue: level, + time: Date.now(), + message: message == null ? '' : String(message), + context, + }); + } +} + +/** + * @param {object} options + * @returns {string} + */ +function inferEnvName(options) { + const sink = options.sink; + const level = resolveLogLevel(options.level); + if (sink === 'noop' || sink === LogEnv.disable || level >= LogLevel.silent) { + return LogEnv.disable; + } + if (sink === 'alert' || sink === LogEnv.wechat) return LogEnv.wechat; + return LogEnv.chrome; +} + +/** + * Legacy `new Log(env, contexts)`. Prefer `new LogProvider(...).logger`. + */ +export class Log extends Logger { + /** + * @param {string} [env] + * @param {Record|string[]} [contexts] + */ + constructor(env = LogEnv.chrome, contexts) { + const provider = LogProvider.fromEnv(env, contexts); + super(provider, provider.logger.context); + } +} diff --git a/src/pyouter/webui/gd5/engine/styles.js b/src/pyouter/webui/gd5/engine/styles.js new file mode 100644 index 0000000..5ff63ca --- /dev/null +++ b/src/pyouter/webui/gd5/engine/styles.js @@ -0,0 +1,418 @@ +/** + * 组件样式注入(引擎) + * + * 无 bundler:用 `new URL('./X.css', import.meta.url)` 旁挂 CSS, + * mount 时按 key 注入一次,unmount 不移除(多实例共享)。 + * + * Component `static styles` 默认自动 scope:作者仍写 `.toolbar` / + * `className: 'toolbar'`,引擎注入时改写成 + * `[data-gd-c="ToolbarComponent"] .toolbar`,并在 host 上打标。 + * 使用方零前缀噪音。`static stylesScoped = false` 可关闭。 + */ + +/** Host / CSS 上的 scope 属性名(作者不必手写) */ +export const STYLE_SCOPE_ATTR = 'data-gd-c'; + +/** @type {Map>} */ +const adopted = new Map(); + +/** + * @param {string|URL} spec + * @returns {string} + */ +function styleKey(spec) { + if (spec instanceof URL) return spec.href; + if (typeof spec === 'string') { + if (isStylesheetRef(spec)) { + try { + return new URL(spec, globalThis.location?.href || 'http://local/').href; + } catch { + return spec; + } + } + return `css:${fnv1a(spec)}`; + } + throw new Error('adoptStyles: unsupported spec'); +} + +/** + * 路径 / URL 才当 stylesheet;含 `{` 的字符串当 CSS 文本。 + * @param {string} s + */ +function isStylesheetRef(s) { + if (typeof s !== 'string') return false; + if (s.includes('{')) return false; + return ( + s.endsWith('.css') || + s.startsWith('./') || + s.startsWith('../') || + s.startsWith('/') || + s.startsWith('http://') || + s.startsWith('https://') || + s.startsWith('blob:') + ); +} + +/** @param {string} str */ +function fnv1a(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16); +} + +/** + * Scope id for a Component class (override with `static styleScope`). + * @param {Function|{name?:string,styleScope?:string}|null|undefined} Ctor + * @returns {string} + */ +export function styleScopeId(Ctor) { + if (!Ctor) return 'Component'; + if (typeof Ctor.styleScope === 'string' && Ctor.styleScope.trim()) { + return Ctor.styleScope.trim(); + } + return Ctor.name || 'Component'; +} + +/** + * @param {string} scopeId + * @returns {string} + */ +export function styleScopeSelector(scopeId) { + const safe = String(scopeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `[${STYLE_SCOPE_ATTR}="${safe}"]`; +} + +/** + * Stamp scope attr on a component host. No-op when scoping disabled. + * @param {Element|{setAttribute?:Function,dataset?:DOMStringMap}|null} host + * @param {Function} Ctor + */ +export function applyStyleScope(host, Ctor) { + if (!host || !Ctor || Ctor.styles == null || Ctor.stylesScoped === false) return; + const id = styleScopeId(Ctor); + if (typeof host.setAttribute === 'function') { + host.setAttribute(STYLE_SCOPE_ATTR, id); + } else if (host.dataset && typeof host.dataset === 'object') { + host.dataset.gdC = id; + } else { + host[STYLE_SCOPE_ATTR] = id; + } +} + +/** + * Rewrite CSS so every rule is limited to `[data-gd-c="…"]` descendants. + * Authors keep writing `.toolbar` — no prefix in source. + * + * @param {string} cssText + * @param {string} scopeId + * @returns {string} + */ +export function scopeCssText(cssText, scopeId) { + const css = String(cssText ?? ''); + if (!css.trim() || !scopeId) return css; + const scope = styleScopeSelector(scopeId); + return transformStyleSheet(css, scope); +} + +/** + * @param {string} sel + * @param {string} scope + */ +function prefixSelector(sel, scope) { + const s = sel.trim(); + if (!s) return s; + if (s === ':root' || s === 'html' || s === 'body') return s; + if (s.startsWith(':root') || s.startsWith('html ') || s.startsWith('html:') || s.startsWith('html[')) { + return s; + } + if (s.startsWith('body ') || s.startsWith('body:') || s.startsWith('body[')) return s; + if (s.startsWith(scope)) return s; + if (s === ':scope' || s === '&') return scope; + if (s.startsWith(':scope') || s.startsWith('&')) { + return scope + s.slice(s.startsWith(':scope') ? 6 : 1); + } + return `${scope} ${s}`; +} + +/** + * @param {string} selectorList + * @param {string} scope + */ +function prefixSelectorList(selectorList, scope) { + return selectorList + .split(',') + .map((part) => prefixSelector(part, scope)) + .join(', '); +} + +/** + * Advance past comments / strings starting at `i`. + * @param {string} css + * @param {number} i + * @param {number} n + * @returns {{ i: number, chunk: string }|null} + */ +function takeCommentOrString(css, i, n) { + if (css[i] === '/' && css[i + 1] === '*') { + const end = css.indexOf('*/', i + 2); + const j = end < 0 ? n : end + 2; + return { i: j, chunk: css.slice(i, j) }; + } + if (css[i] === '"' || css[i] === "'") { + const q = css[i]; + let j = i + 1; + while (j < n) { + if (css[j] === '\\') { + j += 2; + continue; + } + if (css[j] === q) { + j += 1; + break; + } + j += 1; + } + return { i: j, chunk: css.slice(i, j) }; + } + return null; +} + +/** + * Read a `{ … }` block starting at `i` (must be `{`). + * @param {string} css + * @param {number} i + * @param {number} n + */ +function takeBlock(css, i, n) { + let depth = 0; + let j = i; + while (j < n) { + const cos = takeCommentOrString(css, j, n); + if (cos) { + j = cos.i; + continue; + } + if (css[j] === '{') depth += 1; + else if (css[j] === '}') { + depth -= 1; + j += 1; + if (depth === 0) break; + continue; + } + j += 1; + } + return { i: j, chunk: css.slice(i, j) }; +} + +/** + * Brace-aware stylesheet transform. + * @param {string} css + * @param {string} scope + */ +function transformStyleSheet(css, scope) { + let out = ''; + let i = 0; + const n = css.length; + + while (i < n) { + const cos = takeCommentOrString(css, i, n); + if (cos) { + out += cos.chunk; + i = cos.i; + continue; + } + + if (/\s/.test(css[i])) { + out += css[i]; + i += 1; + continue; + } + + // Prelude until `{` or `;` + let prelude = ''; + while (i < n) { + const inner = takeCommentOrString(css, i, n); + if (inner) { + prelude += inner.chunk; + i = inner.i; + continue; + } + if (css[i] === '{' || css[i] === ';') break; + prelude += css[i]; + i += 1; + } + + if (i >= n) { + out += prelude; + break; + } + + if (css[i] === ';') { + out += prelude + ';'; + i += 1; + continue; + } + + const trimmed = prelude.trim(); + const block = takeBlock(css, i, n); + i = block.i; + + if (trimmed.startsWith('@')) { + const atName = trimmed.match(/^@([a-zA-Z-]+)/)?.[1]?.toLowerCase() || ''; + if ( + atName === 'media' || + atName === 'supports' || + atName === 'layer' || + atName === 'document' || + atName === 'container' + ) { + const inner = block.chunk.slice(1, -1); + out += prelude + '{' + transformStyleSheet(inner, scope) + '}'; + continue; + } + // @keyframes / @font-face / @property / @page — keep global + out += prelude + block.chunk; + continue; + } + + out += prefixSelectorList(prelude, scope) + block.chunk; + } + + return out; +} + +/** + * 注入一份样式(URL / 相对路径 / CSS 文本),相同 key 只注入一次。 + * 全局、无 scope(UI kit / 手动调用)。 + * @param {string|URL} spec + * @param {string} [key] + * @returns {Promise} + */ +export function adoptStyles(spec, key) { + const id = key || styleKey(spec); + if (adopted.has(id)) return adopted.get(id); + + const done = inject(spec, id); + adopted.set(id, done); + return done; +} + +/** + * @param {string} text + * @param {string} id + * @returns {Promise} + */ +function injectStyleText(text, id) { + if (typeof document === 'undefined' || !document.head) { + return Promise.resolve(); + } + const style = document.createElement('style'); + style.dataset.componentStyle = id; + style.textContent = text; + document.head.appendChild(style); + return Promise.resolve(); +} + +/** + * Fetch stylesheet text for scoping (URL / path → text). + * @param {string|URL} spec + * @returns {Promise} + */ +async function loadCssText(spec) { + if (typeof spec === 'string' && !isStylesheetRef(spec)) { + return spec; + } + const href = spec instanceof URL ? spec.href : String(spec); + if (typeof fetch !== 'function') { + throw new Error(`adoptStyles: fetch unavailable for ${href}`); + } + const res = await fetch(href); + if (!res.ok) { + throw new Error(`adoptStyles: failed to load ${href} (${res.status})`); + } + return res.text(); +} + +/** + * Adopt one Component style entry, optionally scoped. + * @param {string|URL} spec + * @param {string|null} scopeId + * @returns {Promise} + */ +async function adoptComponentStyle(spec, scopeId) { + if (!scopeId) { + return adoptStyles(spec); + } + + const id = `scoped:${scopeId}:${styleKey(spec)}`; + if (adopted.has(id)) return adopted.get(id); + + const done = (async () => { + try { + const text = await loadCssText(spec); + await injectStyleText(scopeCssText(text, scopeId), id); + } catch (err) { + // Fallback: unscoped so the page still paints + console.warn('[adoptStyles] scoped load failed, falling back to link:', err); + await inject(spec, id); + } + })(); + + adopted.set(id, done); + return done; +} + +/** + * 读取 Component 子类的 `static styles`(单项或数组)并注入。 + * 默认按组件名自动 scope;`static stylesScoped = false` 关闭。 + * @param {typeof import('./component.js').Component} Ctor + */ +export async function adoptStylesFor(Ctor) { + if (!Ctor || Ctor.styles == null) return; + const scoped = Ctor.stylesScoped !== false; + const scopeId = scoped ? styleScopeId(Ctor) : null; + const list = Array.isArray(Ctor.styles) ? Ctor.styles : [Ctor.styles]; + await Promise.all(list.map((item) => adoptComponentStyle(item, scopeId))); +} + +/** @returns {string[]} */ +export function adoptedStyleKeys() { + return [...adopted.keys()]; +} + +/** 测试用:清空登记(不移除已插入的 DOM) */ +export function resetAdoptedStyles() { + adopted.clear(); +} + +/** + * @param {string|URL} spec + * @param {string} id + */ +function inject(spec, id) { + if (typeof document === 'undefined' || !document.head) { + return Promise.resolve(); + } + + if (spec instanceof URL || (typeof spec === 'string' && isStylesheetRef(spec))) { + const href = spec instanceof URL ? spec.href : spec; + return new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.dataset.componentStyle = id; + link.onload = () => resolve(); + link.onerror = () => reject(new Error(`adoptStyles: failed to load ${href}`)); + document.head.appendChild(link); + }); + } + + if (typeof spec === 'string') { + return injectStyleText(spec, id); + } + + return Promise.reject(new Error('adoptStyles: unsupported spec')); +} diff --git a/src/pyouter/webui/gd5/ui/CommandPalette.js b/src/pyouter/webui/gd5/ui/CommandPalette.js new file mode 100644 index 0000000..e7f288d --- /dev/null +++ b/src/pyouter/webui/gd5/ui/CommandPalette.js @@ -0,0 +1,128 @@ +import { Component } from '../engine/index.js'; +import { loadUi } from './util.js'; + +/** + * CommandPalette — keyboard-first searchable action launcher. + * + * const cmd = new CommandPalette({}); + * await cmd.mount(); + * cmd.open({ items: [{ id, label, onSelect }] }); + */ +export class CommandPalette extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.open = false; + this.state.data.query = ''; + this.state.data.items = []; + this._portal = null; + } + + async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) { + const target = + typeof host === 'string' ? document.getElementById(host) : host || document.body; + if (!target) throw new Error('CommandPalette.mount: host not found'); + if (!this._portal) { + this._portal = document.createElement('div'); + this._portal.className = 'gd-cmd-portal'; + target.appendChild(this._portal); + } + return super.mount(this._portal, params); + } + + componentDidUnMount() { + if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal); + this._portal = null; + } + + async componentDidMount() { + await loadUi(); + } + + open(options = {}) { + this.setData({ + open: true, + query: '', + items: options.items || this.state.data.items, + }); + return this; + } + + close() { + this.setData({ open: false, query: '' }); + return this; + } + + componentDidAttachEvents() { + this.on(window, 'keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + if (this.state.data.open) this.close(); + else this.open(); + } else if (e.key === 'Escape' && this.state.data.open) { + this.close(); + } + }); + } + + render() { + const { open, query, items } = this.state.data; + const q = query.toLowerCase(); + const filtered = items.filter((it) => + String(it.label || '') + .toLowerCase() + .includes(q) + ); + + this.ensureShell( + (ids) => ids.gdCmdRoot, + ({ div, input }) => { + div( + { className: 'gd-cmd-root', id: 'gdCmdRoot' }, + div({ + className: 'gd-cmd-mask', + id: 'gdCmdMask', + onClick: () => this.close(), + }), + div( + { className: 'gd-cmd', id: 'gdCmdPanel' }, + input({ + id: 'gdCmdInput', + className: 'gd-cmd__input', + placeholder: 'Type a command…', + onInput: (e) => this.setData({ query: e.target.value }), + }), + div({ className: 'gd-cmd__list', id: 'gdCmdList' }) + ) + ); + } + ); + + const root = this.ids().gdCmdRoot; + const list = this.ids().gdCmdList; + const inputEl = this.ids().gdCmdInput; + if (root) root.classList.toggle('is-open', !!open); + if (inputEl && open && document.activeElement !== inputEl) { + queueMicrotask(() => inputEl.focus()); + } + if (list) { + list.textContent = ''; + for (const it of filtered) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'gd-cmd__item'; + btn.textContent = it.label; + btn.addEventListener('click', () => { + it.onSelect?.(it); + this.close(); + }); + list.appendChild(btn); + } + if (!filtered.length) { + const empty = document.createElement('div'); + empty.className = 'gd-cmd__empty'; + empty.textContent = 'No results'; + list.appendChild(empty); + } + } + } +} diff --git a/src/pyouter/webui/gd5/ui/CoverFlow.js b/src/pyouter/webui/gd5/ui/CoverFlow.js new file mode 100644 index 0000000..3e37088 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/CoverFlow.js @@ -0,0 +1,685 @@ +import { Component } from '../engine/index.js'; +import { cx, el, loadUi } from './util.js'; + +/** Pixels of drag that map to one card before the curve. */ +const COVERFLOW_PITCH = 86; + +/** + * Normalize items to a stable shape. + * @param {Array} items + * @returns {Array<{key:string,title:string,subtitle?:string,image?:string,color?:string,content?:any,raw:any}>} + */ +export function normalizeCoverFlowItems(items = []) { + return (items || []).map((item, i) => { + if (item == null || typeof item === 'string' || typeof item === 'number') { + const title = String(item ?? ''); + return { key: `item-${i}`, title, raw: item }; + } + const key = String(item.key ?? item.id ?? `item-${i}`); + return { + key, + title: String(item.title ?? item.label ?? item.name ?? key), + subtitle: item.subtitle != null ? String(item.subtitle) : item.desc != null ? String(item.desc) : undefined, + image: item.image || item.src || undefined, + color: item.color || undefined, + content: item.content, + raw: item, + }; + }); +} + +/** + * Map a horizontal drag to card slots. + * Short drags resist (sub-linear); long drags accelerate past one-card-per-pitch. + * Positive dx (drag right) yields positive slots (toward previous cards). + * @param {number} dx + * @param {number} [pitch] + */ +export function coverFlowDragSlots(dx, pitch = COVERFLOW_PITCH) { + if (!pitch) return 0; + const t = dx / pitch; + if (t === 0) return 0; + const sign = Math.sign(t); + return sign * Math.pow(Math.abs(t), 1.4); +} + +/** + * Card index after a drag is released. + * Distance uses the non-linear slot curve. `velocity` (px/ms) then adds + * extra slots — about one card at 1px/ms — so a flick travels further + * without pushing a moderate drag through the curve a second time. + * Dragging left (dx < 0) advances to the next card. + * @param {{origin:number,dx:number,velocity?:number,pitch?:number,count:number,loop?:boolean}} opts + */ +export function coverFlowSettleIndex({ origin, dx, velocity = 0, pitch = COVERFLOW_PITCH, count, loop = true }) { + if (!count) return 0; + const speed = Math.max(-1.2, Math.min(1.2, velocity || 0)); + let slots = coverFlowDragSlots(dx, pitch) + speed * 0.85; + if (count > 1) { + const limit = count - 1; + if (slots > limit) slots = limit; + else if (slots < -limit) slots = -limit; + } + const raw = origin - slots; + const rounded = Math.round(raw); + if (loop) return wrapIndex(rounded, count); + return clampIndex(rounded, count); +} + +/** + * Shortest signed distance from a fractional position to card `i`. + * With `loop`, the seam stays continuous (the first card sits just past the last). + * @param {number} i + * @param {number} pos + * @param {number} count + * @param {boolean} loop + */ +export function coverFlowOffset(i, pos, count, loop) { + let d = i - pos; + if (!loop || count <= 1) return d; + d -= count * Math.round(d / count); + return d; +} + +/** + * CoverFlow — Apple Cover Flow / game character-select style carousel. + * Center card faces forward; neighbors tilt & stack in perspective. + * Drag and touch follow the pointer, then ease onto a card. A longer or + * faster swipe crosses more than one card. + * + * Also known as: Cover Flow, coverflow carousel, 3D card carousel. + * + * @param {object} [deps] + * @param {Array} [deps.items] + * @param {number} [deps.index] + * @param {number} [deps.defaultIndex] + * @param {boolean} [deps.loop] + * @param {number} [deps.sideCount] cards visible each side (default 2) + * @param {number} [deps.height] + * @param {(index:number,item:object)=>void} [deps.onChange] + * @param {(index:number,item:object)=>void} [deps.onSelect] + */ +export class CoverFlow extends Component { + constructor(deps = {}) { + super(deps); + const items = normalizeCoverFlowItems(deps.items); + const initial = + Number.isFinite(deps.index) ? deps.index : Number.isFinite(deps.defaultIndex) ? deps.defaultIndex : 0; + this.state.data.items = items; + this.state.data.index = clampIndex(initial, items.length); + this.state.data.loop = deps.loop !== false; + this.state.data.sideCount = Math.max(1, Math.min(4, deps.sideCount ?? 2)); + this.state.data.height = deps.height ?? 320; + this._onChange = deps.onChange || null; + this._onSelect = deps.onSelect || null; + this._drag = null; + /** @type {number|null} fractional position while dragging or gliding */ + this._pos = null; + this._raf = 0; + this._suppressClick = false; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + componentDidUnMount() { + this._drag = null; + this._stopMotion(false); + } + + setItems(items) { + this._drag = null; + this._stopMotion(false); + const next = normalizeCoverFlowItems(items); + this.setData( + { + items: next, + index: clampIndex(this.state.data.index, next.length), + }, + { refresh: !!this._mounted } + ); + return this; + } + + getIndex() { + return this.state.data.index; + } + + getActiveItem() { + const { items, index } = this.state.data; + return items[index] || null; + } + + setIndex(index, { emit = true } = {}) { + this._drag = null; + this._stopMotion(false); + const { items, loop } = this.state.data; + if (!items.length) return this; + const next = loop ? wrapIndex(index, items.length) : clampIndex(index, items.length); + if (next === this.state.data.index) { + if (this._mounted) this._applyPose(); + return this; + } + this.setData({ index: next }, { refresh: !!this._mounted }); + if (emit) this._onChange?.(next, items[next]); + return this; + } + + next() { + return this.setIndex(this.state.data.index + 1); + } + + prev() { + return this.setIndex(this.state.data.index - 1); + } + + componentDidAttachEvents() { + const stage = this.ids().gdCfStage; + if (!stage) return; + + this.on(stage, 'pointerdown', (e) => { + if (e.button != null && e.button !== 0) return; + if (this._drag && e.pointerId != null && this._drag.pointerId !== e.pointerId) return; + this._stopMotion(true); + const now = e.timeStamp || nowMs(); + this._drag = { + pointerId: e.pointerId, + x0: e.clientX, + x: e.clientX, + t: now, + vx: 0, + moved: false, + origin: this._pos == null ? this.state.data.index : this._pos, + }; + try { + stage.setPointerCapture?.(e.pointerId); + } catch { + /* synthetic or already-released pointers */ + } + }); + + this.on( + stage, + 'pointermove', + (e) => { + const drag = this._drag; + if (!drag || (e.pointerId != null && e.pointerId !== drag.pointerId)) return; + const dx = e.clientX - drag.x0; + const now = e.timeStamp || nowMs(); + const dt = now - drag.t; + if (dt > 0 && dt < 120) { + // Floor the sample window so a burst of events in one frame cannot + // look like dozens of pixels per millisecond. + const inst = (e.clientX - drag.x) / Math.max(dt, 16); + const blended = drag.vx * 0.55 + inst * 0.45; + drag.vx = Math.max(-1.2, Math.min(1.2, blended)); + } else if (dt >= 120) { + drag.vx = 0; + } + drag.x = e.clientX; + drag.t = now; + if (Math.abs(dx) > 6) { + drag.moved = true; + e.preventDefault?.(); + this._follow(dx); + } + }, + { passive: false } + ); + + this.on(stage, 'pointerup', (e) => { + const drag = this._drag; + if (!drag || (e.pointerId != null && e.pointerId !== drag.pointerId)) return; + const dx = e.clientX - drag.x0; + const moved = drag.moved; + let vx = drag.vx; + const idle = (e.timeStamp || nowMs()) - drag.t; + if (idle > 80) vx = 0; + this._drag = null; + try { + stage.releasePointerCapture?.(e.pointerId); + } catch { + /* ignore */ + } + if (!moved) return; + this._armClickSuppress(); + this._release(drag.origin, dx, vx); + }); + + this.on(stage, 'pointercancel', (e) => this._abortDrag(e)); + this.on(stage, 'lostpointercapture', (e) => this._abortDrag(e)); + + this.on(stage, 'keydown', (e) => { + if (e.key === 'ArrowRight') { + e.preventDefault(); + this.next(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + this.prev(); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const item = this.getActiveItem(); + if (item) this._onSelect?.(this.state.data.index, item); + } + }); + } + + _armClickSuppress() { + this._suppressClick = true; + setTimeout(() => { + this._suppressClick = false; + }, 0); + } + + _abortDrag(e) { + const drag = this._drag; + if (!drag) return; + if (e?.pointerId != null && e.pointerId !== drag.pointerId) return; + const moved = drag.moved; + const origin = drag.origin; + this._drag = null; + if (!moved) return; + this._armClickSuppress(); + const from = this._pos == null ? origin : this._pos; + this._glide(from, Math.round(origin)); + } + + _follow(dx) { + const { items, loop } = this.state.data; + const count = items.length; + if (!count || !this._drag) return; + this._pos = livePosition(this._drag.origin, dx, count, loop); + this._setLive(true); + this._applyPose(); + } + + _release(origin, dx, velocity) { + const { items, loop } = this.state.data; + const count = items.length; + if (!count) return; + const index = coverFlowSettleIndex({ origin, dx, velocity, count, loop }); + const from = this._pos == null ? origin : this._pos; + let target = index; + if (loop && count > 1) { + let best = index; + let bestDist = Infinity; + for (const cand of [index - count, index, index + count]) { + const dist = Math.abs(cand - from); + if (dist < bestDist) { + best = cand; + bestDist = dist; + } + } + target = best; + } + if (prefersReducedMotion() || typeof globalThis.requestAnimationFrame !== 'function') { + this._finish(target); + return; + } + this._glide(from, target); + } + + _glide(from, to) { + this._stopRaf(); + const delta = to - from; + if (Math.abs(delta) < 0.0001 || prefersReducedMotion() || typeof globalThis.requestAnimationFrame !== 'function') { + this._finish(to); + return; + } + const duration = Math.min(680, 280 + Math.abs(delta) * 110); + const t0 = nowMs(); + this._setLive(true); + const step = (now) => { + if (!this.isMounted()) return; + const u = Math.min(1, (now - t0) / duration); + const eased = 1 - (1 - u) ** 3; + this._pos = from + delta * eased; + this._applyPose(); + if (u < 1) this._raf = globalThis.requestAnimationFrame(step); + else this._finish(to); + }; + this._raf = globalThis.requestAnimationFrame(step); + } + + _finish(unwrapped) { + this._raf = 0; + const { items, loop } = this.state.data; + const count = items.length; + const index = !count + ? 0 + : loop + ? wrapIndex(Math.round(unwrapped), count) + : clampIndex(Math.round(unwrapped), count); + this._pos = null; + this._setLive(false); + if (index !== this.state.data.index) this.setIndex(index); + else if (this._mounted) this._applyPose(); + } + + _stopRaf() { + if (this._raf) { + globalThis.cancelAnimationFrame?.(this._raf); + this._raf = 0; + } + } + + /** + * @param {boolean} keepPos keep the fractional position (grab during a glide) + */ + _stopMotion(keepPos) { + this._stopRaf(); + if (!keepPos) { + this._pos = null; + this._setLive(false); + } + } + + _setLive(on) { + this.ids().gdCfRoot?.classList?.toggle('gd-coverflow--live', on); + } + + _cardStyle(offset, sideCount) { + const abs = Math.abs(offset); + const curve = abs === 0 ? 0 : Math.pow(abs, 0.82); + const x = Math.sign(offset) * 78 * curve; + const rot = -Math.sign(offset) * 42 * Math.min(curve, 1); + const z = -curve * 70; + let scale = abs === 0 ? 1 : Math.max(0.78, 1 - curve * 0.1); + let opacity = abs === 0 ? 1 : Math.max(0.45, 1 - curve * 0.22); + if (abs > sideCount) opacity *= Math.max(0, 1 - (abs - sideCount) / 0.45); + const hidden = abs > sideCount + 0.45; + if (hidden) opacity = 0; + return { + opacity: String(opacity), + transform: `translate(-50%, -50%) translateX(${x}px) translateZ(${z}px) rotateY(${rot}deg) scale(${scale})`, + zIndex: String(100 - Math.round(abs * 10)), + pointerEvents: hidden ? 'none' : 'auto', + }; + } + + _applyPose() { + const stage = this.ids().gdCfStage; + if (!stage?.querySelectorAll) return; + const { items, sideCount, loop, index } = this.state.data; + const count = items.length; + const pos = this._pos == null ? index : this._pos; + const cards = [...stage.querySelectorAll('.gd-coverflow__card')]; + let nearest = index; + let nearestAbs = Infinity; + const posed = cards.map((card) => { + const i = Number(card.getAttribute('data-index')); + const offset = coverFlowOffset(i, pos, count, loop); + const abs = Math.abs(offset); + if (abs < nearestAbs) { + nearestAbs = abs; + nearest = i; + } + return { card, i, offset }; + }); + for (const { card, i, offset } of posed) { + const style = this._cardStyle(offset, sideCount); + card.style.opacity = style.opacity; + card.style.transform = style.transform; + card.style.zIndex = style.zIndex; + card.style.pointerEvents = style.pointerEvents; + card.setAttribute('data-offset', String(offset)); + const active = i === nearest; + card.classList.toggle('gd-coverflow__card--active', active); + card.setAttribute('aria-current', active ? 'true' : 'false'); + if ('tabIndex' in card) card.tabIndex = active ? 0 : -1; + } + const dots = this.ids().gdCfDots; + if (dots?.querySelectorAll && count) { + const activeDot = loop ? wrapIndex(Math.round(pos), count) : clampIndex(Math.round(pos), count); + [...dots.querySelectorAll('.gd-coverflow__dot')].forEach((dot, i) => { + dot.classList.toggle('gd-coverflow__dot--active', i === activeDot); + dot.setAttribute('aria-current', i === activeDot ? 'true' : 'false'); + }); + } + this._syncCaption(nearest); + } + + _syncCaption(nearest) { + const caption = this.ids().gdCfCaption; + if (!caption) return; + const { items, loop } = this.state.data; + const count = items.length; + if (!count) return; + const i = loop ? wrapIndex(nearest, count) : clampIndex(nearest, count); + const item = items[i]; + if (!item) return; + const titleEl = caption.querySelector?.('.gd-coverflow__caption-title'); + if (titleEl) titleEl.textContent = item.title; + const subEl = caption.querySelector?.('.gd-coverflow__caption-sub'); + if (item.subtitle) { + if (subEl) subEl.textContent = item.subtitle; + else if (typeof document !== 'undefined') { + const created = document.createElement('div'); + created.className = 'gd-coverflow__caption-sub'; + created.textContent = item.subtitle; + caption.appendChild(created); + } + } else if (subEl?.remove) { + subEl.remove(); + } + } + + _renderCard(item, offset, index) { + const { sideCount } = this.state.data; + const style = this._cardStyle(offset, sideCount); + const active = offset === 0; + const kids = []; + + if (item.image) { + kids.push( + el('img', { + className: 'gd-coverflow__img', + attrs: { src: item.image, alt: item.title, draggable: 'false' }, + }) + ); + } else { + kids.push( + el('div', { + className: 'gd-coverflow__art', + style: item.color ? { background: item.color } : undefined, + text: item.title.slice(0, 1).toUpperCase(), + }) + ); + } + + kids.push( + el( + 'div', + { className: 'gd-coverflow__meta' }, + el('div', { className: 'gd-coverflow__title', text: item.title }), + item.subtitle + ? el('div', { className: 'gd-coverflow__subtitle', text: item.subtitle }) + : null + ) + ); + + if (item.content) kids.push(item.content); + + return el( + 'button', + { + type: 'button', + className: cx('gd-coverflow__card', active && 'gd-coverflow__card--active'), + style, + attrs: { + 'data-offset': String(offset), + 'data-index': String(index), + 'aria-current': active ? 'true' : 'false', + tabindex: active ? '0' : '-1', + }, + onClick: (e) => { + e.preventDefault(); + if (this._suppressClick || this._drag?.moved || this._raf) return; + if (offset === 0) { + this._onSelect?.(index, item); + return; + } + this.setIndex(index); + }, + }, + ...kids + ); + } + + render() { + const { items, index, height, loop } = this.state.data; + const count = items.length; + + this.ensureShell( + (ids) => ids.gdCfRoot, + ({ div, button }) => { + div( + { + id: 'gdCfRoot', + className: 'gd-coverflow', + attrs: { role: 'group', 'aria-roledescription': 'coverflow carousel' }, + }, + div({ + id: 'gdCfStage', + className: 'gd-coverflow__stage', + attrs: { tabindex: '0', 'aria-label': 'Cover Flow' }, + }), + div( + { id: 'gdCfControls', className: 'gd-coverflow__controls' }, + button({ + type: 'button', + id: 'gdCfPrev', + className: 'gd-btn gd-btn--default gd-btn--sm gd-coverflow__arrow', + text: '‹', + attrs: { 'aria-label': 'Previous' }, + onClick: () => this.prev(), + }), + div({ id: 'gdCfCaption', className: 'gd-coverflow__caption' }), + button({ + type: 'button', + id: 'gdCfNext', + className: 'gd-btn gd-btn--default gd-btn--sm gd-coverflow__arrow', + text: '›', + attrs: { 'aria-label': 'Next' }, + onClick: () => this.next(), + }) + ), + div({ id: 'gdCfDots', className: 'gd-coverflow__dots' }) + ); + void button; + } + ); + + const root = this.ids().gdCfRoot; + const stage = this.ids().gdCfStage; + const caption = this.ids().gdCfCaption; + const dots = this.ids().gdCfDots; + const prev = this.ids().gdCfPrev; + const next = this.ids().gdCfNext; + + if (root) root.style.setProperty('--gd-coverflow-h', typeof height === 'number' ? `${height}px` : String(height)); + if (stage) { + stage.style.height = typeof height === 'number' ? `${height}px` : String(height); + stage.textContent = ''; + if (!count) { + stage.appendChild( + el('div', { className: 'gd-coverflow__empty', text: 'No cards' }).dom() + ); + } else { + for (let i = 0; i < count; i++) { + const offset = coverFlowOffset(i, index, count, loop); + stage.appendChild(this._renderCard(items[i], offset, i).dom()); + } + } + } + + const active = items[index]; + if (caption) { + caption.textContent = ''; + if (active) { + caption.appendChild(el('div', { className: 'gd-coverflow__caption-title', text: active.title }).dom()); + if (active.subtitle) { + caption.appendChild( + el('div', { className: 'gd-coverflow__caption-sub', text: active.subtitle }).dom() + ); + } + } + } + + if (dots) { + dots.textContent = ''; + for (let i = 0; i < count; i++) { + dots.appendChild( + el('button', { + type: 'button', + className: cx('gd-coverflow__dot', i === index && 'gd-coverflow__dot--active'), + attrs: { 'aria-label': `Go to ${items[i].title}`, 'aria-current': i === index ? 'true' : 'false' }, + onClick: () => this.setIndex(i), + }).dom() + ); + } + } + + if (prev) prev.disabled = !loop && index <= 0; + if (next) next.disabled = !loop && index >= count - 1; + } +} + +function clampIndex(i, len) { + if (!len) return 0; + return Math.max(0, Math.min(len - 1, Number(i) || 0)); +} + +function wrapIndex(i, len) { + if (!len) return 0; + return ((Number(i) % len) + len) % len; +} + +/** + * Fractional position while the pointer is down. + * Past either end (when not looping) the extra distance rubber-bands. + */ +function livePosition(origin, dx, count, loop) { + const pos = origin - coverFlowDragSlots(dx); + if (loop || count <= 1) return pos; + if (pos < 0) return rubber(pos); + if (pos > count - 1) return count - 1 + rubber(pos - (count - 1)); + return pos; +} + +function rubber(over) { + const sign = Math.sign(over); + if (!sign) return 0; + return sign * Math.pow(Math.abs(over), 0.65) * 0.45; +} + +function prefersReducedMotion() { + return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; +} + +function nowMs() { + return globalThis.performance?.now?.() ?? Date.now(); +} + +/** + * @param {Array} items + * @param {HTMLElement|string} host + * @param {object} [options] + */ +export function createCoverFlow(items, host, options = {}) { + const cf = new CoverFlow({ ...options, items }); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) void cf.mount(elHost); + return cf; +} + +/** Alias names people search for */ +export const Coverflow = CoverFlow; +export const CoverflowCarousel = CoverFlow; diff --git a/src/pyouter/webui/gd5/ui/Drawer.js b/src/pyouter/webui/gd5/ui/Drawer.js new file mode 100644 index 0000000..0893ba4 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/Drawer.js @@ -0,0 +1,116 @@ +import { Component } from '../engine/index.js'; +import { loadUi } from './util.js'; + +/** + * Drawer / Sheet / NavDrawer — side or bottom overlay panel. + * + * const drawer = new Drawer({}); + * await drawer.mount(); + * drawer.open({ title: 'Menu', content, placement: 'left' }); + */ +export class Drawer extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.open = false; + this.state.data.title = ''; + this.state.data.content = ''; + this.state.data.placement = 'right'; // left | right | bottom | top + this.state.data.size = 'md'; + this._portal = null; + this._onClose = null; + } + + async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) { + const target = + typeof host === 'string' ? document.getElementById(host) : host || document.body; + if (!target) throw new Error('Drawer.mount: host not found'); + if (!this._portal) { + this._portal = document.createElement('div'); + this._portal.className = 'gd-drawer-portal'; + target.appendChild(this._portal); + } + return super.mount(this._portal, params); + } + + componentDidUnMount() { + if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal); + this._portal = null; + } + + async componentDidMount() { + await loadUi(); + } + + open(options = {}) { + this._onClose = options.onClose || null; + this.setData({ + open: true, + title: options.title ?? this.state.data.title, + content: options.content ?? this.state.data.content, + placement: options.placement ?? 'right', + size: options.size ?? 'md', + }); + return this; + } + + close() { + this._onClose?.(); + this.setData({ open: false }); + return this; + } + + componentDidAttachEvents() { + this.on(window, 'keydown', (event) => { + if (event.key === 'Escape' && this.state.data.open) this.close(); + }); + } + + render() { + const { open, title, content, placement, size } = this.state.data; + this.ensureShell( + (ids) => ids.gdDrawerRoot, + ({ div, button }) => { + div( + { className: 'gd-drawer-root', id: 'gdDrawerRoot' }, + div({ + className: 'gd-drawer-mask', + id: 'gdDrawerMask', + onClick: () => this.close(), + }), + div( + { + className: `gd-drawer gd-drawer--${placement} gd-drawer--${size}`, + id: 'gdDrawerPanel', + }, + div( + { className: 'gd-drawer__header', id: 'gdDrawerHeader' }, + div({ className: 'gd-drawer__title', id: 'gdDrawerTitle', text: title }), + button({ + type: 'button', + className: 'gd-btn gd-btn--text gd-btn--sm', + id: 'gdDrawerClose', + text: '×', + onClick: () => this.close(), + }) + ), + div({ className: 'gd-drawer__body', id: 'gdDrawerBody', text: content }) + ) + ); + } + ); + + const root = this.ids().gdDrawerRoot; + const titleEl = this.ids().gdDrawerTitle; + const body = this.ids().gdDrawerBody; + const panel = this.ids().gdDrawerPanel; + if (root) root.classList.toggle('is-open', !!open); + if (titleEl) titleEl.textContent = title || ''; + if (body) body.textContent = typeof content === 'string' ? content : body.textContent; + if (panel) { + panel.className = `gd-drawer gd-drawer--${placement} gd-drawer--${size}`; + } + } +} + +/** Alias */ +export class Sheet extends Drawer {} diff --git a/src/pyouter/webui/gd5/ui/EChart.js b/src/pyouter/webui/gd5/ui/EChart.js new file mode 100644 index 0000000..470c633 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/EChart.js @@ -0,0 +1,160 @@ +import { Component } from '../engine/index.js'; +import { cx, loadUi } from './util.js'; + +const DEFAULT_ECHARTS_URL = 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.esm.min.js'; + +/** @type {Promise|null} */ +let echartsPromise = null; + +/** + * Load ECharts once (CDN ESM or inject `echarts` module). + * @param {string|object} [spec] module URL or `{ init, … }` namespace + */ +export function loadEcharts(spec = DEFAULT_ECHARTS_URL) { + if (spec && typeof spec === 'object' && typeof spec.init === 'function') { + return Promise.resolve(spec); + } + if (!echartsPromise) { + const url = typeof spec === 'string' && spec ? spec : DEFAULT_ECHARTS_URL; + echartsPromise = import(/* @vite-ignore */ url).then((mod) => mod.default || mod); + } + return echartsPromise; +} + +/** + * ECharts host Component — same composition as TodoList. + */ +export class EChartView extends Component { + /** + * @param {object} [deps] + * @param {object} [deps.option] ECharts option + * @param {string|number} [deps.height] + * @param {string|number} [deps.width] + * @param {string} [deps.className] + * @param {string|object} [deps.echarts] CDN URL or echarts namespace + * @param {boolean} [deps.autoResize] + * @param {(chart:any, echarts:any)=>void} [deps.onReady] + */ + constructor(deps = {}) { + super(deps); + this.state.data.option = deps.option || {}; + this.state.data.height = deps.height ?? 320; + this.state.data.width = deps.width ?? '100%'; + this.state.data.className = deps.className || ''; + this._echartsSpec = deps.echarts ?? DEFAULT_ECHARTS_URL; + this._autoResize = deps.autoResize !== false; + this._onReady = typeof deps.onReady === 'function' ? deps.onReady : null; + this._chart = null; + this._ro = null; + this._initing = false; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + getChart() { + return this._chart; + } + + setOption(option, notMerge = false) { + this.state.data.option = option || {}; + if (this._chart) this._chart.setOption(this.state.data.option, notMerge); + else if (this._mounted) this.refresh(); + return this; + } + + resize() { + this._chart?.resize?.(); + return this; + } + + componentDidUnMount() { + this._ro?.disconnect?.(); + this._ro = null; + this._chart?.dispose?.(); + this._chart = null; + } + + async componentDidMount() { + // Chart inits after first paint (host must exist). + } + + async _ensureChart() { + const host = this.ids().gdEchartHost; + if (!host || this._chart || this._initing) return; + this._initing = true; + try { + const echarts = await loadEcharts(this._echartsSpec); + if (this._chart || !this.ids().gdEchartHost) return; + this._chart = echarts.init(host); + this._chart.setOption(this.state.data.option || {}); + if (this._autoResize && typeof ResizeObserver !== 'undefined') { + this._ro = new ResizeObserver(() => this._chart?.resize?.()); + this._ro.observe(host); + } + this._onReady?.(this._chart, echarts); + } finally { + this._initing = false; + } + } + + render() { + const { height, width, className } = this.state.data; + const h = typeof height === 'number' ? `${height}px` : String(height); + const w = typeof width === 'number' ? `${width}px` : String(width); + + this.ensureShell( + (ids) => ids.gdEchartRoot, + ({ div }) => { + div( + { + id: 'gdEchartRoot', + className: cx('gd-echart', className), + style: { width: w, height: h }, + }, + div({ + id: 'gdEchartHost', + className: 'gd-echart__host', + style: { width: '100%', height: '100%' }, + }) + ); + } + ); + + if (this._mounted || this.host()) { + void this._ensureChart(); + } + } +} + +/** + * EChart — call inline like TodoList / Button: + * + * ```js + * div( + * { id: 'root' }, + * EChart({ option: { xAxis: {}, yAxis: {}, series: [{ type: 'bar', data: [1,2,3] }] } }), + * ThreeView({ onInit(ctx) { … } }) + * ) + * ``` + * + * @param {object} [props] + * @returns {EChartView} + */ +export function EChart(props = {}) { + return new EChartView(props); +} + +export function createEChart(options = {}, host) { + const chart = EChart(options); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) void chart.mount(elHost); + return chart; +} diff --git a/src/pyouter/webui/gd5/ui/JsonTree.js b/src/pyouter/webui/gd5/ui/JsonTree.js new file mode 100644 index 0000000..afe21bd --- /dev/null +++ b/src/pyouter/webui/gd5/ui/JsonTree.js @@ -0,0 +1,531 @@ +import { Component } from '../engine/index.js'; +import { cx, el, loadUi } from './util.js'; + +export const JSON_TREE_ROW_H = 22; +const OVERSCAN = 8; + +/** + * @param {unknown} val + * @returns {'null'|'boolean'|'number'|'string'|'array'|'object'} + */ +export function jsonValueType(val) { + if (val === null) return 'null'; + const t = typeof val; + if (t === 'number' || t === 'boolean' || t === 'string') return t; + if (Array.isArray(val)) return 'array'; + if (t === 'object') return 'object'; + throw new Error(`Unsupported JSON type: ${Object.prototype.toString.call(val)}`); +} + +/** + * @param {string} parentPath + * @param {string|number} key + * @param {'object'|'array'} parentType + */ +export function jsonChildPath(parentPath, key, parentType) { + if (parentType === 'array') return `${parentPath}[${key}]`; + const safe = String(key).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + return `${parentPath}['${safe}']`; +} + +/** + * Flatten JSON into visible rows given an expanded path set. + * Cost is proportional to currently expanded nodes (not full tree size when collapsed). + * @param {unknown} data + * @param {Set} expanded + * @param {{ sortKeys?: boolean }} [opts] + * @returns {Array} + */ +export function flattenJsonTree(data, expanded, opts = {}) { + const sortKeys = opts.sortKeys !== false; + const rows = []; + + const walk = (value, path, key, depth, isLast) => { + let type; + try { + type = jsonValueType(value); + } catch { + return; + } + const complex = type === 'object' || type === 'array'; + const childCount = complex + ? type === 'array' + ? value.length + : Object.keys(value).length + : 0; + const isExpanded = complex && childCount > 0 && expanded.has(path); + + rows.push({ + path, + key, + value, + type, + depth, + isLast, + complex, + childCount, + expanded: isExpanded, + }); + + if (!isExpanded) return; + + if (type === 'array') { + const last = value.length - 1; + for (let i = 0; i < value.length; i++) { + walk(value[i], jsonChildPath(path, i, 'array'), i, depth + 1, i === last); + } + return; + } + + const keys = Object.keys(value); + if (sortKeys) keys.sort(); + const last = keys.length - 1; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + walk(value[k], jsonChildPath(path, k, 'object'), k, depth + 1, i === last); + } + }; + + walk(data, '$', null, 0, true); + return rows; +} + +function collectComplexPaths(data, filter) { + const out = []; + const walk = (value, path) => { + let type; + try { + type = jsonValueType(value); + } catch { + return; + } + if (type !== 'object' && type !== 'array') return; + const childCount = type === 'array' ? value.length : Object.keys(value).length; + if (childCount === 0) return; + const node = { path, type, childCount, value, label: path === '$' ? null : path }; + if (!filter || filter(node)) out.push(path); + if (type === 'array') { + value.forEach((v, i) => walk(v, jsonChildPath(path, i, 'array'))); + } else { + Object.keys(value) + .sort() + .forEach((k) => walk(value[k], jsonChildPath(path, k, 'object'))); + } + }; + walk(data, '$'); + return out; +} + +function previewComplex(type, childCount) { + if (type === 'array') return childCount ? `[${childCount}]` : '[]'; + return childCount ? `{${childCount}}` : '{}'; +} + +function formatPrimitive(type, value) { + if (type === 'string') return JSON.stringify(value); + if (type === 'null') return 'null'; + return String(value); +} + +/** + * JsonTree — high-performance virtualized JSON viewer. + * + * Inspired by jsonTreeViewer (expand / mark / JSONPath), styled with gd5 themes. + * Only visible rows are in the DOM; collapse keeps large subtrees cheap. + * + * const jt = new JsonTree({ height: 360 }); + * await jt.mount(host); + * jt.loadData(data); + * jt.expand(); jt.collapse(); + */ +export class JsonTree extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.data = deps.data ?? null; + this.state.data.height = deps.height ?? 360; + this.state.data.expanded = new Set(); + this.state.data.marked = new Set(); + this.state.data.scrollTop = 0; + this.state.data.sortKeys = deps.sortKeys !== false; + this.state.data.rootLabel = deps.rootLabel ?? null; + this._onPath = deps.onPath || null; + this._defaultExpand = + deps.defaultExpand === undefined ? 1 : deps.defaultExpand; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + /** + * @param {unknown} data + */ + loadData(data) { + const expanded = new Set(); + if (data != null) { + this._applyDefaultExpand(data, expanded); + } + this.setData( + { + data, + expanded, + marked: new Set(), + scrollTop: 0, + }, + { refresh: !!this._mounted } + ); + return this; + } + + _applyDefaultExpand(data, expanded) { + const level = this._defaultExpand; + if (level === true || level === 'all') { + for (const p of collectComplexPaths(data)) expanded.add(p); + return; + } + if (level === false || level === 0) return; + if (typeof level === 'number') { + const walk = (value, path, depth) => { + let type; + try { + type = jsonValueType(value); + } catch { + return; + } + if (type !== 'object' && type !== 'array') return; + const n = type === 'array' ? value.length : Object.keys(value).length; + if (n === 0) return; + if (depth < level) expanded.add(path); + if (depth + 1 >= level) return; + if (type === 'array') { + value.forEach((v, i) => + walk(v, jsonChildPath(path, i, 'array'), depth + 1) + ); + } else { + Object.keys(value) + .sort() + .forEach((k) => + walk(value[k], jsonChildPath(path, k, 'object'), depth + 1) + ); + } + }; + walk(data, '$', 0); + } + } + + /** Expand all, or nodes matching filter(node). */ + expand(filter) { + const data = this.state.data.data; + if (data == null) return this; + const next = new Set(this.state.data.expanded); + for (const p of collectComplexPaths(data, filter)) next.add(p); + this.setData({ expanded: next }, { refresh: !!this._mounted }); + return this; + } + + collapse() { + this.setData({ expanded: new Set() }, { refresh: !!this._mounted }); + return this; + } + + unmarkAll() { + this.setData({ marked: new Set() }, { refresh: !!this._mounted }); + return this; + } + + getJSONPath(path) { + return path || '$'; + } + + _rows() { + const { data, expanded, sortKeys } = this.state.data; + if (data == null) return []; + return flattenJsonTree(data, expanded, { sortKeys }); + } + + componentDidAttachEvents() { + const viewport = this.ids().gdJtViewport; + if (!viewport) return; + this.on(viewport, 'scroll', () => { + this.state.data.scrollTop = viewport.scrollTop; + this._paintWindow(); + }); + } + + _togglePath(path, recursive) { + const data = this.state.data.data; + const expanded = new Set(this.state.data.expanded); + const open = expanded.has(path); + if (!recursive) { + if (open) expanded.delete(path); + else expanded.add(path); + } else { + const rows = flattenJsonTree(data, new Set([...expanded, path]), { + sortKeys: this.state.data.sortKeys, + }); + const row = rows.find((r) => r.path === path); + if (!row?.complex) return; + if (open) { + for (const p of [...expanded]) { + if ( + p === path || + p.startsWith(`${path}[`) || + p.startsWith(`${path}['`) + ) { + expanded.delete(p); + } + } + } else { + for (const p of collectComplexPaths(row.value)) { + expanded.add(p === '$' ? path : path + p.slice(1)); + } + } + } + this.setData({ expanded }, { refresh: false }); + this._paintWindow(true); + } + + _toggleMark(path) { + const marked = new Set(this.state.data.marked); + if (marked.has(path)) marked.delete(path); + else marked.add(path); + this.setData({ marked }, { refresh: false }); + this._paintWindow(true); + } + + _onRowClick(row, e) { + if (e.shiftKey) { + e.preventDefault(); + const path = this.getJSONPath(row.path); + if (this._onPath) this._onPath(path, row); + else if (typeof window !== 'undefined') window.alert?.(path); + return; + } + if (e.altKey) { + e.preventDefault(); + this._toggleMark(row.path); + return; + } + if (!row.complex || row.childCount === 0) return; + this._togglePath(row.path, !!(e.ctrlKey || e.metaKey)); + } + + _renderRow(row) { + const marked = this.state.data.marked.has(row.path); + const indent = row.depth * 16; + const kids = []; + + if (row.complex && row.childCount > 0) { + kids.push( + el('button', { + type: 'button', + className: cx( + 'gd-json-tree__twist', + row.expanded && 'gd-json-tree__twist--open' + ), + attrs: { 'aria-label': row.expanded ? 'Collapse' : 'Expand' }, + onClick: (e) => { + e.stopPropagation(); + this._togglePath(row.path, !!(e.ctrlKey || e.metaKey)); + }, + }) + ); + } else { + kids.push(el('span', { className: 'gd-json-tree__twist gd-json-tree__twist--leaf' })); + } + + if (row.key != null) { + const label = + typeof row.key === 'number' ? String(row.key) : JSON.stringify(String(row.key)); + kids.push(el('span', { className: 'gd-json-tree__key', text: label })); + kids.push(el('span', { className: 'gd-json-tree__colon', text: ':' })); + } else if (this.state.data.rootLabel) { + kids.push( + el('span', { + className: 'gd-json-tree__key', + text: this.state.data.rootLabel, + }) + ); + kids.push(el('span', { className: 'gd-json-tree__colon', text: ':' })); + } + + if (row.complex) { + if (row.expanded) { + kids.push( + el('span', { + className: 'gd-json-tree__punct', + text: row.type === 'array' ? '[' : '{', + }) + ); + } else { + kids.push( + el('span', { + className: cx('gd-json-tree__preview', `gd-json-tree__value--${row.type}`), + text: previewComplex(row.type, row.childCount), + }) + ); + } + } else { + kids.push( + el('span', { + className: cx('gd-json-tree__value', `gd-json-tree__value--${row.type}`), + text: formatPrimitive(row.type, row.value), + }) + ); + } + + if (!row.isLast && !(row.complex && row.expanded)) { + kids.push(el('span', { className: 'gd-json-tree__punct', text: ',' })); + } + + return el( + 'div', + { + className: cx( + 'gd-json-tree__row', + row.complex && 'gd-json-tree__row--complex', + marked && 'gd-json-tree__row--marked', + row.expanded && 'gd-json-tree__row--expanded' + ), + style: { + height: `${JSON_TREE_ROW_H}px`, + paddingLeft: `${indent + 4}px`, + }, + attrs: { 'data-path': row.path, role: 'treeitem' }, + onClick: (e) => this._onRowClick(row, e), + }, + ...kids + ); + } + + _paintWindow(force = false) { + const viewport = this.ids().gdJtViewport; + const windowEl = this.ids().gdJtWindow; + const spacerTop = this.ids().gdJtPadTop; + const spacerBot = this.ids().gdJtPadBot; + if (!viewport || !windowEl || !spacerTop || !spacerBot) return; + + const rows = this._rows(); + const total = rows.length; + const scrollTop = viewport.scrollTop; + const viewH = viewport.clientHeight || this.state.data.height; + const start = Math.max(0, Math.floor(scrollTop / JSON_TREE_ROW_H) - OVERSCAN); + const visible = Math.ceil(viewH / JSON_TREE_ROW_H) + OVERSCAN * 2; + const end = Math.min(total, start + visible); + const topH = start * JSON_TREE_ROW_H; + const botH = Math.max(0, (total - end) * JSON_TREE_ROW_H); + + spacerTop.style.height = `${topH}px`; + spacerBot.style.height = `${botH}px`; + + const sig = `${start}:${end}:${total}:${this.state.data.expanded.size}:${this.state.data.marked.size}`; + if (!force && windowEl.dataset.sig === sig) return; + windowEl.dataset.sig = sig; + + windowEl.textContent = ''; + for (let i = start; i < end; i++) { + windowEl.appendChild(this._renderRow(rows[i]).dom()); + } + } + + render() { + const { height, data } = this.state.data; + + this.ensureShell( + (ids) => ids.gdJtRoot, + ({ div }) => { + div( + { id: 'gdJtRoot', className: 'gd-json-tree' }, + div({ + id: 'gdJtToolbar', + className: 'gd-json-tree__toolbar', + }), + div( + { + id: 'gdJtViewport', + className: 'gd-json-tree__viewport', + style: { height: `${height}px` }, + attrs: { role: 'tree', tabindex: '0' }, + }, + div({ id: 'gdJtPadTop', className: 'gd-json-tree__spacer' }), + div({ id: 'gdJtWindow', className: 'gd-json-tree__window' }), + div({ id: 'gdJtPadBot', className: 'gd-json-tree__spacer' }) + ), + div({ + className: 'gd-json-tree__hint', + text: 'Click expand · Ctrl/⌘+click recursive · Alt+click mark · Shift+click path', + }) + ); + } + ); + + const toolbar = this.ids().gdJtToolbar; + if (toolbar && !toolbar.dataset.ready) { + toolbar.dataset.ready = '1'; + toolbar.appendChild( + el('button', { + type: 'button', + className: 'gd-btn gd-btn--default gd-btn--sm', + text: 'Expand all', + onClick: () => this.expand(), + }).dom() + ); + toolbar.appendChild( + el('button', { + type: 'button', + className: 'gd-btn gd-btn--default gd-btn--sm', + text: 'Collapse all', + onClick: () => this.collapse(), + }).dom() + ); + toolbar.appendChild( + el('button', { + type: 'button', + className: 'gd-btn gd-btn--text gd-btn--sm', + text: 'Unmark', + onClick: () => this.unmarkAll(), + }).dom() + ); + } + + const viewport = this.ids().gdJtViewport; + if (viewport) viewport.style.height = `${height}px`; + + if (data == null) { + const win = this.ids().gdJtWindow; + if (win) { + win.textContent = ''; + win.appendChild( + el('div', { + className: 'gd-json-tree__empty', + text: 'No JSON loaded', + }).dom() + ); + } + return; + } + + this._paintWindow(true); + } +} + +/** + * Lightweight factory wrapper: mounts a JsonTree into a host element node. + * Prefer `new JsonTree()` for expand/collapse control. + */ +export function createJsonTree(data, host, options = {}) { + const jt = new JsonTree({ ...options, data }); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) { + void jt.mount(elHost).then(() => jt.loadData(data)); + } else { + jt.loadData(data); + } + return jt; +} diff --git a/src/pyouter/webui/gd5/ui/Lightbox.js b/src/pyouter/webui/gd5/ui/Lightbox.js new file mode 100644 index 0000000..f5f7723 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/Lightbox.js @@ -0,0 +1,87 @@ +import { Component } from '../engine/index.js'; +import { loadUi } from './util.js'; + +/** + * Lightbox — click-to-enlarge image overlay. + */ +export class Lightbox extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.open = false; + this.state.data.src = ''; + this.state.data.alt = ''; + this._portal = null; + } + + async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) { + const target = + typeof host === 'string' ? document.getElementById(host) : host || document.body; + if (!target) throw new Error('Lightbox.mount: host not found'); + if (!this._portal) { + this._portal = document.createElement('div'); + this._portal.className = 'gd-lightbox-portal'; + target.appendChild(this._portal); + } + return super.mount(this._portal, params); + } + + componentDidUnMount() { + if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal); + this._portal = null; + } + + async componentDidMount() { + await loadUi(); + } + + open({ src, alt } = {}) { + this.setData({ open: true, src: src || '', alt: alt || '' }); + return this; + } + + close() { + this.setData({ open: false }); + return this; + } + + componentDidAttachEvents() { + this.on(window, 'keydown', (e) => { + if (e.key === 'Escape' && this.state.data.open) this.close(); + }); + } + + render() { + const { open, src, alt } = this.state.data; + this.ensureShell( + (ids) => ids.gdLightboxRoot, + ({ div, img, button }) => { + div( + { className: 'gd-lightbox-root', id: 'gdLightboxRoot' }, + div({ + className: 'gd-lightbox-mask', + id: 'gdLightboxMask', + onClick: () => this.close(), + }), + div( + { className: 'gd-lightbox__stage', id: 'gdLightboxStage' }, + img({ id: 'gdLightboxImg', className: 'gd-lightbox__img', src: '', alt: '' }), + button({ + type: 'button', + className: 'gd-lightbox__close', + id: 'gdLightboxClose', + text: '×', + onClick: () => this.close(), + }) + ) + ); + } + ); + const root = this.ids().gdLightboxRoot; + const image = this.ids().gdLightboxImg; + if (root) root.classList.toggle('is-open', !!open); + if (image) { + image.src = src || ''; + image.alt = alt || ''; + } + } +} diff --git a/src/pyouter/webui/gd5/ui/LoadingButton.js b/src/pyouter/webui/gd5/ui/LoadingButton.js new file mode 100644 index 0000000..947858f --- /dev/null +++ b/src/pyouter/webui/gd5/ui/LoadingButton.js @@ -0,0 +1,139 @@ +import { Component } from '../engine/index.js'; +import { Button } from './button.js'; +import { cx, loadUi } from './util.js'; + +/** + * LoadingButton — async action button (Loading Button). + * + * Click → busy color + dots/spin indicator → ignores further clicks + * until the promise settles → back to plain text. + * + * @param {object} [deps] + * @param {string} [deps.text] + * @param {string} [deps.loadingText] + * @param {'primary'|'default'|'ghost'|'danger'|'text'} [deps.type] idle type + * @param {'primary'|'default'|'ghost'|'danger'|'text'} [deps.loadingType] busy type (default primary) + * @param {'sm'|'md'|'lg'} [deps.size] + * @param {boolean} [deps.block] + * @param {'dots'|'spin'} [deps.indicator] + * @param {boolean} [deps.disabled] + * @param {() => (void|Promise)} [deps.onClick] + * @param {(err: unknown) => void} [deps.onError] + * @param {string} [deps.className] + */ +export class LoadingButton extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.text = deps.text ?? 'Submit'; + this.state.data.loadingText = deps.loadingText ?? deps.text ?? 'Submit'; + this.state.data.type = deps.type ?? 'default'; + this.state.data.loadingType = deps.loadingType ?? 'primary'; + this.state.data.size = deps.size ?? 'md'; + this.state.data.block = !!deps.block; + this.state.data.indicator = deps.indicator === 'spin' ? 'spin' : 'dots'; + this.state.data.disabled = !!deps.disabled; + this.state.data.className = deps.className || ''; + this.state.data.loading = false; + this._onClick = deps.onClick || null; + this._onError = deps.onError || null; + this._pending = false; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + isLoading() { + return !!this.state.data.loading; + } + + setDisabled(disabled) { + this.setData({ disabled: !!disabled }, { refresh: !!this._mounted }); + return this; + } + + setText(text) { + this.setData({ text: String(text ?? '') }, { refresh: !!this._mounted }); + return this; + } + + /** + * Programmatically run the action (same lock as click). + * @returns {Promise} + */ + async trigger() { + return this._run(); + } + + async _run() { + if (this._pending || this.state.data.loading || this.state.data.disabled) return; + this._pending = true; + this.setData({ loading: true }, { refresh: !!this._mounted }); + try { + await this._onClick?.(); + } catch (err) { + this._onError?.(err); + throw err; + } finally { + this._pending = false; + if (this._mounted) { + this.setData({ loading: false }); + } else { + this.state.data.loading = false; + } + } + } + + render() { + const d = this.state.data; + const busy = !!d.loading; + + this.ensureShell( + (ids) => ids.gdLbRoot, + ({ div }) => { + div({ id: 'gdLbRoot', className: 'gd-loading-button' }); + } + ); + + const root = this.ids().gdLbRoot; + if (!root) return; + + root.textContent = ''; + root.appendChild( + Button({ + type: busy ? d.loadingType : d.type, + size: d.size, + block: d.block, + text: d.text, + loadingText: d.loadingText, + loading: busy, + indicator: d.indicator, + disabled: d.disabled, + className: cx('gd-loading-button__btn', d.className), + onClick: () => { + void this._run(); + }, + }).dom() + ); + } +} + +/** + * @param {object} options + * @param {HTMLElement|string} [host] + */ +export function createLoadingButton(options = {}, host) { + const btn = new LoadingButton(options); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) void btn.mount(elHost); + return btn; +} + +/** Alias */ +export const AsyncButton = LoadingButton; diff --git a/src/pyouter/webui/gd5/ui/Modal.js b/src/pyouter/webui/gd5/ui/Modal.js new file mode 100644 index 0000000..7fa0c75 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/Modal.js @@ -0,0 +1,169 @@ +import { Component } from '../engine/index.js'; +import { loadUi } from './util.js'; + +/** + * Modal — full Component (overlay lifecycle). + * + * const modal = new Modal({ navigator, logger }); + * await modal.mount(document.body); + * modal.open({ title: 'Confirm', content: 'Delete item?', onOk: () => … }); + */ +export class Modal extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.open = false; + this.state.data.title = ''; + this.state.data.content = ''; + this.state.data.okText = 'OK'; + this.state.data.cancelText = 'Cancel'; + this.state.data.size = 'md'; + this._onOk = null; + this._onCancel = null; + /** @type {HTMLElement|null} outer portal node (never clears document.body) */ + this._portal = null; + } + + /** + * Mount into a dedicated portal under `host` (default `document.body`). + * Avoids `paint` → `D.clear(body)` wiping the page. + * @param {HTMLElement|string} [host] + * @param {object} [params] + */ + async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) { + const target = + typeof host === 'string' ? document.getElementById(host) : host || document.body; + if (!target) throw new Error('Modal.mount: host not found'); + + if (!this._portal) { + this._portal = document.createElement('div'); + this._portal.className = 'gd-modal-portal'; + target.appendChild(this._portal); + } + return super.mount(this._portal, params); + } + + componentDidUnMount() { + if (this._portal?.parentNode) { + this._portal.parentNode.removeChild(this._portal); + } + this._portal = null; + } + + async componentDidMount() { + await loadUi(); + } + + /** + * @param {object} [options] + * @param {string} [options.title] + * @param {string} [options.content] + * @param {string} [options.okText] + * @param {string} [options.cancelText] + * @param {'sm'|'md'|'lg'} [options.size] + * @param {function} [options.onOk] + * @param {function} [options.onCancel] + */ + open(options = {}) { + this._onOk = options.onOk || null; + this._onCancel = options.onCancel || null; + this.setData({ + open: true, + title: options.title ?? this.state.data.title, + content: options.content ?? this.state.data.content, + okText: options.okText ?? 'OK', + cancelText: options.cancelText ?? 'Cancel', + size: options.size ?? 'md', + }); + return this; + } + + close() { + this.setData({ open: false }); + return this; + } + + componentDidAttachEvents() { + this.on(window, 'keydown', (event) => { + if (event.key === 'Escape' && this.state.data.open) this._cancel(); + }); + } + + _ok() { + this._onOk?.(); + this.close(); + } + + _cancel() { + this._onCancel?.(); + this.close(); + } + + render() { + const { open, title, content, okText, cancelText, size } = this.state.data; + + this.ensureShell( + (ids) => ids.gdModalRoot, + ({ div, button }) => { + div( + { className: 'gd-modal-root', id: 'gdModalRoot' }, + div({ + className: 'gd-modal-mask', + id: 'gdModalMask', + onClick: () => this._cancel(), + }), + div( + { className: 'gd-modal-wrap', id: 'gdModalWrap' }, + div( + { className: 'gd-modal', id: 'gdModalPanel' }, + div( + { className: 'gd-modal__header', id: 'gdModalHeader' }, + div({ className: 'gd-modal__title', id: 'gdModalTitle', text: title }), + button({ + id: 'gdModalClose', + type: 'button', + className: 'gd-btn gd-btn--text gd-btn--sm', + text: '×', + onClick: () => this._cancel(), + }) + ), + div({ className: 'gd-modal__body', id: 'gdModalBody', text: content }), + div( + { className: 'gd-modal__footer', id: 'gdModalFooter' }, + button({ + id: 'gdModalCancel', + type: 'button', + className: 'gd-btn gd-btn--ghost gd-btn--md', + text: cancelText, + onClick: () => this._cancel(), + }), + button({ + id: 'gdModalOk', + type: 'button', + className: 'gd-btn gd-btn--primary gd-btn--md', + text: okText, + onClick: () => this._ok(), + }) + ) + ) + ) + ); + } + ); + + const root = this.ids().gdModalRoot; + const panel = this.ids().gdModalPanel; + const titleEl = this.ids().gdModalTitle; + const body = this.ids().gdModalBody; + const ok = this.ids().gdModalOk; + const cancel = this.ids().gdModalCancel; + + if (root) root.classList.toggle('is-open', !!open); + if (panel) { + panel.className = `gd-modal${size && size !== 'md' ? ` gd-modal--${size}` : ''}`; + } + if (titleEl) titleEl.textContent = title || ''; + if (body) body.textContent = content || ''; + if (ok) ok.textContent = okText || 'OK'; + if (cancel) cancel.textContent = cancelText || 'Cancel'; + } +} diff --git a/src/pyouter/webui/gd5/ui/Notification.js b/src/pyouter/webui/gd5/ui/Notification.js new file mode 100644 index 0000000..878d795 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/Notification.js @@ -0,0 +1,123 @@ +import { Component } from '../engine/index.js'; +import { loadUi } from './util.js'; + +/** + * Notification — top-right notice stack (antd Notification). + * Distinct from Toast (bottom snackbar). + * + * const n = new Notification({}); + * await n.mount(); + * n.open({ message: 'Title', description: '…', type: 'info' }); + */ +export class Notification extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.items = []; + this._portal = null; + this._seq = 0; + } + + async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) { + const target = + typeof host === 'string' ? document.getElementById(host) : host || document.body; + if (!target) throw new Error('Notification.mount: host not found'); + if (!this._portal) { + this._portal = document.createElement('div'); + this._portal.className = 'gd-notification-portal'; + target.appendChild(this._portal); + } + return super.mount(this._portal, params); + } + + componentDidUnMount() { + if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal); + this._portal = null; + } + + async componentDidMount() { + await loadUi(); + } + + /** + * @param {object} options + * @param {string} options.message + * @param {string} [options.description] + * @param {'info'|'success'|'warning'|'error'} [options.type] + * @param {number} [options.duration] + */ + open(options = {}) { + const id = ++this._seq; + const item = { + id, + message: options.message || '', + description: options.description || '', + type: options.type || 'info', + }; + this.setData({ items: [...this.state.data.items, item] }); + const duration = options.duration ?? 4500; + if (duration > 0) setTimeout(() => this.close(id), duration); + return id; + } + + close(id) { + this.setData({ items: this.state.data.items.filter((it) => it.id !== id) }); + } + + success(opts) { + return this.open({ ...opts, type: 'success' }); + } + error(opts) { + return this.open({ ...opts, type: 'error' }); + } + warning(opts) { + return this.open({ ...opts, type: 'warning' }); + } + info(opts) { + return this.open({ ...opts, type: 'info' }); + } + + render() { + const { items } = this.state.data; + const host = this.host(); + if (!host) return; + host.textContent = ''; + const root = document.createElement('div'); + root.className = 'gd-notification-root'; + root.setAttribute('aria-live', 'polite'); + for (const item of items) { + const node = document.createElement('div'); + node.className = `gd-notification gd-notification--${item.type}`; + const title = document.createElement('div'); + title.className = 'gd-notification__message'; + title.textContent = item.message; + node.appendChild(title); + if (item.description) { + const desc = document.createElement('div'); + desc.className = 'gd-notification__desc'; + desc.textContent = item.description; + node.appendChild(desc); + } + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'gd-notification__close'; + close.textContent = '×'; + close.addEventListener('click', () => this.close(item.id)); + node.appendChild(close); + root.appendChild(node); + } + host.appendChild(root); + } +} + +/** + * Message API helper — thin alias over Toast-like bottom notices. + * Prefer `Toast` for snackbars; this mirrors antd `message` naming via Notification.open short form. + */ +export function messageApi(notification) { + return { + success: (text) => notification?.open({ message: text, type: 'success', duration: 2500 }), + error: (text) => notification?.open({ message: text, type: 'error', duration: 2500 }), + info: (text) => notification?.open({ message: text, type: 'info', duration: 2500 }), + warning: (text) => notification?.open({ message: text, type: 'warning', duration: 2500 }), + }; +} diff --git a/src/pyouter/webui/gd5/ui/PeleView.js b/src/pyouter/webui/gd5/ui/PeleView.js new file mode 100644 index 0000000..3ad239a --- /dev/null +++ b/src/pyouter/webui/gd5/ui/PeleView.js @@ -0,0 +1,249 @@ +import { Component } from '../engine/index.js'; +import { cx, loadUi } from './util.js'; + +export const DEFAULT_PELE_URL = 'https://cdn.jsdelivr.net/npm/pele@1.6.0/dist/pele.js'; + +export const DEFAULT_PELE_SOURCE = `flowchart LR + Ask[Ask] --> Plan[Plan] + Plan --> Edit[Edit] + Edit --> Test[Test] + Test --> Done[Done]`; + +/** @type {Promise|null} */ +let pelePromise = null; + +/** + * Load Pele once (CDN ESM or inject `{ mount, render }`). + * @param {string|object} [spec] + */ +export function loadPele(spec = DEFAULT_PELE_URL) { + if ( + spec && + typeof spec === 'object' && + (typeof spec.mount === 'function' || typeof spec.render === 'function') + ) { + return Promise.resolve(spec); + } + if (!pelePromise) { + const url = typeof spec === 'string' && spec ? spec : DEFAULT_PELE_URL; + pelePromise = import(/* @vite-ignore */ url).then((mod) => mod.default || mod); + } + return pelePromise; +} + +/** + * Pele Mermaid→SVG host — same composition as TodoList / SparkView. + */ +export class PeleViewView extends Component { + /** + * @param {object} [deps] + * @param {string} [deps.source] Mermaid source + * @param {string|number} [deps.height] + * @param {string|number} [deps.width] + * @param {string} [deps.className] + * @param {string|object} [deps.pele] CDN URL or `{ mount, render }` + * @param {object} [deps.options] Pele mount/render options + * @param {boolean} [deps.zoom] + * @param {(ctx:{ + * pele:any, mounted:any, result:any, host:HTMLElement, component:PeleViewView + * })=> (void|(()=>void)|Promisevoid)>)} [deps.onReady] + * @param {(error:unknown)=>void} [deps.onError] + */ + constructor(deps = {}) { + super(deps); + this.state.data.source = deps.source ?? DEFAULT_PELE_SOURCE; + this.state.data.height = deps.height ?? 280; + this.state.data.width = deps.width ?? '100%'; + this.state.data.className = deps.className || ''; + this._peleSpec = deps.pele ?? DEFAULT_PELE_URL; + this._options = deps.options || {}; + this._zoom = deps.zoom; + this._onReady = typeof deps.onReady === 'function' ? deps.onReady.bind(this) : null; + this._onError = typeof deps.onError === 'function' ? deps.onError.bind(this) : null; + this._pele = null; + this._mountedDiagram = null; + this._disposeUser = null; + this._started = false; + this._initing = false; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + getMounted() { + return this._mountedDiagram; + } + + getResult() { + return this._mountedDiagram?.result; + } + + getContext() { + return { + pele: this._pele, + mounted: this._mountedDiagram, + result: this._mountedDiagram?.result, + host: this.ids().gdPeleHost, + component: this, + }; + } + + /** + * Update Mermaid source (forwards to Pele mount().update). + * @param {string} source + * @param {object} [options] + */ + setSource(source, options) { + this.state.data.source = String(source ?? ''); + if (this._mountedDiagram?.update) { + this._mountedDiagram.update(this.state.data.source, options); + } else if (this._mounted) { + this.refresh(); + } + return this; + } + + refreshDiagram() { + this._mountedDiagram?.refresh?.(); + return this; + } + + componentDidUnMount() { + try { + this._disposeUser?.(); + } catch { + /* ignore */ + } + this._disposeUser = null; + try { + this._mountedDiagram?.destroy?.(); + } catch { + /* ignore */ + } + this._mountedDiagram = null; + this._pele = null; + this._started = false; + } + + async _ensureDiagram() { + const host = this.ids().gdPeleHost; + if (!host || this._started || this._initing) return; + this._initing = true; + try { + const pele = await loadPele(this._peleSpec); + const liveHost = this.ids().gdPeleHost; + if (this._started || !liveHost) return; + this._pele = pele; + + if (typeof pele.mount !== 'function') { + throw new Error('PeleView: pele.mount missing — need pele package'); + } + + const opts = { + ...this._options, + onError: (err) => { + this._onError?.(err); + this._options.onError?.(err); + }, + }; + if (this._zoom !== undefined) opts.zoom = this._zoom; + + liveHost.textContent = ''; + this._mountedDiagram = pele.mount(liveHost, this.state.data.source || '', opts); + this._started = true; + + const maybeDispose = await this._onReady?.(this.getContext()); + if (typeof maybeDispose === 'function') this._disposeUser = maybeDispose; + } catch (err) { + this._started = false; + const liveHost = this.ids().gdPeleHost; + if (liveHost) { + liveHost.textContent = ''; + const msg = document.createElement?.('div'); + if (msg) { + msg.className = 'gd-pele__error'; + msg.textContent = `PeleView failed: ${err?.message || err}`; + liveHost.appendChild(msg); + } + } + this._onError?.(err); + throw err; + } finally { + this._initing = false; + } + } + + render() { + const { height, width, className } = this.state.data; + const h = typeof height === 'number' ? `${height}px` : String(height); + const w = typeof width === 'number' ? `${width}px` : String(width); + + this.ensureShell( + (ids) => ids.gdPeleRoot, + ({ div }) => { + div( + { + id: 'gdPeleRoot', + className: cx('gd-pele', className), + style: { width: w, height: h }, + }, + div({ + id: 'gdPeleHost', + className: 'gd-pele__host pele', + style: { width: '100%', height: '100%', overflow: 'auto' }, + }) + ); + } + ); + + // Source change after first mount → update in place + if (this._mountedDiagram && this._started) { + const cur = this._mountedDiagram.source; + if (cur !== this.state.data.source) { + this._mountedDiagram.update?.(this.state.data.source || ''); + } + return; + } + + if (this._mounted || this.host()) { + void this._ensureDiagram(); + } + } +} + +/** + * PeleView — Mermaid diagram host (obsidianmd/pele), nested like TodoList: + * + * ```js + * div( + * { id: 'root' }, + * PeleView({ + * source: 'flowchart LR\\n A --> B', + * onReady({ mounted }) { … }, + * }) + * ) + * ``` + * + * @param {object} [props] + * @returns {PeleViewView} + */ +export function PeleView(props = {}) { + return new PeleViewView(props); +} + +/** Alias */ +export const MermaidView = PeleView; + +export function createPeleView(options = {}, host) { + const view = PeleView(options); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) void view.mount(elHost); + return view; +} diff --git a/src/pyouter/webui/gd5/ui/README.md b/src/pyouter/webui/gd5/ui/README.md new file mode 100644 index 0000000..a39cbb5 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/README.md @@ -0,0 +1,5 @@ +# gd5 UI + +Component library for the gd5 engine. **White panels**; selectable themes: **orange** (default), **blue**, **green** (`setTheme` / `ConfigProvider`). Optional jelly material sits beside the theme: `setJelly(true)` (default off, remembered as `gd5-jelly`). + +Catalog aligned with [NameThatUI](https://namethatui.com/) + Ant Design — see [`docs/ui.md`](../../docs/ui.md) and [`examples/ui-gallery.html`](../../examples/ui-gallery.html). diff --git a/src/pyouter/webui/gd5/ui/SparkView.js b/src/pyouter/webui/gd5/ui/SparkView.js new file mode 100644 index 0000000..15b5c6b --- /dev/null +++ b/src/pyouter/webui/gd5/ui/SparkView.js @@ -0,0 +1,372 @@ +import { Component } from '../engine/index.js'; +import { cx, loadUi } from './util.js'; + +/** Spark 2.x peer: three >= 0.180 */ +export const DEFAULT_SPARK_THREE_URL = + 'https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js'; +export const DEFAULT_SPARK_THREE_ADDONS = + 'https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/'; +export const DEFAULT_SPARK_URL = + 'https://sparkjs.dev/releases/spark/2.3.1/spark.module.js'; +export const DEFAULT_SPLAT_URL = + 'https://sparkjs.dev/assets/splats/butterfly.spz'; + +/** @type {Promise|null} */ +let sparkPromise = null; +/** @type {Promise|null} */ +let sparkThreePromise = null; + +/** + * Inject importmap for `three` / `three/addons/` / `@sparkjsdev/spark`. + * Prefer declaring the same map in HTML `` before any module script. + * @param {{ threeUrl?: string, threeAddons?: string, sparkUrl?: string }} [opts] + */ +export function ensureSparkImportMap(opts = {}) { + if (typeof document === 'undefined') return false; + if (document.querySelector('script[data-gd-spark-importmap]')) return true; + + const threeUrl = opts.threeUrl || DEFAULT_SPARK_THREE_URL; + const addonsRaw = opts.threeAddons || DEFAULT_SPARK_THREE_ADDONS; + const addons = addonsRaw.endsWith('/') ? addonsRaw : `${addonsRaw}/`; + const sparkUrl = opts.sparkUrl || DEFAULT_SPARK_URL; + + const script = document.createElement('script'); + script.type = 'importmap'; + script.setAttribute('data-gd-spark-importmap', '1'); + script.textContent = JSON.stringify({ + imports: { + three: threeUrl, + 'three/addons/': addons, + '@sparkjsdev/spark': sparkUrl, + }, + }); + + const firstModule = document.querySelector('script[type="module"]'); + if (firstModule?.parentNode) firstModule.parentNode.insertBefore(script, firstModule); + else (document.head || document.documentElement).appendChild(script); + return true; +} + +/** + * Load three.js for Spark (0.180+), via importmap or injected namespace. + * @param {string|object} [spec] + */ +export function loadSparkThree(spec = DEFAULT_SPARK_THREE_URL) { + if (spec && typeof spec === 'object' && (spec.WebGLRenderer || spec.Scene)) { + return Promise.resolve(spec); + } + if (!sparkThreePromise) { + ensureSparkImportMap({ + threeUrl: typeof spec === 'string' && spec ? spec : DEFAULT_SPARK_THREE_URL, + }); + sparkThreePromise = import(/* @vite-ignore */ 'three').then((mod) => mod.default || mod); + } + return sparkThreePromise; +} + +/** + * Load @sparkjsdev/spark once (CDN ESM via importmap, or inject `{ SparkRenderer, SplatMesh }`). + * @param {string|object} [spec] + */ +export function loadSpark(spec = DEFAULT_SPARK_URL) { + if ( + spec && + typeof spec === 'object' && + (typeof spec.SparkRenderer === 'function' || typeof spec.SplatMesh === 'function') + ) { + return Promise.resolve(spec); + } + if (!sparkPromise) { + const sparkUrl = typeof spec === 'string' && spec ? spec : DEFAULT_SPARK_URL; + ensureSparkImportMap({ sparkUrl }); + sparkPromise = import(/* @vite-ignore */ '@sparkjsdev/spark').then((mod) => mod.default || mod); + } + return sparkPromise; +} + +/** + * Spark.js Gaussian Splatting host — same composition as TodoList / ThreeView. + */ +export class SparkViewView extends Component { + /** + * @param {object} [deps] + * @param {string} [deps.url] splat URL (.spz / .ply / …); default butterfly demo asset + * @param {boolean} [deps.autoSplat=true] create SplatMesh from `url` when true + * @param {boolean} [deps.autoRotate=true] + * @param {string|number} [deps.height] + * @param {string|number} [deps.width] + * @param {string} [deps.className] + * @param {number} [deps.background] clear color hex + * @param {string|object} [deps.three] three CDN URL or namespace + * @param {string|object} [deps.spark] spark CDN URL or `{ SparkRenderer, SplatMesh }` + * @param {object} [deps.sparkOptions] extra SparkRenderer options + * @param {(ctx:object)=> (void|(()=>void)|Promisevoid)>)} [deps.onReady] + * @param {(ctx:object)=> (void|(()=>void)|Promisevoid)>)} [deps.onInit] alias of onReady + * @param {(dt:number, ctx:object)=>void} [deps.onFrame] + */ + constructor(deps = {}) { + super(deps); + this.state.data.height = deps.height ?? 360; + this.state.data.width = deps.width ?? '100%'; + this.state.data.className = deps.className || ''; + this.state.data.background = deps.background ?? 0x111111; + this.state.data.url = deps.url ?? DEFAULT_SPLAT_URL; + this.state.data.autoSplat = deps.autoSplat !== false; + this.state.data.autoRotate = deps.autoRotate !== false; + this._threeSpec = deps.three ?? DEFAULT_SPARK_THREE_URL; + this._sparkSpec = deps.spark ?? DEFAULT_SPARK_URL; + this._sparkOptions = deps.sparkOptions || {}; + const ready = deps.onReady || deps.onInit; + this._onReady = typeof ready === 'function' ? ready.bind(this) : null; + this._onFrame = typeof deps.onFrame === 'function' ? deps.onFrame.bind(this) : null; + this._THREE = null; + this._Spark = null; + this._scene = null; + this._camera = null; + this._renderer = null; + this._sparkRenderer = null; + this._splat = null; + this._ownedSplats = []; + this._raf = 0; + this._disposeUser = null; + this._ro = null; + this._started = false; + this._initing = false; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + getContext() { + return { + THREE: this._THREE, + Spark: this._Spark, + scene: this._scene, + camera: this._camera, + renderer: this._renderer, + spark: this._sparkRenderer, + splat: this._splat, + host: this.ids().gdSparkHost, + component: this, + }; + } + + getSpark() { + return this._sparkRenderer; + } + + getSplat() { + return this._splat; + } + + componentDidUnMount() { + if (this._raf) cancelAnimationFrame(this._raf); + this._raf = 0; + this._ro?.disconnect?.(); + this._ro = null; + try { + this._disposeUser?.(); + } catch { + /* ignore */ + } + this._disposeUser = null; + + for (const mesh of this._ownedSplats) { + try { + this._scene?.remove?.(mesh); + mesh.dispose?.(); + } catch { + /* ignore */ + } + } + this._ownedSplats = []; + this._splat = null; + + try { + this._sparkRenderer?.dispose?.(); + } catch { + /* ignore */ + } + this._sparkRenderer = null; + + this._renderer?.dispose?.(); + this._renderer?.domElement?.remove?.(); + this._renderer = null; + this._scene = null; + this._camera = null; + this._THREE = null; + this._Spark = null; + this._started = false; + } + + _sizeHost() { + const host = this.ids().gdSparkHost; + if (!host) return { w: 1, h: 1 }; + const w = Math.max(1, host.clientWidth || host.offsetWidth || 1); + const h = Math.max(1, host.clientHeight || host.offsetHeight || 1); + return { w, h }; + } + + _syncSize() { + const { w, h } = this._sizeHost(); + if (this._camera) { + this._camera.aspect = w / h; + this._camera.updateProjectionMatrix?.(); + } + this._renderer?.setSize?.(w, h, false); + } + + _loop = (t0) => { + this._raf = requestAnimationFrame((t1) => { + const dt = Math.min(0.05, (t1 - t0) / 1000); + if (this.state.data.autoRotate && this._splat) { + this._splat.rotation.y += dt * 0.45; + } + this._onFrame?.(dt, this.getContext()); + this._renderer?.render?.(this._scene, this._camera); + this._loop(t1); + }); + }; + + async _ensureScene() { + if (!this.ids().gdSparkHost || this._started || this._initing) return; + this._initing = true; + try { + const [THREE, Spark] = await Promise.all([ + loadSparkThree(this._threeSpec), + loadSpark(this._sparkSpec), + ]); + const host = this.ids().gdSparkHost; + if (this._started || !host) return; + + this._THREE = THREE; + this._Spark = Spark; + const { w, h } = this._sizeHost(); + + this._scene = new THREE.Scene(); + this._camera = new THREE.PerspectiveCamera(60, w / h, 0.01, 1000); + this._camera.position.set(0, 0, 0.01); + + // antialias:false — required for Gaussian Splatting performance + this._renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); + this._renderer.setPixelRatio(Math.min(2, globalThis.devicePixelRatio || 1)); + this._renderer.setSize(w, h, false); + this._renderer.setClearColor(this.state.data.background, 1); + host.textContent = ''; + host.appendChild(this._renderer.domElement); + + const { SparkRenderer, SplatMesh } = Spark; + if (typeof SparkRenderer !== 'function') { + throw new Error('SparkView: SparkRenderer missing — need @sparkjsdev/spark 2.x'); + } + + this._sparkRenderer = new SparkRenderer({ + renderer: this._renderer, + ...this._sparkOptions, + }); + this._scene.add(this._sparkRenderer); + + if (this.state.data.autoSplat && this.state.data.url && typeof SplatMesh === 'function') { + const splat = new SplatMesh({ url: this.state.data.url }); + // Match official hello-world orientation for many .spz assets + splat.quaternion?.set?.(1, 0, 0, 0); + splat.position?.set?.(0, 0, -3); + this._scene.add(splat); + this._ownedSplats.push(splat); + this._splat = splat; + } + + if (typeof ResizeObserver !== 'undefined') { + this._ro = new ResizeObserver(() => this._syncSize()); + this._ro.observe(host); + } + + const maybeDispose = await this._onReady?.(this.getContext()); + if (typeof maybeDispose === 'function') this._disposeUser = maybeDispose; + + this._started = true; + this._loop(performance.now()); + } catch (err) { + this._started = false; + const host = this.ids().gdSparkHost; + if (host && typeof host.replaceChildren === 'function') { + const msg = document.createElement?.('div'); + if (msg) { + msg.className = 'gd-spark__error'; + msg.textContent = `SparkView failed: ${err?.message || err}`; + host.replaceChildren(msg); + } + } + throw err; + } finally { + this._initing = false; + } + } + + render() { + const { height, width, className } = this.state.data; + const h = typeof height === 'number' ? `${height}px` : String(height); + const w = typeof width === 'number' ? `${width}px` : String(width); + + this.ensureShell( + (ids) => ids.gdSparkRoot, + ({ div }) => { + div( + { + id: 'gdSparkRoot', + className: cx('gd-spark', className), + style: { width: w, height: h }, + }, + div({ + id: 'gdSparkHost', + className: 'gd-spark__host', + style: { width: '100%', height: '100%', overflow: 'hidden' }, + }) + ); + } + ); + + if (this._mounted || this.host()) { + void this._ensureScene(); + } + } +} + +/** + * SparkView — Spark.js (3D Gaussian Splatting) host, nested like TodoList: + * + * ```js + * div( + * { id: 'root' }, + * SparkView({ + * url: 'https://sparkjs.dev/assets/splats/butterfly.spz', + * onReady({ splat }) { splat.position.set(0, 0, -3); }, + * }) + * ) + * ``` + * + * Host page should include an importmap for `three` + `@sparkjsdev/spark` (see spark-demo.html). + * + * @param {object} [props] + * @returns {SparkViewView} + */ +export function SparkView(props = {}) { + return new SparkViewView(props); +} + +/** Alias */ +export const SplatView = SparkView; + +export function createSparkView(options = {}, host) { + const view = SparkView(options); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) void view.mount(elHost); + return view; +} diff --git a/src/pyouter/webui/gd5/ui/ThreeView.js b/src/pyouter/webui/gd5/ui/ThreeView.js new file mode 100644 index 0000000..1daf491 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/ThreeView.js @@ -0,0 +1,242 @@ +import { Component } from '../engine/index.js'; +import { cx, loadUi } from './util.js'; + +const DEFAULT_THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js'; + +/** @type {Promise|null} */ +let threePromise = null; + +/** + * Load three.js once (CDN ESM or inject THREE namespace). + * @param {string|object} [spec] + */ +export function loadThree(spec = DEFAULT_THREE_URL) { + if (spec && typeof spec === 'object' && (spec.WebGLRenderer || spec.Scene)) { + return Promise.resolve(spec); + } + if (!threePromise) { + const url = typeof spec === 'string' && spec ? spec : DEFAULT_THREE_URL; + threePromise = import(/* @vite-ignore */ url).then((mod) => mod.default || mod); + } + return threePromise; +} + +/** + * Three.js host Component — same composition as TodoList. + */ +export class ThreeViewView extends Component { + /** + * @param {object} [deps] + * @param {string|number} [deps.height] + * @param {string|number} [deps.width] + * @param {string} [deps.className] + * @param {string|object} [deps.three] CDN URL or THREE namespace + * @param {number} [deps.background] clear color hex + * @param {(ctx:{ + * THREE:any, scene:any, camera:any, renderer:any, host:HTMLElement, component:ThreeViewView + * })=> (void|(()=>void)|Promisevoid)>)} [deps.onInit] + * @param {(dt:number, ctx:object)=>void} [deps.onFrame] + */ + constructor(deps = {}) { + super(deps); + this.state.data.height = deps.height ?? 320; + this.state.data.width = deps.width ?? '100%'; + this.state.data.className = deps.className || ''; + this.state.data.background = deps.background ?? 0xf5f5f5; + this._threeSpec = deps.three ?? DEFAULT_THREE_URL; + this._onInit = typeof deps.onInit === 'function' ? deps.onInit.bind(this) : null; + this._onFrame = typeof deps.onFrame === 'function' ? deps.onFrame.bind(this) : null; + this._THREE = null; + this._scene = null; + this._camera = null; + this._renderer = null; + this._raf = 0; + this._disposeUser = null; + this._ro = null; + this._started = false; + this._initing = false; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + getContext() { + return { + THREE: this._THREE, + scene: this._scene, + camera: this._camera, + renderer: this._renderer, + host: this.ids().gdThreeHost, + component: this, + }; + } + + componentDidUnMount() { + if (this._raf) cancelAnimationFrame(this._raf); + this._raf = 0; + this._ro?.disconnect?.(); + this._ro = null; + try { + this._disposeUser?.(); + } catch { + /* ignore */ + } + this._disposeUser = null; + this._renderer?.dispose?.(); + this._renderer?.domElement?.remove?.(); + this._renderer = null; + this._scene = null; + this._camera = null; + this._THREE = null; + this._started = false; + } + + _sizeHost() { + const host = this.ids().gdThreeHost; + if (!host) return { w: 1, h: 1 }; + const w = Math.max(1, host.clientWidth || host.offsetWidth || 1); + const h = Math.max(1, host.clientHeight || host.offsetHeight || 1); + return { w, h }; + } + + _syncSize() { + const { w, h } = this._sizeHost(); + if (this._camera) { + this._camera.aspect = w / h; + this._camera.updateProjectionMatrix?.(); + } + this._renderer?.setSize?.(w, h, false); + } + + _loop = (t0) => { + this._raf = requestAnimationFrame((t1) => { + const dt = Math.min(0.05, (t1 - t0) / 1000); + this._onFrame?.(dt, this.getContext()); + this._renderer?.render?.(this._scene, this._camera); + this._loop(t1); + }); + }; + + async _ensureScene() { + if (!this.ids().gdThreeHost || this._started || this._initing) return; + this._initing = true; + try { + const THREE = await loadThree(this._threeSpec); + const host = this.ids().gdThreeHost; + if (this._started || !host) return; + this._THREE = THREE; + const { w, h } = this._sizeHost(); + + this._scene = new THREE.Scene(); + this._camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 1000); + this._camera.position.set(0, 0.6, 2.4); + + this._renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + this._renderer.setPixelRatio(Math.min(2, globalThis.devicePixelRatio || 1)); + this._renderer.setSize(w, h, false); + this._renderer.setClearColor(this.state.data.background, 1); + host.textContent = ''; + host.appendChild(this._renderer.domElement); + + if (typeof ResizeObserver !== 'undefined') { + this._ro = new ResizeObserver(() => this._syncSize()); + this._ro.observe(host); + } + + const maybeDispose = await this._onInit?.(this.getContext()); + if (typeof maybeDispose === 'function') this._disposeUser = maybeDispose; + + // Default demo content if onInit did not add meshes + if (!this._onInit && this._scene.children.length === 0) { + const light = new THREE.DirectionalLight(0xffffff, 1.1); + light.position.set(2, 3, 4); + this._scene.add(light); + this._scene.add(new THREE.AmbientLight(0xffffff, 0.45)); + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(0.8, 0.8, 0.8), + new THREE.MeshStandardMaterial({ color: 0xe86a17, metalness: 0.2, roughness: 0.45 }) + ); + this._scene.add(mesh); + this._onFrame = (dt) => { + mesh.rotation.x += dt * 0.7; + mesh.rotation.y += dt * 1.1; + }; + } + + this._started = true; + this._loop(performance.now()); + } catch (err) { + this._started = false; + throw err; + } finally { + this._initing = false; + } + } + + render() { + const { height, width, className } = this.state.data; + const h = typeof height === 'number' ? `${height}px` : String(height); + const w = typeof width === 'number' ? `${width}px` : String(width); + + this.ensureShell( + (ids) => ids.gdThreeRoot, + ({ div }) => { + div( + { + id: 'gdThreeRoot', + className: cx('gd-three', className), + style: { width: w, height: h }, + }, + div({ + id: 'gdThreeHost', + className: 'gd-three__host', + style: { width: '100%', height: '100%', overflow: 'hidden' }, + }) + ); + } + ); + + if (this._mounted || this.host()) { + void this._ensureScene(); + } + } +} + +/** + * ThreeView — Three.js scene host, nested like TodoList: + * + * ```js + * div( + * { id: 'root' }, + * ThreeView({ + * height: 360, + * onInit({ THREE, scene }) { + * scene.add(new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial())); + * }, + * }) + * ) + * ``` + * + * @param {object} [props] + * @returns {ThreeViewView} + */ +export function ThreeView(props = {}) { + return new ThreeViewView(props); +} + +/** Alias */ +export const ThreeScene = ThreeView; + +export function createThreeView(options = {}, host) { + const view = ThreeView(options); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) void view.mount(elHost); + return view; +} diff --git a/src/pyouter/webui/gd5/ui/Toast.js b/src/pyouter/webui/gd5/ui/Toast.js new file mode 100644 index 0000000..a215d3e --- /dev/null +++ b/src/pyouter/webui/gd5/ui/Toast.js @@ -0,0 +1,89 @@ +import { Component } from '../engine/index.js'; +import { loadUi } from './util.js'; + +/** + * Toast / Snackbar — brief non-blocking message stack. + * + * const toast = new Toast({}); + * await toast.mount(); + * toast.show({ message: 'Saved', type: 'success' }); + */ +export class Toast extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.items = []; + this._portal = null; + this._seq = 0; + } + + async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) { + const target = + typeof host === 'string' ? document.getElementById(host) : host || document.body; + if (!target) throw new Error('Toast.mount: host not found'); + if (!this._portal) { + this._portal = document.createElement('div'); + this._portal.className = 'gd-toast-portal'; + target.appendChild(this._portal); + } + return super.mount(this._portal, params); + } + + componentDidUnMount() { + if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal); + this._portal = null; + } + + async componentDidMount() { + await loadUi(); + } + + /** + * @param {object} options + * @param {string} options.message + * @param {'info'|'success'|'warning'|'error'} [options.type] + * @param {number} [options.duration] ms; 0 = sticky + */ + show(options = {}) { + const id = ++this._seq; + const item = { + id, + message: options.message || '', + type: options.type || 'info', + }; + this.setData({ items: [...this.state.data.items, item] }); + const duration = options.duration ?? 3000; + if (duration > 0) { + setTimeout(() => this.dismiss(id), duration); + } + return id; + } + + dismiss(id) { + this.setData({ items: this.state.data.items.filter((it) => it.id !== id) }); + } + + render() { + const { items } = this.state.data; + // Always rebuild list (small) + const host = this.host(); + if (!host) return; + host.textContent = ''; + const root = document.createElement('div'); + root.className = 'gd-toast-root'; + root.setAttribute('aria-live', 'polite'); + for (const item of items) { + const node = document.createElement('div'); + node.className = `gd-toast gd-toast--${item.type}`; + node.setAttribute('role', 'status'); + node.textContent = item.message; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'gd-toast__close'; + close.textContent = '×'; + close.addEventListener('click', () => this.dismiss(item.id)); + node.appendChild(close); + root.appendChild(node); + } + host.appendChild(root); + } +} diff --git a/src/pyouter/webui/gd5/ui/WorkflowDAG.js b/src/pyouter/webui/gd5/ui/WorkflowDAG.js new file mode 100644 index 0000000..3b65949 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/WorkflowDAG.js @@ -0,0 +1,438 @@ +import { Component } from '../engine/index.js'; +import { Drawer } from './Drawer.js'; +import { cx, el, loadUi, omit } from './util.js'; + +/** + * @typedef {{ key: string, title?: string, status?: string, description?: string, meta?: object }} WfNode + * @typedef {{ key: string, title?: string, nodes?: WfNode[] }} WfColumn + * @typedef {{ from: string, to: string }} WfEdge + */ + +/** + * WorkflowDAG — multi-column DAG canvas. + * + * - Scroll in all directions + * - Drag blank area to pan the viewport + * - Click a node → right Drawer with details + * + * const wf = new WorkflowDAG({}); + * await wf.mount(hostEl); + * wf.setGraph({ columns, edges }); + */ +export class WorkflowDAG extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.columns = []; + this.state.data.edges = []; + this.state.data.selectedKey = null; + /** @type {(node: WfNode) => string|HTMLElement|{dom(): HTMLElement}|null|undefined} */ + this._renderDetail = deps.renderDetail || null; + this._onNodeClick = deps.onNodeClick || null; + this._drawer = new Drawer({}); + this._panning = false; + this._panStart = null; + this._edgeRaf = 0; + } + + /** + * @param {HTMLElement|string} host + * @param {object} [params] + */ + async mount(host, params = {}) { + await loadUi(); + await super.mount(host, params); + await this._drawer.mount( + typeof document !== 'undefined' ? document.body : null + ); + return this; + } + + componentDidUnMount() { + this._drawer.unmount(); + if (this._edgeRaf) cancelAnimationFrame(this._edgeRaf); + } + + /** + * Replace graph data and refresh. + * @param {{ columns?: WfColumn[], edges?: WfEdge[], selectedKey?: string|null }} graph + */ + setGraph(graph = {}) { + this.setData( + { + columns: graph.columns ?? this.state.data.columns, + edges: graph.edges ?? this.state.data.edges, + selectedKey: + graph.selectedKey !== undefined + ? graph.selectedKey + : this.state.data.selectedKey, + }, + { refresh: !!this._mounted } + ); + return this; + } + + /** @param {(node: WfNode) => any} fn */ + setRenderDetail(fn) { + this._renderDetail = fn; + return this; + } + + /** @param {(node: WfNode, ctx: { openDrawer: Function }) => void} fn */ + setOnNodeClick(fn) { + this._onNodeClick = fn; + return this; + } + + findNode(key) { + for (const col of this.state.data.columns || []) { + for (const n of col.nodes || []) { + if (n.key === key) return n; + } + } + return null; + } + + openDrawerFor(node) { + if (!node) return this; + const custom = this._renderDetail?.(node); + let content = ''; + if (typeof custom === 'string') { + content = custom; + } else if (!custom) { + const bits = [ + node.description || '', + node.status ? `Status: ${node.status}` : '', + node.key ? `Key: ${node.key}` : '', + ].filter(Boolean); + content = bits.join('\n\n') || 'No details.'; + } + + this._drawer.open({ + title: node.title || node.key, + content, + placement: 'right', + size: 'md', + onClose: () => { + this.setData({ selectedKey: null }, { refresh: false }); + this._syncSelection(); + }, + }); + + if (custom && typeof custom !== 'string') { + const body = this._drawer.ids().gdDrawerBody; + if (body) { + body.textContent = ''; + const dom = typeof custom.dom === 'function' ? custom.dom() : custom; + if (dom) body.appendChild(dom); + } + } + return this; + } + + componentDidAttachEvents() { + const viewport = this.ids().gdWfViewport; + if (!viewport) return; + + this.on(viewport, 'pointerdown', (e) => this._onPointerDown(e)); + this.on(viewport, 'pointermove', (e) => this._onPointerMove(e)); + this.on(viewport, 'pointerup', (e) => this._onPointerUp(e)); + this.on(viewport, 'pointercancel', (e) => this._onPointerUp(e)); + this.on(viewport, 'scroll', () => this._scheduleEdges()); + this.on(window, 'resize', () => this._scheduleEdges()); + } + + _onPointerDown(e) { + if (e.button !== 0) return; + if (e.target.closest?.('.gd-workflow__node')) return; + const viewport = this.ids().gdWfViewport; + if (!viewport) return; + this._panning = true; + this._panStart = { + x: e.clientX, + y: e.clientY, + left: viewport.scrollLeft, + top: viewport.scrollTop, + }; + viewport.classList.add('is-panning'); + try { + viewport.setPointerCapture(e.pointerId); + } catch { + /* ignore */ + } + e.preventDefault(); + } + + _onPointerMove(e) { + if (!this._panning || !this._panStart) return; + const viewport = this.ids().gdWfViewport; + if (!viewport) return; + const dx = e.clientX - this._panStart.x; + const dy = e.clientY - this._panStart.y; + viewport.scrollLeft = this._panStart.left - dx; + viewport.scrollTop = this._panStart.top - dy; + } + + _onPointerUp(e) { + if (!this._panning) return; + this._panning = false; + this._panStart = null; + const viewport = this.ids().gdWfViewport; + viewport?.classList.remove('is-panning'); + try { + viewport?.releasePointerCapture?.(e.pointerId); + } catch { + /* ignore */ + } + } + + _scheduleEdges() { + if (typeof requestAnimationFrame !== 'function') { + this._drawEdges(); + return; + } + if (this._edgeRaf) cancelAnimationFrame(this._edgeRaf); + this._edgeRaf = requestAnimationFrame(() => { + this._edgeRaf = 0; + this._drawEdges(); + }); + } + + _syncSelection() { + const key = this.state.data.selectedKey; + const root = this.ids().gdWfRoot; + if (!root) return; + root.querySelectorAll('.gd-workflow__node').forEach((nodeEl) => { + nodeEl.classList.toggle( + 'gd-workflow__node--selected', + nodeEl.getAttribute('data-node-key') === key + ); + }); + } + + _ensureSvg() { + const host = this.ids().gdWfEdgesHost; + if (!host) return null; + let svg = this.ids().gdWfEdges; + if (svg && svg.parentNode === host) return svg; + host.textContent = ''; + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'gd-workflow__edges'); + svg.id = 'gdWfEdges'; + host.appendChild(svg); + if (!this.state.ref) this.state.ref = { class: {}, id: {}, key: {} }; + if (!this.state.ref.id) this.state.ref.id = {}; + this.state.ref.id.gdWfEdges = svg; + return svg; + } + + _drawEdges() { + const canvas = this.ids().gdWfCanvas; + const svg = this._ensureSvg(); + if (!canvas || !svg) return; + + const edges = this.state.data.edges || []; + const cRect = canvas.getBoundingClientRect(); + const paths = []; + + for (const edge of edges) { + const esc = + typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape + : (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const fromEl = canvas.querySelector( + `[data-node-key="${esc(String(edge.from))}"]` + ); + const toEl = canvas.querySelector( + `[data-node-key="${esc(String(edge.to))}"]` + ); + if (!fromEl || !toEl) continue; + const a = fromEl.getBoundingClientRect(); + const b = toEl.getBoundingClientRect(); + const x1 = a.right - cRect.left; + const y1 = a.top + a.height / 2 - cRect.top; + const x2 = b.left - cRect.left; + const y2 = b.top + b.height / 2 - cRect.top; + const dx = Math.max(48, Math.abs(x2 - x1) * 0.5); + paths.push( + `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}` + ); + } + + const w = Math.max(canvas.scrollWidth, canvas.offsetWidth, 1); + const h = Math.max(canvas.scrollHeight, canvas.offsetHeight, 1); + svg.setAttribute('width', String(w)); + svg.setAttribute('height', String(h)); + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); + svg.innerHTML = ` + + + + ${paths + .map( + (d) => + `` + ) + .join('')}`; + } + + _onNodeActivate(node) { + this.setData({ selectedKey: node.key }, { refresh: false }); + this._syncSelection(); + if (this._onNodeClick) { + this._onNodeClick(node, { + openDrawer: () => this.openDrawerFor(node), + }); + } else { + this.openDrawerFor(node); + } + } + + render() { + const { columns, selectedKey } = this.state.data; + + this.ensureShell( + (ids) => ids.gdWfRoot, + ({ div }) => { + div( + { id: 'gdWfRoot', className: 'gd-workflow' }, + div( + { + id: 'gdWfViewport', + className: 'gd-workflow__viewport', + attrs: { tabindex: '0' }, + }, + div( + { id: 'gdWfCanvas', className: 'gd-workflow__canvas' }, + div({ + id: 'gdWfEdgesHost', + className: 'gd-workflow__edges-host', + }), + div({ id: 'gdWfColumns', className: 'gd-workflow__columns' }) + ) + ), + div({ + className: 'gd-workflow__hint', + text: 'Scroll or drag blank area to pan · Click a node for details', + }) + ); + } + ); + + const colsHost = this.ids().gdWfColumns; + if (!colsHost) return; + + colsHost.textContent = ''; + for (const col of columns || []) { + const colEl = el( + 'div', + { + className: 'gd-workflow__column', + attrs: { 'data-column-key': col.key || '' }, + }, + el('div', { + className: 'gd-workflow__column-title', + text: col.title || col.key || '', + }), + el( + 'div', + { className: 'gd-workflow__column-body' }, + ...(col.nodes || []).map((node) => { + const kids = [ + el('div', { + className: 'gd-workflow__node-title', + text: node.title || node.key, + }), + ]; + if (node.status) { + kids.push( + el('div', { + className: 'gd-workflow__node-status', + text: node.status, + }) + ); + } + if (node.description) { + kids.push( + el('div', { + className: 'gd-workflow__node-desc', + text: node.description, + }) + ); + } + return el( + 'button', + { + type: 'button', + className: cx( + 'gd-workflow__node', + node.status && `gd-workflow__node--${node.status}`, + selectedKey === node.key && 'gd-workflow__node--selected' + ), + attrs: { 'data-node-key': node.key }, + onClick: (e) => { + e.stopPropagation(); + this._onNodeActivate(node); + }, + }, + ...kids + ); + }) + ) + ); + colsHost.appendChild(colEl.dom()); + } + + this._ensureSvg(); + this._scheduleEdges(); + } +} + +/** + * Lightweight static board (no pan / Drawer). Prefer WorkflowDAG Component. + */ +export function WorkflowBoard(props = {}) { + const { columns = [], className, selectedKey, onNodeClick } = props; + return el( + 'div', + { + ...omit(props, [ + 'columns', + 'className', + 'selectedKey', + 'onNodeClick', + 'edges', + ]), + className: cx('gd-workflow gd-workflow--static', className), + }, + el( + 'div', + { className: 'gd-workflow__columns' }, + ...columns.map((col) => + el( + 'div', + { className: 'gd-workflow__column' }, + el('div', { + className: 'gd-workflow__column-title', + text: col.title || col.key || '', + }), + el( + 'div', + { className: 'gd-workflow__column-body' }, + ...(col.nodes || []).map((node) => + el('button', { + type: 'button', + className: cx( + 'gd-workflow__node', + selectedKey === node.key && 'gd-workflow__node--selected' + ), + text: node.title || node.key, + onClick: () => onNodeClick?.(node), + }) + ) + ) + ) + ) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/accordion.js b/src/pyouter/webui/gd5/ui/accordion.js new file mode 100644 index 0000000..45bda73 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/accordion.js @@ -0,0 +1,51 @@ +import { cx, el, omit } from './util.js'; + +/** + * Accordion — stacked disclosures (details/summary). + * @param {object} [props] + * @param {Array<{key?:string,title:string,content:any,defaultOpen?:boolean}>} [props.items] + * @param {boolean} [props.multiple] + */ +export function Accordion(props = {}, ...children) { + const { items = [], multiple = true, className } = props; + if (children.length) { + return el('div', { className: cx('gd-accordion', className) }, ...children); + } + return el( + 'div', + { + ...omit(props, ['items', 'multiple', 'className']), + className: cx('gd-accordion', className), + }, + ...items.map((item, i) => + Collapse({ + key: item.key || `acc-${i}`, + title: item.title, + open: item.defaultOpen, + name: multiple ? undefined : 'gd-accordion-group', + }, item.content) + ) + ); +} + +/** + * Collapse / Collapsible — single disclosure panel. + * @param {object} [props] + * @param {string} [props.title] + * @param {boolean} [props.open] + * @param {string} [props.name] same name → exclusive open (details) + */ +export function Collapse(props = {}, ...children) { + const { title = 'Section', open = false, name, className } = props; + return el( + 'details', + { + ...omit(props, ['title', 'open', 'name', 'className', 'key']), + className: cx('gd-collapse', className), + ...(open ? { open: true } : {}), + ...(name ? { name } : {}), + }, + el('summary', { className: 'gd-collapse__trigger', text: title }), + el('div', { className: 'gd-collapse__panel' }, ...children) + ); +} diff --git a/src/pyouter/webui/gd5/ui/affix.js b/src/pyouter/webui/gd5/ui/affix.js new file mode 100644 index 0000000..f605204 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/affix.js @@ -0,0 +1,60 @@ +import { cx, el, omit } from './util.js'; + +/** + * Affix — sticky wrapper with offset (antd Affix simplified via CSS sticky). + * @param {object} [props] + * @param {number} [props.offsetTop] + * @param {number} [props.offsetBottom] + */ +export function Affix(props = {}, ...children) { + const { offsetTop = 0, offsetBottom, className, style } = props; + const s = { ...(typeof style === 'object' ? style : {}) }; + if (offsetBottom != null) { + s.bottom = typeof offsetBottom === 'number' ? `${offsetBottom}px` : offsetBottom; + s.top = 'auto'; + } else { + s.top = typeof offsetTop === 'number' ? `${offsetTop}px` : offsetTop; + } + return el( + 'div', + { + ...omit(props, ['offsetTop', 'offsetBottom', 'className', 'style']), + className: cx('gd-affix', className), + style: s, + }, + ...children + ); +} + +/** + * Anchor — in-page link list (antd Anchor). Similar to Scrollspy with ink. + * @param {object} [props] + * @param {Array<{key:string,href?:string,title:string}>} [props.items] + * @param {string} [props.activeKey] + * @param {function} [props.onChange] + */ +export function Anchor(props = {}) { + const { items = [], activeKey, onChange, className, offsetTop = 0 } = props; + return el( + 'div', + { + ...omit(props, ['items', 'activeKey', 'onChange', 'className', 'offsetTop']), + className: cx('gd-anchor', className), + style: { top: typeof offsetTop === 'number' ? `${offsetTop}px` : offsetTop }, + }, + el('div', { className: 'gd-anchor__ink' }), + ...items.map((it) => + el('a', { + className: cx('gd-anchor__link', it.key === activeKey && 'gd-anchor__link--active'), + text: it.title, + href: it.href || `#${it.key}`, + onClick: (e) => { + if (onChange) { + e.preventDefault(); + onChange(it.key); + } + }, + }) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/avatar.js b/src/pyouter/webui/gd5/ui/avatar.js new file mode 100644 index 0000000..ab00c52 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/avatar.js @@ -0,0 +1,81 @@ +import { cx, el, omit } from './util.js'; + +/** + * Avatar — image / initials circle with optional StatusDot. + * @param {object} [props] + * @param {string} [props.src] + * @param {string} [props.alt] + * @param {string} [props.name] used for initials fallback + * @param {'sm'|'md'|'lg'|'xl'} [props.size] + * @param {'online'|'away'|'busy'|'offline'|'processing'} [props.status] + */ +export function Avatar(props = {}) { + const { src, alt, name, size = 'md', status, className, text } = props; + const initials = + text || + (name + ? name + .split(/\s+/) + .slice(0, 2) + .map((p) => p[0]) + .join('') + .toUpperCase() + : '?'); + + return el( + 'span', + { + ...omit(props, ['src', 'alt', 'name', 'size', 'status', 'className', 'text']), + className: cx('gd-avatar', `gd-avatar--${size}`, className), + }, + src + ? el('img', { className: 'gd-avatar__img', src, alt: alt || name || '' }) + : el('span', { className: 'gd-avatar__fallback', text: initials }), + status ? StatusDot({ status, className: 'gd-avatar__status' }) : null + ); +} + +/** + * StatusDot — presence indicator. + * @param {object} [props] + * @param {'online'|'away'|'busy'|'offline'|'processing'|'success'|'error'|'warning'} [props.status] + */ +export function StatusDot(props = {}) { + const { status = 'offline', className, title } = props; + return el('span', { + ...omit(props, ['status', 'className']), + className: cx('gd-status-dot', `gd-status-dot--${status}`, className), + title: title || status, + attrs: { 'aria-label': title || status }, + }); +} + +/** + * AvatarGroup — overlapping faces with +N overflow. + * @param {object} [props] + * @param {Array} [props.items] Avatar props + * @param {number} [props.max] + */ +export function AvatarGroup(props = {}, ...children) { + const { items = [], max = 5, className, size = 'md' } = props; + const list = children.length + ? children + : items.map((item) => Avatar({ size, ...item })); + const shown = list.slice(0, max); + const overflow = list.length - shown.length; + + return el( + 'div', + { + ...omit(props, ['items', 'max', 'className', 'size']), + className: cx('gd-avatar-group', className), + }, + ...shown, + overflow > 0 + ? el('span', { + className: cx('gd-avatar', `gd-avatar--${size}`, 'gd-avatar--overflow'), + text: `+${overflow}`, + }) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/banner.js b/src/pyouter/webui/gd5/ui/banner.js new file mode 100644 index 0000000..7f2b7af --- /dev/null +++ b/src/pyouter/webui/gd5/ui/banner.js @@ -0,0 +1,48 @@ +import { cx, el, omit } from './util.js'; + +/** + * Banner — full-bleed in-page notice (typically top of view). + * @param {object} [props] + * @param {'info'|'success'|'warning'|'error'} [props.type] + * @param {string} [props.message] + * @param {boolean} [props.closable] + * @param {function} [props.onClose] + */ +export function Banner(props = {}, ...children) { + const { type = 'info', message, closable, onClose, className } = props; + const kids = children.length ? children : message != null ? [message] : []; + return el( + 'div', + { + ...omit(props, ['type', 'message', 'closable', 'onClose', 'className']), + className: cx('gd-banner', `gd-banner--${type}`, className), + role: 'status', + }, + el('div', { className: 'gd-banner__body' }, ...kids), + closable + ? el('button', { + type: 'button', + className: 'gd-banner__close', + text: '×', + onClick: onClose, + }) + : null + ); +} + +/** + * Callout — bordered aside notice in the content flow. + */ +export function Callout(props = {}, ...children) { + const { type = 'info', title, message, className } = props; + const kids = children.length ? children : message != null ? [message] : []; + return el( + 'aside', + { + ...omit(props, ['type', 'title', 'message', 'className']), + className: cx('gd-callout', `gd-callout--${type}`, className), + }, + title != null ? el('div', { className: 'gd-callout__title', text: title }) : null, + el('div', { className: 'gd-callout__body' }, ...kids) + ); +} diff --git a/src/pyouter/webui/gd5/ui/breadcrumbs.js b/src/pyouter/webui/gd5/ui/breadcrumbs.js new file mode 100644 index 0000000..4f3c712 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/breadcrumbs.js @@ -0,0 +1,57 @@ +import { cx, el, omit } from './util.js'; + +/** + * Breadcrumbs — hierarchy trail. + * @param {object} [props] + * @param {Array<{label:string,href?:string,onClick?:function}>} [props.items] + * @param {string} [props.separator] + */ +export function Breadcrumbs(props = {}) { + const { items = [], separator = '/', className } = props; + const nodes = []; + items.forEach((item, i) => { + const last = i === items.length - 1; + if (i > 0) { + nodes.push( + el('span', { + className: 'gd-breadcrumb__sep', + text: separator, + attrs: { 'aria-hidden': 'true' }, + }) + ); + } + if (last || (!item.href && !item.onClick)) { + nodes.push( + el('span', { + className: cx('gd-breadcrumb__item', last && 'gd-breadcrumb__item--current'), + text: item.label, + attrs: last ? { 'aria-current': 'page' } : undefined, + }) + ); + } else { + nodes.push( + el('a', { + className: 'gd-breadcrumb__item gd-breadcrumb__link', + text: item.label, + href: item.href || '#', + onClick: (e) => { + if (item.onClick) { + e.preventDefault(); + item.onClick(e); + } + }, + }) + ); + } + }); + + return el( + 'nav', + { + ...omit(props, ['items', 'separator', 'className']), + className: cx('gd-breadcrumb', className), + attrs: { 'aria-label': 'Breadcrumb' }, + }, + ...nodes + ); +} diff --git a/src/pyouter/webui/gd5/ui/button.js b/src/pyouter/webui/gd5/ui/button.js new file mode 100644 index 0000000..5d78b81 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/button.js @@ -0,0 +1,136 @@ +import { JELLY_HOST_ATTR } from './jelly.js'; +import { cx, el, omit } from './util.js'; + +/** + * Inline loading indicator for buttons. + * @param {'dots'|'spin'} [kind] + */ +export function ButtonIndicator(kind = 'dots') { + if (kind === 'spin') { + return el('span', { + className: 'gd-btn__indicator gd-btn__indicator--spin', + attrs: { 'aria-hidden': 'true' }, + }); + } + return el( + 'span', + { + className: 'gd-btn__indicator gd-btn__indicator--dots', + attrs: { 'aria-hidden': 'true' }, + }, + el('i'), + el('i'), + el('i') + ); +} + +/** + * Button — antd-like primitive. + * + * @param {object} [props] + * @param {'primary'|'default'|'ghost'|'danger'|'text'} [props.type] + * @param {'sm'|'md'|'lg'} [props.size] + * @param {boolean} [props.block] + * @param {boolean} [props.disabled] + * @param {boolean} [props.loading] shows indicator + locks click + * @param {'dots'|'spin'} [props.indicator] + * @param {string} [props.text] + * @param {string} [props.loadingText] label while loading (default: keep text) + * @param {string} [props.className] + * @param {function} [props.onClick] + * @param {...any} children + */ +export function Button(props = {}, ...children) { + const { + type = 'default', + size = 'md', + block = false, + className, + text, + loadingText, + disabled, + loading = false, + indicator = 'dots', + } = props; + + const busy = !!loading; + const locked = busy || !!disabled; + const kids = children.length ? children : []; + const label = + kids.length === 0 + ? busy && loadingText != null + ? String(loadingText) + : text != null + ? String(text) + : null + : null; + + return el( + 'button', + { + ...omit(props, [ + 'type', + 'size', + 'block', + 'className', + 'text', + 'loadingText', + 'htmlType', + 'loading', + 'indicator', + ]), + type: props.htmlType || 'button', + disabled: locked, + attrs: buttonAttrs(type, busy, props.attrs), + className: cx( + 'gd-btn', + `gd-btn--${type}`, + `gd-btn--${size}`, + block && 'gd-btn--block', + busy && 'gd-btn--loading', + className + ), + onClick: (e) => { + if (busy) { + e.preventDefault(); + e.stopPropagation(); + return; + } + props.onClick?.(e); + }, + }, + busy ? ButtonIndicator(indicator === 'spin' ? 'spin' : 'dots') : null, + label != null ? el('span', { className: 'gd-btn__label', text: label }) : null, + ...kids + ); +} + +/** Solid primary and default buttons can carry the global jelly membrane. */ +function buttonAttrs(type, busy, extra) { + const attrs = { + ...(extra || {}), + 'aria-busy': busy ? 'true' : 'false', + }; + if (type === 'primary' || type === 'default' || type === 'ghost' || type === 'danger') { + attrs[JELLY_HOST_ATTR] = 'solid'; + } else delete attrs[JELLY_HOST_ATTR]; + return attrs; +} + +/** + * Run an async click handler once; ignore re-entry while pending. + * @param {() => (void|Promise)} fn + * @returns {() => Promise} + */ +export function onceAsync(fn) { + let pending = false; + return async () => { + if (pending) return; + pending = true; + try { + await fn?.(); + } finally { + pending = false; + } + }; +} diff --git a/src/pyouter/webui/gd5/ui/calendar.js b/src/pyouter/webui/gd5/ui/calendar.js new file mode 100644 index 0000000..997592d --- /dev/null +++ b/src/pyouter/webui/gd5/ui/calendar.js @@ -0,0 +1,93 @@ +import { cx, el, omit } from './util.js'; + +/** + * Calendar — month grid (antd Calendar, simplified). + * @param {object} [props] + * @param {Date|string} [props.value] + * @param {function} [props.onChange] (date: Date) + * @param {function} [props.onSelect] + */ +export function Calendar(props = {}) { + const { value, onChange, onSelect, className } = props; + const current = value ? new Date(value) : new Date(); + const year = current.getFullYear(); + const month = current.getMonth(); + const selectedDay = value ? new Date(value).getDate() : null; + const selectedMonth = value ? new Date(value).getMonth() : null; + const selectedYear = value ? new Date(value).getFullYear() : null; + + const first = new Date(year, month, 1); + const startWeekday = first.getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const cells = []; + for (let i = 0; i < startWeekday; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + const monthLabel = first.toLocaleString(undefined, { month: 'long', year: 'numeric' }); + + const shift = (delta) => { + const next = new Date(year, month + delta, 1); + onChange?.(next); + }; + + return el( + 'div', + { + ...omit(props, ['value', 'onChange', 'onSelect', 'className']), + className: cx('gd-calendar', className), + }, + el( + 'div', + { className: 'gd-calendar__header' }, + el('button', { + type: 'button', + className: 'gd-calendar__nav', + text: '‹', + onClick: () => shift(-1), + }), + el('div', { className: 'gd-calendar__title', text: monthLabel }), + el('button', { + type: 'button', + className: 'gd-calendar__nav', + text: '›', + onClick: () => shift(1), + }) + ), + el( + 'div', + { className: 'gd-calendar__weekdays' }, + ...['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((w) => + el('div', { className: 'gd-calendar__weekday', text: w }) + ) + ), + el( + 'div', + { className: 'gd-calendar__grid' }, + ...cells.map((d, i) => { + if (d == null) return el('div', { className: 'gd-calendar__cell gd-calendar__cell--empty', key: `e-${i}` }); + const selected = + d === selectedDay && month === selectedMonth && year === selectedYear; + const today = new Date(); + const isToday = + d === today.getDate() && + month === today.getMonth() && + year === today.getFullYear(); + return el('button', { + type: 'button', + key: `d-${d}`, + className: cx( + 'gd-calendar__cell', + selected && 'gd-calendar__cell--selected', + isToday && 'gd-calendar__cell--today' + ), + text: String(d), + onClick: () => { + const date = new Date(year, month, d); + onSelect?.(date); + onChange?.(date); + }, + }); + }) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/card.js b/src/pyouter/webui/gd5/ui/card.js new file mode 100644 index 0000000..9d3aa20 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/card.js @@ -0,0 +1,31 @@ +import { cx, el, omit } from './util.js'; + +/** + * @param {object} [props] + * @param {string} [props.title] + * @param {any} [props.extra] + * @param {any} [props.footer] + */ +export function Card(props = {}, ...children) { + const { title, extra, footer, className, id } = props; + const hasHead = title != null || extra != null; + + return el( + 'div', + { + ...omit(props, ['title', 'extra', 'footer', 'className']), + id, + className: cx('gd-card', className), + }, + hasHead + ? el( + 'div', + { className: 'gd-card__head' }, + title != null ? el('h3', { className: 'gd-card__title', text: title }) : null, + extra || null + ) + : null, + el('div', { className: 'gd-card__body' }, ...children), + footer != null ? el('div', { className: 'gd-card__foot' }, footer) : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/carousel.js b/src/pyouter/webui/gd5/ui/carousel.js new file mode 100644 index 0000000..fd97769 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/carousel.js @@ -0,0 +1,65 @@ +import { cx, el, omit } from './util.js'; +import { PageControl } from './pagination.js'; + +/** + * Carousel — horizontal slides with arrows + dots. + * Controlled via `index` + `onChange`. + * @param {object} [props] + * @param {Array} [props.slides] Element or string + * @param {number} [props.index] + * @param {function} [props.onChange] + */ +export function Carousel(props = {}) { + const { slides = [], index = 0, onChange, className } = props; + const count = slides.length; + const i = ((index % count) + count) % count || 0; + + return el( + 'div', + { + ...omit(props, ['slides', 'index', 'onChange', 'className']), + className: cx('gd-carousel', className), + }, + el( + 'div', + { className: 'gd-carousel__viewport' }, + el( + 'div', + { + className: 'gd-carousel__track', + style: { transform: `translateX(-${i * 100}%)` }, + }, + ...slides.map((slide, n) => + el( + 'div', + { className: 'gd-carousel__slide', key: `slide-${n}` }, + typeof slide === 'string' ? el('div', { text: slide }) : slide + ) + ) + ) + ), + count > 1 + ? el( + 'div', + { className: 'gd-carousel__controls' }, + el('button', { + type: 'button', + className: 'gd-carousel__arrow', + text: '‹', + onClick: () => onChange?.((i - 1 + count) % count), + }), + PageControl({ + count, + current: i, + onChange, + }), + el('button', { + type: 'button', + className: 'gd-carousel__arrow', + text: '›', + onClick: () => onChange?.((i + 1) % count), + }) + ) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/cascader.js b/src/pyouter/webui/gd5/ui/cascader.js new file mode 100644 index 0000000..f23df79 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/cascader.js @@ -0,0 +1,86 @@ +import { cx, el, omit } from './util.js'; + +/** + * Cascader — multi-level select (antd Cascader). + * Options: [{ value, label, children? }] + * @param {object} [props] + * @param {Array} [props.options] + * @param {string[]} [props.value] path of values + * @param {boolean} [props.open] + * @param {function} [props.onChange] + * @param {function} [props.onOpenChange] + */ +export function Cascader(props = {}) { + const { + options = [], + value = [], + open = false, + onChange, + onOpenChange, + placeholder = 'Please select', + className, + } = props; + + const columns = []; + let level = options; + columns.push(level); + for (const v of value) { + const node = level.find((o) => o.value === v); + if (node?.children?.length) { + level = node.children; + columns.push(level); + } else break; + } + + const labels = []; + let walk = options; + for (const v of value) { + const node = walk.find((o) => o.value === v); + if (!node) break; + labels.push(node.label); + walk = node.children || []; + } + + return el( + 'div', + { + ...omit(props, ['options', 'value', 'open', 'onChange', 'onOpenChange', 'placeholder', 'className']), + className: cx('gd-cascader', open && 'gd-cascader--open', className), + }, + el('button', { + type: 'button', + className: 'gd-cascader__trigger', + text: labels.length ? labels.join(' / ') : placeholder, + onClick: () => onOpenChange?.(!open), + }), + open + ? el( + 'div', + { className: 'gd-cascader__menus' }, + ...columns.map((col, depth) => + el( + 'div', + { className: 'gd-cascader__menu' }, + ...col.map((opt) => { + const selected = value[depth] === opt.value; + return el('button', { + type: 'button', + className: cx( + 'gd-cascader__option', + selected && 'gd-cascader__option--active', + opt.children?.length && 'gd-cascader__option--parent' + ), + text: opt.children?.length ? `${opt.label} ›` : opt.label, + onClick: () => { + const next = [...value.slice(0, depth), opt.value]; + onChange?.(next, opt); + if (!opt.children?.length) onOpenChange?.(false); + }, + }); + }) + ) + ) + ) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/chart.js b/src/pyouter/webui/gd5/ui/chart.js new file mode 100644 index 0000000..b80dedc --- /dev/null +++ b/src/pyouter/webui/gd5/ui/chart.js @@ -0,0 +1,84 @@ +import { cx, el, omit } from './util.js'; + +/** + * PieChart / DonutChart — simple SVG sector chart. + * @param {object} [props] + * @param {Array<{value:number,color?:string,label?:string}>} [props.data] + * @param {number} [props.size] + * @param {boolean} [props.donut] + * @param {number} [props.innerRatio] 0–1 hole size when donut + */ +export function PieChart(props = {}) { + const { + data = [], + size = 160, + donut = false, + innerRatio = 0.55, + className, + } = props; + const total = data.reduce((s, d) => s + (Number(d.value) || 0), 0) || 1; + const cx0 = size / 2; + const cy0 = size / 2; + const r = size / 2 - 2; + const ir = donut ? r * innerRatio : 0; + const colors = [ + 'var(--gd-primary)', + '#f0a35a', + '#2f8f5b', + '#2f6fed', + '#d14343', + '#8a8178', + ]; + + let angle = -Math.PI / 2; + const paths = []; + data.forEach((d, i) => { + const slice = ((Number(d.value) || 0) / total) * Math.PI * 2; + const a0 = angle; + const a1 = angle + slice; + angle = a1; + const x0 = cx0 + r * Math.cos(a0); + const y0 = cy0 + r * Math.sin(a0); + const x1 = cx0 + r * Math.cos(a1); + const y1 = cy0 + r * Math.sin(a1); + const large = slice > Math.PI ? 1 : 0; + let dPath; + if (donut) { + const ix0 = cx0 + ir * Math.cos(a1); + const iy0 = cy0 + ir * Math.sin(a1); + const ix1 = cx0 + ir * Math.cos(a0); + const iy1 = cy0 + ir * Math.sin(a0); + dPath = `M ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} L ${ix0} ${iy0} A ${ir} ${ir} 0 ${large} 0 ${ix1} ${iy1} Z`; + } else { + dPath = `M ${cx0} ${cy0} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z`; + } + paths.push({ d: dPath, color: d.color || colors[i % colors.length], label: d.label }); + }); + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', `0 0 ${size} ${size}`); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('class', 'gd-chart__svg'); + for (const p of paths) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', p.d); + path.setAttribute('fill', p.color); + if (p.label) path.setAttribute('aria-label', p.label); + svg.appendChild(path); + } + + return el( + 'div', + { + ...omit(props, ['data', 'size', 'donut', 'innerRatio', 'className']), + className: cx('gd-chart', donut && 'gd-chart--donut', className), + role: 'img', + }, + { e: svg, dom: () => svg } + ); +} + +export function DonutChart(props = {}) { + return PieChart({ ...props, donut: true }); +} diff --git a/src/pyouter/webui/gd5/ui/chat.js b/src/pyouter/webui/gd5/ui/chat.js new file mode 100644 index 0000000..eceaeae --- /dev/null +++ b/src/pyouter/webui/gd5/ui/chat.js @@ -0,0 +1,31 @@ +import { cx, el, omit } from './util.js'; +import { Avatar } from './avatar.js'; + +/** + * ChatBubble — message bubble (mine / theirs). + * @param {object} [props] + * @param {'mine'|'theirs'} [props.side] + * @param {string} [props.text] + * @param {string} [props.author] + * @param {boolean} [props.showAvatar] + */ +export function ChatBubble(props = {}, ...children) { + const { side = 'theirs', text, author, showAvatar = false, className } = props; + const kids = children.length ? children : text != null ? [text] : []; + return el( + 'div', + { + ...omit(props, ['side', 'text', 'author', 'showAvatar', 'className']), + className: cx('gd-chat-row', `gd-chat-row--${side}`, className), + }, + showAvatar ? Avatar({ name: author || (side === 'mine' ? 'Me' : 'Them'), size: 'sm' }) : null, + el( + 'div', + { className: cx('gd-chat-bubble', `gd-chat-bubble--${side}`) }, + author && side === 'theirs' + ? el('div', { className: 'gd-chat-bubble__author', text: author }) + : null, + el('div', { className: 'gd-chat-bubble__text' }, ...kids) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/chip.js b/src/pyouter/webui/gd5/ui/chip.js new file mode 100644 index 0000000..9de05f6 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/chip.js @@ -0,0 +1,57 @@ +import { JELLY_HOST_ATTR } from './jelly.js'; +import { cx, el, omit } from './util.js'; + +/** + * Chip — interactive compact label (closable / selectable). + * Distinct from Badge (count) and Tag (static). + * @param {object} [props] + * @param {string} [props.text] + * @param {boolean} [props.closable] + * @param {boolean} [props.selected] + * @param {function} [props.onClose] + * @param {function} [props.onClick] + */ +export function Chip(props = {}, ...children) { + const { text, closable, selected, className, onClose, onClick, color } = props; + const kids = children.length ? children : text != null ? [text] : []; + return el( + 'span', + { + ...omit(props, ['text', 'closable', 'selected', 'className', 'onClose', 'color']), + attrs: { ...(props.attrs || {}), [JELLY_HOST_ATTR]: 'solid' }, + className: cx( + 'gd-chip', + selected && 'gd-chip--selected', + color && `gd-chip--${color}`, + className + ), + onClick, + }, + ...kids, + closable + ? el('button', { + type: 'button', + className: 'gd-chip__close', + text: '×', + onClick: (e) => { + e.stopPropagation(); + onClose?.(e); + }, + }) + : null + ); +} + +/** Pill — fully rounded static label. */ +export function Pill(props = {}, ...children) { + const { text, className, color = 'default' } = props; + const kids = children.length ? children : text != null ? [text] : []; + return el( + 'span', + { + ...omit(props, ['text', 'className', 'color']), + className: cx('gd-pill', `gd-pill--${color}`, className), + }, + ...kids + ); +} diff --git a/src/pyouter/webui/gd5/ui/code.js b/src/pyouter/webui/gd5/ui/code.js new file mode 100644 index 0000000..baadd31 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/code.js @@ -0,0 +1,615 @@ +import { cx, el, omit } from './util.js'; + +const BREAK = /\r\n|\r|\n/; + +/** + * Split source into display lines. + * + * Fixes highlightjs-line-numbers.js behavior that always `pop()`s a trailing + * empty segment — that disagrees with editors when the file ends with `\n`. + * We keep the trailing empty line so line count matches the source. + * + * @param {string} code + * @returns {string[]} + */ +export function splitCodeLines(code) { + const text = String(code ?? ''); + if (text.length === 0) return ['']; + return text.split(BREAK); +} + +/** + * @param {string} code + * @returns {number} + */ +export function countCodeLines(code) { + return splitCodeLines(code).length; +} + +/** + * Lightweight tokenizer (no highlight.js dependency). + * Emits tokens that may contain `\n`; use {@link tokensToLineNodes}. + * @param {string} code + * @param {string} [language] + * @returns {Array<{type:string,text:string}>} + */ +export function tokenizeCode(code, language = 'plaintext') { + const text = String(code ?? ''); + const lang = String(language || 'plaintext').toLowerCase(); + if (lang === 'plaintext' || lang === 'text' || lang === 'plain') { + return text ? [{ type: 'text', text }] : []; + } + if (lang === 'json') return tokenizeJsonLike(text); + if (lang === 'css') return tokenizeCss(text); + if (lang === 'html' || lang === 'xml' || lang === 'svg') return tokenizeMarkup(text); + // javascript / typescript / jsx / default programming + return tokenizeJsFamily(text); +} + +function pushText(out, text, type = 'text') { + if (!text) return; + out.push({ type, text }); +} + +function tokenizeJsFamily(text) { + const out = []; + const re = + /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b(?:abstract|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|false|finally|for|from|function|if|import|in|instanceof|let|new|null|of|return|static|super|switch|this|throw|true|try|typeof|undefined|var|void|while|with|yield)\b)|(\b(?:NaN|Infinity)\b|\b0[xX][\da-fA-F]+\b|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|([{}()[\];,.?:]|=>|[+\-*/%&|^~!=<>]=?=?)|(\/\/|\/\*|\*\/)|(\s+)|([^\s]+)/g; + let m; + while ((m = re.exec(text))) { + if (m[1]) pushText(out, m[1], 'comment'); + else if (m[2]) pushText(out, m[2], 'string'); + else if (m[3]) pushText(out, m[3], 'keyword'); + else if (m[4]) pushText(out, m[4], 'number'); + else if (m[5]) pushText(out, m[5], 'punct'); + else if (m[7]) pushText(out, m[7], 'text'); + else if (m[8]) pushText(out, m[8], 'text'); + } + return out; +} + +function tokenizeJsonLike(text) { + const out = []; + const re = + /("(?:\\.|[^"\\])*")|(\btrue\b|\bfalse\b|\bnull\b)|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|([{}[\]:,])|(\s+)|([^\s]+)/g; + let m; + while ((m = re.exec(text))) { + if (m[1]) pushText(out, m[1], 'string'); + else if (m[2]) pushText(out, m[2], 'keyword'); + else if (m[3]) pushText(out, m[3], 'number'); + else if (m[4]) pushText(out, m[4], 'punct'); + else if (m[5]) pushText(out, m[5], 'text'); + else if (m[6]) pushText(out, m[6], 'text'); + } + return out; +} + +function tokenizeCss(text) { + const out = []; + const re = + /(\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(@[a-zA-Z-]+)|([#.]?[a-zA-Z_-][\w-]*)|([{}:;,()\[\]])|(\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms)?)|(\s+)|([^\s]+)/g; + let m; + while ((m = re.exec(text))) { + if (m[1]) pushText(out, m[1], 'comment'); + else if (m[2]) pushText(out, m[2], 'string'); + else if (m[3]) pushText(out, m[3], 'keyword'); + else if (m[4]) pushText(out, m[4], 'text'); + else if (m[5]) pushText(out, m[5], 'punct'); + else if (m[6]) pushText(out, m[6], 'number'); + else if (m[7]) pushText(out, m[7], 'text'); + else if (m[8]) pushText(out, m[8], 'text'); + } + return out; +} + +function tokenizeMarkup(text) { + const out = []; + const re = + /()|(<\/?[a-zA-Z][^>]*>)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|([^<]+)/g; + let m; + while ((m = re.exec(text))) { + if (m[1]) pushText(out, m[1], 'comment'); + else if (m[2]) pushText(out, m[2], 'keyword'); + else if (m[3]) pushText(out, m[3], 'string'); + else if (m[4]) pushText(out, m[4], 'text'); + } + return out; +} + +/** + * Map tokens (possibly multi-line) onto per-line token lists. + * This replaces highlightjs-line-numbers' fragile `duplicateMultilineNodes` + * HTML rewrite — we never split markup mid-tag. + * + * @param {Array<{type:string,text:string}>} tokens + * @returns {Array>} + */ +export function tokensToLineNodes(tokens) { + /** @type {Array>} */ + const lines = [[]]; + for (const tok of tokens) { + const parts = String(tok.text).split(BREAK); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + if (parts[i].length) lines[lines.length - 1].push({ type: tok.type, text: parts[i] }); + } + } + return lines; +} + +/** + * Build highlighted line contents from source. + * Guarantees line count === splitCodeLines(code).length + * @param {string} code + * @param {string} [language] + * @returns {Array>} + */ +export function codeToHighlightedLines(code, language) { + const lines = splitCodeLines(code); + const tokens = tokenizeCode(code, language); + const mapped = tokensToLineNodes(tokens); + // Pad / trim to exact source line count (tokenizer must not drop trailing empty). + while (mapped.length < lines.length) mapped.push([]); + if (mapped.length > lines.length) mapped.length = lines.length; + // Ensure empty lines stay empty arrays (render as blank). + return mapped.map((parts, i) => (parts.length ? parts : lines[i] ? [{ type: 'text', text: lines[i] }] : [])); +} + +/** + * Reconstruct plain text from a DOM selection inside a CodeBlock. + * Avoids table-row copy bugs (extra blank lines in Firefox/Edge). + * + * @param {ParentNode} root + * @param {string[]} sourceLines + * @returns {string|null} + */ +export function copyTextFromCodeSelection(root, sourceLines) { + const sel = typeof window !== 'undefined' ? window.getSelection() : null; + if (!sel || sel.isCollapsed || !sel.rangeCount) return null; + const anchor = sel.anchorNode; + if (!anchor || !root.contains(anchor)) return null; + + const lineEls = [...root.querySelectorAll('.gd-code__line')]; + if (!lineEls.length) return sel.toString(); + + const selected = lineEls.filter((el) => { + try { + return sel.containsNode(el, true); + } catch { + return false; + } + }); + if (!selected.length) return sel.toString(); + + const idxs = selected + .map((el) => Number(el.getAttribute('data-line-number'))) + .filter((n) => Number.isFinite(n)) + .sort((a, b) => a - b); + if (!idxs.length) return sel.toString(); + + const first = idxs[0]; + const last = idxs[idxs.length - 1]; + // Map data-line-number back to 0-based source index via startFrom on root. + const startFrom = Number(root.getAttribute('data-start-from') || '1') || 1; + const slice = sourceLines.slice(first - startFrom, last - startFrom + 1); + + if (idxs.length === 1) { + // Single-line: prefer actual selection string (partial line copy). + return sel.toString().replace(/\r\n|\r|\n/g, ''); + } + return slice.join('\n'); +} + +function renderLineContent(parts) { + if (!parts.length) { + // Keep height for empty lines without using a NBSP hack that copies as space. + return el('span', { className: 'gd-code__empty', attrs: { 'aria-hidden': 'true' } }); + } + return parts.map((p) => + el('span', { + className: cx('gd-code__tok', p.type && p.type !== 'text' && `gd-code__tok--${p.type}`), + text: p.text, + }) + ); +} + +/** + * CodeBlock — native code display with line numbers (no highlight.js dependency). + * + * Design fixes vs highlightjs-line-numbers.js: + * - No `` wrapper (copy/paste no longer invents blank lines) + * - Tokenize → map to lines (no innerHTML multiline-span duplication) + * - Trailing newline kept as a real empty line + * - Single-line blocks always numbered when `lineNumbers` is on + * - Copy uses source lines / selection helper, not table textContent + * + * @param {object} [props] + * @param {string} [props.code] + * @param {string} [props.language] + * @param {boolean} [props.lineNumbers] + * @param {number} [props.startFrom] + * @param {number[]|Set} [props.highlightLines] 1-based including startFrom offset + * @param {boolean} [props.showCopy] + * @param {string} [props.className] + */ +export function CodeBlock(props = {}) { + if (props.mode === 'diff' || props.diff != null) { + return DiffBlock({ + ...props, + diff: props.diff ?? props.code, + }); + } + + const { + code = '', + language = 'javascript', + lineNumbers = true, + startFrom = 1, + highlightLines, + showCopy = true, + className, + } = props; + + const source = String(code ?? ''); + const lines = splitCodeLines(source); + const highlighted = codeToHighlightedLines(source, language); + const hi = highlightLines instanceof Set ? highlightLines : new Set(highlightLines || []); + const start = Number.isFinite(startFrom) ? startFrom : 1; + + const lineNodes = lines.map((_, i) => { + const n = start + i; + const parts = highlighted[i] || []; + return el( + 'div', + { + className: cx( + 'gd-code__line', + hi.has(n) && 'gd-code__line--hl' + ), + attrs: { 'data-line-number': String(n) }, + }, + lineNumbers + ? el('span', { + className: 'gd-code__n', + attrs: { 'data-line-number': String(n), 'aria-hidden': 'true' }, + }) + : null, + el( + 'code', + { className: 'gd-code__c' }, + ...[].concat(renderLineContent(parts)) + ) + ); + }); + + const root = el( + 'div', + { + ...omit(props, [ + 'code', + 'diff', + 'mode', + 'language', + 'lineNumbers', + 'startFrom', + 'highlightLines', + 'showCopy', + 'className', + ]), + className: cx( + 'gd-code', + lineNumbers && 'gd-code--numbered', + `gd-code--${String(language || 'plaintext').toLowerCase()}`, + className + ), + attrs: { + 'data-start-from': String(start), + 'data-language': String(language || 'plaintext'), + }, + }, + showCopy + ? el('button', { + type: 'button', + className: 'gd-code__copy gd-btn gd-btn--default gd-btn--sm', + text: 'Copy', + onClick: async (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(source); + } else { + const ta = document.createElement('textarea'); + ta.value = source; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + btn.textContent = 'Copied'; + setTimeout(() => { + btn.textContent = 'Copy'; + }, 1200); + } catch { + btn.textContent = 'Failed'; + setTimeout(() => { + btn.textContent = 'Copy'; + }, 1200); + } + }, + }) + : null, + el( + 'pre', + { + className: 'gd-code__pre', + onCopy: (e) => { + const rootEl = e.currentTarget?.closest?.('.gd-code'); + if (!rootEl) return; + const text = copyTextFromCodeSelection(rootEl, lines); + if (text == null) return; + e.clipboardData?.setData('text/plain', text); + e.preventDefault(); + }, + }, + ...lineNodes + ) + ); + + return root; +} + +/** Alias */ +export const Code = CodeBlock; + +/** + * @typedef {'hunk'|'meta'|'add'|'del'|'ctx'} DiffLineType + * @typedef {{ + * type: DiffLineType, + * text: string, + * oldLine: number|null, + * newLine: number|null, + * marker: string, + * raw: string, + * }} DiffLine + */ + +const HUNK_RE = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s@@/; + +/** + * Parse unified diff (or hunk-less +/-/space lines) into typed rows. + * Dual line counters follow GitHub-style unified hunks. + * + * @param {string} text + * @returns {DiffLine[]} + */ +export function parseDiffLines(text) { + const source = String(text ?? ''); + if (!source.length) return []; + + const rawLines = source.split(BREAK); + // Trailing newline from split → empty segment; drop for diff rows. + if (rawLines.length && rawLines[rawLines.length - 1] === '') rawLines.pop(); + + /** @type {DiffLine[]} */ + const out = []; + let oldLine = 0; + let newLine = 0; + + for (const raw of rawLines) { + const hunk = HUNK_RE.exec(raw); + if (hunk) { + oldLine = Number(hunk[1]) || 0; + newLine = Number(hunk[2]) || 0; + out.push({ + type: 'hunk', + text: raw, + oldLine: null, + newLine: null, + marker: '', + raw, + }); + continue; + } + + if ( + raw.startsWith('diff ') || + raw.startsWith('index ') || + raw.startsWith('---') || + raw.startsWith('+++') || + raw.startsWith('Binary ') || + raw.startsWith('\\') + ) { + out.push({ + type: 'meta', + text: raw, + oldLine: null, + newLine: null, + marker: '', + raw, + }); + continue; + } + + const ch = raw.charAt(0); + if (ch === '+') { + const n = newLine || 1; + if (newLine) newLine += 1; + else newLine = 2; + out.push({ + type: 'add', + text: raw.slice(1), + oldLine: null, + newLine: n, + marker: '+', + raw, + }); + continue; + } + if (ch === '-') { + const n = oldLine || 1; + if (oldLine) oldLine += 1; + else oldLine = 2; + out.push({ + type: 'del', + text: raw.slice(1), + oldLine: n, + newLine: null, + marker: '-', + raw, + }); + continue; + } + + // Context: leading space (unified) or bare text (simplified). + const body = ch === ' ' ? raw.slice(1) : raw; + const o = oldLine || 1; + const n = newLine || 1; + if (oldLine) oldLine += 1; + else oldLine = 2; + if (newLine) newLine += 1; + else newLine = 2; + out.push({ + type: 'ctx', + text: body, + oldLine: o, + newLine: n, + marker: ' ', + raw, + }); + } + + return out; +} + +/** Alias */ +export const parseUnifiedDiff = parseDiffLines; + +function gutterSpan(kind, n) { + const attrs = { 'aria-hidden': 'true' }; + if (n != null) attrs['data-line-number'] = String(n); + return el('span', { + className: cx('gd-code__n', `gd-code__n--${kind}`, n == null && 'gd-code__n--empty'), + attrs, + }); +} + +/** + * DiffBlock — unified diff viewer (GitHub-style dual gutters). + * Reuses CodeBlock tokens / chrome; no highlight.js. + * + * @param {object} [props] + * @param {string} [props.diff] unified / simplified diff text + * @param {string} [props.code] alias of diff + * @param {DiffLine[]} [props.lines] pre-parsed rows (skips parse) + * @param {string} [props.language] highlighter for content body + * @param {boolean} [props.showCopy] + * @param {string} [props.className] + */ +export function DiffBlock(props = {}) { + const { + diff, + code, + lines: preLines, + language = 'javascript', + showCopy = true, + className, + } = props; + + const source = String(diff ?? code ?? ''); + const rows = Array.isArray(preLines) ? preLines : parseDiffLines(source); + + const lineNodes = rows.map((row) => { + const isCode = row.type === 'add' || row.type === 'del' || row.type === 'ctx'; + const parts = isCode + ? codeToHighlightedLines(row.text, language)[0] || [] + : row.text + ? [{ type: 'text', text: row.text }] + : []; + + return el( + 'div', + { + className: cx('gd-code__line', `gd-code__line--${row.type}`), + attrs: { + 'data-diff-type': row.type, + ...(row.oldLine != null ? { 'data-old-line': String(row.oldLine) } : {}), + ...(row.newLine != null ? { 'data-new-line': String(row.newLine) } : {}), + }, + }, + gutterSpan('old', row.oldLine), + gutterSpan('new', row.newLine), + el('span', { + className: cx('gd-code__marker', !row.marker && 'gd-code__marker--empty'), + attrs: { + 'aria-hidden': 'true', + ...(row.marker ? { 'data-marker': row.marker } : {}), + }, + }), + el( + 'code', + { className: 'gd-code__c' }, + ...[].concat(renderLineContent(parts)) + ) + ); + }); + + const root = el( + 'div', + { + ...omit(props, ['diff', 'code', 'lines', 'language', 'showCopy', 'className', 'mode']), + className: cx( + 'gd-code', + 'gd-code--diff', + 'gd-code--numbered', + `gd-code--${String(language || 'plaintext').toLowerCase()}`, + className + ), + attrs: { + 'data-language': String(language || 'plaintext'), + 'data-mode': 'diff', + }, + }, + showCopy + ? el('button', { + type: 'button', + className: 'gd-code__copy gd-btn gd-btn--default gd-btn--sm', + text: 'Copy', + onClick: async (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + const payload = + source || + rows.map((r) => r.raw).join('\n'); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(payload); + } else { + const ta = document.createElement('textarea'); + ta.value = payload; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + btn.textContent = 'Copied'; + setTimeout(() => { + btn.textContent = 'Copy'; + }, 1200); + } catch { + btn.textContent = 'Failed'; + setTimeout(() => { + btn.textContent = 'Copy'; + }, 1200); + } + }, + }) + : null, + el('pre', { className: 'gd-code__pre' }, ...lineNodes) + ); + + return root; +} + +/** Alias */ +export const Diff = DiffBlock; diff --git a/src/pyouter/webui/gd5/ui/config.js b/src/pyouter/webui/gd5/ui/config.js new file mode 100644 index 0000000..e4cbb36 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/config.js @@ -0,0 +1,41 @@ +import { JELLY_ATTR } from './jelly.js'; +import { cx, el, omit } from './util.js'; +import { THEME_ATTR, resolveThemeInput } from './theme.js'; + +/** + * ConfigProvider — named theme and/or token overrides on a subtree. + * + * ConfigProvider({ theme: 'blue' }, …children) + * ConfigProvider({ theme: { name: 'green' } }, …) + * ConfigProvider({ theme: { token: { primary: '#e86a17' } } }, …) + * ConfigProvider({ jelly: true }, …) // subtree data-gd-jelly="on" + */ +export function ConfigProvider(props = {}, ...children) { + const { theme, jelly, className, style } = props; + const { name, token } = resolveThemeInput(theme); + const cssVars = { ...(typeof style === 'object' ? style : {}) }; + + if (token && typeof token === 'object') { + for (const [k, v] of Object.entries(token)) { + if (v == null) continue; + const varName = k.startsWith('--') ? k : k.startsWith('gd-') ? `--${k}` : `--gd-${k}`; + cssVars[varName] = v; + } + } + + const attrs = {}; + if (name) attrs[THEME_ATTR] = name; + if (jelly === true) attrs[JELLY_ATTR] = 'on'; + else if (jelly === false) attrs[JELLY_ATTR] = 'off'; + + return el( + 'div', + { + ...omit(props, ['theme', 'jelly', 'className', 'style']), + className: cx('gd-config-provider', className), + style: cssVars, + attrs, + }, + ...children + ); +} diff --git a/src/pyouter/webui/gd5/ui/control.js b/src/pyouter/webui/gd5/ui/control.js new file mode 100644 index 0000000..486585b --- /dev/null +++ b/src/pyouter/webui/gd5/ui/control.js @@ -0,0 +1,147 @@ +import { cx, el, omit } from './util.js'; + +/** + * Checkbox — antd-like labeled control. + * + * @param {object} [props] + * @param {string} [props.label] + * @param {boolean} [props.checked] + * @param {boolean} [props.disabled] + * @param {function} [props.onChange] + */ +export function Checkbox(props = {}) { + const { label, className, checked, disabled, onChange, id } = props; + const input = el('input', { + ...omit(props, ['label', 'className', 'text']), + type: 'checkbox', + className: 'gd-checkbox__input', + checked: !!checked, + disabled: !!disabled, + onChange, + id, + }); + + return el( + 'label', + { + className: cx('gd-checkbox', disabled && 'gd-checkbox--disabled', className), + }, + input, + el('span', { className: 'gd-checkbox__box', attrs: { 'aria-hidden': 'true' } }), + label != null ? el('span', { className: 'gd-checkbox__label', text: label }) : null + ); +} + +/** + * Switch — antd-like toggle. + * + * @param {object} [props] + * @param {boolean} [props.checked] + * @param {boolean} [props.disabled] + * @param {string} [props.checkedChildren] + * @param {string} [props.unCheckedChildren] + * @param {function} [props.onChange] + */ +export function Switch(props = {}) { + const { + checked = false, + disabled = false, + className, + checkedChildren, + unCheckedChildren, + onChange, + id, + } = props; + + return el( + 'button', + { + ...omit(props, [ + 'checked', + 'className', + 'checkedChildren', + 'unCheckedChildren', + 'onChange', + ]), + type: 'button', + id, + role: 'switch', + disabled: !!disabled, + className: cx( + 'gd-switch', + checked && 'gd-switch--checked', + disabled && 'gd-switch--disabled', + className + ), + attrs: { ...(props.attrs || {}), 'aria-checked': checked ? 'true' : 'false' }, + onClick: (event) => { + if (disabled) return; + onChange?.(!checked, event); + }, + }, + el('span', { className: 'gd-switch__knob' }), + checked && checkedChildren != null + ? el('span', { className: 'gd-switch__text gd-switch__text--on', text: checkedChildren }) + : null, + !checked && unCheckedChildren != null + ? el('span', { className: 'gd-switch__text gd-switch__text--off', text: unCheckedChildren }) + : null + ); +} + +/** + * Badge — count / status / dot overlay. + * + * @param {object} [props] + * @param {number|string} [props.count] + * @param {boolean} [props.dot] + * @param {'success'|'processing'|'default'|'error'|'warning'} [props.status] + * @param {string} [props.text] + * @param {number} [props.overflowCount] + */ +export function Badge(props = {}, ...children) { + const { + count, + dot = false, + status, + text, + overflowCount = 99, + className, + } = props; + + const showCount = count != null && !dot && !status; + const display = + typeof count === 'number' && count > overflowCount ? `${overflowCount}+` : count; + + const indicator = status + ? el('span', { + className: cx('gd-badge__status', `gd-badge__status--${status}`), + }) + : dot + ? el('span', { className: 'gd-badge__dot' }) + : showCount + ? el('span', { className: 'gd-badge__count', text: String(display) }) + : null; + + if (!children.length && (status || text != null)) { + return el( + 'span', + { + ...omit(props, ['count', 'dot', 'status', 'text', 'overflowCount', 'className']), + className: cx('gd-badge', 'gd-badge--standalone', className), + }, + indicator, + text != null ? el('span', { className: 'gd-badge__text', text }) : null + ); + } + + return el( + 'span', + { + ...omit(props, ['count', 'dot', 'status', 'text', 'overflowCount', 'className']), + className: cx('gd-badge', className), + }, + ...children, + indicator + ); +} diff --git a/src/pyouter/webui/gd5/ui/css/antd-gap.css b/src/pyouter/webui/gd5/ui/css/antd-gap.css new file mode 100644 index 0000000..b292982 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/antd-gap.css @@ -0,0 +1,528 @@ +/* InputNumber */ +.gd-input-number { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); + overflow: hidden; + height: var(--gd-control-h); +} +.gd-input-number__btn { + width: 32px; + border: 0; + background: var(--gd-bg-spotlight); + cursor: pointer; + color: var(--gd-text); + font-size: 14px; +} +.gd-input-number__btn:disabled { opacity: 0.4; cursor: not-allowed; } +.gd-input-number__input { + width: 72px; + border: 0; + text-align: center; + font: inherit; + outline: none; + background: transparent; + color: var(--gd-text); +} +.gd-input-number__input::-webkit-outer-spin-button, +.gd-input-number__input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + +/* Rate */ +.gd-rate { display: inline-flex; gap: 4px; } +.gd-rate__star { + border: 0; + background: transparent; + cursor: pointer; + font-size: 20px; + color: var(--gd-border-strong); + padding: 0; + line-height: 1; +} +.gd-rate__star--active { color: var(--gd-warning); } +.gd-rate--disabled { opacity: 0.55; pointer-events: none; } + +/* Cascader / TreeSelect */ +.gd-cascader, +.gd-tree-select { position: relative; display: inline-block; min-width: 200px; } +.gd-cascader__trigger, +.gd-tree-select__trigger { + width: 100%; + height: var(--gd-control-h); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); + text-align: left; + padding: 0 10px; + cursor: pointer; + font: inherit; + color: var(--gd-text); +} +.gd-cascader__menus { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 30; + display: flex; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); +} +.gd-cascader__menu { + min-width: 140px; + max-height: 220px; + overflow: auto; + border-right: 1px solid var(--gd-border); + padding: 4px; +} +.gd-cascader__menu:last-child { border-right: 0; } +.gd-cascader__option, +.gd-tree-select__option { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + font: inherit; + color: var(--gd-text); +} +.gd-cascader__option:hover, +.gd-tree-select__option:hover { background: var(--gd-bg-spotlight); } +.gd-cascader__option--active, +.gd-tree-select__option--active { + background: var(--gd-primary-soft); + color: var(--gd-primary); + font-weight: 600; +} +.gd-tree-select__dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 30; + max-height: 240px; + overflow: auto; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); + padding: 4px; +} + +/* Upload / Mentions */ +.gd-upload__input { position: absolute; width: 0; height: 0; opacity: 0; } +.gd-upload__trigger { display: inline-block; cursor: pointer; } +.gd-upload__list { list-style: none; margin: 8px 0 0; padding: 0; } +.gd-upload__item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + border-radius: 4px; + font-size: 13px; +} +.gd-upload__item:hover { background: var(--gd-bg-spotlight); } +.gd-upload__remove { + border: 0; + background: transparent; + cursor: pointer; + color: var(--gd-text-muted); +} +.gd-mentions { position: relative; } +.gd-mentions__list { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 4px); + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); + max-height: 160px; + overflow: auto; + z-index: 20; +} +.gd-mentions__option { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 8px 10px; + cursor: pointer; + font: inherit; +} +.gd-mentions__option:hover { background: var(--gd-primary-soft); color: var(--gd-primary); } + +/* Form */ +.gd-form { display: flex; flex-direction: column; gap: 16px; } +.gd-form--horizontal .gd-form-item { + display: grid; + grid-template-columns: 120px 1fr; + gap: 8px 12px; + align-items: start; +} +.gd-form-item__label { + display: block; + font-size: 13px; + color: var(--gd-text-secondary); + margin-bottom: 6px; +} +.gd-form-item__label--required::before { + content: '*'; + color: var(--gd-danger); + margin-right: 4px; +} +.gd-form-item__error { font-size: 12px; color: var(--gd-danger); margin-top: 4px; } +.gd-form-item__hint { font-size: 12px; color: var(--gd-text-muted); margin-top: 4px; } +.gd-form-item--error .gd-input, +.gd-form-item--error .gd-textarea, +.gd-form-item--error .gd-select { border-color: var(--gd-danger); } + +/* List / Descriptions / Statistic */ +.gd-list--bordered { + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + background: var(--gd-bg-elevated); + overflow: hidden; +} +.gd-list__header, +.gd-list__footer { + padding: 12px 16px; + border-bottom: 1px solid var(--gd-border); + font-weight: 600; +} +.gd-list__footer { border-bottom: 0; border-top: 1px solid var(--gd-border); } +.gd-list__items { list-style: none; margin: 0; padding: 0; } +.gd-list__item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; +} +.gd-list--split .gd-list__item + .gd-list__item { border-top: 1px solid var(--gd-border); } +.gd-list__item-main { flex: 1; min-width: 0; } +.gd-list__actions { display: flex; gap: 8px; } +.gd-list--small .gd-list__item { padding: 8px 12px; } +.gd-descriptions__title { font-weight: 600; margin-bottom: 12px; font-size: 16px; } +.gd-descriptions__view { display: grid; gap: 0; } +.gd-descriptions--bordered .gd-descriptions__item { + display: grid; + grid-template-columns: 120px 1fr; + border: 1px solid var(--gd-border); + margin: -1px 0 0 -1px; + background: var(--gd-bg-elevated); +} +.gd-descriptions__label { + padding: 10px 12px; + background: var(--gd-bg-spotlight); + color: var(--gd-text-secondary); + font-size: 13px; +} +.gd-descriptions__content { padding: 10px 12px; font-size: 14px; color: var(--gd-text); } +.gd-statistic__title { font-size: 13px; color: var(--gd-text-secondary); margin-bottom: 4px; } +.gd-statistic__content { display: flex; align-items: baseline; gap: 4px; } +.gd-statistic__value { + font-size: 28px; + font-weight: 700; + color: var(--gd-text); + line-height: 1.2; +} +.gd-statistic__suffix, +.gd-statistic__prefix { color: var(--gd-text-secondary); font-size: 14px; } + +/* Calendar */ +.gd-calendar { + width: 280px; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + padding: 12px; +} +.gd-calendar__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.gd-calendar__title { font-weight: 600; font-size: 14px; } +.gd-calendar__nav { + border: 0; + background: transparent; + cursor: pointer; + font-size: 18px; + width: 28px; + height: 28px; + border-radius: 4px; +} +.gd-calendar__nav:hover { background: var(--gd-bg-spotlight); } +.gd-calendar__weekdays, +.gd-calendar__grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} +.gd-calendar__weekday { + text-align: center; + font-size: 11px; + color: var(--gd-text-muted); + padding: 4px 0; +} +.gd-calendar__cell { + height: 32px; + border: 0; + background: transparent; + border-radius: 4px; + cursor: pointer; + font: inherit; + font-size: 13px; +} +.gd-calendar__cell:hover { background: var(--gd-bg-spotlight); } +.gd-calendar__cell--today { color: var(--gd-primary); font-weight: 700; } +.gd-calendar__cell--selected { + background: var(--gd-primary); + color: var(--gd-text-inverse); + font-weight: 600; +} +.gd-calendar__cell--empty { pointer-events: none; } + +/* Image */ +.gd-image { + position: relative; + display: inline-block; + overflow: hidden; + border-radius: var(--gd-radius-sm); + line-height: 0; +} +.gd-image__img { display: block; max-width: 100%; height: auto; } +.gd-image--previewable { cursor: pointer; } +.gd-image__mask { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.45); + color: #fff; + font-size: 13px; + opacity: 0; + transition: opacity 160ms var(--gd-ease); + line-height: 1.4; +} +.gd-image--previewable:hover .gd-image__mask { opacity: 1; } + +/* Result / Popconfirm */ +.gd-result { + text-align: center; + padding: 32px 16px; +} +.gd-result__icon { + width: 64px; + height: 64px; + margin: 0 auto 16px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 28px; + font-weight: 700; + background: var(--gd-bg-spotlight); + color: var(--gd-text-secondary); +} +.gd-result--success .gd-result__icon { background: rgba(47, 143, 91, 0.15); color: var(--gd-success); } +.gd-result--error .gd-result__icon { background: var(--gd-danger-soft); color: var(--gd-danger); } +.gd-result--warning .gd-result__icon { background: rgba(212, 137, 10, 0.15); color: var(--gd-warning); } +.gd-result--info .gd-result__icon { background: rgba(47, 111, 237, 0.12); color: var(--gd-info); } +.gd-result__title { font-size: 20px; font-weight: 700; color: var(--gd-text); margin-bottom: 8px; } +.gd-result__subtitle { color: var(--gd-text-secondary); font-size: 14px; margin-bottom: 16px; } +.gd-result__extra { margin-top: 16px; display: flex; gap: 8px; justify-content: center; } +.gd-popconfirm { position: relative; display: inline-flex; } +.gd-popconfirm__panel { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + min-width: 200px; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); + padding: 12px; + z-index: 40; +} +.gd-popconfirm__title { font-size: 14px; margin-bottom: 10px; color: var(--gd-text); } +.gd-popconfirm__actions { display: flex; justify-content: flex-end; gap: 8px; } + +/* Notification */ +.gd-notification-root { + position: fixed; + top: 16px; + right: 16px; + z-index: 1100; + display: flex; + flex-direction: column; + gap: 10px; + max-width: min(380px, calc(100vw - 32px)); +} +.gd-notification { + position: relative; + padding: 14px 36px 14px 16px; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); +} +.gd-notification--success { border-left: 3px solid var(--gd-success); } +.gd-notification--error { border-left: 3px solid var(--gd-danger); } +.gd-notification--warning { border-left: 3px solid var(--gd-warning); } +.gd-notification--info { border-left: 3px solid var(--gd-info); } +.gd-notification__message { font-weight: 600; color: var(--gd-text); } +.gd-notification__desc { margin-top: 4px; font-size: 13px; color: var(--gd-text-secondary); } +.gd-notification__close { + position: absolute; + top: 8px; + right: 10px; + border: 0; + background: transparent; + cursor: pointer; + color: var(--gd-text-muted); + font-size: 16px; +} + +/* Watermark */ +.gd-watermark { position: relative; } +.gd-watermark__layer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; +} +.gd-watermark__content { position: relative; z-index: 0; } + +/* Flex / Grid */ +.gd-flex { display: flex; flex-direction: row; } +.gd-flex--vertical { flex-direction: column; } +.gd-flex--wrap { flex-wrap: wrap; } +.gd-row { display: flex; flex-wrap: wrap; } +.gd-col { box-sizing: border-box; min-width: 0; } + +/* Layout */ +.gd-layout { display: flex; flex-direction: column; min-height: 100%; background: var(--gd-bg); } +.gd-layout--has-sider { flex-direction: row; align-items: stretch; } +.gd-layout__header { + height: 56px; + display: flex; + align-items: center; + padding: 0 16px; + background: var(--gd-bg-elevated); + border-bottom: 1px solid var(--gd-border); + flex-shrink: 0; +} +.gd-layout__sider { + background: var(--gd-bg-elevated); + border-right: 1px solid var(--gd-border); + padding: 12px; + transition: width 160ms var(--gd-ease); +} +.gd-layout__content { flex: 1; padding: 16px; min-width: 0; } +.gd-layout__footer { + padding: 12px 16px; + text-align: center; + color: var(--gd-text-muted); + font-size: 13px; + border-top: 1px solid var(--gd-border); + background: var(--gd-bg-elevated); +} + +/* Menu */ +.gd-menu { display: flex; flex-direction: column; gap: 2px; } +.gd-menu--horizontal { flex-direction: row; align-items: center; gap: 4px; } +.gd-menu__item { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 8px 12px; + border-radius: var(--gd-radius-sm); + cursor: pointer; + font: inherit; + color: var(--gd-text); +} +.gd-menu--horizontal .gd-menu__item { width: auto; } +.gd-menu__item:hover { background: var(--gd-bg-spotlight); } +.gd-menu__item--selected { + background: var(--gd-primary-soft); + color: var(--gd-primary); + font-weight: 600; +} +.gd-menu__item--danger { color: var(--gd-danger); } +.gd-menu__divider { height: 1px; background: var(--gd-border); margin: 4px 0; } +.gd-menu__group-title { + padding: 8px 12px 4px; + font-size: 12px; + color: var(--gd-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.gd-menu__sub { padding-left: 8px; } +.gd-menu--horizontal .gd-menu__submenu { position: relative; } +.gd-menu--horizontal .gd-menu__sub--popup { + position: absolute; + top: 100%; + left: 0; + min-width: 160px; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); + padding: 4px; + z-index: 30; + display: none; +} +.gd-menu--horizontal .gd-menu__submenu:hover .gd-menu__sub--popup { display: block; } + +/* Affix / Anchor */ +.gd-affix { position: sticky; z-index: 10; } +.gd-anchor { + position: sticky; + padding-left: 12px; + border-left: 2px solid var(--gd-border); +} +.gd-anchor__link { + display: block; + padding: 4px 0 4px 10px; + color: var(--gd-text-secondary); + text-decoration: none; + font-size: 13px; + margin-left: -2px; + border-left: 2px solid transparent; +} +.gd-anchor__link--active { + color: var(--gd-primary); + border-left-color: var(--gd-primary); + font-weight: 600; +} + +/* Icon */ +.gd-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + vertical-align: middle; +} +.gd-icon--sm { font-size: 12px; } +.gd-icon--md { font-size: 16px; } +.gd-icon--lg { font-size: 22px; } +.gd-icon--spin { animation: gd-spin 0.8s linear infinite; } + +/* ConfigProvider — inherits data-gd-theme + CSS vars to descendants */ +.gd-config-provider { + display: block; + color: var(--gd-text); + background: transparent; +} diff --git a/src/pyouter/webui/gd5/ui/css/button.css b/src/pyouter/webui/gd5/ui/css/button.css new file mode 100644 index 0000000..aaf9695 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/button.css @@ -0,0 +1,251 @@ +.gd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + box-sizing: border-box; + margin: 0; + border: 1px solid transparent; + border-radius: var(--gd-radius-sm); + font-family: var(--gd-font); + font-size: 14px; + font-weight: 500; + line-height: 1; + cursor: pointer; + user-select: none; + transition: background 160ms var(--gd-ease), border-color 160ms var(--gd-ease), + color 160ms var(--gd-ease), transform 120ms var(--gd-ease), + --gd-jelly-fill 160ms var(--gd-ease); +} + +.gd-btn:active:not(:disabled) { + transform: translateY(1px); +} + +.gd-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.gd-btn--sm { + height: var(--gd-control-h-sm); + padding: 0 10px; + font-size: 12px; +} + +.gd-btn--md { + height: var(--gd-control-h); + padding: 0 14px; +} + +.gd-btn--lg { + height: var(--gd-control-h-lg); + padding: 0 18px; + font-size: 15px; +} + +.gd-btn--block { + display: flex; + width: 100%; +} + +.gd-btn--default { + --gd-jelly-fill: var(--gd-bg-elevated); + --gd-jelly-edge: var(--gd-border-strong); + background: var(--gd-jelly-fill); + border-color: var(--gd-border-strong); + color: var(--gd-text); +} + +.gd-btn--default:hover:not(:disabled) { + --gd-jelly-fill: var(--gd-bg-spotlight); + background: var(--gd-jelly-fill); +} + +.gd-btn--primary { + --gd-jelly-fill: var(--gd-primary); + --gd-jelly-edge: transparent; + background: var(--gd-jelly-fill); + color: var(--gd-text-inverse); + font-weight: 600; +} + +.gd-btn--primary:hover:not(:disabled) { + --gd-jelly-fill: var(--gd-primary-hover); + background: var(--gd-jelly-fill); +} + +.gd-btn--primary:active:not(:disabled) { + --gd-jelly-fill: var(--gd-primary-active); + background: var(--gd-jelly-fill); +} + +.gd-btn--ghost { + --gd-jelly-fill: transparent; + --gd-jelly-edge: var(--gd-border); + background: transparent; + border-color: var(--gd-border); + color: var(--gd-text-secondary); +} + +.gd-btn--ghost:hover:not(:disabled) { + --gd-jelly-edge: var(--gd-border-strong); + border-color: var(--gd-border-strong); + color: var(--gd-text); +} + +.gd-btn--danger { + --gd-jelly-fill: transparent; + --gd-jelly-edge: var(--gd-danger); + background: transparent; + border-color: var(--gd-danger); + color: var(--gd-danger); +} + +.gd-btn--danger:hover:not(:disabled) { + --gd-jelly-fill: var(--gd-danger-soft); + background: var(--gd-jelly-fill); +} + +.gd-btn--text { + background: transparent; + border-color: transparent; + color: var(--gd-primary); + padding-left: 8px; + padding-right: 8px; +} + +.gd-btn--text:hover:not(:disabled) { + background: var(--gd-primary-soft); +} + +/* ── Loading Button ─────────────────────────────────────────── */ + +.gd-btn--loading { + position: relative; + cursor: wait; + gap: 8px; +} + +.gd-btn--loading:disabled { + opacity: 1; + cursor: wait; +} + +.gd-btn--loading.gd-btn--default { + --gd-jelly-fill: var(--gd-primary-soft); + --gd-jelly-edge: transparent; + background: var(--gd-jelly-fill); + border-color: transparent; + color: var(--gd-primary); +} + +.gd-btn--loading.gd-btn--primary { + --gd-jelly-fill: var(--gd-primary-active); + background: var(--gd-jelly-fill); + filter: saturate(1.05); +} + +.gd-btn--loading.gd-btn--ghost, +.gd-btn--loading.gd-btn--text { + background: var(--gd-primary-soft); + border-color: transparent; + color: var(--gd-primary); +} + +.gd-btn--loading.gd-btn--danger { + background: var(--gd-danger-soft); + color: var(--gd-danger); +} + +.gd-btn__label { + line-height: 1; +} + +.gd-btn__indicator { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.gd-btn__indicator--dots { + gap: 3px; + height: 1em; +} + +.gd-btn__indicator--dots i { + display: block; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: 0.35; + animation: gd-btn-dots 900ms var(--gd-ease) infinite; +} + +.gd-btn__indicator--dots i:nth-child(1) { + animation-delay: 0ms; +} + +.gd-btn__indicator--dots i:nth-child(2) { + animation-delay: 150ms; +} + +.gd-btn__indicator--dots i:nth-child(3) { + animation-delay: 300ms; +} + +.gd-btn__indicator--spin { + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + opacity: 0.85; + animation: gd-btn-spin 640ms linear infinite; +} + +.gd-btn--sm .gd-btn__indicator--spin { + width: 10px; + height: 10px; +} + +.gd-btn--lg .gd-btn__indicator--spin { + width: 14px; + height: 14px; +} + +.gd-btn--sm .gd-btn__indicator--dots i { + width: 3px; + height: 3px; +} + +@keyframes gd-btn-dots { + 0%, + 80%, + 100% { + opacity: 0.3; + transform: translateY(0) scale(0.85); + } + 40% { + opacity: 1; + transform: translateY(-3px) scale(1.15); + } +} + +@keyframes gd-btn-spin { + to { + transform: rotate(360deg); + } +} + +.gd-loading-button { + display: inline-flex; + vertical-align: middle; +} + +.gd-loading-button:has(.gd-btn--block) { + display: flex; + width: 100%; +} diff --git a/src/pyouter/webui/gd5/ui/css/card.css b/src/pyouter/webui/gd5/ui/css/card.css new file mode 100644 index 0000000..f397c76 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/card.css @@ -0,0 +1,33 @@ +.gd-card { + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + overflow: hidden; +} + +.gd-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--gd-border); +} + +.gd-card__title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--gd-text); +} + +.gd-card__body { + padding: 16px; +} + +.gd-card__foot { + padding: 12px 16px; + border-top: 1px solid var(--gd-border); + background: var(--gd-bg-spotlight); +} diff --git a/src/pyouter/webui/gd5/ui/css/code-block.css b/src/pyouter/webui/gd5/ui/css/code-block.css new file mode 100644 index 0000000..42e04c1 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/code-block.css @@ -0,0 +1,207 @@ +/** + * CodeBlock — native line-numbered code display (no highlight.js). + * Theme-aware via --gd-* tokens. + */ + +:root, +[data-gd-theme='orange'], +[data-gd-theme='blue'], +[data-gd-theme='green'] { + --gd-code-bg: var(--gd-bg-elevated); + --gd-code-fg: var(--gd-text); + --gd-code-gutter: var(--gd-text-muted); + --gd-code-gutter-border: var(--gd-border); + --gd-code-hl: var(--gd-primary-soft); + --gd-code-comment: #6a737d; + --gd-code-keyword: #cf222e; + --gd-code-string: #0a3069; + --gd-code-number: #0550ae; + --gd-code-punct: var(--gd-text-secondary); + --gd-code-line-h: 1.55; + --gd-code-add-bg: #e6ffec; + --gd-code-add-fg: #1a7f37; + --gd-code-del-bg: #ffebe9; + --gd-code-del-fg: #cf222e; + --gd-code-hunk-bg: #ddf4ff; + --gd-code-hunk-fg: #0550ae; + --gd-code-meta-fg: var(--gd-text-muted); +} + +.gd-code { + position: relative; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + background: var(--gd-code-bg); + color: var(--gd-code-fg); + overflow: hidden; +} + +.gd-code__copy { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + opacity: 0; + transition: opacity 120ms var(--gd-ease); +} + +.gd-code:hover .gd-code__copy, +.gd-code:focus-within .gd-code__copy { + opacity: 1; +} + +.gd-code__pre { + margin: 0; + padding: 12px 0; + overflow: auto; + font-family: var(--gd-font-mono); + font-size: 12.5px; + line-height: var(--gd-code-line-h); + tab-size: 2; +} + +.gd-code__line { + display: grid; + grid-template-columns: 1fr; + min-height: calc(1em * var(--gd-code-line-h)); +} + +.gd-code--numbered .gd-code__line { + grid-template-columns: var(--gd-code-gutter-w, 3.25rem) minmax(0, 1fr); +} + +.gd-code__line--hl { + background: var(--gd-code-hl); +} + +.gd-code__n { + display: block; + padding: 0 12px 0 10px; + border-right: 1px solid var(--gd-code-gutter-border); + color: var(--gd-code-gutter); + text-align: right; + user-select: none; + -webkit-user-select: none; + white-space: nowrap; +} + +/* Numbers via attr — not in the DOM text, so they never enter the clipboard */ +.gd-code__n::before { + content: attr(data-line-number); +} + +.gd-code__c { + display: block; + padding: 0 16px 0 12px; + white-space: pre; + font: inherit; + color: inherit; + overflow-wrap: normal; +} + +.gd-code__empty { + display: inline-block; + width: 0; + height: 1em; +} + +.gd-code__tok--comment { + color: var(--gd-code-comment); + font-style: italic; +} + +.gd-code__tok--keyword { + color: var(--gd-code-keyword); + font-weight: 600; +} + +.gd-code__tok--string { + color: var(--gd-code-string); +} + +.gd-code__tok--number { + color: var(--gd-code-number); +} + +.gd-code__tok--punct { + color: var(--gd-code-punct); +} + +/* ── DiffBlock (unified, dual gutters) ─────────────────────────── */ + +.gd-code--diff .gd-code__line { + grid-template-columns: + var(--gd-code-gutter-w, 2.75rem) + var(--gd-code-gutter-w, 2.75rem) + 1.25rem + minmax(0, 1fr); +} + +.gd-code--diff .gd-code__n { + border-right: none; + padding: 0 8px; +} + +.gd-code--diff .gd-code__n--new { + border-right: 1px solid var(--gd-code-gutter-border); +} + +.gd-code--diff .gd-code__n--empty::before { + content: ''; +} + +.gd-code__marker { + display: block; + text-align: center; + user-select: none; + -webkit-user-select: none; + font-weight: 600; + color: var(--gd-code-gutter); +} + +.gd-code__marker::before { + content: attr(data-marker); +} + +.gd-code__marker--empty::before { + content: ''; +} + +.gd-code__line--add { + background: var(--gd-code-add-bg); +} + +.gd-code__line--add .gd-code__marker { + color: var(--gd-code-add-fg); +} + +.gd-code__line--del { + background: var(--gd-code-del-bg); +} + +.gd-code__line--del .gd-code__marker { + color: var(--gd-code-del-fg); +} + +.gd-code__line--hunk { + background: var(--gd-code-hunk-bg); + color: var(--gd-code-hunk-fg); +} + +.gd-code__line--hunk .gd-code__c { + color: var(--gd-code-hunk-fg); +} + +.gd-code__line--meta .gd-code__c { + color: var(--gd-code-meta-fg); +} + +.gd-code--diff .gd-code__line--hunk .gd-code__n, +.gd-code--diff .gd-code__line--meta .gd-code__n { + border-right-color: transparent; +} + +.gd-code--diff .gd-code__line--hunk .gd-code__n--new, +.gd-code--diff .gd-code__line--meta .gd-code__n--new { + border-right: 1px solid var(--gd-code-gutter-border); +} diff --git a/src/pyouter/webui/gd5/ui/css/control.css b/src/pyouter/webui/gd5/ui/css/control.css new file mode 100644 index 0000000..5beb671 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/control.css @@ -0,0 +1,210 @@ +/* Checkbox */ +.gd-checkbox { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + font-family: var(--gd-font); + font-size: 14px; + color: var(--gd-text); + line-height: 1.2; +} + +.gd-checkbox--disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.gd-checkbox__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + margin: 0; +} + +.gd-checkbox__box { + box-sizing: border-box; + width: 16px; + height: 16px; + border: 1px solid var(--gd-border-strong); + border-radius: 4px; + background: var(--gd-bg-elevated); + flex-shrink: 0; + transition: background 140ms var(--gd-ease), border-color 140ms var(--gd-ease), + box-shadow 140ms var(--gd-ease); +} + +.gd-checkbox__input:focus-visible + .gd-checkbox__box { + box-shadow: 0 0 0 3px var(--gd-primary-soft); +} + +.gd-checkbox__input:checked + .gd-checkbox__box { + background: var(--gd-primary); + border-color: var(--gd-primary); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fffaf5' stroke-width='2' d='M3.5 8.5l3 3 6-6'/%3E%3C/svg%3E"); + background-size: 12px 12px; + background-repeat: no-repeat; + background-position: center; +} + +.gd-checkbox__label { + color: var(--gd-text); +} + +/* Switch */ +.gd-switch { + position: relative; + display: inline-flex; + align-items: center; + box-sizing: border-box; + width: 44px; + height: 22px; + margin: 0; + padding: 0; + border: none; + border-radius: 999px; + background: var(--gd-border-strong); + cursor: pointer; + transition: background 160ms var(--gd-ease); + vertical-align: middle; +} + +.gd-switch--checked { + background: var(--gd-primary); +} + +.gd-switch--disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.gd-switch__knob { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 160ms var(--gd-ease); +} + +.gd-switch--checked .gd-switch__knob { + transform: translateX(22px); +} + +.gd-switch__text { + position: absolute; + top: 0; + bottom: 0; + display: flex; + align-items: center; + font-size: 10px; + font-weight: 600; + color: var(--gd-text-inverse); + pointer-events: none; +} + +.gd-switch__text--on { + left: 8px; +} + +.gd-switch__text--off { + right: 8px; + color: var(--gd-text-secondary); +} + +/* Badge */ +.gd-badge { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.gd-badge--standalone { + align-items: center; + gap: 8px; +} + +.gd-badge__count, +.gd-badge__dot { + position: absolute; + top: 0; + right: 0; + transform: translate(50%, -50%); + z-index: 1; +} + +.gd-badge__count { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--gd-primary); + color: var(--gd-text-inverse); + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + box-sizing: border-box; + box-shadow: 0 0 0 1px var(--gd-bg); +} + +.gd-badge__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--gd-danger); + box-shadow: 0 0 0 1px var(--gd-bg); +} + +.gd-badge__status { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--gd-text-muted); + flex-shrink: 0; +} + +.gd-badge__status--success { + background: var(--gd-success); +} + +.gd-badge__status--processing { + background: var(--gd-primary); + box-shadow: 0 0 0 0 var(--gd-primary-soft); + animation: gd-badge-pulse 1.2s ease-out infinite; +} + +.gd-badge__status--error { + background: var(--gd-danger); +} + +.gd-badge__status--warning { + background: var(--gd-warning); +} + +.gd-badge__status--default { + background: var(--gd-text-muted); +} + +.gd-badge__text { + font-size: 14px; + color: var(--gd-text); +} + +@keyframes gd-badge-pulse { + 0% { + box-shadow: 0 0 0 0 var(--gd-primary-soft); + } + 70% { + box-shadow: 0 0 0 6px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} diff --git a/src/pyouter/webui/gd5/ui/css/coverflow.css b/src/pyouter/webui/gd5/ui/css/coverflow.css new file mode 100644 index 0000000..7bcf281 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/coverflow.css @@ -0,0 +1,213 @@ +/** + * CoverFlow — centered front card, tilted stacked neighbors (Cover Flow). + */ + +:root, +[data-gd-theme='orange'], +[data-gd-theme='blue'], +[data-gd-theme='green'] { + --gd-coverflow-h: 320px; + --gd-coverflow-card-w: 168px; + --gd-coverflow-card-h: 220px; + --gd-coverflow-bg: transparent; + --gd-coverflow-card-bg: var(--gd-bg-elevated); + --gd-coverflow-card-border: var(--gd-border); + --gd-coverflow-shadow: var(--gd-shadow); + --gd-coverflow-art: var(--gd-primary-soft); + --gd-coverflow-art-fg: var(--gd-primary); +} + +.gd-coverflow { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + min-width: 0; + user-select: none; + -webkit-user-select: none; +} + +.gd-coverflow__stage { + position: relative; + height: var(--gd-coverflow-h); + perspective: 900px; + perspective-origin: 50% 45%; + transform-style: preserve-3d; + overflow: hidden; + border-radius: var(--gd-radius-lg); + background: + radial-gradient(ellipse 70% 55% at 50% 60%, var(--gd-primary-soft), transparent 70%), + var(--gd-coverflow-bg); + outline: none; + touch-action: pan-y; + cursor: grab; +} + +.gd-coverflow__stage:active, +.gd-coverflow--live .gd-coverflow__stage { + cursor: grabbing; +} + +.gd-coverflow--live .gd-coverflow__card { + transition: none; +} + +.gd-coverflow__stage:focus-visible { + box-shadow: 0 0 0 2px var(--gd-primary-soft); +} + +.gd-coverflow__card { + position: absolute; + left: 50%; + top: 50%; + width: var(--gd-coverflow-card-w); + height: var(--gd-coverflow-card-h); + margin: 0; + padding: 0; + border: 1px solid var(--gd-coverflow-card-border); + border-radius: var(--gd-radius); + background: var(--gd-coverflow-card-bg); + box-shadow: var(--gd-coverflow-shadow); + overflow: hidden; + display: flex; + flex-direction: column; + transform-style: preserve-3d; + transition: + transform 320ms var(--gd-ease), + opacity 280ms var(--gd-ease), + box-shadow 280ms var(--gd-ease), + border-color 200ms var(--gd-ease); + cursor: pointer; + color: inherit; + font: inherit; + text-align: left; +} + +.gd-coverflow__card--active { + border-color: var(--gd-primary); + box-shadow: + 0 16px 36px rgba(0, 0, 0, 0.14), + 0 0 0 2px var(--gd-primary-soft); + z-index: 100; +} + +.gd-coverflow__img { + display: block; + width: 100%; + height: 68%; + object-fit: cover; + pointer-events: none; + background: var(--gd-bg-spotlight); +} + +.gd-coverflow__art { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 68%; + background: var(--gd-coverflow-art); + color: var(--gd-coverflow-art-fg); + font-size: 56px; + font-weight: 700; + letter-spacing: -0.02em; + pointer-events: none; +} + +.gd-coverflow__meta { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 10px 12px 12px; + box-sizing: border-box; + background: var(--gd-bg-elevated); +} + +.gd-coverflow__title { + font-size: 14px; + font-weight: 650; + color: var(--gd-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gd-coverflow__subtitle { + font-size: 12px; + color: var(--gd-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gd-coverflow__controls { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.gd-coverflow__caption { + min-width: 140px; + max-width: 280px; + text-align: center; +} + +.gd-coverflow__caption-title { + font-size: 15px; + font-weight: 650; + color: var(--gd-text); +} + +.gd-coverflow__caption-sub { + margin-top: 2px; + font-size: 12px; + color: var(--gd-text-muted); +} + +.gd-coverflow__arrow { + min-width: 32px; + font-size: 18px; + line-height: 1; +} + +.gd-coverflow__dots { + display: flex; + justify-content: center; + gap: 6px; + flex-wrap: wrap; +} + +.gd-coverflow__dot { + width: 7px; + height: 7px; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--gd-border-strong); + cursor: pointer; + transition: transform 160ms var(--gd-ease), background 160ms var(--gd-ease); +} + +.gd-coverflow__dot--active { + background: var(--gd-primary); + transform: scale(1.25); +} + +.gd-coverflow__empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--gd-text-muted); + font-size: 13px; +} + +@media (max-width: 640px) { + .gd-coverflow { + --gd-coverflow-card-w: 140px; + --gd-coverflow-card-h: 190px; + } +} diff --git a/src/pyouter/webui/gd5/ui/css/embed.css b/src/pyouter/webui/gd5/ui/css/embed.css new file mode 100644 index 0000000..80b75b6 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/embed.css @@ -0,0 +1,82 @@ +/** + * EChart / ThreeView / SparkView / PeleView — third-party embed hosts. + */ + +.gd-echart, +.gd-three, +.gd-spark, +.gd-pele { + box-sizing: border-box; + position: relative; + min-width: 0; + min-height: 120px; + border: 1px solid var(--gd-border, #e8e4df); + border-radius: 12px; + background: var(--gd-bg-elevated, #fff); + overflow: hidden; +} + +.gd-spark { + background: #111; +} + +.gd-echart__host, +.gd-three__host, +.gd-spark__host, +.gd-pele__host { + box-sizing: border-box; + width: 100%; + height: 100%; +} + +.gd-three__host canvas, +.gd-spark__host canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.gd-spark__error, +.gd-pele__error { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 1rem; + font: 13px/1.4 system-ui, sans-serif; + text-align: center; +} + +.gd-spark__error { + color: #f5f5f4; +} + +.gd-pele__error { + color: var(--gd-text-secondary, #6b7c8d); +} + +/* Map gd5 tokens → Pele CSS variables */ +.gd-pele.pele, +.gd-pele .pele, +.gd-pele__host.pele { + --pele-bg: var(--gd-bg-elevated, #fff); + --pele-fg: var(--gd-text, #1a2330); + --pele-muted: var(--gd-text-secondary, #6b7c8d); + --pele-line: var(--gd-border-strong, #3d4d60); + --pele-surface: var(--gd-bg, #f7f4ef); + --pele-surface-alt: var(--gd-bg-muted, #ebe6de); + --pele-border: var(--gd-border, #e8e4df); + --pele-accent: var(--gd-primary, #e86a17); + --pele-font: var(--gd-font, system-ui, sans-serif); + --pele-radius: 8px; + --pele-error: var(--gd-danger, #c23b22); +} + +.gd-pele__host svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} diff --git a/src/pyouter/webui/gd5/ui/css/extra.css b/src/pyouter/webui/gd5/ui/css/extra.css new file mode 100644 index 0000000..22155bf --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/extra.css @@ -0,0 +1,1221 @@ +/* Radio */ +.gd-radio { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + color: var(--gd-text); +} +.gd-radio--disabled { opacity: 0.45; cursor: not-allowed; } +.gd-radio__input { position: absolute; opacity: 0; width: 0; height: 0; } +.gd-radio__dot { + box-sizing: border-box; + width: 16px; + height: 16px; + border: 1px solid var(--gd-border-strong); + border-radius: 50%; + background: var(--gd-bg-elevated); +} +.gd-radio__input:checked + .gd-radio__dot { + border-color: var(--gd-primary); + box-shadow: inset 0 0 0 4px var(--gd-primary); +} +.gd-radio-group { display: flex; flex-direction: column; gap: 8px; } +.gd-radio-group--horizontal { flex-direction: row; gap: 16px; } + +/* Progress */ +.gd-progress { display: flex; align-items: center; gap: 10px; width: 100%; } +.gd-progress__track { + flex: 1; + height: 8px; + border-radius: 999px; + background: var(--gd-bg-spotlight); + overflow: hidden; +} +.gd-progress--sm .gd-progress__track { height: 4px; } +.gd-progress--lg .gd-progress__track { height: 12px; } +.gd-progress__bar { + height: 100%; + background: var(--gd-primary); + border-radius: inherit; + transition: width 200ms var(--gd-ease); +} +.gd-progress__bar--indeterminate { + width: 40%; + animation: gd-progress-indeterminate 1.2s ease-in-out infinite; +} +@keyframes gd-progress-indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(280%); } +} +.gd-progress__info { font-size: 12px; color: var(--gd-text-secondary); min-width: 36px; } +.gd-progress-ring { + position: relative; + display: inline-grid; + place-items: center; +} +.gd-progress-ring__track { stroke: var(--gd-border); } +.gd-progress-ring__bar { + stroke: var(--gd-primary); + stroke-linecap: round; + transition: stroke-dashoffset 200ms var(--gd-ease); +} +.gd-progress-ring__label { + position: absolute; + font-size: 11px; + font-weight: 600; + color: var(--gd-text); +} +.gd-meter { display: flex; flex-direction: column; gap: 6px; } +.gd-meter__label { font-size: 12px; color: var(--gd-text-secondary); } +.gd-meter__control { width: 100%; height: 12px; } + +/* Skeleton */ +.gd-skeleton { + display: block; + background: var(--gd-bg-spotlight); + border-radius: var(--gd-radius-sm); +} +.gd-skeleton--text { height: 14px; margin: 6px 0; width: 100%; } +.gd-skeleton--title { height: 22px; width: 55%; margin: 8px 0; } +.gd-skeleton--avatar { width: 40px; height: 40px; border-radius: 50%; } +.gd-skeleton--button { width: 88px; height: 32px; border-radius: var(--gd-radius-sm); } +.gd-skeleton--image { width: 100%; height: 120px; } +.gd-skeleton--block { width: 100%; height: 64px; } +.gd-skeleton--active { + background: linear-gradient( + 90deg, + var(--gd-bg-spotlight) 25%, + #fff 37%, + var(--gd-bg-spotlight) 63% + ); + background-size: 400% 100%; + animation: gd-skeleton 1.4s ease infinite; +} +@keyframes gd-skeleton { + 0% { background-position: 100% 50%; } + 100% { background-position: 0 50%; } +} +.gd-skeleton-paragraph { display: flex; flex-direction: column; gap: 4px; } + +/* Avatar / status */ +.gd-avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--gd-primary-soft); + color: var(--gd-primary); + font-weight: 600; + overflow: visible; + flex-shrink: 0; +} +.gd-avatar--sm { width: 24px; height: 24px; font-size: 10px; } +.gd-avatar--md { width: 36px; height: 36px; font-size: 13px; } +.gd-avatar--lg { width: 48px; height: 48px; font-size: 16px; } +.gd-avatar--xl { width: 64px; height: 64px; font-size: 20px; } +.gd-avatar__img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + display: block; +} +.gd-avatar__fallback { + width: 100%; + height: 100%; + display: grid; + place-items: center; + border-radius: 50%; + border: 2px solid var(--gd-bg-elevated); +} +.gd-avatar--overflow { + background: var(--gd-bg-spotlight); + color: var(--gd-text-secondary); + border: 2px solid var(--gd-bg-elevated); + font-size: 12px; +} +.gd-avatar-group { display: inline-flex; align-items: center; } +.gd-avatar-group .gd-avatar { margin-inline-start: -10px; border: 2px solid var(--gd-bg-elevated); } +.gd-avatar-group .gd-avatar:first-child { margin-inline-start: 0; } +.gd-status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--gd-text-muted); + box-shadow: 0 0 0 2px var(--gd-bg-elevated); +} +.gd-avatar__status { position: absolute; right: 0; bottom: 0; } +.gd-status-dot--online, +.gd-status-dot--success { background: var(--gd-success); } +.gd-status-dot--away, +.gd-status-dot--warning { background: var(--gd-warning); } +.gd-status-dot--busy, +.gd-status-dot--error { background: var(--gd-danger); } +.gd-status-dot--processing { + background: var(--gd-primary); + animation: gd-badge-pulse 1.2s ease-out infinite; +} +.gd-status-dot--offline { background: var(--gd-text-muted); } + +/* Chip / Pill */ +.gd-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--gd-border); + --gd-jelly-fill: var(--gd-bg-elevated); + --gd-jelly-edge: var(--gd-border); + background: var(--gd-jelly-fill); + color: var(--gd-text); + font-size: 13px; + cursor: default; +} +.gd-chip--selected { + border-color: var(--gd-primary); + --gd-jelly-fill: var(--gd-primary-soft); + --gd-jelly-edge: var(--gd-primary); + background: var(--gd-jelly-fill); + color: var(--gd-primary); +} +.gd-chip__close { + border: 0; + background: transparent; + cursor: pointer; + color: inherit; + font-size: 14px; + line-height: 1; + padding: 0 2px; +} +.gd-pill { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + background: var(--gd-bg-spotlight); + color: var(--gd-text-secondary); +} +.gd-pill--primary { background: var(--gd-primary-soft); color: var(--gd-primary); } +.gd-pill--danger { background: var(--gd-danger-soft); color: var(--gd-danger); } + +/* Accordion */ +.gd-accordion { display: flex; flex-direction: column; gap: 8px; } +.gd-collapse { + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + background: var(--gd-bg-elevated); + overflow: hidden; +} +.gd-collapse__trigger { + list-style: none; + cursor: pointer; + padding: 12px 14px; + font-weight: 600; + color: var(--gd-text); +} +.gd-collapse__trigger::-webkit-details-marker { display: none; } +.gd-collapse__panel { padding: 0 14px 14px; color: var(--gd-text-secondary); font-size: 14px; } + +/* Tabs / toggle group */ +.gd-tabs__list { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--gd-border); + margin-bottom: 12px; +} +.gd-tabs__tab { + border: 0; + background: transparent; + padding: 10px 14px; + cursor: pointer; + color: var(--gd-text-secondary); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font: inherit; +} +.gd-tabs__tab--active { + color: var(--gd-primary); + border-bottom-color: var(--gd-primary); + font-weight: 600; +} +.gd-toggle-group { + display: inline-flex; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + overflow: hidden; + background: var(--gd-bg-elevated); +} +.gd-toggle-group__item { + border: 0; + background: transparent; + padding: 0 14px; + height: var(--gd-control-h); + cursor: pointer; + color: var(--gd-text-secondary); + font: inherit; + border-right: 1px solid var(--gd-border); +} +.gd-toggle-group__item:last-child { border-right: 0; } +.gd-toggle-group__item--active { + background: var(--gd-primary); + color: var(--gd-text-inverse); + font-weight: 600; +} +.gd-toggle-group--sm .gd-toggle-group__item { height: var(--gd-control-h-sm); font-size: 12px; } + +/* Breadcrumb */ +.gd-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; } +.gd-breadcrumb__sep { color: var(--gd-text-muted); } +.gd-breadcrumb__link { color: var(--gd-primary); text-decoration: none; } +.gd-breadcrumb__item--current { color: var(--gd-text); font-weight: 600; } + +/* Steps / qty */ +.gd-steps { display: flex; gap: 12px; width: 100%; } +.gd-steps--vertical { flex-direction: column; } +.gd-steps__item { display: flex; gap: 10px; align-items: flex-start; flex: 1; } +.gd-steps__indicator { + width: 28px; + height: 28px; + border-radius: 50%; + display: grid; + place-items: center; + border: 1px solid var(--gd-border-strong); + background: var(--gd-bg-elevated); + font-size: 13px; + font-weight: 600; + flex-shrink: 0; +} +.gd-steps__item--process .gd-steps__indicator { + background: var(--gd-primary); + border-color: var(--gd-primary); + color: var(--gd-text-inverse); +} +.gd-steps__item--finish .gd-steps__indicator { + background: var(--gd-primary-soft); + border-color: var(--gd-primary); + color: var(--gd-primary); +} +.gd-steps__title { font-weight: 600; font-size: 14px; color: var(--gd-text); } +.gd-steps__desc { font-size: 12px; color: var(--gd-text-muted); margin-top: 2px; } +.gd-qty-stepper { + display: inline-flex; + align-items: center; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + overflow: hidden; + background: var(--gd-bg-elevated); +} +.gd-qty-stepper__btn { + width: 32px; + height: var(--gd-control-h); + border: 0; + background: transparent; + cursor: pointer; + font-size: 16px; +} +.gd-qty-stepper__btn:disabled { opacity: 0.4; cursor: not-allowed; } +.gd-qty-stepper__value { + min-width: 36px; + text-align: center; + font-weight: 600; + border-inline: 1px solid var(--gd-border); + line-height: var(--gd-control-h); +} + +/* Pagination */ +.gd-pagination { display: inline-flex; gap: 4px; align-items: center; } +.gd-pagination__btn { + min-width: 32px; + height: 32px; + border: 1px solid var(--gd-border); + background: var(--gd-bg-elevated); + border-radius: var(--gd-radius-sm); + cursor: pointer; + color: var(--gd-text); +} +.gd-pagination__btn--active { + background: var(--gd-primary); + border-color: var(--gd-primary); + color: var(--gd-text-inverse); + font-weight: 600; +} +.gd-pagination__btn:disabled { opacity: 0.4; cursor: not-allowed; } +.gd-page-control { display: inline-flex; gap: 6px; align-items: center; } +.gd-page-control__dot { + width: 8px; + height: 8px; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--gd-border-strong); + cursor: pointer; +} +.gd-page-control__dot--active { background: var(--gd-primary); width: 18px; border-radius: 999px; } + +/* Navigation chrome */ +.gd-site-header { + display: flex; + align-items: center; + gap: 20px; + padding: 12px 16px; + background: var(--gd-bg-elevated); + border-bottom: 1px solid var(--gd-border); +} +.gd-site-header__brand { font-weight: 700; color: var(--gd-primary); font-size: 16px; } +.gd-navbar { display: flex; gap: 12px; align-items: center; flex: 1; } +.gd-navbar__link { + color: var(--gd-text-secondary); + text-decoration: none; + font-size: 14px; + padding: 4px 2px; +} +.gd-navbar__link--active { color: var(--gd-primary); font-weight: 600; } +.gd-bottom-nav { + display: flex; + justify-content: space-around; + background: var(--gd-bg-elevated); + border-top: 1px solid var(--gd-border); + padding: 6px 8px; +} +.gd-bottom-nav__item { + border: 0; + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + color: var(--gd-text-muted); + cursor: pointer; + font: inherit; + font-size: 11px; + padding: 6px 10px; +} +.gd-bottom-nav__item--active { color: var(--gd-primary); font-weight: 600; } +.gd-bottom-nav__icon { font-size: 16px; } +.gd-sidebar { + background: var(--gd-bg-elevated); + border-right: 1px solid var(--gd-border); + padding: 12px; + min-height: 200px; +} +.gd-sidebar__title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--gd-text-muted); + margin-bottom: 8px; +} +.gd-sidebar__nav { display: flex; flex-direction: column; gap: 2px; } +.gd-sidebar__link { + display: block; + padding: 8px 10px; + border-radius: var(--gd-radius-sm); + color: var(--gd-text-secondary); + text-decoration: none; + font-size: 14px; +} +.gd-sidebar__link--active, +.gd-sidebar__link:hover { + background: var(--gd-primary-soft); + color: var(--gd-primary); +} +.gd-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 0; +} +.gd-toolbar__actions { display: flex; gap: 8px; flex-wrap: wrap; } +.gd-hamburger { + width: 40px; + height: 32px; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); + display: inline-flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 0 10px; + cursor: pointer; +} +.gd-hamburger span { + display: block; + height: 2px; + background: var(--gd-text); + border-radius: 1px; +} +.gd-scrollspy { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +.gd-scrollspy__title { font-weight: 600; margin-bottom: 4px; color: var(--gd-text); } +.gd-scrollspy__link { + color: var(--gd-text-secondary); + text-decoration: none; + padding: 4px 8px; + border-left: 2px solid transparent; +} +.gd-scrollspy__link--active { + color: var(--gd-primary); + border-left-color: var(--gd-primary); + font-weight: 600; +} +.gd-split-btn { display: inline-flex; } +.gd-split-btn__main { border-top-right-radius: 0; border-bottom-right-radius: 0; } +.gd-split-btn__caret { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 1px solid rgba(255, 255, 255, 0.35); + padding-left: 8px; + padding-right: 8px; +} + +/* Overlays */ +.gd-scrim { + position: fixed; + inset: 0; + background: var(--gd-mask); + opacity: 0; + pointer-events: none; + transition: opacity 160ms var(--gd-ease); + z-index: 900; +} +.gd-scrim--visible { opacity: 1; pointer-events: auto; } + +.gd-tooltip { position: relative; display: inline-flex; } +.gd-tooltip__bubble { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + padding: 4px 8px; + border-radius: 4px; + background: #1f1a17; + color: #fff; + font-size: 12px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 120ms var(--gd-ease); + z-index: 20; +} +.gd-tooltip:hover .gd-tooltip__bubble, +.gd-tooltip:focus-within .gd-tooltip__bubble { opacity: 1; } + +.gd-popover, +.gd-dropdown, +.gd-hover-card { position: relative; display: inline-flex; } +.gd-popover__panel, +.gd-dropdown__menu, +.gd-hover-card__panel { + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 180px; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); + z-index: 30; + padding: 8px; +} +.gd-popover__title, +.gd-hover-card__title { font-weight: 600; margin-bottom: 4px; } +.gd-popover:not(.gd-popover--open) .gd-popover__panel { display: none; } +.gd-dropdown:not(.gd-dropdown--open) .gd-dropdown__menu { display: none; } +.gd-hover-card__panel { + display: none; + width: 240px; +} +.gd-hover-card:hover .gd-hover-card__panel, +.gd-hover-card:focus-within .gd-hover-card__panel { display: block; } +.gd-dropdown__item { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + font: inherit; + color: var(--gd-text); +} +.gd-dropdown__item:hover { background: var(--gd-bg-spotlight); } +.gd-dropdown__item--danger { color: var(--gd-danger); } +.gd-dropdown__divider { + height: 1px; + background: var(--gd-border); + margin: 4px 0; +} +.gd-context-menu { + position: fixed; + z-index: 1200; + min-width: 160px; + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); + padding: 6px; +} +.gd-context-menu--hidden { display: none; } +.gd-overflow-menu__trigger { + border: 1px solid var(--gd-border); + background: var(--gd-bg-elevated); + border-radius: var(--gd-radius-sm); + width: 32px; + height: 32px; + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +/* Drawer */ +.gd-drawer-root { position: fixed; inset: 0; z-index: 1000; display: none; } +.gd-drawer-root.is-open { display: block; } +.gd-drawer-mask { position: absolute; inset: 0; background: var(--gd-mask); } +.gd-drawer { + position: absolute; + background: var(--gd-bg-elevated); + box-shadow: var(--gd-shadow); + display: flex; + flex-direction: column; +} +.gd-drawer--right { top: 0; right: 0; bottom: 0; width: min(100%, 360px); } +.gd-drawer--left { top: 0; left: 0; bottom: 0; width: min(100%, 360px); } +.gd-drawer--bottom { left: 0; right: 0; bottom: 0; height: min(70vh, 420px); border-radius: 12px 12px 0 0; } +.gd-drawer--top { left: 0; right: 0; top: 0; height: min(50vh, 320px); } +.gd-drawer--sm.gd-drawer--right, +.gd-drawer--sm.gd-drawer--left { width: min(100%, 280px); } +.gd-drawer--lg.gd-drawer--right, +.gd-drawer--lg.gd-drawer--left { width: min(100%, 480px); } +.gd-drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--gd-border); +} +.gd-drawer__title { font-weight: 600; } +.gd-drawer__body { padding: 16px; overflow: auto; flex: 1; color: var(--gd-text-secondary); } + +/* Toast */ +.gd-toast-root { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 1100; + display: flex; + flex-direction: column; + gap: 8px; + max-width: min(360px, calc(100vw - 32px)); +} +.gd-toast { + position: relative; + padding: 12px 36px 12px 14px; + border-radius: var(--gd-radius); + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + box-shadow: var(--gd-shadow); + font-size: 14px; + color: var(--gd-text); +} +.gd-toast--success { border-color: rgba(47, 143, 91, 0.4); } +.gd-toast--error { border-color: rgba(209, 67, 67, 0.45); } +.gd-toast--warning { border-color: rgba(212, 137, 10, 0.45); } +.gd-toast__close { + position: absolute; + top: 6px; + right: 8px; + border: 0; + background: transparent; + cursor: pointer; + font-size: 16px; + color: var(--gd-text-muted); +} + +/* Lightbox */ +.gd-lightbox-root { position: fixed; inset: 0; z-index: 1050; display: none; } +.gd-lightbox-root.is-open { display: block; } +.gd-lightbox-mask { position: absolute; inset: 0; background: var(--gd-mask-heavy); } +.gd-lightbox__stage { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; +} +.gd-lightbox__img { + max-width: min(920px, 92vw); + max-height: 86vh; + pointer-events: auto; + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); +} +.gd-lightbox__close { + position: absolute; + top: 16px; + right: 16px; + pointer-events: auto; + border: 0; + background: rgba(0, 0, 0, 0.45); + color: #fff; + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + font-size: 20px; +} + +/* Form extras */ +.gd-search { + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); + padding: 0 8px; + height: var(--gd-control-h); +} +.gd-search__icon { color: var(--gd-text-muted); } +.gd-search__input { + border: 0 !important; + box-shadow: none !important; + height: 100% !important; + padding-left: 0 !important; +} +.gd-search__clear { + border: 0; + background: transparent; + cursor: pointer; + color: var(--gd-text-muted); +} +.gd-combobox, +.gd-multi-select { position: relative; } +.gd-combobox__list, +.gd-multi-select__panel { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 4px); + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + box-shadow: var(--gd-shadow); + z-index: 20; + max-height: 220px; + overflow: auto; + padding: 4px; +} +.gd-combobox__option, +.gd-multi-select__option { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + font: inherit; +} +.gd-combobox__option:hover, +.gd-multi-select__option:hover { background: var(--gd-bg-spotlight); } +.gd-combobox__empty { padding: 10px; color: var(--gd-text-muted); font-size: 13px; } +.gd-multi-select__trigger { + width: 100%; + height: var(--gd-control-h); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); + text-align: left; + padding: 0 10px; + cursor: pointer; + color: var(--gd-text); + font: inherit; +} +.gd-tag-input { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + min-height: var(--gd-control-h); + padding: 4px 8px; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); +} +.gd-tag-input__field { + border: 0; + outline: none; + flex: 1; + min-width: 80px; + font: inherit; + background: transparent; +} +.gd-transfer { display: flex; gap: 12px; } +.gd-transfer__pane { + flex: 1; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + background: var(--gd-bg-elevated); + overflow: hidden; +} +.gd-transfer__title { + padding: 8px 12px; + border-bottom: 1px solid var(--gd-border); + font-weight: 600; + font-size: 13px; +} +.gd-transfer__list { max-height: 180px; overflow: auto; } +.gd-transfer__item { + display: flex; + justify-content: space-between; + width: 100%; + border: 0; + background: transparent; + padding: 8px 12px; + cursor: pointer; + font: inherit; + text-align: left; +} +.gd-transfer__item:hover { background: var(--gd-bg-spotlight); } +.gd-transfer__action { color: var(--gd-primary); } +.gd-slider { display: flex; align-items: center; gap: 12px; width: 100%; } +.gd-slider__input { flex: 1; accent-color: var(--gd-primary); } +.gd-slider__value { min-width: 28px; font-size: 13px; color: var(--gd-text-secondary); } +.gd-color-well { + position: relative; + display: inline-flex; + width: 32px; + height: 32px; + border-radius: var(--gd-radius-sm); + border: 1px solid var(--gd-border); + overflow: hidden; + cursor: pointer; +} +.gd-color-well__swatch { flex: 1; } +.gd-color-well__input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + border: 0; + padding: 0; +} +.gd-date-picker { max-width: 220px; } + +/* Table */ +.gd-table-wrap { width: 100%; overflow: auto; border: 1px solid var(--gd-border); border-radius: var(--gd-radius); background: var(--gd-bg-elevated); } +.gd-table { width: 100%; border-collapse: collapse; font-size: 14px; } +.gd-table__th, +.gd-table__td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--gd-border); } +.gd-table__th { background: var(--gd-bg-spotlight); font-weight: 600; color: var(--gd-text); } +.gd-table__th--sortable { cursor: pointer; user-select: none; } +.gd-table__th--sorted { color: var(--gd-primary); } +.gd-table__tr:hover .gd-table__td { background: rgba(232, 106, 23, 0.04); } + +/* Timeline */ +.gd-timeline { display: flex; flex-direction: column; } +.gd-timeline__item { display: flex; gap: 12px; } +.gd-timeline__rail { + width: 16px; + display: flex; + justify-content: center; + position: relative; +} +.gd-timeline__rail::before { + content: ''; + position: absolute; + top: 8px; + bottom: -8px; + width: 2px; + background: var(--gd-border); +} +.gd-timeline__item:last-child .gd-timeline__rail::before { display: none; } +.gd-timeline__dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--gd-primary); + margin-top: 6px; + z-index: 1; + box-shadow: 0 0 0 3px var(--gd-primary-soft); +} +.gd-timeline__body { padding-bottom: 18px; } +.gd-timeline__time { font-size: 12px; color: var(--gd-text-muted); } +.gd-timeline__title { font-weight: 600; color: var(--gd-text); } +.gd-timeline__desc { font-size: 13px; color: var(--gd-text-secondary); margin-top: 2px; } + +/* Chat */ +.gd-chat-row { display: flex; gap: 8px; align-items: flex-end; margin: 8px 0; } +.gd-chat-row--mine { flex-direction: row-reverse; } +.gd-chat-bubble { + max-width: min(420px, 80%); + padding: 10px 12px; + border-radius: 14px; + font-size: 14px; + line-height: 1.45; +} +.gd-chat-bubble--theirs { + background: var(--gd-bg-spotlight); + color: var(--gd-text); + border-bottom-left-radius: 4px; +} +.gd-chat-bubble--mine { + background: var(--gd-primary); + color: var(--gd-text-inverse); + border-bottom-right-radius: 4px; +} +.gd-chat-bubble__author { + font-size: 11px; + font-weight: 600; + margin-bottom: 2px; + color: var(--gd-text-secondary); +} + +/* Carousel */ +.gd-carousel { width: 100%; } +.gd-carousel__viewport { overflow: hidden; border-radius: var(--gd-radius); border: 1px solid var(--gd-border); background: var(--gd-bg-elevated); } +.gd-carousel__track { display: flex; transition: transform 280ms var(--gd-ease); } +.gd-carousel__slide { flex: 0 0 100%; min-height: 140px; padding: 24px; box-sizing: border-box; } +.gd-carousel__controls { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 10px; +} +.gd-carousel__arrow { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--gd-border); + background: var(--gd-bg-elevated); + cursor: pointer; +} + +/* Layout extras */ +.gd-bento { + display: grid; + grid-template-columns: repeat(var(--gd-bento-cols, 4), 1fr); + gap: 12px; +} +.gd-bento__tile { + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + padding: 16px; + min-height: 80px; +} +.gd-masonry__item { + break-inside: avoid; + margin-bottom: 12px; +} +.gd-marquee { overflow: hidden; width: 100%; } +.gd-marquee__track { + display: flex; + gap: 32px; + width: max-content; + animation: gd-marquee var(--gd-marquee-duration, 30s) linear infinite; +} +.gd-marquee--pause-hover:hover .gd-marquee__track { animation-play-state: paused; } +@keyframes gd-marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +.gd-sticky { position: sticky; z-index: 10; } +.gd-fixed { position: fixed; z-index: 10; } +.gd-scroll-area { overflow: auto; border: 1px solid var(--gd-border); border-radius: var(--gd-radius); background: var(--gd-bg-elevated); } +.gd-resize-handle { + width: 14px; + height: 14px; + margin-left: auto; + background: + linear-gradient(135deg, transparent 40%, var(--gd-border-strong) 40%, var(--gd-border-strong) 45%, transparent 45%), + linear-gradient(135deg, transparent 55%, var(--gd-border-strong) 55%, var(--gd-border-strong) 60%, transparent 60%), + linear-gradient(135deg, transparent 70%, var(--gd-border-strong) 70%, var(--gd-border-strong) 75%, transparent 75%); + cursor: nwse-resize; +} +.gd-splitter { display: flex; width: 100%; min-height: 120px; border: 1px solid var(--gd-border); border-radius: var(--gd-radius); overflow: hidden; background: var(--gd-bg-elevated); } +.gd-splitter--vertical { flex-direction: column; } +.gd-splitter__pane { flex: 1; min-width: 0; min-height: 0; padding: 12px; overflow: auto; } +.gd-splitter__gutter { + flex: 0 0 6px; + background: var(--gd-bg-spotlight); + cursor: col-resize; +} +.gd-splitter--vertical .gd-splitter__gutter { cursor: row-resize; } + +/* FAB */ +.gd-fab { + position: fixed; + right: 20px; + bottom: 20px; + width: 52px; + height: 52px; + border-radius: 50%; + border: 0; + box-shadow: var(--gd-shadow); + cursor: pointer; + font-size: 22px; + z-index: 50; +} +.gd-fab--primary { --gd-jelly-fill: var(--gd-primary); --gd-jelly-edge: transparent; background: var(--gd-jelly-fill); color: var(--gd-text-inverse); } +.gd-fab--default { --gd-jelly-fill: var(--gd-bg-elevated); --gd-jelly-edge: var(--gd-border); background: var(--gd-jelly-fill); color: var(--gd-text); border: 1px solid var(--gd-border); } +.gd-fab--top { bottom: 84px; font-size: 18px; } +.gd-fab--chat { font-size: 20px; } + +/* Chart */ +.gd-chart { display: inline-block; } + +/* Tree */ +/* Tree — VS Code explorer style: compact rows, chevron, indent guides */ +.gd-tree { + font-size: 13px; + line-height: 22px; + --gd-tree-indent: 8px; + --gd-tree-guide: color-mix(in srgb, var(--gd-border-strong) 85%, transparent); + --gd-tree-row-h: 22px; + --gd-tree-twist: 16px; + color: var(--gd-text); + user-select: none; +} +.gd-tree__node { position: relative; } + +.gd-tree__row { + display: flex; + align-items: center; + gap: 2px; + min-height: var(--gd-tree-row-h); + height: var(--gd-tree-row-h); + padding-right: 8px; + cursor: pointer; + border-radius: 0; + position: relative; +} +.gd-tree__row:hover { background: var(--gd-bg-spotlight); } +.gd-tree__row--selected, +.gd-tree__row--selected:hover { + background: var(--gd-primary-soft); +} +.gd-tree__row--selected .gd-tree__label { + color: var(--gd-text); + font-weight: 400; +} + +/* Indent guides: continuous vertical rails (no L-stubs) */ +.gd-tree__children { + position: relative; + margin-left: calc(var(--gd-tree-twist) / 2); + padding-left: var(--gd-tree-indent); + border-left: 1px solid var(--gd-tree-guide); +} +.gd-tree__children > .gd-tree__node { position: relative; } + +/* Chevron (codicon-like), muted until open */ +.gd-tree__twist { + flex: 0 0 var(--gd-tree-twist); + width: var(--gd-tree-twist); + height: var(--gd-tree-twist); + margin-left: 2px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + color: var(--gd-text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + transform: rotate(0deg); + transition: transform 100ms linear, color 100ms linear; + border-radius: 3px; + opacity: 0.9; +} +.gd-tree__twist:hover { + color: var(--gd-text); + background: color-mix(in srgb, var(--gd-text) 8%, transparent); +} +.gd-tree__twist::before { + content: ''; + display: block; + width: 0; + height: 0; + border-style: solid; + border-width: 3.5px 0 3.5px 5px; + border-color: transparent transparent transparent currentColor; +} +.gd-tree__twist--open { + transform: rotate(90deg); + color: var(--gd-text-secondary); +} +.gd-tree__twist--leaf { + cursor: default; + visibility: hidden; + pointer-events: none; + background: transparent; +} +.gd-tree__twist--leaf::before { display: none; } +.gd-tree__twist--leaf:hover { background: transparent; } + +.gd-tree__label { + border: 0; + background: transparent; + cursor: pointer; + font: inherit; + line-height: inherit; + color: inherit; + padding: 0 4px; + border-radius: 0; + text-align: left; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.gd-tree__label:hover { background: transparent; } + +/* Command palette */ +.gd-cmd-root { position: fixed; inset: 0; z-index: 1150; display: none; } +.gd-cmd-root.is-open { display: block; } +.gd-cmd-mask { position: absolute; inset: 0; background: var(--gd-mask); } +.gd-cmd { + position: relative; + width: min(560px, calc(100vw - 32px)); + margin: 12vh auto 0; + background: var(--gd-bg-elevated); + border-radius: var(--gd-radius-lg); + border: 1px solid var(--gd-border); + box-shadow: var(--gd-shadow); + overflow: hidden; +} +.gd-cmd__input { + width: 100%; + border: 0; + border-bottom: 1px solid var(--gd-border); + padding: 14px 16px; + font: inherit; + font-size: 16px; + outline: none; + background: transparent; +} +.gd-cmd__list { max-height: 320px; overflow: auto; padding: 6px; } +.gd-cmd__item { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 10px 12px; + border-radius: 6px; + cursor: pointer; + font: inherit; +} +.gd-cmd__item:hover { background: var(--gd-primary-soft); color: var(--gd-primary); } +.gd-cmd__empty { padding: 16px; color: var(--gd-text-muted); text-align: center; } + +/* Banner / callout */ +.gd-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--gd-border); + background: var(--gd-bg-elevated); + font-size: 14px; +} +.gd-banner--info { background: rgba(47, 111, 237, 0.08); } +.gd-banner--success { background: rgba(47, 143, 91, 0.1); } +.gd-banner--warning { background: rgba(212, 137, 10, 0.12); } +.gd-banner--error { background: var(--gd-danger-soft); } +.gd-banner__body { flex: 1; } +.gd-banner__close { border: 0; background: transparent; cursor: pointer; font-size: 16px; } +.gd-callout { + border: 1px solid var(--gd-border); + border-left-width: 4px; + border-radius: var(--gd-radius); + padding: 12px 14px; + background: var(--gd-bg-elevated); +} +.gd-callout--info { border-left-color: var(--gd-info); } +.gd-callout--success { border-left-color: var(--gd-success); } +.gd-callout--warning { border-left-color: var(--gd-warning); } +.gd-callout--error { border-left-color: var(--gd-danger); } +.gd-callout__title { font-weight: 600; margin-bottom: 4px; } + +/* Drag */ +.gd-drag-handle { + border: 0; + background: transparent; + cursor: grab; + color: var(--gd-text-muted); + font-size: 16px; + padding: 4px; +} +.gd-drop-zone { + border: 2px dashed var(--gd-border-strong); + border-radius: var(--gd-radius); + padding: 24px; + text-align: center; + color: var(--gd-text-muted); + background: var(--gd-bg-elevated); +} +.gd-drop-zone--active { + border-color: var(--gd-primary); + background: var(--gd-primary-soft); + color: var(--gd-primary); +} + +/* Truncate / password / sign-in */ +.gd-truncate--ellipsis { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.gd-truncate--clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} +.gd-password { position: relative; display: flex; align-items: center; } +.gd-password__input { padding-right: 64px !important; } +.gd-password__eye { + position: absolute; + right: 8px; + border: 0; + background: transparent; + color: var(--gd-primary); + cursor: pointer; + font-size: 12px; + font-weight: 600; +} +.gd-or-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 0; + color: var(--gd-text-muted); + font-size: 12px; +} +.gd-or-divider__line { flex: 1; height: 1px; background: var(--gd-border); } +.gd-sign-in { display: flex; flex-direction: column; gap: 12px; max-width: 360px; } +.gd-sign-in__providers { display: flex; flex-direction: column; gap: 8px; } + +/* Focus ring utility */ +.gd-focus-ring:focus-visible { + outline: 2px solid var(--gd-primary); + outline-offset: 2px; +} diff --git a/src/pyouter/webui/gd5/ui/css/feedback.css b/src/pyouter/webui/gd5/ui/css/feedback.css new file mode 100644 index 0000000..169b68e --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/feedback.css @@ -0,0 +1,99 @@ +.gd-tag { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + line-height: 1; + border: 1px solid var(--gd-border); + background: var(--gd-bg-elevated); + color: var(--gd-text-secondary); +} + +.gd-tag--primary { + border-color: transparent; + background: var(--gd-primary-soft); + color: var(--gd-primary); +} + +.gd-tag--danger { + border-color: transparent; + background: var(--gd-danger-soft); + color: var(--gd-danger); +} + +.gd-alert { + display: flex; + gap: 10px; + padding: 12px 14px; + border-radius: var(--gd-radius); + border: 1px solid var(--gd-border); + background: var(--gd-bg-elevated); + color: var(--gd-text); + font-size: 14px; + line-height: 1.5; +} + +.gd-alert--info { + border-color: rgba(74, 143, 212, 0.45); + background: rgba(74, 143, 212, 0.12); +} + +.gd-alert--success { + border-color: rgba(61, 154, 106, 0.45); + background: rgba(61, 154, 106, 0.12); +} + +.gd-alert--warning { + border-color: rgba(212, 160, 23, 0.45); + background: rgba(212, 160, 23, 0.12); +} + +.gd-alert--error { + border-color: rgba(224, 106, 106, 0.45); + background: var(--gd-danger-soft); +} + +.gd-spin { + display: inline-block; + width: 18px; + height: 18px; + border: 2px solid var(--gd-border-strong); + border-top-color: var(--gd-primary); + border-radius: 50%; + animation: gd-spin 0.7s linear infinite; +} + +.gd-spin--lg { + width: 28px; + height: 28px; +} + +@keyframes gd-spin { + to { + transform: rotate(360deg); + } +} + +.gd-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 32px 16px; + color: var(--gd-text-muted); + text-align: center; +} + +.gd-empty__title { + margin: 0; + font-size: 14px; + color: var(--gd-text-secondary); +} + +.gd-empty__desc { + margin: 0; + font-size: 13px; +} diff --git a/src/pyouter/webui/gd5/ui/css/input.css b/src/pyouter/webui/gd5/ui/css/input.css new file mode 100644 index 0000000..145028c --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/input.css @@ -0,0 +1,81 @@ +.gd-input, +.gd-textarea, +.gd-select { + box-sizing: border-box; + width: 100%; + margin: 0; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); + color: var(--gd-text); + font-family: var(--gd-font); + font-size: 14px; + transition: border-color 160ms var(--gd-ease), box-shadow 160ms var(--gd-ease); +} + +.gd-input, +.gd-select { + height: var(--gd-control-h); + padding: 0 10px; +} + +.gd-input--sm, +.gd-select--sm { + height: var(--gd-control-h-sm); + font-size: 12px; +} + +.gd-input--lg, +.gd-select--lg { + height: var(--gd-control-h-lg); + font-size: 15px; +} + +.gd-textarea { + min-height: 80px; + padding: 8px 10px; + resize: vertical; + line-height: 1.5; +} + +.gd-input:focus, +.gd-textarea:focus, +.gd-select:focus { + outline: none; + border-color: var(--gd-primary); + box-shadow: 0 0 0 3px var(--gd-primary-soft); +} + +.gd-input:disabled, +.gd-textarea:disabled, +.gd-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gd-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.gd-field__label { + font-size: 12px; + color: var(--gd-text-secondary); +} + +.gd-field__hint { + font-size: 12px; + color: var(--gd-text-muted); +} + +.gd-field__error { + font-size: 12px; + color: var(--gd-danger); +} + +.gd-field--error .gd-input, +.gd-field--error .gd-textarea, +.gd-field--error .gd-select { + border-color: var(--gd-danger); +} diff --git a/src/pyouter/webui/gd5/ui/css/jelly.css b/src/pyouter/webui/gd5/ui/css/jelly.css new file mode 100644 index 0000000..5a03b99 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/jelly.css @@ -0,0 +1,73 @@ +/** + * Live jelly hosts. The canvas is the fill; the real control stays on top. + * position is set in JS only when computed position is static, so a fixed + * FAB keeps its own positioning. + */ + +@property --gd-jelly-fill { + syntax: ''; + inherits: true; + initial-value: transparent; +} + +@property --gd-jelly-edge { + syntax: ''; + inherits: true; + initial-value: transparent; +} + +[data-gd-jelly-host].gd-jelly-live { + background: transparent !important; + background-color: transparent !important; + border-color: transparent !important; + isolation: isolate; +} + +.gd-btn.gd-jelly-live, +.btn.gd-jelly-live, +.gd-chip.gd-jelly-live { + border-radius: 999px; +} + +.gd-btn.gd-jelly-live:active:not(:disabled), +.btn.gd-jelly-live:active { + transform: none; +} + +.gd-jelly-switch { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.gd-jelly-switch__label { + font-size: 12px; + letter-spacing: 0.08em; + opacity: 0.82; +} + +.gd-jelly-canvas { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: -1; +} + +.gd-jelly-probe, +[data-gd-jelly-host].gd-jelly-live > .gd-jelly-probe { + position: absolute; + z-index: -1; + width: 0; + height: 0; + overflow: hidden; + pointer-events: none; + background-color: var(--gd-jelly-fill); +} + +[data-gd-jelly-host].gd-jelly-live > :not(canvas) { + position: relative; + z-index: 1; +} diff --git a/src/pyouter/webui/gd5/ui/css/json-tree.css b/src/pyouter/webui/gd5/ui/css/json-tree.css new file mode 100644 index 0000000..fbe11f0 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/json-tree.css @@ -0,0 +1,170 @@ +/** + * JsonTree — virtualized JSON viewer. + * Type colors work across orange / blue / green themes (white panels). + */ + +:root, +[data-gd-theme='orange'], +[data-gd-theme='blue'], +[data-gd-theme='green'] { + --gd-json-key: var(--gd-text); + --gd-json-string: #1a7f37; + --gd-json-number: #0958d9; + --gd-json-boolean: #cf1322; + --gd-json-null: var(--gd-text-muted); + --gd-json-punct: var(--gd-text-muted); + --gd-json-preview: var(--gd-text-secondary); + --gd-json-mark: color-mix(in srgb, var(--gd-warning) 28%, #fff8d6); + --gd-json-row-h: 22px; +} + +.gd-json-tree { + display: flex; + flex-direction: column; + gap: 8px; + font-family: var(--gd-font-mono); + font-size: 12.5px; + color: var(--gd-text); +} + +.gd-json-tree__toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.gd-json-tree__viewport { + overflow: auto; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius); + background: var(--gd-bg-elevated); + overscroll-behavior: contain; +} + +.gd-json-tree__viewport:focus { + outline: 2px solid var(--gd-primary-soft); + outline-offset: 1px; +} + +.gd-json-tree__spacer { + width: 1px; + pointer-events: none; +} + +.gd-json-tree__window { + position: relative; +} + +.gd-json-tree__row { + display: flex; + align-items: center; + gap: 4px; + box-sizing: border-box; + white-space: nowrap; + line-height: var(--gd-json-row-h); + cursor: default; +} + +.gd-json-tree__row--complex { + cursor: pointer; +} + +.gd-json-tree__row:hover { + background: var(--gd-bg-spotlight); +} + +.gd-json-tree__row--marked { + background: var(--gd-json-mark); +} + +.gd-json-tree__row--marked:hover { + background: color-mix(in srgb, var(--gd-warning) 36%, #fff8d6); +} + +.gd-json-tree__twist { + flex: 0 0 14px; + width: 14px; + height: 14px; + padding: 0; + border: 0; + background: transparent; + color: var(--gd-text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transform: rotate(0deg); + transition: transform 100ms linear, color 100ms linear; + border-radius: 2px; +} + +.gd-json-tree__twist::before { + content: ''; + display: block; + width: 0; + height: 0; + border-style: solid; + border-width: 3.5px 0 3.5px 5px; + border-color: transparent transparent transparent currentColor; +} + +.gd-json-tree__twist--open { + transform: rotate(90deg); + color: var(--gd-primary); +} + +.gd-json-tree__twist--leaf { + visibility: hidden; + pointer-events: none; +} + +.gd-json-tree__twist:hover { + color: var(--gd-text); + background: color-mix(in srgb, var(--gd-text) 6%, transparent); +} + +.gd-json-tree__key { + color: var(--gd-json-key); + font-weight: 600; +} + +.gd-json-tree__colon, +.gd-json-tree__punct { + color: var(--gd-json-punct); +} + +.gd-json-tree__value--string { + color: var(--gd-json-string); +} + +.gd-json-tree__value--number { + color: var(--gd-json-number); +} + +.gd-json-tree__value--boolean { + color: var(--gd-json-boolean); + font-weight: 600; +} + +.gd-json-tree__value--null { + color: var(--gd-json-null); + font-style: italic; +} + +.gd-json-tree__preview { + color: var(--gd-json-preview); + font-weight: 600; +} + +.gd-json-tree__empty { + padding: 16px; + color: var(--gd-text-muted); + font-family: var(--gd-font); +} + +.gd-json-tree__hint { + font-family: var(--gd-font); + font-size: 12px; + color: var(--gd-text-muted); +} diff --git a/src/pyouter/webui/gd5/ui/css/layout.css b/src/pyouter/webui/gd5/ui/css/layout.css new file mode 100644 index 0000000..614842c --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/layout.css @@ -0,0 +1,59 @@ +.gd-space { + display: flex; + flex-wrap: wrap; +} + +.gd-space--vertical { + flex-direction: column; +} + +.gd-space--align-center { + align-items: center; +} + +.gd-space--align-start { + align-items: flex-start; +} + +.gd-space--align-end { + align-items: flex-end; +} + +.gd-space--justify-center { + justify-content: center; +} + +.gd-space--justify-end { + justify-content: flex-end; +} + +.gd-space--justify-between { + justify-content: space-between; +} + +.gd-space--gap-sm { + gap: 8px; +} + +.gd-space--gap-md { + gap: 12px; +} + +.gd-space--gap-lg { + gap: 20px; +} + +.gd-divider { + border: 0; + margin: 16px 0; + height: 1px; + background: var(--gd-border); +} + +.gd-divider--vertical { + display: inline-block; + width: 1px; + height: 1em; + margin: 0 8px; + vertical-align: middle; +} diff --git a/src/pyouter/webui/gd5/ui/css/markdown-editor.css b/src/pyouter/webui/gd5/ui/css/markdown-editor.css new file mode 100644 index 0000000..861c5bf --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/markdown-editor.css @@ -0,0 +1,237 @@ +/** + * MarkdownEditor — toolbar + edit / split / preview. + * Theme-aware via --gd-* tokens. + */ + +:root, +[data-gd-theme='orange'], +[data-gd-theme='blue'], +[data-gd-theme='green'] { + --gd-md-bg: var(--gd-bg-elevated); + --gd-md-border: var(--gd-border); + --gd-md-toolbar-bg: var(--gd-bg-spotlight); + --gd-md-preview-fg: var(--gd-text); + --gd-md-preview-muted: var(--gd-text-muted); + --gd-md-code-bg: var(--gd-bg-spotlight); + --gd-md-quote-border: var(--gd-primary); + --gd-md-link: var(--gd-primary); +} + +.gd-md-editor { + display: flex; + flex-direction: column; + border: 1px solid var(--gd-md-border); + border-radius: var(--gd-radius); + background: var(--gd-md-bg); + overflow: hidden; + min-width: 0; +} + +.gd-md-editor__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-bottom: 1px solid var(--gd-md-border); + background: var(--gd-md-toolbar-bg); +} + +.gd-md-editor__tools, +.gd-md-editor__modes { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; +} + +.gd-md-editor__tool--bold { + font-weight: 700; +} + +.gd-md-editor__tool--italic { + font-style: italic; +} + +.gd-md-editor__mode--active { + background: var(--gd-primary-soft) !important; + color: var(--gd-primary) !important; + border-color: transparent !important; +} + +.gd-md-editor__body { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 160px; + min-width: 0; +} + +.gd-md-editor--edit .gd-md-editor__body { + grid-template-columns: 1fr; +} + +.gd-md-editor--preview .gd-md-editor__body { + grid-template-columns: 1fr; +} + +.gd-md-editor__edit, +.gd-md-editor__preview-pane { + min-width: 0; + min-height: 0; + overflow: auto; +} + +.gd-md-editor--split .gd-md-editor__edit { + border-right: 1px solid var(--gd-md-border); +} + +.gd-md-editor__input { + display: block; + width: 100%; + height: 100%; + min-height: 100%; + margin: 0; + padding: 12px 14px; + border: 0; + border-radius: 0; + resize: none; + background: transparent; + color: var(--gd-text); + font-family: var(--gd-font-mono); + font-size: 13px; + line-height: 1.55; + box-shadow: none; +} + +.gd-md-editor__input:focus { + outline: none; +} + +.gd-md-editor__preview { + padding: 12px 16px 20px; + color: var(--gd-md-preview-fg); + font-family: var(--gd-font); + font-size: 14px; + line-height: 1.65; +} + +.gd-md-editor__empty { + margin: 0; + color: var(--gd-md-preview-muted); + font-style: italic; +} + +.gd-md-editor__preview > :first-child { + margin-top: 0; +} + +.gd-md-editor__preview > :last-child { + margin-bottom: 0; +} + +.gd-md-editor__preview h1, +.gd-md-editor__preview h2, +.gd-md-editor__preview h3, +.gd-md-editor__preview h4, +.gd-md-editor__preview h5, +.gd-md-editor__preview h6 { + margin: 1.1em 0 0.45em; + line-height: 1.3; + font-weight: 650; + color: var(--gd-text); +} + +.gd-md-editor__preview h1 { + font-size: 1.55em; + padding-bottom: 0.25em; + border-bottom: 1px solid var(--gd-md-border); +} + +.gd-md-editor__preview h2 { + font-size: 1.3em; +} + +.gd-md-editor__preview h3 { + font-size: 1.12em; +} + +.gd-md-editor__preview p { + margin: 0.65em 0; +} + +.gd-md-editor__preview a { + color: var(--gd-md-link); + text-decoration: underline; + text-underline-offset: 2px; +} + +.gd-md-editor__preview ul, +.gd-md-editor__preview ol { + margin: 0.5em 0; + padding-left: 1.4em; +} + +.gd-md-editor__preview li + li { + margin-top: 0.2em; +} + +.gd-md-editor__preview blockquote { + margin: 0.75em 0; + padding: 0.15em 0 0.15em 0.9em; + border-left: 3px solid var(--gd-md-quote-border); + color: var(--gd-text-secondary); +} + +.gd-md-editor__preview blockquote p { + margin: 0.25em 0; +} + +.gd-md-editor__preview hr { + border: 0; + border-top: 1px solid var(--gd-md-border); + margin: 1.2em 0; +} + +.gd-md-editor__preview code { + padding: 0.1em 0.35em; + border-radius: 4px; + background: var(--gd-md-code-bg); + font-family: var(--gd-font-mono); + font-size: 0.9em; +} + +.gd-md-editor__preview .gd-md-pre { + margin: 0.75em 0; + padding: 10px 12px; + overflow: auto; + border-radius: var(--gd-radius-sm); + background: var(--gd-md-code-bg); + border: 1px solid var(--gd-md-border); +} + +.gd-md-editor__preview .gd-md-pre code { + padding: 0; + background: transparent; + font-size: 12.5px; + line-height: 1.5; + white-space: pre; +} + +.gd-md-editor__preview img { + max-width: 100%; + height: auto; + border-radius: var(--gd-radius-sm); +} + +@media (max-width: 720px) { + .gd-md-editor--split .gd-md-editor__body { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } + + .gd-md-editor--split .gd-md-editor__edit { + border-right: 0; + border-bottom: 1px solid var(--gd-md-border); + } +} diff --git a/src/pyouter/webui/gd5/ui/css/modal.css b/src/pyouter/webui/gd5/ui/css/modal.css new file mode 100644 index 0000000..32c2161 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/modal.css @@ -0,0 +1,74 @@ +.gd-modal-root { + position: fixed; + inset: 0; + z-index: 1000; + display: none; +} + +.gd-modal-root.is-open { + display: block; +} + +.gd-modal-mask { + position: absolute; + inset: 0; + background: var(--gd-mask); +} + +.gd-modal-wrap { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + pointer-events: none; +} + +.gd-modal { + pointer-events: auto; + width: min(100%, 480px); + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-lg); + box-shadow: var(--gd-shadow); + overflow: hidden; +} + +.gd-modal--sm { + width: min(100%, 360px); +} + +.gd-modal--lg { + width: min(100%, 720px); +} + +.gd-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--gd-border); +} + +.gd-modal__title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--gd-text); +} + +.gd-modal__body { + padding: 16px; + color: var(--gd-text-secondary); + font-size: 14px; + line-height: 1.6; +} + +.gd-modal__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--gd-border); +} diff --git a/src/pyouter/webui/gd5/ui/css/todo.css b/src/pyouter/webui/gd5/ui/css/todo.css new file mode 100644 index 0000000..f5938b8 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/todo.css @@ -0,0 +1,174 @@ +/** + * TodoList — Cursor-style to-dos card. + */ + +:root, +[data-gd-theme='orange'], +[data-gd-theme='blue'], +[data-gd-theme='green'] { + --gd-todo-bg: var(--gd-bg-elevated); + --gd-todo-border: var(--gd-border); + --gd-todo-title: var(--gd-text); + --gd-todo-muted: var(--gd-text-muted); + --gd-todo-active-fg: #ffffff; + --gd-todo-active-bg: #3d3d3d; + --gd-todo-pending-ring: #c8c8c8; + --gd-todo-done-bg: var(--gd-success); + --gd-todo-divider: var(--gd-border); +} + +.gd-todo { + box-sizing: border-box; + width: 100%; + max-width: 420px; + margin: 0; + padding: 18px 18px 8px; + border: 1px solid var(--gd-todo-border); + border-radius: 16px; + background: var(--gd-todo-bg); + font-family: var(--gd-font); + color: var(--gd-todo-title); +} + +.gd-todo__header { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 2px 14px; + border-bottom: 1px solid var(--gd-todo-divider); +} + +.gd-todo__title { + font-size: 16px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--gd-todo-title); +} + +.gd-todo__progress { + font-size: 14px; + font-weight: 500; + color: var(--gd-todo-muted); +} + +.gd-todo__list { + list-style: none; + margin: 0; + padding: 0; +} + +.gd-todo__item { + margin: 0; + border-bottom: 1px solid var(--gd-todo-divider); +} + +.gd-todo__item:last-child { + border-bottom: 0; +} + +.gd-todo__row { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + margin: 0; + padding: 14px 2px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + box-sizing: border-box; +} + +button.gd-todo__row { + cursor: default; +} + +button.gd-todo__row--interactive { + cursor: pointer; +} + +button.gd-todo__row--interactive:hover .gd-todo__text { + color: var(--gd-primary); +} + +button.gd-todo__row--interactive:focus-visible { + outline: 2px solid var(--gd-primary-soft); + outline-offset: 2px; + border-radius: 8px; +} + +.gd-todo__icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin-top: 2px; + border-radius: 50%; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.gd-todo__icon--pending { + border: 1.5px dashed var(--gd-todo-pending-ring); + background: transparent; +} + +.gd-todo__icon--active { + background: var(--gd-todo-active-bg); + border: 1.5px solid var(--gd-todo-active-bg); +} + +.gd-todo__arrow { + width: 0; + height: 0; + border-style: solid; + border-width: 3.5px 0 3.5px 6px; + border-color: transparent transparent transparent var(--gd-todo-active-fg); + margin-left: 1px; +} + +.gd-todo__icon--done { + background: var(--gd-todo-done-bg); + border: 1.5px solid var(--gd-todo-done-bg); +} + +.gd-todo__check { + width: 5px; + height: 9px; + margin-top: -1px; + border: solid #fff; + border-width: 0 1.75px 1.75px 0; + transform: rotate(45deg); +} + +.gd-todo__text { + flex: 1; + min-width: 0; + font-size: 14px; + line-height: 1.45; + font-weight: 500; + color: var(--gd-text); +} + +.gd-todo__item--pending .gd-todo__text { + color: var(--gd-todo-muted); + font-weight: 450; +} + +.gd-todo__item--active .gd-todo__text { + color: var(--gd-todo-title); + font-weight: 550; +} + +.gd-todo__item--done .gd-todo__text { + color: var(--gd-todo-muted); + text-decoration: none; +} + +.gd-todo__item--disabled { + opacity: 0.55; + pointer-events: none; +} diff --git a/src/pyouter/webui/gd5/ui/css/tokens.css b/src/pyouter/webui/gd5/ui/css/tokens.css new file mode 100644 index 0000000..74126fc --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/tokens.css @@ -0,0 +1,81 @@ +/** + * gd5 UI kit tokens — white panels + selectable accent themes. + * Default: orange. Also: blue, green via data-gd-theme / setTheme(). + */ +:root, +[data-gd-theme='orange'] { + --gd-font: "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif; + --gd-font-mono: "SF Mono", "Cascadia Code", "Consolas", monospace; + + /* Shared structure: white panels + neutral masks */ + --gd-bg-elevated: #ffffff; + --gd-bg-container: #ffffff; + --gd-text-inverse: #ffffff; + --gd-mask: rgba(0, 0, 0, 0.45); + --gd-mask-heavy: rgba(0, 0, 0, 0.75); + + --gd-danger: #d14343; + --gd-danger-soft: rgba(209, 67, 67, 0.1); + --gd-warning: #d4890a; + --gd-success: #2f8f5b; + --gd-info: #2f6fed; + + --gd-radius-sm: 4px; + --gd-radius: 8px; + --gd-radius-lg: 12px; + + --gd-ease: cubic-bezier(0.22, 1, 0.36, 1); + + --gd-control-h-sm: 28px; + --gd-control-h: 32px; + --gd-control-h-lg: 40px; + + /* Orange theme (default): white surfaces, orange accent only */ + --gd-theme-name: orange; + --gd-bg: #ffffff; + --gd-bg-spotlight: #f5f5f5; + --gd-text: #1f1a17; + --gd-text-secondary: #5c534c; + --gd-text-muted: #8a8178; + --gd-primary: #e86a17; + --gd-primary-hover: #f07a2a; + --gd-primary-active: #c95510; + --gd-primary-soft: rgba(232, 106, 23, 0.1); + --gd-border: #e8e2db; + --gd-border-strong: #d4cbc2; + --gd-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} + +[data-gd-theme='blue'] { + --gd-theme-name: blue; + --gd-bg: #f3f6fb; + --gd-bg-spotlight: #e8eef8; + --gd-text: #152033; + --gd-text-secondary: #4a5a73; + --gd-text-muted: #7a8aa3; + --gd-primary: #2f6fed; + --gd-primary-hover: #4580f5; + --gd-primary-active: #1f56c9; + --gd-primary-soft: rgba(47, 111, 237, 0.12); + --gd-border: #d9e2f0; + --gd-border-strong: #b8c7de; + --gd-shadow: 0 8px 24px rgba(30, 50, 90, 0.1); + --gd-info: #2f6fed; +} + +[data-gd-theme='green'] { + --gd-theme-name: green; + --gd-bg: #f3f8f4; + --gd-bg-spotlight: #e6f2ea; + --gd-text: #15261b; + --gd-text-secondary: #456352; + --gd-text-muted: #6f8a78; + --gd-primary: #2f8f5b; + --gd-primary-hover: #3aa36a; + --gd-primary-active: #247549; + --gd-primary-soft: rgba(47, 143, 91, 0.12); + --gd-border: #d5e6db; + --gd-border-strong: #b3cfbc; + --gd-shadow: 0 8px 24px rgba(28, 60, 40, 0.1); + --gd-success: #2f8f5b; +} diff --git a/src/pyouter/webui/gd5/ui/css/typography.css b/src/pyouter/webui/gd5/ui/css/typography.css new file mode 100644 index 0000000..ff964fb --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/typography.css @@ -0,0 +1,69 @@ +.gd-title { + margin: 0; + color: var(--gd-text); + font-family: var(--gd-font); + font-weight: 650; + letter-spacing: 0.01em; +} + +.gd-title--1 { + font-size: 28px; + line-height: 1.25; +} + +.gd-title--2 { + font-size: 22px; + line-height: 1.3; +} + +.gd-title--3 { + font-size: 18px; + line-height: 1.35; +} + +.gd-title--4 { + font-size: 16px; + line-height: 1.4; +} + +.gd-title--5 { + font-size: 14px; + line-height: 1.4; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--gd-text-muted); + font-weight: 600; +} + +.gd-text { + margin: 0; + color: var(--gd-text); + font-size: 14px; + line-height: 1.6; + font-family: var(--gd-font); +} + +.gd-text--secondary { + color: var(--gd-text-secondary); +} + +.gd-text--muted { + color: var(--gd-text-muted); +} + +.gd-text--danger { + color: var(--gd-danger); +} + +.gd-text--small { + font-size: 12px; +} + +.gd-text--code { + font-family: var(--gd-font-mono); + font-size: 12px; + padding: 1px 6px; + border-radius: var(--gd-radius-sm); + background: var(--gd-bg-elevated); + border: 1px solid var(--gd-border); +} diff --git a/src/pyouter/webui/gd5/ui/css/ui.css b/src/pyouter/webui/gd5/ui/css/ui.css new file mode 100644 index 0000000..94aeb5f --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/ui.css @@ -0,0 +1,19 @@ +@import './tokens.css'; +@import './button.css'; +@import './input.css'; +@import './control.css'; +@import './layout.css'; +@import './typography.css'; +@import './card.css'; +@import './feedback.css'; +@import './modal.css'; +@import './extra.css'; +@import './workflow.css'; +@import './json-tree.css'; +@import './code-block.css'; +@import './markdown-editor.css'; +@import './coverflow.css'; +@import './todo.css'; +@import './embed.css'; +@import './antd-gap.css'; +@import './jelly.css'; diff --git a/src/pyouter/webui/gd5/ui/css/workflow.css b/src/pyouter/webui/gd5/ui/css/workflow.css new file mode 100644 index 0000000..f99b01e --- /dev/null +++ b/src/pyouter/webui/gd5/ui/css/workflow.css @@ -0,0 +1,188 @@ +/* WorkflowDAG — multi-column DAG canvas */ + +.gd-workflow { + --gd-wf-col-w: 220px; + --gd-wf-gap: 56px; + --gd-wf-node-radius: 8px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 320px; +} + +.gd-workflow__viewport { + position: relative; + flex: 1; + min-height: 280px; + max-height: 480px; + overflow: auto; + border: 1px solid var(--gd-border); + border-radius: var(--gd-radius-lg); + background: + radial-gradient(circle at 1px 1px, var(--gd-border) 1px, transparent 0) 0 0 / 20px 20px, + var(--gd-bg); + cursor: grab; + overscroll-behavior: contain; +} + +.gd-workflow__viewport:focus { + outline: 2px solid var(--gd-primary-soft); + outline-offset: 1px; +} + +.gd-workflow__viewport.is-panning { + cursor: grabbing; + user-select: none; +} + +.gd-workflow__canvas { + position: relative; + display: inline-block; + min-width: 100%; + min-height: 100%; + padding: 28px 32px 40px; + box-sizing: border-box; +} + +.gd-workflow__edges-host { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; +} + +.gd-workflow__edges { + display: block; + overflow: visible; +} + +.gd-workflow__edge { + stroke: var(--gd-border-strong); + stroke-width: 1.75; + stroke-linecap: round; +} + +.gd-workflow__arrow { + fill: var(--gd-border-strong); +} + +.gd-workflow__columns { + position: relative; + z-index: 1; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: var(--gd-wf-gap); + width: max-content; +} + +.gd-workflow__column { + width: var(--gd-wf-col-w); + flex: 0 0 var(--gd-wf-col-w); + display: flex; + flex-direction: column; + gap: 10px; +} + +.gd-workflow__column-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--gd-text-muted); + padding: 0 4px; +} + +.gd-workflow__column-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.gd-workflow__node { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + padding: 12px 14px; + border: 1px solid var(--gd-border); + border-radius: var(--gd-wf-node-radius); + background: var(--gd-bg-elevated); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + color: var(--gd-text); + font: inherit; + text-align: left; + cursor: pointer; + transition: + border-color 120ms var(--gd-ease), + box-shadow 120ms var(--gd-ease), + transform 120ms var(--gd-ease); +} + +.gd-workflow__node:hover { + border-color: var(--gd-border-strong); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); +} + +.gd-workflow__node--selected { + border-color: var(--gd-primary); + box-shadow: 0 0 0 2px var(--gd-primary-soft); +} + +.gd-workflow__node-title { + font-size: 14px; + font-weight: 600; + line-height: 1.3; +} + +.gd-workflow__node-status { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--gd-text-muted); + padding: 1px 6px; + border-radius: 999px; + background: var(--gd-bg-spotlight); +} + +.gd-workflow__node--running .gd-workflow__node-status { + color: var(--gd-primary); + background: var(--gd-primary-soft); +} + +.gd-workflow__node--success .gd-workflow__node-status { + color: var(--gd-success); + background: color-mix(in srgb, var(--gd-success) 12%, transparent); +} + +.gd-workflow__node--error .gd-workflow__node-status { + color: var(--gd-danger); + background: var(--gd-danger-soft); +} + +.gd-workflow__node--pending .gd-workflow__node-status { + color: var(--gd-text-muted); +} + +.gd-workflow__node-desc { + font-size: 12px; + line-height: 1.4; + color: var(--gd-text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.gd-workflow__hint { + font-size: 12px; + color: var(--gd-text-muted); +} + +.gd-workflow--static .gd-workflow__viewport { + max-height: none; + overflow: visible; + cursor: default; +} diff --git a/src/pyouter/webui/gd5/ui/drag.js b/src/pyouter/webui/gd5/ui/drag.js new file mode 100644 index 0000000..e73fca9 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/drag.js @@ -0,0 +1,26 @@ +import { cx, el, omit } from './util.js'; + +/** DragHandle — grip affordance for drag & drop. */ +export function DragHandle(props = {}) { + const { className } = props; + return el('button', { + ...omit(props, ['className']), + type: 'button', + className: cx('gd-drag-handle', className), + attrs: { 'aria-label': 'Drag', draggable: 'true' }, + text: '⠿', + }); +} + +/** DropZone — landing target with active state. */ +export function DropZone(props = {}, ...children) { + const { active = false, className, label = 'Drop here' } = props; + return el( + 'div', + { + ...omit(props, ['active', 'className', 'label']), + className: cx('gd-drop-zone', active && 'gd-drop-zone--active', className), + }, + children.length ? children : el('span', { className: 'gd-drop-zone__label', text: label }) + ); +} diff --git a/src/pyouter/webui/gd5/ui/feedback.js b/src/pyouter/webui/gd5/ui/feedback.js new file mode 100644 index 0000000..280ab99 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/feedback.js @@ -0,0 +1,67 @@ +import { cx, el, omit } from './util.js'; + +/** + * @param {object} [props] + * @param {'default'|'primary'|'danger'} [props.color] + * @param {string} [props.text] + */ +export function Tag(props = {}, ...children) { + const { color = 'default', className, text } = props; + const kids = children.length ? children : text != null ? [text] : []; + return el( + 'span', + { + ...omit(props, ['color', 'className', 'text']), + className: cx('gd-tag', color !== 'default' && `gd-tag--${color}`, className), + }, + ...kids + ); +} + +/** + * @param {object} [props] + * @param {'info'|'success'|'warning'|'error'} [props.type] + * @param {string} [props.message] + */ +export function Alert(props = {}, ...children) { + const { type = 'info', className, message } = props; + const kids = children.length ? children : message != null ? [message] : []; + return el( + 'div', + { + ...omit(props, ['type', 'className', 'message']), + className: cx('gd-alert', `gd-alert--${type}`, className), + role: 'alert', + }, + ...kids + ); +} + +export function Spin(props = {}) { + const { size = 'md', className } = props; + return el('div', { + ...omit(props, ['size', 'className']), + className: cx('gd-spin', size === 'lg' && 'gd-spin--lg', className), + attrs: { 'aria-label': 'loading' }, + }); +} + +/** + * @param {object} [props] + * @param {string} [props.description] + * @param {any} [props.extra] + */ +export function Empty(props = {}, ...children) { + const { description = 'No data', className, extra, title = 'Empty' } = props; + return el( + 'div', + { + ...omit(props, ['description', 'className', 'extra', 'title']), + className: cx('gd-empty', className), + }, + el('p', { className: 'gd-empty__title', text: title }), + el('p', { className: 'gd-empty__desc', text: description }), + extra || null, + ...children + ); +} diff --git a/src/pyouter/webui/gd5/ui/flex.js b/src/pyouter/webui/gd5/ui/flex.js new file mode 100644 index 0000000..3a68e32 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/flex.js @@ -0,0 +1,95 @@ +import { cx, el, omit } from './util.js'; + +/** + * Flex — flexbox container (antd Flex). + * @param {object} [props] + * @param {'horizontal'|'vertical'} [props.vertical] use vertical=true for column + * @param {boolean} [props.wrap] + * @param {string|number} [props.gap] + * @param {string} [props.justify] + * @param {string} [props.align] + */ +export function Flex(props = {}, ...children) { + const { + vertical = false, + wrap = false, + gap, + justify, + align, + className, + style, + } = props; + + const s = { ...(typeof style === 'object' ? style : {}) }; + if (gap != null) s.gap = typeof gap === 'number' ? `${gap}px` : gap; + if (justify) s.justifyContent = justify; + if (align) s.alignItems = align; + + return el( + 'div', + { + ...omit(props, ['vertical', 'wrap', 'gap', 'justify', 'align', 'className', 'style']), + className: cx( + 'gd-flex', + vertical && 'gd-flex--vertical', + wrap && 'gd-flex--wrap', + className + ), + style: s, + }, + ...children + ); +} + +/** + * Row / Col — 24-grid (antd Grid). + * @param {object} [props] + * @param {number|string} [props.gutter] + */ +export function Row(props = {}, ...children) { + const { gutter = 0, className, style, align, justify } = props; + const g = typeof gutter === 'number' ? gutter : 0; + return el( + 'div', + { + ...omit(props, ['gutter', 'className', 'style', 'align', 'justify']), + className: cx('gd-row', className), + style: { + ...(typeof style === 'object' ? style : {}), + marginLeft: g ? `-${g / 2}px` : undefined, + marginRight: g ? `-${g / 2}px` : undefined, + alignItems: align, + justifyContent: justify, + '--gd-row-gutter': g ? `${g / 2}px` : '0px', + }, + }, + ...children + ); +} + +/** + * @param {object} [props] + * @param {number} [props.span] 1–24 + * @param {number} [props.offset] + */ +export function Col(props = {}, ...children) { + const { span = 24, offset = 0, className, style } = props; + const pct = (span / 24) * 100; + const off = (offset / 24) * 100; + return el( + 'div', + { + ...omit(props, ['span', 'offset', 'className', 'style']), + className: cx('gd-col', className), + style: { + ...(typeof style === 'object' ? style : {}), + flex: `0 0 ${pct}%`, + maxWidth: `${pct}%`, + marginLeft: offset ? `${off}%` : undefined, + paddingLeft: 'var(--gd-row-gutter, 0px)', + paddingRight: 'var(--gd-row-gutter, 0px)', + }, + }, + ...children + ); +} diff --git a/src/pyouter/webui/gd5/ui/float.js b/src/pyouter/webui/gd5/ui/float.js new file mode 100644 index 0000000..fea1098 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/float.js @@ -0,0 +1,46 @@ +import { JELLY_HOST_ATTR } from './jelly.js'; +import { cx, el, omit } from './util.js'; + +/** + * Fab — Floating Action Button. + * @param {object} [props] + * @param {string} [props.text] + * @param {'primary'|'default'} [props.type] + * @param {function} [props.onClick] + */ +export function Fab(props = {}, ...children) { + const { text = '+', type = 'primary', className } = props; + const kids = children.length ? children : [text]; + return el( + 'button', + { + ...omit(props, ['text', 'type', 'className']), + type: 'button', + attrs: { ...(props.attrs || {}), [JELLY_HOST_ATTR]: 'solid' }, + className: cx('gd-fab', `gd-fab--${type}`, className), + }, + ...kids + ); +} + +/** ChatLauncher — FAB variant for chat. */ +export function ChatLauncher(props = {}) { + return Fab({ + ...props, + text: props.text ?? '💬', + className: cx('gd-fab--chat', props.className), + attrs: { 'aria-label': props.label || 'Open chat' }, + }); +} + +/** BackToTop — FAB that scrolls window to top. */ +export function BackToTop(props = {}) { + return Fab({ + ...props, + text: props.text ?? '↑', + type: props.type || 'default', + className: cx('gd-fab--top', props.className), + onClick: props.onClick || (() => window.scrollTo({ top: 0, behavior: 'smooth' })), + attrs: { 'aria-label': 'Back to top' }, + }); +} diff --git a/src/pyouter/webui/gd5/ui/form-extra.js b/src/pyouter/webui/gd5/ui/form-extra.js new file mode 100644 index 0000000..524e733 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/form-extra.js @@ -0,0 +1,331 @@ +import { cx, el, omit } from './util.js'; +import { Chip } from './chip.js'; +import { Input } from './input.js'; + +/** + * SearchInput — search field with clear affordance. + */ +export function SearchInput(props = {}) { + const { className, value, onClear, placeholder = 'Search…', ...rest } = props; + return el( + 'div', + { className: cx('gd-search', className) }, + el('span', { className: 'gd-search__icon', text: '⌕', attrs: { 'aria-hidden': 'true' } }), + Input({ + ...rest, + value, + placeholder, + className: 'gd-search__input', + type: 'search', + }), + value + ? el('button', { + type: 'button', + className: 'gd-search__clear', + text: '×', + attrs: { 'aria-label': 'Clear' }, + onClick: onClear, + }) + : null + ); +} + +/** + * Combobox — text input + filtered suggestion list. + * @param {object} [props] + * @param {string} [props.value] + * @param {Array} [props.options] + * @param {boolean} [props.open] + * @param {function} [props.onChange] + * @param {function} [props.onSelect] + */ +export function Combobox(props = {}) { + const { + value = '', + options = [], + open = false, + onChange, + onSelect, + onOpenChange, + placeholder = 'Type to search…', + className, + } = props; + const normalized = options.map((o) => + typeof o === 'string' ? { value: o, label: o } : o + ); + const q = String(value).toLowerCase(); + const filtered = normalized.filter((o) => o.label.toLowerCase().includes(q)); + + return el( + 'div', + { + ...omit(props, [ + 'value', + 'options', + 'open', + 'onChange', + 'onSelect', + 'onOpenChange', + 'placeholder', + 'className', + ]), + className: cx('gd-combobox', open && 'gd-combobox--open', className), + }, + Input({ + value, + placeholder, + className: 'gd-combobox__input', + onInput: (e) => { + onChange?.(e.target.value); + onOpenChange?.(true); + }, + onFocus: () => onOpenChange?.(true), + }), + open + ? el( + 'div', + { className: 'gd-combobox__list', role: 'listbox' }, + filtered.length + ? filtered.map((o) => + el('button', { + type: 'button', + role: 'option', + className: 'gd-combobox__option', + text: o.label, + onClick: () => { + onSelect?.(o.value, o); + onOpenChange?.(false); + }, + }) + ) + : [el('div', { className: 'gd-combobox__empty', text: 'No matches' })] + ) + : null + ); +} + +/** + * TagInput / TokenField — chips + text entry. + * @param {object} [props] + * @param {string[]} [props.value] + * @param {function} [props.onChange] + */ +export function TagInput(props = {}) { + const { value = [], onChange, placeholder = 'Add…', className, id } = props; + return el( + 'div', + { + ...omit(props, ['value', 'onChange', 'placeholder', 'className']), + className: cx('gd-tag-input', className), + id, + }, + ...value.map((v, i) => + Chip({ + text: v, + closable: true, + onClose: () => onChange?.(value.filter((_, j) => j !== i)), + }) + ), + el('input', { + className: 'gd-tag-input__field', + placeholder, + onKeyDown: (e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const t = e.target.value.trim().replace(/,$/, ''); + if (t && !value.includes(t)) onChange?.([...value, t]); + e.target.value = ''; + } else if (e.key === 'Backspace' && !e.target.value && value.length) { + onChange?.(value.slice(0, -1)); + } + }, + }) + ); +} + +/** + * MultiSelect — checkbox dropdown of many values. + * @param {object} [props] + * @param {Array<{value:string,label:string}>} [props.options] + * @param {string[]} [props.value] + * @param {boolean} [props.open] + */ +export function MultiSelect(props = {}) { + const { + options = [], + value = [], + open = false, + onChange, + onOpenChange, + placeholder = 'Select…', + className, + } = props; + const label = + value.length === 0 + ? placeholder + : value + .map((v) => options.find((o) => o.value === v)?.label || v) + .join(', '); + + return el( + 'div', + { + ...omit(props, [ + 'options', + 'value', + 'open', + 'onChange', + 'onOpenChange', + 'placeholder', + 'className', + ]), + className: cx('gd-multi-select', open && 'gd-multi-select--open', className), + }, + el('button', { + type: 'button', + className: 'gd-multi-select__trigger', + text: label, + onClick: () => onOpenChange?.(!open), + }), + open + ? el( + 'div', + { className: 'gd-multi-select__panel' }, + ...options.map((o) => { + const checked = value.includes(o.value); + return el( + 'label', + { className: 'gd-multi-select__option' }, + el('input', { + type: 'checkbox', + checked, + onChange: () => { + onChange?.( + checked ? value.filter((v) => v !== o.value) : [...value, o.value] + ); + }, + }), + el('span', { text: o.label }) + ); + }) + ) + : null + ); +} + +/** + * Transfer — two-pane multi-select. + * @param {object} [props] + * @param {Array<{key:string,title:string}>} [props.dataSource] + * @param {string[]} [props.targetKeys] + * @param {function} [props.onChange] + */ +export function Transfer(props = {}) { + const { dataSource = [], targetKeys = [], onChange, className, titles = ['Source', 'Target'] } = + props; + const left = dataSource.filter((d) => !targetKeys.includes(d.key)); + const right = dataSource.filter((d) => targetKeys.includes(d.key)); + + const moveRight = (key) => onChange?.([...targetKeys, key]); + const moveLeft = (key) => onChange?.(targetKeys.filter((k) => k !== key)); + + const pane = (title, list, onMove, actionLabel) => + el( + 'div', + { className: 'gd-transfer__pane' }, + el('div', { className: 'gd-transfer__title', text: title }), + el( + 'div', + { className: 'gd-transfer__list' }, + ...list.map((item) => + el( + 'button', + { + type: 'button', + className: 'gd-transfer__item', + onClick: () => onMove(item.key), + }, + el('span', { text: item.title }), + el('span', { className: 'gd-transfer__action', text: actionLabel }) + ) + ) + ) + ); + + return el( + 'div', + { + ...omit(props, ['dataSource', 'targetKeys', 'onChange', 'className', 'titles']), + className: cx('gd-transfer', className), + }, + pane(titles[0], left, moveRight, '→'), + pane(titles[1], right, moveLeft, '←') + ); +} + +/** + * Slider — range input with optional label. + */ +export function Slider(props = {}) { + const { value = 0, min = 0, max = 100, step = 1, onChange, className, showValue = true } = + props; + return el( + 'div', + { className: cx('gd-slider', className) }, + el('input', { + ...omit(props, ['className', 'showValue', 'onChange']), + type: 'range', + className: 'gd-slider__input', + value, + min, + max, + step, + onInput: (e) => onChange?.(Number(e.target.value), e), + }), + showValue ? el('span', { className: 'gd-slider__value', text: String(value) }) : null + ); +} + +/** + * ColorWell — swatch + native color input. + */ +export function ColorWell(props = {}) { + const { value = '#e86a17', onChange, className } = props; + return el( + 'label', + { + ...omit(props, ['value', 'onChange', 'className']), + className: cx('gd-color-well', className), + title: value, + }, + el('span', { + className: 'gd-color-well__swatch', + style: { background: value }, + }), + el('input', { + type: 'color', + className: 'gd-color-well__input', + value, + onInput: (e) => onChange?.(e.target.value, e), + }) + ); +} + +/** + * DatePicker — native date input (calendar popup is browser-provided). + */ +export function DatePicker(props = {}) { + const { value, onChange, className, type = 'date' } = props; + return el('input', { + ...omit(props, ['className', 'onChange']), + type, // date | datetime-local | month | week + className: cx('gd-input', 'gd-date-picker', className), + value, + onInput: (e) => onChange?.(e.target.value, e), + }); +} + +/** TimeField — native time input. */ +export function TimeField(props = {}) { + return DatePicker({ ...props, type: 'time' }); +} diff --git a/src/pyouter/webui/gd5/ui/form.js b/src/pyouter/webui/gd5/ui/form.js new file mode 100644 index 0000000..966eb55 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/form.js @@ -0,0 +1,58 @@ +import { cx, el, omit } from './util.js'; +import { Field } from './input.js'; + +/** + * Form — vertical form shell (antd Form, simplified). + * @param {object} [props] + * @param {function} [props.onSubmit] (values, event) + * @param {'vertical'|'horizontal'} [props.layout] + */ +export function Form(props = {}, ...children) { + const { onSubmit, layout = 'vertical', className } = props; + return el( + 'form', + { + ...omit(props, ['onSubmit', 'layout', 'className']), + className: cx('gd-form', `gd-form--${layout}`, className), + onSubmit: (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const values = Object.fromEntries(fd.entries()); + onSubmit?.(values, e); + }, + }, + ...children + ); +} + +/** + * FormItem — labeled control with optional required / error (antd Form.Item). + * Prefer over raw Field when inside Form. + */ +export function FormItem(props = {}, ...children) { + const { label, name, required, error, hint, control, className } = props; + const body = children.length ? children : control ? [control] : []; + return el( + 'div', + { + ...omit(props, ['label', 'name', 'required', 'error', 'hint', 'control', 'className']), + className: cx('gd-form-item', error && 'gd-form-item--error', className), + }, + label != null + ? el('label', { + className: cx('gd-form-item__label', required && 'gd-form-item__label--required'), + text: label, + attrs: name ? { for: name } : undefined, + }) + : null, + el('div', { className: 'gd-form-item__control' }, ...body), + error != null + ? el('div', { className: 'gd-form-item__error', text: error }) + : hint != null + ? el('div', { className: 'gd-form-item__hint', text: hint }) + : null + ); +} + +/** Re-export Field for Form composition convenience */ +export { Field }; diff --git a/src/pyouter/webui/gd5/ui/icon.js b/src/pyouter/webui/gd5/ui/icon.js new file mode 100644 index 0000000..cf7926c --- /dev/null +++ b/src/pyouter/webui/gd5/ui/icon.js @@ -0,0 +1,57 @@ +import { cx, el, omit } from './util.js'; + +/** Built-in glyph map for common antd-like icons (text/emoji stand-ins). */ +const GLYPHS = { + search: '⌕', + close: '×', + check: '✓', + plus: '+', + minus: '−', + edit: '✎', + delete: '🗑', + setting: '⚙', + user: '☺', + home: '⌂', + menu: '☰', + more: '⋯', + left: '‹', + right: '›', + up: '⌃', + down: '⌄', + info: 'ℹ', + warning: '⚠', + star: '★', + heart: '♥', + link: '🔗', + download: '⇩', + upload: '⇧', + calendar: '📅', + mail: '✉', +}; + +/** + * Icon — inline glyph (antd Icon simplified; no @ant-design/icons dependency). + * @param {object} [props] + * @param {string} [props.type] key in built-in map, or pass `text` + * @param {string} [props.text] custom glyph + * @param {'sm'|'md'|'lg'} [props.size] + * @param {string} [props.spin] + */ +export function Icon(props = {}) { + const { type, text, size = 'md', spin = false, className, rotate } = props; + const glyph = text ?? GLYPHS[type] ?? type ?? '•'; + return el('span', { + ...omit(props, ['type', 'text', 'size', 'spin', 'className', 'rotate']), + className: cx( + 'gd-icon', + `gd-icon--${size}`, + spin && 'gd-icon--spin', + className + ), + text: glyph, + style: rotate != null ? { transform: `rotate(${rotate}deg)` } : undefined, + attrs: { 'aria-hidden': 'true' }, + }); +} + +Icon.glyphs = GLYPHS; diff --git a/src/pyouter/webui/gd5/ui/image.js b/src/pyouter/webui/gd5/ui/image.js new file mode 100644 index 0000000..644f18d --- /dev/null +++ b/src/pyouter/webui/gd5/ui/image.js @@ -0,0 +1,48 @@ +import { cx, el, omit } from './util.js'; + +/** + * Image — img with optional preview click (antd Image). + * Wire preview via onPreview (e.g. open Lightbox). + * @param {object} [props] + * @param {string} [props.src] + * @param {string} [props.alt] + * @param {number|string} [props.width] + * @param {boolean} [props.preview] + * @param {function} [props.onPreview] + */ +export function Image(props = {}) { + const { + src, + alt = '', + width, + height, + preview = true, + onPreview, + className, + style, + } = props; + + const dim = { ...(typeof style === 'object' ? style : {}) }; + if (width != null) dim.width = typeof width === 'number' ? `${width}px` : width; + if (height != null) dim.height = typeof height === 'number' ? `${height}px` : height; + + return el( + 'div', + { + className: cx('gd-image', preview && 'gd-image--previewable', className), + style: dim, + onClick: preview + ? () => onPreview?.({ src, alt }) + : undefined, + }, + el('img', { + ...omit(props, ['preview', 'onPreview', 'className', 'style', 'width', 'height']), + className: 'gd-image__img', + src, + alt, + }), + preview + ? el('span', { className: 'gd-image__mask', text: 'Preview' }) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/index.js b/src/pyouter/webui/gd5/ui/index.js new file mode 100644 index 0000000..8bd5c23 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/index.js @@ -0,0 +1,226 @@ +/** + * gd5 UI — component library on top of the engine. + * Catalog: NameThatUI web elements + Ant Design component coverage. + * + * Primitives return real-DOM Elements for ensureShell / bind trees. + * Overlay widgets are Components (portal mount). + * + * import { loadUi, Button, Input, Modal } from '../ui/index.js'; + * await loadUi(); + */ + +export { loadUi, ensureUiStyles, cx } from './util.js'; +export { + themes, + setTheme, + getTheme, + isThemeName, + resolveThemeInput, + THEME_ATTR, +} from './theme.js'; +export { + setJelly, + getJelly, + JellySwitch, + ensureJelly, + applyRememberedJelly, + scaleJellyConfig, + JELLY_ATTR, + JELLY_HOST_ATTR, + JELLY_KEY, +} from './jelly.js'; +export { JELLY_REVIEW } from './jelly-review.js'; + +export { Button, ButtonIndicator, onceAsync } from './button.js'; +export { LoadingButton, AsyncButton, createLoadingButton } from './LoadingButton.js'; +export { Input, TextArea, Select, Field } from './input.js'; +export { InputNumber } from './input-number.js'; +export { Checkbox, Switch, Badge } from './control.js'; +export { Radio, RadioGroup } from './radio.js'; +export { Rate } from './rate.js'; +export { Chip, Pill } from './chip.js'; +export { Space, Divider } from './space.js'; +export { Flex, Row, Col } from './flex.js'; +export { Title, Text } from './typography.js'; +export { Truncate, PasswordInput, OrDivider, SignInForm } from './text-extra.js'; +export { Card } from './card.js'; +export { Tag, Alert, Spin, Empty } from './feedback.js'; +export { Banner, Callout } from './banner.js'; +export { ProgressBar, ProgressRing, Meter } from './progress.js'; +export { Skeleton, SkeletonParagraph } from './skeleton.js'; +export { Avatar, AvatarGroup, StatusDot } from './avatar.js'; +export { Accordion, Collapse } from './accordion.js'; +export { Tabs, ToggleGroup, SegmentedControl } from './tabs.js'; +export { Breadcrumbs } from './breadcrumbs.js'; +export { Steps, QuantityStepper } from './steps.js'; +export { Pagination, PageControl } from './pagination.js'; +export { + Navbar, + SiteHeader, + BottomNav, + Sidebar, + Toolbar, + HamburgerButton, + Scrollspy, + SplitButton, +} from './navigation.js'; +export { + Scrim, + Tooltip, + Popover, + DropdownMenu, + HoverCard, + ContextMenu, + OverflowMenu, +} from './overlay.js'; +export { + SearchInput, + Combobox, + TagInput, + MultiSelect, + Transfer, + Slider, + ColorWell, + DatePicker, + TimeField, +} from './form-extra.js'; +export { Cascader } from './cascader.js'; +export { Upload, Mentions } from './upload.js'; +export { TreeSelect } from './tree-select.js'; +export { Form, FormItem } from './form.js'; +export { Table, DataTable } from './table.js'; +export { List, ListItem, Descriptions, Statistic } from './list.js'; +export { Calendar } from './calendar.js'; +export { Image } from './image.js'; +export { Result, Popconfirm } from './result.js'; +export { Timeline } from './timeline.js'; +export { ChatBubble } from './chat.js'; +export { Carousel } from './carousel.js'; +export { + CoverFlow, + Coverflow, + CoverflowCarousel, + createCoverFlow, + normalizeCoverFlowItems, + coverFlowDragSlots, + coverFlowSettleIndex, + coverFlowOffset, +} from './CoverFlow.js'; +export { + TodoList, + TodoListView, + Todos, + Todo, + createTodoList, + normalizeTodoItems, + normalizeTodoStatus, + countTodoProgress, + nextTodoStatus, +} from './todo.js'; +export { + EChart, + EChartView, + createEChart, + loadEcharts, +} from './EChart.js'; +export { + ThreeView, + ThreeViewView, + ThreeScene, + createThreeView, + loadThree, +} from './ThreeView.js'; +export { + SparkView, + SparkViewView, + SplatView, + createSparkView, + loadSpark, + loadSparkThree, + ensureSparkImportMap, + DEFAULT_SPARK_URL, + DEFAULT_SPLAT_URL, + DEFAULT_SPARK_THREE_URL, +} from './SparkView.js'; +export { + PeleView, + PeleViewView, + MermaidView, + createPeleView, + loadPele, + DEFAULT_PELE_URL, + DEFAULT_PELE_SOURCE, +} from './PeleView.js'; +export { + BentoGrid, + BentoTile, + Masonry, + Marquee, + Sticky, + Fixed, + ScrollArea, + ResizeHandle, + Splitter, + SplitView, +} from './layout-extra.js'; +export { + Layout, + LayoutHeader, + LayoutSider, + LayoutContent, + LayoutFooter, +} from './layout.js'; +export { Menu } from './menu.js'; +export { Affix, Anchor } from './affix.js'; +export { Icon } from './icon.js'; +export { ConfigProvider } from './config.js'; +export { Watermark } from './watermark.js'; +export { Fab, ChatLauncher, BackToTop } from './float.js'; +export { PieChart, DonutChart } from './chart.js'; +export { + Tree, + OutlineView, + DisclosureTriangle, + treePathKeys, + nextTreeExpandedKeys, +} from './tree.js'; +export { DragHandle, DropZone } from './drag.js'; + +export { Modal } from './Modal.js'; +export { Drawer, Sheet } from './Drawer.js'; +export { Toast } from './Toast.js'; +export { Notification, messageApi } from './Notification.js'; +export { Lightbox } from './Lightbox.js'; +export { CommandPalette } from './CommandPalette.js'; +export { WorkflowDAG, WorkflowBoard } from './WorkflowDAG.js'; +export { + JsonTree, + createJsonTree, + flattenJsonTree, + jsonValueType, + jsonChildPath, + JSON_TREE_ROW_H, +} from './JsonTree.js'; +export { + CodeBlock, + Code, + DiffBlock, + Diff, + parseDiffLines, + parseUnifiedDiff, + splitCodeLines, + countCodeLines, + tokenizeCode, + tokensToLineNodes, + codeToHighlightedLines, + copyTextFromCodeSelection, +} from './code.js'; +export { + MarkdownEditor, + createMarkdownEditor, + renderMarkdown, + escapeHtml, + wrapSelection, + insertAtCursor, + prefixLines, +} from './markdown.js'; diff --git a/src/pyouter/webui/gd5/ui/input-number.js b/src/pyouter/webui/gd5/ui/input-number.js new file mode 100644 index 0000000..913315f --- /dev/null +++ b/src/pyouter/webui/gd5/ui/input-number.js @@ -0,0 +1,61 @@ +import { cx, el, omit } from './util.js'; + +/** + * InputNumber — numeric stepper field (antd InputNumber). + * @param {object} [props] + * @param {number} [props.value] + * @param {number} [props.min] + * @param {number} [props.max] + * @param {number} [props.step] + * @param {function} [props.onChange] + */ +export function InputNumber(props = {}) { + const { + value = 0, + min = -Infinity, + max = Infinity, + step = 1, + onChange, + className, + disabled, + placeholder, + } = props; + + const clamp = (n) => Math.min(max, Math.max(min, n)); + + return el( + 'div', + { + ...omit(props, ['value', 'min', 'max', 'step', 'onChange', 'className', 'placeholder']), + className: cx('gd-input-number', disabled && 'gd-input-number--disabled', className), + }, + el('button', { + type: 'button', + className: 'gd-input-number__btn', + text: '−', + disabled: disabled || value <= min, + onClick: () => onChange?.(clamp(Number(value) - step)), + }), + el('input', { + type: 'number', + className: 'gd-input-number__input', + value, + min: Number.isFinite(min) ? min : undefined, + max: Number.isFinite(max) ? max : undefined, + step, + disabled: !!disabled, + placeholder, + onInput: (e) => { + const n = Number(e.target.value); + if (!Number.isNaN(n)) onChange?.(clamp(n)); + }, + }), + el('button', { + type: 'button', + className: 'gd-input-number__btn', + text: '+', + disabled: disabled || value >= max, + onClick: () => onChange?.(clamp(Number(value) + step)), + }) + ); +} diff --git a/src/pyouter/webui/gd5/ui/input.js b/src/pyouter/webui/gd5/ui/input.js new file mode 100644 index 0000000..d3486de --- /dev/null +++ b/src/pyouter/webui/gd5/ui/input.js @@ -0,0 +1,72 @@ +import { cx, el, omit } from './util.js'; + +/** + * @param {object} [props] + * @param {'sm'|'md'|'lg'} [props.size] + */ +export function Input(props = {}) { + const { size = 'md', className, ...rest } = props; + return el('input', { + ...rest, + className: cx('gd-input', size !== 'md' && `gd-input--${size}`, className), + }); +} + +export function TextArea(props = {}, ...children) { + const { className, value, text, ...rest } = props; + const content = children.length ? children : value ?? text; + return el( + 'textarea', + { + ...omit(rest, []), + className: cx('gd-textarea', className), + ...(content != null && !children.length ? { text: String(content) } : {}), + }, + ...children + ); +} + +/** + * @param {object} [props] + * @param {Array<{value:string,label:string}>} [props.options] + */ +export function Select(props = {}) { + const { size = 'md', className, options = [], ...rest } = props; + return el( + 'select', + { + ...omit(rest, ['options']), + className: cx('gd-select', size !== 'md' && `gd-select--${size}`, className), + }, + ...options.map((opt) => + el('option', { + value: opt.value, + text: opt.label, + selected: opt.value === props.value ? true : undefined, + }) + ) + ); +} + +/** + * Labeled field wrapper. + * @param {object} [props] + * @param {string} [props.label] + * @param {string} [props.hint] + * @param {import('../engine/element.js').Element|any} [props.control] + */ +export function Field(props = {}, ...children) { + const { label, hint, error, control, className, id } = props; + const body = children.length ? children : control ? [control] : []; + return el( + 'div', + { className: cx('gd-field', error && 'gd-field--error', className), id }, + label != null ? el('label', { className: 'gd-field__label', text: label }) : null, + ...body, + error != null + ? el('div', { className: 'gd-field__error', text: error }) + : hint != null + ? el('div', { className: 'gd-field__hint', text: hint }) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/jelly-body.js b/src/pyouter/webui/gd5/ui/jelly-body.js new file mode 100644 index 0000000..1e7c6d1 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/jelly-body.js @@ -0,0 +1,669 @@ +/** + * Soft-body membrane from Jelly UI (https://github.com/jelly-org/ui). + * MIT License. Copyright Jelly UI contributors. + * Vendored as plain ESM so gd5 can run it without a bundler. + * The DOM layer in jelly.js decides which gd5 controls use it. + */ + +// ../tmp/jelly/src/utilities/index.ts +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} +function integrateSpring(position, velocity, target, stiffness, damping, dt) { + const acceleration = (target - position) * stiffness - velocity * damping; + const nextVelocity = velocity + acceleration * dt; + return [position + nextVelocity * dt, nextVelocity]; +} + +// ../tmp/jelly/src/core/config.ts +var DEFAULT_CONFIG = { + clickDepthSpring: 115, + clickDepthDamping: 18, + insidePressSpring: 92, + insidePressDamping: 15, + insideLocalBulgeImpulse: 390, + insideLocalHoldBulgeForce: 48, + pressSpring: 76, + pressDamping: 13, + holdPressAmount: 0, + heldCurveSpring: 135, + heldCurveDamping: 22, + insideHeldBulgeAmount: 10.8, + insideHeldHaloAmount: 2.65, + insideHeldDepthAmount: 0, + insidePointInfluenceWidth: 38, + insidePointHaloWidth: 70, + insidePointEdgeBoost: 0.44, + outsideHeldDentAmount: 7.2, + outsideHeldSideBulgeAmount: 1.7, + outsideHeldDepthAmount: 9.4, + normalBlendPasses: 3, + curveTension: 0.68, + axisDepth: 34, + axisSpring: 48, + axisDamping: 11, + depthImpulse: 420, + depthBulgeImpulse: 130, + depthSpring: 76, + depthDamping: 14, + depthCoupling: 150, + maxDepthIn: -26, + maxDepthOut: 16, + zRotateSpring: 42, + zRotateDamping: 12, + zRotateImpulse: 0.14, + membraneSpring: 96, + membraneDamping: 17, + waveCoupling: 145, + pressure: 620, + volumeCorrection: 0.1, + outsideDentImpulse: 255, + outsideSideBulgeImpulse: 54, + outsideOppositeBulgeImpulse: 74, + rippleWidth: 8, + outsideHoldForce: 20, + outsideHoldDepthForce: 52, + maxDent: 8, + maxBulge: 24, + perspective: 390, + samples: 240 +}; + +// ../tmp/jelly/src/core/body.ts +var MAX_STEP = 1 / 58; +function smoothstep(edge0, edge1, value) { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} +function wrap(index, length) { + return (index + length) % length; +} +function ringDistance(a, b, length) { + const distance = Math.abs(a - b); + return Math.min(distance, length - distance); +} +function gaussian(distance, width) { + return Math.exp(-(distance * distance) / (2 * width * width)); +} +function roundedRectSDF(x, y, halfW, halfH, radius) { + const r = Math.min(radius, halfW, halfH); + const qx = Math.abs(x) - (halfW - r); + const qy = Math.abs(y) - (halfH - r); + const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)); + const inside = Math.min(Math.max(qx, qy), 0); + return outside + inside - r; +} +function createRoundedRectMembrane(width, height, radius, targetSamples) { + const halfW = width / 2; + const halfH = height / 2; + const r = Math.min(radius, halfW, halfH); + const dense = []; + const push = (x, y) => { + dense.push({ x, y }); + }; + const edgeSteps = 48; + const arcSteps = 48; + function line(ax, ay, bx, by, includeStart) { + for (let i = includeStart ? 0 : 1; i <= edgeSteps; i++) { + const t = i / edgeSteps; + push(ax + (bx - ax) * t, ay + (by - ay) * t); + } + } + function arc(cx, cy, a0, a1) { + for (let i = 1; i <= arcSteps; i++) { + const a = a0 + (a1 - a0) * (i / arcSteps); + push(cx + Math.cos(a) * r, cy + Math.sin(a) * r); + } + } + line(-halfW + r, -halfH, halfW - r, -halfH, true); + arc(halfW - r, -halfH + r, -Math.PI / 2, 0); + line(halfW, -halfH + r, halfW, halfH - r, false); + arc(halfW - r, halfH - r, 0, Math.PI / 2); + line(halfW - r, halfH, -halfW + r, halfH, false); + arc(-halfW + r, halfH - r, Math.PI / 2, Math.PI); + line(-halfW, halfH - r, -halfW, -halfH + r, false); + arc(-halfW + r, -halfH + r, Math.PI, Math.PI * 1.5); + const cumulative = [0]; + let perimeter = 0; + for (let i = 0; i < dense.length; i++) { + const a = dense[i]; + const b = dense[wrap(i + 1, dense.length)]; + perimeter += Math.hypot(b.x - a.x, b.y - a.y); + cumulative.push(perimeter); + } + const points = []; + for (let s = 0; s < targetSamples; s++) { + const targetDistance = s / targetSamples * perimeter; + let segmentIndex = 0; + while (segmentIndex < dense.length - 1 && cumulative[segmentIndex + 1] < targetDistance) { + segmentIndex += 1; + } + const a = dense[segmentIndex]; + const b = dense[(segmentIndex + 1) % dense.length]; + const segmentStart = cumulative[segmentIndex]; + const segmentEnd = cumulative[segmentIndex + 1]; + const segmentLength = Math.max(segmentEnd - segmentStart, 1e-4); + const t = (targetDistance - segmentStart) / segmentLength; + points.push({ + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + nx: 0, + ny: 0, + d: 0, + v: 0, + z: 0, + zv: 0 + }); + } + softenNormals(points); + return points; +} +function outwardNormalFromNeighbors(points, index) { + const length = points.length; + const previous = points[wrap(index - 1, length)]; + const next = points[wrap(index + 1, length)]; + const tx = next.x - previous.x; + const ty = next.y - previous.y; + const normalLength = Math.hypot(ty, -tx) || 1; + return { nx: ty / normalLength, ny: -tx / normalLength }; +} +function softenNormals(points) { + const length = points.length; + let normals = points.map((_, index) => outwardNormalFromNeighbors(points, index)); + for (let pass = 0; pass < DEFAULT_CONFIG.normalBlendPasses; pass++) { + normals = normals.map((normal, index) => { + const previous = normals[wrap(index - 1, length)]; + const next = normals[wrap(index + 1, length)]; + const nx = previous.nx * 0.22 + normal.nx * 0.56 + next.nx * 0.22; + const ny = previous.ny * 0.22 + normal.ny * 0.56 + next.ny * 0.22; + const normalLength = Math.hypot(nx, ny) || 1; + return { nx: nx / normalLength, ny: ny / normalLength }; + }); + } + for (let i = 0; i < length; i++) { + points[i].nx = normals[i].nx; + points[i].ny = normals[i].ny; + } +} +function polygonArea(points) { + let area = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[wrap(i + 1, points.length)]; + area += a.x * b.y - b.x * a.y; + } + return Math.abs(area) * 0.5; +} +function traceSmoothPath(ctx, points, tension = DEFAULT_CONFIG.curveTension) { + const length = points.length; + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 0; i < length; i++) { + const p0 = points[wrap(i - 1, length)]; + const p1 = points[i]; + const p2 = points[wrap(i + 1, length)]; + const p3 = points[wrap(i + 2, length)]; + const cp1x = p1.x + (p2.x - p0.x) * tension / 6; + const cp1y = p1.y + (p2.y - p0.y) * tension / 6; + const cp2x = p2.x - (p3.x - p1.x) * tension / 6; + const cp2y = p2.y - (p3.y - p1.y) * tension / 6; + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + ctx.closePath(); +} +var JellyBody = class { + width; + height; + radius; + config; + lean; + leanAmount; + membrane; + state; + baseArea; + constructor({ width, height, radius, config }) { + this.width = width; + this.height = height; + this.radius = radius ?? Math.min(width, height) / 2; + this.config = { ...DEFAULT_CONFIG, ...config ?? {} }; + this.lean = 0; + this.leanAmount = 0; + this.membrane = createRoundedRectMembrane(width, height, this.radius, this.config.samples); + this.state = { + clickDepth: 0, + clickDepthV: 0, + targetClickDepth: 0, + insidePress: 0, + insidePressV: 0, + targetInsidePress: 0, + press: 0, + pressV: 0, + targetPress: 0, + insideCurveHold: 0, + insideCurveHoldV: 0, + targetInsideCurveHold: 0, + rotateZ: 0, + rotateZV: 0, + tiltX: 0, + tiltXV: 0, + tiltY: 0, + tiltYV: 0, + targetTiltX: 0, + targetTiltY: 0, + pointerActive: false, + pointerInsideWeight: 0, + pointerIndex: 0, + pointerLocalX: 0, + pointerLocalY: 0 + }; + this.baseArea = polygonArea(this.getSurfacePoints()); + } + // Rebuild the ring for a new size, preserving no motion (used on resize) + resize(width, height, radius) { + this.width = width; + this.height = height; + this.radius = radius ?? Math.min(width, height) / 2; + this.membrane = createRoundedRectMembrane(width, height, this.radius, this.config.samples); + this.baseArea = polygonArea(this.getSurfacePoints()); + } + // Signed distance from a local point to the resting surface + sdf(x, y) { + return roundedRectSDF(x, y, this.width / 2, this.height / 2, this.radius); + } + // Index of the membrane point closest to a local coordinate + nearestMembraneIndex(x, y) { + let nearest = 0; + let nearestDistance = Infinity; + for (let i = 0; i < this.membrane.length; i++) { + const p = this.membrane[i]; + const dx = x - p.x; + const dy = y - p.y; + const distance = dx * dx + dy * dy; + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = i; + } + } + return nearest; + } + // Kick the membrane outward around a ring index with a gaussian falloff + addMembraneImpulse(index, amount, width) { + const length = this.membrane.length; + for (let i = 0; i < length; i++) { + const distance = ringDistance(i, index, length); + this.membrane[i].v += amount * gaussian(distance, width); + } + } + // Kick the membrane's depth (z) around a ring index with a gaussian falloff + addDepthImpulse(index, amount, width) { + const length = this.membrane.length; + for (let i = 0; i < length; i++) { + const distance = ringDistance(i, index, length); + this.membrane[i].zv += amount * gaussian(distance, width); + } + } + // Bulge the membrane around the pointer with a softer counter-halo + addInsidePointImpulse(amount) { + for (let i = 0; i < this.membrane.length; i++) { + const influence = this.insidePointInfluence(i); + this.membrane[i].v += amount * (influence.local - influence.halo * 0.18); + } + } + // Five-tap smoothing of one membrane channel around an index + smoothedMembraneValue(index, key) { + const length = this.membrane.length; + const p0 = this.membrane[wrap(index - 2, length)][key]; + const p1 = this.membrane[wrap(index - 1, length)][key]; + const p2 = this.membrane[index][key]; + const p3 = this.membrane[wrap(index + 1, length)][key]; + const p4 = this.membrane[wrap(index + 2, length)][key]; + return p0 * 0.06 + p1 * 0.2 + p2 * 0.48 + p3 * 0.2 + p4 * 0.06; + } + // How strongly the pointer's held position affects one membrane point + insidePointInfluence(index) { + const point = this.membrane[index]; + const dx = point.x - this.state.pointerLocalX; + const dy = point.y - this.state.pointerLocalY; + const distanceFromClick = Math.hypot(dx, dy); + const local = gaussian(distanceFromClick, this.config.insidePointInfluenceWidth); + const halo = gaussian(distanceFromClick, this.config.insidePointHaloWidth); + const pointerRadius = clamp( + Math.hypot( + this.state.pointerLocalX / (this.width / 2), + this.state.pointerLocalY / (this.height / 2) + ), + 0, + 1 + ); + const edgeBoost = 1 + smoothstep(0.12, 0.82, pointerRadius) * this.config.insidePointEdgeBoost; + return { + local: local * edgeBoost, + halo: Math.max(halo - local * 0.34, 0) + }; + } + // Sustained bulge / depth offsets while the pointer is held inside + heldMembraneOffsets(index) { + const insidePoint = this.insidePointInfluence(index); + return { + d: this.state.insideCurveHold * (this.config.insideHeldBulgeAmount * insidePoint.local - this.config.insideHeldHaloAmount * insidePoint.halo), + z: -this.state.insideCurveHold * this.config.insideHeldDepthAmount * insidePoint.local + }; + } + // Five-tap smoothing over a plain array of ring values + smoothArrayValue(values, index) { + const length = values.length; + return values[wrap(index - 2, length)] * 0.06 + values[wrap(index - 1, length)] * 0.2 + values[index] * 0.48 + values[wrap(index + 1, length)] * 0.2 + values[wrap(index + 2, length)] * 0.06; + } + /* + * The current deformed surface. `offset` pushes every point outward along + * its normal - used to trace a focus ring that follows the same jiggle. + */ + getSurfacePoints(offset = 0) { + const points = []; + const length = this.membrane.length; + const totalD = new Array(length); + const totalZ = new Array(length); + for (let i = 0; i < length; i++) { + const held = this.heldMembraneOffsets(i); + totalD[i] = this.smoothedMembraneValue(i, "d") + held.d; + totalZ[i] = this.smoothedMembraneValue(i, "z") + held.z; + } + for (let i = 0; i < length; i++) { + const p = this.membrane[i]; + const smoothedD = this.smoothArrayValue(totalD, i); + const smoothedZ = this.smoothArrayValue(totalZ, i); + const prevD = this.smoothArrayValue(totalD, wrap(i - 1, length)); + const nextD = this.smoothArrayValue(totalD, wrap(i + 1, length)); + const gradient = nextD - prevD; + const tx = -p.ny; + const ty = p.nx; + const tangentSlide = gradient * 0.05; + const leanBias = this.leanAmount === 0 ? 0 : this.lean * clamp(p.x / (this.width / 2), -1, 1) * this.leanAmount; + const x = p.x + p.nx * (smoothedD + offset + leanBias) + tx * tangentSlide; + const y = p.y + p.ny * (smoothedD + offset + leanBias) + ty * tangentSlide; + const xNorm = clamp(x / (this.width / 2), -1, 1); + const yNorm = clamp(y / (this.height / 2), -1, 1); + const planeDepth = -(xNorm * this.state.tiltY + yNorm * this.state.tiltX) * this.config.axisDepth; + points.push({ x, y, z: planeDepth + smoothedZ }); + } + return points; + } + // Apply the perspective projection to one surface point + projectPoint(point) { + const perspective = this.config.perspective / (this.config.perspective - point.z); + return { x: point.x * perspective, y: point.y * perspective, z: point.z }; + } + // Aim the held-press springs at the pointer's current position + updatePressTargets(localX, localY, influence = 1) { + this.state.pointerLocalX = localX; + this.state.pointerLocalY = localY; + const signedDistance = this.sdf(localX, localY); + const insideWeight = (1 - smoothstep(-2, 5, signedDistance)) * influence; + this.state.pointerInsideWeight = insideWeight; + this.state.targetClickDepth = insideWeight; + this.state.targetInsidePress = insideWeight; + this.state.targetPress = insideWeight * this.config.holdPressAmount; + this.state.targetInsideCurveHold = insideWeight; + return { insideWeight }; + } + // Press at a point already in local (shape-centered) coordinates + pressAtLocal(localX, localY, strength = 1, influence = 1) { + const index = this.nearestMembraneIndex(localX, localY); + this.state.pointerIndex = index; + this.state.pointerActive = true; + const { insideWeight } = this.updatePressTargets(localX, localY, influence); + const force = strength * 1.15 * influence; + if (insideWeight > 0.01) { + this.addInsidePointImpulse(this.config.insideLocalBulgeImpulse * insideWeight * force); + this.state.pressV += 1.9 * insideWeight * force; + } + } + // Follow the pointer while it is held down + moveToLocal(localX, localY, influence = 1) { + this.state.pointerIndex = this.nearestMembraneIndex(localX, localY); + this.updatePressTargets(localX, localY, influence); + } + // A press whose energy comes from the shape center (keyboard activation) + centerPulse(strength = 1) { + this.pressAtLocal(0, 0, strength); + } + /* + * A one-shot squish from the center that settles on its own - for feedback + * that isn't a sustained press (focus, toggle, grab). Unlike centerPulse it + * sets no held target, so the body returns to rest and can sleep. + */ + centerPop(strength = 1) { + this.state.pointerLocalX = 0; + this.state.pointerLocalY = 0; + this.addInsidePointImpulse(this.config.insideLocalBulgeImpulse * 0.5 * strength); + this.state.pressV += 0.9 * strength; + } + /* + * A one-shot directional jiggle along an axis: the leading edge (normals + * aligned with the direction) bulges out and the trailing edge tucks in, so + * the blob stretches the way it's thrown. Used by the sliding controls and + * jelly-alert's shake. + */ + stretchAlong(dirX, dirY, strength = 1) { + const length = Math.hypot(dirX, dirY) || 1; + const dx = dirX / length; + const dy = dirY / length; + const amount = this.config.insideLocalBulgeImpulse * 0.42 * strength; + for (const p of this.membrane) { + const align = p.nx * dx + p.ny * dy; + p.v += amount * align; + } + } + // A one-shot ripple with no hold (typing feedback, toggles, etc.) + pulseAt(localX, localY, strength = 1) { + const index = this.nearestMembraneIndex(localX, localY); + this.state.pointerLocalX = localX; + this.state.pointerLocalY = localY; + this.addMembraneImpulse( + index, + this.config.insideLocalBulgeImpulse * 0.5 * strength, + this.config.rippleWidth * 1.6 + ); + } + // Let go: clear every held target and give a small parting kick + release() { + this.state.pointerActive = false; + this.state.targetClickDepth = 0; + this.state.targetInsidePress = 0; + this.state.targetPress = 0; + this.state.targetInsideCurveHold = 0; + this.state.targetTiltX = 0; + this.state.targetTiltY = 0; + this.state.pointerInsideWeight = 0; + this.state.pressV -= 0.55; + } + // Advance the whole-body springs (press depth, tilt, rotation) one step + updateGlobal(dt) { + const s = this.state; + const c = this.config; + [s.clickDepth, s.clickDepthV] = integrateSpring( + s.clickDepth, + s.clickDepthV, + s.targetClickDepth, + c.clickDepthSpring, + c.clickDepthDamping, + dt + ); + [s.insidePress, s.insidePressV] = integrateSpring( + s.insidePress, + s.insidePressV, + s.targetInsidePress, + c.insidePressSpring, + c.insidePressDamping, + dt + ); + [s.press, s.pressV] = integrateSpring( + s.press, + s.pressV, + s.targetPress, + c.pressSpring, + c.pressDamping, + dt + ); + [s.insideCurveHold, s.insideCurveHoldV] = integrateSpring( + s.insideCurveHold, + s.insideCurveHoldV, + s.targetInsideCurveHold, + c.heldCurveSpring, + c.heldCurveDamping, + dt + ); + [s.rotateZ, s.rotateZV] = integrateSpring( + s.rotateZ, + s.rotateZV, + 0, + c.zRotateSpring, + c.zRotateDamping, + dt + ); + [s.tiltX, s.tiltXV] = integrateSpring( + s.tiltX, + s.tiltXV, + s.targetTiltX, + c.axisSpring, + c.axisDamping, + dt + ); + [s.tiltY, s.tiltYV] = integrateSpring( + s.tiltY, + s.tiltYV, + s.targetTiltY, + c.axisSpring, + c.axisDamping, + dt + ); + s.clickDepth = clamp(s.clickDepth, 0, 1); + s.insidePress = clamp(s.insidePress, 0, 1); + s.press = clamp(s.press, -0.025, 0.085); + s.insideCurveHold = clamp(s.insideCurveHold, 0, 1); + s.rotateZ = clamp(s.rotateZ, -0.018, 0.018); + s.tiltX = clamp(s.tiltX, -1, 1); + s.tiltY = clamp(s.tiltY, -1, 1); + } + // Advance the membrane wave equation one step + updateMembrane(dt) { + const length = this.membrane.length; + const c = this.config; + const points = this.getSurfacePoints(); + const area = polygonArea(points); + const areaError = clamp((this.baseArea - area) / this.baseArea, -0.08, 0.08); + const membraneAccel = new Array(length); + const depthAccel = new Array(length); + for (let i = 0; i < length; i++) { + const p = this.membrane[i]; + const prev = this.membrane[wrap(i - 1, length)]; + const next = this.membrane[wrap(i + 1, length)]; + const membraneLap = prev.d + next.d - 2 * p.d; + const depthLap = prev.z + next.z - 2 * p.z; + membraneAccel[i] = -p.d * c.membraneSpring + membraneLap * c.waveCoupling - p.v * c.membraneDamping + areaError * c.pressure; + depthAccel[i] = -p.z * c.depthSpring + depthLap * c.depthCoupling - p.zv * c.depthDamping; + } + if (this.state.pointerActive && this.state.pointerInsideWeight > 0.02) { + for (let i = 0; i < length; i++) { + const influence = this.insidePointInfluence(i); + membraneAccel[i] += c.insideLocalHoldBulgeForce * influence.local * this.state.pointerInsideWeight; + membraneAccel[i] -= c.insideLocalHoldBulgeForce * 0.18 * influence.halo * this.state.pointerInsideWeight; + } + } + for (let i = 0; i < length; i++) { + const p = this.membrane[i]; + p.v += membraneAccel[i] * dt; + p.d += p.v * dt; + p.zv += depthAccel[i] * dt; + p.z += p.zv * dt; + p.d = clamp(p.d, -c.maxDent, c.maxBulge); + p.v = clamp(p.v, -410, 410); + p.z = clamp(p.z, c.maxDepthIn, c.maxDepthOut); + p.zv = clamp(p.zv, -600, 600); + } + let average = 0; + for (const p of this.membrane) { + average += p.d; + } + average /= length; + for (const p of this.membrane) { + p.d -= average * c.volumeCorrection; + } + } + /* + * Advance the simulation by a frame delta. The delta is split into + * substeps no longer than MAX_STEP so the integration stays stable on + * slow frames, low-Hz displays and after background-tab pauses. + */ + update(dt) { + const steps = Math.max(1, Math.ceil(dt / MAX_STEP)); + const h = dt / steps; + for (let i = 0; i < steps; i++) { + this.updateGlobal(h); + this.updateMembrane(h); + } + this.recoverIfUnstable(); + } + /* + * Defensive backstop: if anything ever produced a non-finite number + * (a zero-sized rebuild, an extreme external mutation), reset all motion + * instead of painting garbage forever. The clamps make this near + * impossible, but a physics toy should never be able to wedge the UI. + */ + recoverIfUnstable() { + const s = this.state; + let healthy = Number.isFinite(s.press) && Number.isFinite(s.clickDepth) && Number.isFinite(s.rotateZ); + if (healthy) { + for (const p of this.membrane) { + if (!Number.isFinite(p.d) || !Number.isFinite(p.v) || !Number.isFinite(p.z) || !Number.isFinite(p.zv)) { + healthy = false; + break; + } + } + } + if (healthy) { + return; + } + for (const p of this.membrane) { + p.d = 0; + p.v = 0; + p.z = 0; + p.zv = 0; + } + s.clickDepth = 0; + s.clickDepthV = 0; + s.insidePress = 0; + s.insidePressV = 0; + s.press = 0; + s.pressV = 0; + s.insideCurveHold = 0; + s.insideCurveHoldV = 0; + s.rotateZ = 0; + s.rotateZV = 0; + s.tiltX = 0; + s.tiltXV = 0; + s.tiltY = 0; + s.tiltYV = 0; + } + // True when the body has essentially stopped moving and nothing is held + isResting() { + const s = this.state; + if (s.pointerActive) { + return false; + } + if (Math.abs(s.clickDepth) > 1e-3 || Math.abs(s.insidePress) > 1e-3 || Math.abs(s.insideCurveHold) > 1e-3 || Math.abs(s.rotateZ) > 1e-4 || Math.abs(s.tiltX) > 1e-3 || Math.abs(s.tiltY) > 1e-3) { + return false; + } + for (const p of this.membrane) { + if (Math.abs(p.d) > 0.03 || Math.abs(p.v) > 0.05 || Math.abs(p.z) > 0.03 || Math.abs(p.zv) > 0.05) { + return false; + } + } + return true; + } +}; +export { + DEFAULT_CONFIG, + JellyBody, + traceSmoothPath +}; diff --git a/src/pyouter/webui/gd5/ui/jelly-review.js b/src/pyouter/webui/gd5/ui/jelly-review.js new file mode 100644 index 0000000..f00b848 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/jelly-review.js @@ -0,0 +1,116 @@ +/** + * Suitability of every public UI component for the jelly membrane. + * The global switch is data-gd-jelly; only `jelly: true` entries are hosts. + * A new PascalCase export must be added here or the kit test fails. + */ + +/** @type {Record} */ +export const JELLY_REVIEW = {}; + +/** + * @param {string[]} names + * @param {string} reason + */ +function skip(names, reason) { + for (const name of names) JELLY_REVIEW[name] = { jelly: false, reason }; +} + +function host(name, reason) { + JELLY_REVIEW[name] = { jelly: true, reason }; +} + +host( + 'Button', + 'Primary and default are solid pressable surfaces. Ghost and danger keep an outline membrane. Text stays flat.' +); +host('LoadingButton', 'Renders Button, so it follows the same solid-type rule.'); +host('AsyncButton', 'Alias of LoadingButton.'); +host('Chip', 'Interactive pill with a solid fill. Static pills and tags stay rigid.'); +host('Fab', 'Round solid floating button. The membrane rests as the same circle.'); +host('ChatLauncher', 'Renders Fab.'); +host('BackToTop', 'Renders Fab.'); + +skip( + ['ButtonIndicator'], + 'Loading dots sit inside the button; the membrane belongs to the button, not the glyph.' +); +skip( + ['Input', 'TextArea', 'Select', 'Field', 'InputNumber', 'PasswordInput', 'SearchInput', 'Combobox', 'TagInput', 'MultiSelect', 'Transfer', 'DatePicker', 'TimeField', 'Cascader', 'TreeSelect', 'Mentions', 'ColorWell'], + 'Text fields need a stable caret, cursor, and outline. A wobbling membrane fights typing.' +); +skip( + ['Checkbox', 'Radio', 'RadioGroup', 'Rate', 'Slider', 'QuantityStepper'], + 'The state glyph is the control. A membrane would swallow the mark or the track.' +); +skip( + ['Switch', 'JellySwitch'], + 'The 22px track and the separate knob are two shapes. The global jelly switch stays rigid.' +); +skip( + ['ToggleGroup', 'SegmentedControl'], + 'Connected segments share one silhouette and clip overflow. A wobble breaks the group.' +); +skip( + ['Tabs'], + 'Selection is an underline, not a solid pressable body.' +); +skip( + ['SplitButton'], + 'The joined pair shares a silhouette. Hosts inside .gd-split-btn stay rigid so the halves remain connected.' +); +skip( + ['HamburgerButton', 'Navbar', 'SiteHeader', 'BottomNav', 'Sidebar', 'Toolbar', 'Scrollspy', 'Menu', 'Breadcrumbs', 'Steps', 'Pagination', 'PageControl', 'Anchor', 'Affix'], + 'Navigation chrome is a layout of links, not a single solid control.' +); +skip( + ['Pill', 'Tag', 'Badge', 'Avatar', 'AvatarGroup', 'StatusDot', 'Icon'], + 'Static labels and glyphs. They are read, not pressed as a body of material.' +); +skip( + ['Card', 'Alert', 'Banner', 'Callout', 'Result', 'Empty', 'Statistic', 'Descriptions', 'List', 'ListItem', 'Timeline', 'Calendar', 'Accordion', 'Collapse', 'Watermark'], + 'Large surfaces hold nested content. A membrane would move the whole layout on an inner click.' +); +skip( + ['Modal', 'Drawer', 'Sheet', 'Toast', 'Notification', 'Lightbox', 'CommandPalette', 'Popover', 'Tooltip', 'DropdownMenu', 'HoverCard', 'ContextMenu', 'OverflowMenu', 'Scrim', 'Popconfirm'], + 'Overlays contain their own targets. Deforming the shell steals the click from what is inside.' +); +skip( + ['Table', 'DataTable', 'Tree', 'OutlineView', 'DisclosureTriangle', 'JsonTree', 'WorkflowDAG', 'WorkflowBoard'], + 'Rows, nodes, and edges are information. Physics would make the structure feel unstable.' +); +skip( + ['CodeBlock', 'Code', 'DiffBlock', 'Diff', 'MarkdownEditor'], + 'Reading and editing need a still surface.' +); +skip( + ['PieChart', 'DonutChart', 'EChart', 'EChartView', 'ThreeView', 'ThreeViewView', 'ThreeScene', 'SparkView', 'SparkViewView', 'SplatView', 'PeleView', 'PeleViewView', 'MermaidView', 'CoverFlow', 'Coverflow', 'CoverflowCarousel', 'Carousel'], + 'Charts and viewers already own their motion. A second membrane fights the picture.' +); +skip( + ['ProgressBar', 'ProgressRing', 'Meter', 'Skeleton', 'SkeletonParagraph', 'Spin'], + 'Feedback of progress or waiting. The shape is a status, not a pressable solid.' +); +skip( + ['Space', 'Divider', 'Flex', 'Row', 'Col', 'Layout', 'LayoutHeader', 'LayoutSider', 'LayoutContent', 'LayoutFooter', 'BentoGrid', 'BentoTile', 'Masonry', 'Marquee', 'Sticky', 'Fixed', 'ScrollArea', 'ResizeHandle', 'Splitter', 'SplitView', 'DragHandle', 'DropZone'], + 'Layout boxes arrange children. They are not a material you press.' +); +skip( + ['Title', 'Text', 'Truncate', 'OrDivider'], + 'Typography stays still so it can be read.' +); +skip( + ['Form', 'FormItem', 'SignInForm', 'Upload'], + 'Forms group fields and actions. The solid buttons inside already carry jelly.' +); +skip( + ['ChatBubble'], + 'Message text must stay still while it is read.' +); +skip( + ['TodoList', 'TodoListView', 'Todos', 'Todo', 'Image'], + 'Lists and pictures are content. Press targets inside them use Button or Chip on their own.' +); +skip( + ['ConfigProvider'], + 'Scopes theme and the jelly switch for a subtree. The provider itself is not a surface.' +); diff --git a/src/pyouter/webui/gd5/ui/jelly.js b/src/pyouter/webui/gd5/ui/jelly.js new file mode 100644 index 0000000..456520d --- /dev/null +++ b/src/pyouter/webui/gd5/ui/jelly.js @@ -0,0 +1,519 @@ +/** + * Global jelly switch, peer to the theme. + * + * setJelly(true) // , remembered as gd5-jelly + * setJelly(false) + * getJelly() + * + * Hosts are marked data-gd-jelly-host="solid" (primary/default buttons, + * chips, FABs). A canvas membrane attaches only when the nearest + * [data-gd-jelly] ancestor is "on" and motion is allowed. + */ + +import { Switch } from './control.js'; +import { DEFAULT_CONFIG, JellyBody, traceSmoothPath } from './jelly-body.js'; +import { el } from './util.js'; + +export const JELLY_ATTR = 'data-gd-jelly'; +export const JELLY_HOST_ATTR = 'data-gd-jelly-host'; +export const JELLY_KEY = 'gd5-jelly'; + +const LIVE_CLASS = 'gd-jelly-live'; +const PIXEL_KEYS = [ + 'insideHeldBulgeAmount', + 'insideHeldHaloAmount', + 'insideHeldDepthAmount', + 'outsideHeldDentAmount', + 'outsideHeldSideBulgeAmount', + 'outsideHeldDepthAmount', + 'axisDepth', + 'maxDepthIn', + 'maxDepthOut', + 'maxDent', + 'maxBulge', + 'insideLocalBulgeImpulse', + 'insideLocalHoldBulgeForce', + 'depthImpulse', + 'depthBulgeImpulse', + 'outsideDentImpulse', + 'outsideSideBulgeImpulse', + 'outsideOppositeBulgeImpulse', + 'outsideHoldForce', + 'outsideHoldDepthForce', +]; + +/** @type {boolean} */ +let current = false; +/** @type {boolean} */ +let observing = false; +/** @type {MutationObserver|null} */ +let observer = null; +/** @type {boolean} */ +let scanQueued = false; +/** @type {Map} */ +const hosts = new Map(); +/** @type {Set} */ +const active = new Set(); +/** @type {number} */ +let rafId = 0; +/** @type {number} */ +let lastTime = 0; + +/** + * Scale pixel-valued jelly defaults. Index-unit values stay put. + * Defaults are tuned for a ~62px control; gd5 controls are smaller. + * @param {number} minSide + */ +export function scaleJellyConfig(minSide) { + const k = Math.max(0.42, Math.min(1, minSide / 62)); + /** @type {Record} */ + const config = {}; + for (const key of PIXEL_KEYS) config[key] = DEFAULT_CONFIG[key] * k; + config.samples = DEFAULT_CONFIG.samples; + return { k, config }; +} + +/** + * @param {HTMLElement|Document|null|undefined} target + * @returns {HTMLElement|null} + */ +function resolveEl(target) { + if (target && target.nodeType === 9) return /** @type {Document} */ (target).documentElement; + if (target && target.nodeType === 1) return /** @type {HTMLElement} */ (target); + if (typeof document === 'undefined') return null; + return document.documentElement || null; +} + +/** + * @param {HTMLElement|Document} [target] + * @returns {boolean} + */ +export function getJelly(target) { + const el = resolveEl(target); + const fromDom = el?.getAttribute?.(JELLY_ATTR); + if (fromDom === 'on') return true; + if (fromDom === 'off') return false; + return current; +} + +/** + * @param {boolean} on + * @param {HTMLElement|Document} [target] omit to set and persist + * @returns {boolean} + */ +export function setJelly(on, target) { + const enabled = !!on; + const el = resolveEl(target); + if (el?.setAttribute) el.setAttribute(JELLY_ATTR, enabled ? 'on' : 'off'); + const root = typeof document !== 'undefined' ? document.documentElement : null; + if (!target || el === root) { + current = enabled; + try { + localStorage.setItem(JELLY_KEY, enabled ? 'on' : 'off'); + } catch { + /* private mode */ + } + } + ensureJelly(); + refresh(); + return enabled; +} + +/** + * The same switch the catalog uses. Checked state follows getJelly() at click time, + * so a product toolbar can keep the node without rebuilding the page. + * @param {object} [props] + * @param {string} [props.label] + * @param {(on: boolean) => void} [props.onChange] + */ +export function JellySwitch(props = {}) { + const label = props.label == null ? '果冻' : props.label; + return el( + 'span', + { className: 'gd-jelly-switch' }, + label + ? el('span', { className: 'gd-jelly-switch__label', text: label }) + : null, + Switch({ + checked: getJelly(), + attrs: { 'aria-label': '果冻效果' }, + onChange: (_next, event) => { + const next = !getJelly(); + setJelly(next); + const button = event?.currentTarget; + if (button?.classList) { + button.classList.toggle('gd-switch--checked', next); + button.setAttribute?.('aria-checked', next ? 'true' : 'false'); + } + props.onChange?.(next); + }, + }) + ); +} + +/** Restore the document switch from localStorage. No-op when unset. */ +export function applyRememberedJelly() { + let saved = null; + try { + saved = localStorage.getItem(JELLY_KEY); + } catch { + return; + } + if (saved == null) return; + const on = saved === 'on' || saved === 'true' || saved === '1'; + const off = saved === 'off' || saved === 'false' || saved === '0'; + if (!on && !off) return; + const root = typeof document !== 'undefined' ? document.documentElement : null; + if (root?.setAttribute) root.setAttribute(JELLY_ATTR, on ? 'on' : 'off'); + current = on; + ensureJelly(); + refresh(); +} + +/** Watch the document for hosts and for the switch attribute. Safe to call twice. */ +export function ensureJelly() { + if (observing) return; + if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return; + const root = document.documentElement; + if (!root) return; + observing = true; + observer = new MutationObserver(() => scheduleScan()); + observer.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [JELLY_ATTR, JELLY_HOST_ATTR, 'data-gd-theme', 'data-gd-jelly-motion'], + }); + scheduleScan(); +} + +function scheduleScan() { + if (scanQueued) return; + scanQueued = true; + const run = () => { + scanQueued = false; + refresh(); + }; + if (typeof queueMicrotask === 'function') queueMicrotask(run); + else setTimeout(run, 0); +} + +function motionReduced() { + const root = typeof document !== 'undefined' ? document.documentElement : null; + if (root?.getAttribute?.('data-gd-jelly-motion') === 'reduce') return true; + try { + return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; + } catch { + return false; + } +} + +/** + * Nearest ancestor wins, so a ConfigProvider can turn a subtree off. + * @param {Element} node + */ +function jellyOnFor(node) { + const scope = node.closest?.(`[${JELLY_ATTR}]`); + if (!scope) return false; + return scope.getAttribute(JELLY_ATTR) === 'on'; +} + +function wantsLive(node) { + if (!jellyOnFor(node) || motionReduced()) return false; + if (node.closest?.('.gd-split-btn')) return false; + return true; +} + +const PRODUCT_BUTTONS = '.btn--primary, .btn--secondary, .btn--ghost, .btn--danger'; + +function adoptPressableButtons() { + if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return; + for (const node of document.querySelectorAll(PRODUCT_BUTTONS)) { + if (node.getAttribute(JELLY_HOST_ATTR) === 'solid') continue; + if (node.closest?.('.gd-split-btn')) continue; + node.setAttribute(JELLY_HOST_ATTR, 'solid'); + } +} + +function refresh() { + if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return; + adoptPressableButtons(); + const nodes = document.querySelectorAll(`[${JELLY_HOST_ATTR}="solid"]`); + const seen = new Set(); + for (const node of nodes) { + seen.add(node); + if (wantsLive(node)) attach(node); + else detach(node); + } + for (const node of [...hosts.keys()]) { + if (!seen.has(node) || node.isConnected === false) detach(node); + } +} + +/** + * @param {Element} host + */ +function attach(host) { + const existing = hosts.get(host); + if (existing) { + if (existing.canvas.isConnected) { + host.classList.add(LIVE_CLASS); + poke(existing, 48); + return; + } + detach(host); + } + const canvas = document.createElement('canvas'); + canvas.className = 'gd-jelly-canvas'; + canvas.setAttribute('aria-hidden', 'true'); + host.prepend(canvas); + const ctx = canvas.getContext('2d'); + if (!ctx) { + canvas.remove(); + return; + } + /** @type {object} */ + const rec = { + host, + canvas, + ctx, + body: null, + w: 0, + h: 0, + pad: 0, + dpr: 1, + awakeUntil: 0, + positioned: false, + listeners: [], + observer: null, + }; + const pos = getComputedStyle(host).position; + if (pos === 'static') { + host.style.position = 'relative'; + rec.positioned = true; + } + host.classList.add(LIVE_CLASS); + bindInput(rec); + hosts.set(host, rec); + if (typeof ResizeObserver !== 'undefined') { + rec.observer = new ResizeObserver(() => { + syncSize(rec); + poke(rec, 48); + }); + rec.observer.observe(host); + } + syncSize(rec); + poke(rec, 48); +} + +/** + * @param {Element} host + */ +function detach(host) { + const rec = hosts.get(host); + if (!rec) return; + hosts.delete(host); + active.delete(rec); + host.classList.remove(LIVE_CLASS); + if (rec.positioned) host.style.position = ''; + rec.observer?.disconnect(); + for (const [type, fn] of rec.listeners) host.removeEventListener(type, fn); + rec.canvas?.remove(); +} + +/** + * @param {object} rec + */ +function bindInput(rec) { + const { host } = rec; + const listen = (type, fn) => { + host.addEventListener(type, fn); + rec.listeners.push([type, fn]); + }; + listen('pointerdown', (event) => { + if (event.button != null && event.button !== 0) return; + if (host.disabled || motionReduced() || !rec.body) return; + try { + host.setPointerCapture?.(event.pointerId); + } catch { + /* already released */ + } + const point = localFromEvent(rec, event); + rec.body.pressAtLocal(point.x, point.y); + poke(rec, 800); + }); + listen('pointermove', (event) => { + if (!rec.body?.state.pointerActive) return; + const point = localFromEvent(rec, event); + rec.body.moveToLocal(point.x, point.y); + poke(rec, 800); + }); + const release = () => { + if (!rec.body?.state.pointerActive) return; + rec.body.release(); + poke(rec, 700); + }; + listen('pointerup', release); + listen('pointercancel', release); + listen('lostpointercapture', release); + listen('pointerenter', () => poke(rec, 220)); + listen('pointerleave', () => { + if (rec.body?.state.pointerActive) rec.body.release(); + poke(rec, 220); + }); + if (host.tagName === 'BUTTON') { + listen('keydown', (event) => { + if (event.repeat) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + if (host.disabled || motionReduced() || !rec.body) return; + rec.body.centerPulse(1); + poke(rec, 700); + }); + listen('keyup', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + if (!rec.body) return; + rec.body.release(); + poke(rec, 700); + }); + listen('blur', () => { + if (!rec.body?.state.pointerActive && rec.body) rec.body.release(); + poke(rec, 400); + }); + } +} + +/** + * @param {object} rec + * @param {PointerEvent} event + */ +function localFromEvent(rec, event) { + const rect = rec.host.getBoundingClientRect(); + const width = rect.width || rec.body.width; + const height = rect.height || rec.body.height; + const x = ((event.clientX - rect.left) / width) * rec.body.width - rec.body.width / 2; + const y = ((event.clientY - rect.top) / height) * rec.body.height - rec.body.height / 2; + return { x, y }; +} + +/** + * @param {object} rec + */ +function syncSize(rec) { + const w = rec.host.offsetWidth; + const h = rec.host.offsetHeight; + if (w < 2 || h < 2) return; + const dpr = Math.min(globalThis.devicePixelRatio || 1, 3); + if (rec.body && w === rec.w && h === rec.h && dpr === rec.dpr) return; + rec.w = w; + rec.h = h; + rec.dpr = dpr; + const minSide = Math.min(w, h); + const { k, config } = scaleJellyConfig(minSide); + const pad = Math.ceil(48 * k); + rec.pad = pad; + rec.canvas.width = Math.ceil((w + pad * 2) * dpr); + rec.canvas.height = Math.ceil((h + pad * 2) * dpr); + rec.canvas.style.width = `${w + pad * 2}px`; + rec.canvas.style.height = `${h + pad * 2}px`; + const radius = minSide / 2; + if (!rec.body) rec.body = new JellyBody({ width: w, height: h, radius, config }); + else { + Object.assign(rec.body.config, config); + rec.body.resize(w, h, radius); + } +} + +/** + * @param {Element} host + * @param {string} name + */ +function readToken(host, name) { + const raw = getComputedStyle(host).getPropertyValue(name).trim(); + if (!raw || raw.startsWith('var(')) return ''; + return raw; +} + +/** + * @param {Element} host + */ +function readFill(host) { + const raw = readToken(host, '--gd-jelly-fill'); + if (raw === 'transparent' || raw === 'rgba(0, 0, 0, 0)') return 'rgba(0, 0, 0, 0)'; + if (raw) return raw; + let probe = host.querySelector?.(':scope > .gd-jelly-probe'); + if (!probe) { + probe = document.createElement('span'); + probe.className = 'gd-jelly-probe'; + probe.setAttribute('aria-hidden', 'true'); + host.appendChild(probe); + } + const color = getComputedStyle(probe).backgroundColor; + if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') return '#e86a17'; + return color; +} + +/** + * @param {object} rec + */ +function paint(rec) { + if (!rec.body) return; + const { ctx, canvas, body, pad, dpr } = rec; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + ctx.save(); + ctx.translate(pad + body.width / 2, pad + body.height / 2); + ctx.rotate(body.state.rotateZ); + const points = body.getSurfacePoints().map((point) => body.projectPoint(point)); + traceSmoothPath(ctx, points, body.config.curveTension); + ctx.fillStyle = readFill(rec.host); + ctx.fill(); + const edge = readToken(rec.host, '--gd-jelly-edge'); + if (edge && edge !== 'transparent' && edge !== 'rgba(0, 0, 0, 0)') { + ctx.strokeStyle = edge; + ctx.lineWidth = 1; + ctx.stroke(); + } + ctx.restore(); +} + +/** + * @param {object} rec + * @param {number} ms + */ +function poke(rec, ms) { + rec.awakeUntil = now() + ms; + active.add(rec); + if (!rafId) { + lastTime = now(); + rafId = raf(loop); + } +} + +function now() { + return globalThis.performance?.now?.() ?? Date.now(); +} + +function raf(fn) { + if (typeof globalThis.requestAnimationFrame === 'function') return globalThis.requestAnimationFrame(fn); + return 0; +} + +function loop(t) { + rafId = 0; + const dt = Math.min(0.033, Math.max(0, (t - lastTime) / 1000)); + lastTime = t; + for (const rec of [...active]) { + if (rec.host.isConnected === false) { + detach(rec.host); + continue; + } + syncSize(rec); + if (rec.body) { + rec.body.update(dt); + paint(rec); + } + const held = !!rec.body?.state.pointerActive; + if (!held && rec.body?.isResting() && t >= rec.awakeUntil) active.delete(rec); + } + if (active.size) rafId = raf(loop); +} diff --git a/src/pyouter/webui/gd5/ui/layout-extra.js b/src/pyouter/webui/gd5/ui/layout-extra.js new file mode 100644 index 0000000..82ae822 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/layout-extra.js @@ -0,0 +1,149 @@ +import { cx, el, omit } from './util.js'; + +/** BentoGrid — mixed-span tile grid. */ +export function BentoGrid(props = {}, ...children) { + const { className, columns = 4 } = props; + return el( + 'div', + { + ...omit(props, ['className', 'columns']), + className: cx('gd-bento', className), + style: { '--gd-bento-cols': String(columns) }, + }, + ...children + ); +} + +/** BentoTile — one cell; `span` / `rowSpan` for size. */ +export function BentoTile(props = {}, ...children) { + const { span = 1, rowSpan = 1, className } = props; + return el( + 'div', + { + ...omit(props, ['span', 'rowSpan', 'className']), + className: cx('gd-bento__tile', className), + style: { + gridColumn: `span ${span}`, + gridRow: `span ${rowSpan}`, + }, + }, + ...children + ); +} + +/** Masonry — CSS columns masonry. */ +export function Masonry(props = {}, ...children) { + const { columns = 3, className, gap = 12 } = props; + return el( + 'div', + { + ...omit(props, ['columns', 'className', 'gap']), + className: cx('gd-masonry', className), + style: { + columnCount: String(columns), + columnGap: typeof gap === 'number' ? `${gap}px` : gap, + }, + }, + ...children.map((child) => el('div', { className: 'gd-masonry__item' }, child)) + ); +} + +/** Marquee — endless horizontal scroll. */ +export function Marquee(props = {}, ...children) { + const { className, speed = 30, pauseOnHover = true } = props; + return el( + 'div', + { + ...omit(props, ['className', 'speed', 'pauseOnHover']), + className: cx( + 'gd-marquee', + pauseOnHover && 'gd-marquee--pause-hover', + className + ), + style: { '--gd-marquee-duration': `${speed}s` }, + }, + el('div', { className: 'gd-marquee__track' }, ...children, ...children) + ); +} + +/** Sticky — position:sticky helper wrapper. */ +export function Sticky(props = {}, ...children) { + const { top = 0, className } = props; + return el( + 'div', + { + ...omit(props, ['top', 'className']), + className: cx('gd-sticky', className), + style: { top: typeof top === 'number' ? `${top}px` : top }, + }, + ...children + ); +} + +/** Fixed — position:fixed helper. */ +export function Fixed(props = {}, ...children) { + const { className, style } = props; + return el( + 'div', + { + ...omit(props, ['className']), + className: cx('gd-fixed', className), + style, + }, + ...children + ); +} + +/** ScrollArea — scrollable region. */ +export function ScrollArea(props = {}, ...children) { + const { className, height = 200, style } = props; + return el( + 'div', + { + ...omit(props, ['className', 'height']), + className: cx('gd-scroll-area', className), + style: { + ...(typeof style === 'object' ? style : {}), + maxHeight: typeof height === 'number' ? `${height}px` : height, + }, + }, + ...children + ); +} + +/** ResizeHandle — visual size grip. */ +export function ResizeHandle(props = {}) { + const { className } = props; + return el('div', { + ...omit(props, ['className']), + className: cx('gd-resize-handle', className), + attrs: { 'aria-hidden': 'true' }, + }); +} + +/** + * Splitter / SplitView — two panes with a drag gutter (visual; optional onDrag). + */ +export function Splitter(props = {}, ...children) { + const { direction = 'horizontal', className, sizes = ['50%', '50%'] } = props; + const [a, b] = children; + return el( + 'div', + { + ...omit(props, ['direction', 'className', 'sizes']), + className: cx( + 'gd-splitter', + direction === 'vertical' && 'gd-splitter--vertical', + className + ), + }, + el('div', { className: 'gd-splitter__pane', style: { flexBasis: sizes[0] } }, a), + el('div', { + className: 'gd-splitter__gutter', + attrs: { role: 'separator', 'aria-orientation': direction }, + }), + el('div', { className: 'gd-splitter__pane', style: { flexBasis: sizes[1] } }, b) + ); +} + +export const SplitView = Splitter; diff --git a/src/pyouter/webui/gd5/ui/layout.js b/src/pyouter/webui/gd5/ui/layout.js new file mode 100644 index 0000000..162e82d --- /dev/null +++ b/src/pyouter/webui/gd5/ui/layout.js @@ -0,0 +1,72 @@ +import { cx, el, omit } from './util.js'; + +/** + * Layout — page shell (antd Layout). + * Compose Layout.Header / Sider / Content / Footer via helpers below. + */ +export function Layout(props = {}, ...children) { + const { className, hasSider = false } = props; + return el( + 'div', + { + ...omit(props, ['className', 'hasSider']), + className: cx('gd-layout', hasSider && 'gd-layout--has-sider', className), + }, + ...children + ); +} + +export function LayoutHeader(props = {}, ...children) { + const { className } = props; + return el( + 'header', + { + ...omit(props, ['className']), + className: cx('gd-layout__header', className), + }, + ...children + ); +} + +export function LayoutSider(props = {}, ...children) { + const { className, width = 200, collapsed = false, collapsedWidth = 64 } = props; + const w = collapsed ? collapsedWidth : width; + return el( + 'aside', + { + ...omit(props, ['className', 'width', 'collapsed', 'collapsedWidth']), + className: cx('gd-layout__sider', collapsed && 'gd-layout__sider--collapsed', className), + style: { width: typeof w === 'number' ? `${w}px` : w, flex: `0 0 ${typeof w === 'number' ? w + 'px' : w}` }, + }, + ...children + ); +} + +export function LayoutContent(props = {}, ...children) { + const { className } = props; + return el( + 'main', + { + ...omit(props, ['className']), + className: cx('gd-layout__content', className), + }, + ...children + ); +} + +export function LayoutFooter(props = {}, ...children) { + const { className } = props; + return el( + 'footer', + { + ...omit(props, ['className']), + className: cx('gd-layout__footer', className), + }, + ...children + ); +} + +Layout.Header = LayoutHeader; +Layout.Sider = LayoutSider; +Layout.Content = LayoutContent; +Layout.Footer = LayoutFooter; diff --git a/src/pyouter/webui/gd5/ui/list.js b/src/pyouter/webui/gd5/ui/list.js new file mode 100644 index 0000000..a467211 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/list.js @@ -0,0 +1,156 @@ +import { cx, el, omit } from './util.js'; + +/** + * List — data list with optional header/footer (antd List). + * @param {object} [props] + * @param {Array} [props.dataSource] + * @param {function} [props.renderItem] (item, index) => Element|string + * @param {any} [props.header] + * @param {any} [props.footer] + * @param {boolean} [props.bordered] + * @param {'default'|'small'|'large'} [props.size] + */ +export function List(props = {}, ...children) { + const { + dataSource = [], + renderItem, + header, + footer, + bordered = true, + size = 'default', + className, + split = true, + } = props; + + const items = children.length + ? children + : dataSource.map((item, i) => { + const content = renderItem ? renderItem(item, i) : item.title ?? String(item); + return ListItem( + { key: item.key ?? i }, + typeof content === 'string' ? el('span', { text: content }) : content + ); + }); + + return el( + 'div', + { + ...omit(props, [ + 'dataSource', + 'renderItem', + 'header', + 'footer', + 'bordered', + 'size', + 'className', + 'split', + ]), + className: cx( + 'gd-list', + bordered && 'gd-list--bordered', + split && 'gd-list--split', + `gd-list--${size}`, + className + ), + }, + header != null ? el('div', { className: 'gd-list__header' }, header) : null, + el('ul', { className: 'gd-list__items' }, ...items), + footer != null ? el('div', { className: 'gd-list__footer' }, footer) : null + ); +} + +export function ListItem(props = {}, ...children) { + const { className, actions, extra } = props; + return el( + 'li', + { + ...omit(props, ['className', 'actions', 'extra', 'key']), + className: cx('gd-list__item', className), + }, + el('div', { className: 'gd-list__item-main' }, ...children), + extra != null ? el('div', { className: 'gd-list__extra' }, extra) : null, + actions + ? el( + 'div', + { className: 'gd-list__actions' }, + ...(Array.isArray(actions) ? actions : [actions]) + ) + : null + ); +} + +/** + * Descriptions — label/value grid (antd Descriptions). + * @param {object} [props] + * @param {Array<{label:string,children:any,span?:number}>} [props.items] + * @param {string} [props.title] + * @param {number} [props.column] + * @param {boolean} [props.bordered] + */ +export function Descriptions(props = {}) { + const { items = [], title, column = 3, bordered = true, className, size = 'default' } = props; + return el( + 'div', + { + ...omit(props, ['items', 'title', 'column', 'bordered', 'className', 'size']), + className: cx( + 'gd-descriptions', + bordered && 'gd-descriptions--bordered', + `gd-descriptions--${size}`, + className + ), + }, + title != null ? el('div', { className: 'gd-descriptions__title', text: title }) : null, + el( + 'div', + { + className: 'gd-descriptions__view', + style: { gridTemplateColumns: `repeat(${column}, 1fr)` }, + }, + ...items.map((it, i) => + el( + 'div', + { + className: 'gd-descriptions__item', + key: it.key || i, + style: it.span ? { gridColumn: `span ${it.span}` } : undefined, + }, + el('div', { className: 'gd-descriptions__label', text: it.label }), + el( + 'div', + { className: 'gd-descriptions__content' }, + typeof it.children === 'string' || typeof it.children === 'number' + ? el('span', { text: String(it.children) }) + : it.children + ) + ) + ) + ) + ); +} + +/** + * Statistic — big number display (antd Statistic). + */ +export function Statistic(props = {}) { + const { title, value, suffix, prefix, className, precision } = props; + let display = value; + if (typeof value === 'number' && precision != null) { + display = value.toFixed(precision); + } + return el( + 'div', + { + ...omit(props, ['title', 'value', 'suffix', 'prefix', 'className', 'precision']), + className: cx('gd-statistic', className), + }, + title != null ? el('div', { className: 'gd-statistic__title', text: title }) : null, + el( + 'div', + { className: 'gd-statistic__content' }, + prefix != null ? el('span', { className: 'gd-statistic__prefix', text: String(prefix) }) : null, + el('span', { className: 'gd-statistic__value', text: String(display ?? '') }), + suffix != null ? el('span', { className: 'gd-statistic__suffix', text: String(suffix) }) : null + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/markdown.js b/src/pyouter/webui/gd5/ui/markdown.js new file mode 100644 index 0000000..605085b --- /dev/null +++ b/src/pyouter/webui/gd5/ui/markdown.js @@ -0,0 +1,545 @@ +import { Component } from '../engine/index.js'; +import { cx, el, loadUi } from './util.js'; + +/** + * Escape HTML for safe preview injection. + * @param {string} s + */ +export function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Wrap / prefix the current textarea selection. + * @param {HTMLTextAreaElement} ta + * @param {string} before + * @param {string} [after] + * @param {string} [placeholder] + * @returns {string} next full value + */ +export function wrapSelection(ta, before, after = '', placeholder = '') { + if (!ta) return ''; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? 0; + const value = ta.value ?? ''; + const selected = value.slice(start, end); + const body = selected.length ? selected : placeholder; + const next = value.slice(0, start) + before + body + after + value.slice(end); + const selStart = start + before.length; + const selEnd = selStart + body.length; + ta.value = next; + ta.focus(); + ta.setSelectionRange(selStart, selEnd); + return next; +} + +/** + * Insert text at the cursor (or replace selection). + * @param {HTMLTextAreaElement} ta + * @param {string} text + * @returns {string} + */ +export function insertAtCursor(ta, text) { + if (!ta) return ''; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? 0; + const value = ta.value ?? ''; + const next = value.slice(0, start) + text + value.slice(end); + const pos = start + text.length; + ta.value = next; + ta.focus(); + ta.setSelectionRange(pos, pos); + return next; +} + +/** + * Prefix each selected line (or current line) with `prefix`. + * @param {HTMLTextAreaElement} ta + * @param {string} prefix + * @returns {string} + */ +export function prefixLines(ta, prefix) { + if (!ta) return ''; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? 0; + const value = ta.value ?? ''; + let lineStart = value.lastIndexOf('\n', start - 1) + 1; + let lineEnd = value.indexOf('\n', end); + if (lineEnd < 0) lineEnd = value.length; + const block = value.slice(lineStart, lineEnd); + const nextBlock = block + .split('\n') + .map((line) => (line.startsWith(prefix) ? line : prefix + line)) + .join('\n'); + const next = value.slice(0, lineStart) + nextBlock + value.slice(lineEnd); + ta.value = next; + ta.focus(); + ta.setSelectionRange(lineStart, lineStart + nextBlock.length); + return next; +} + +function inlineMarkdown(text) { + let s = escapeHtml(text); + // code first + s = s.replace(/`([^`]+)`/g, '$1'); + // images then links + s = s.replace( + /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, + (_m, alt, src, title) => + `${alt}` + ); + s = s.replace( + /\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, + (_m, label, href, title) => + `${label}` + ); + // bold / italic (order: bold then italic) + s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); + s = s.replace(/__([^_]+)__/g, '$1'); + s = s.replace(/\*([^*]+)\*/g, '$1'); + s = s.replace(/_([^_]+)_/g, '$1'); + s = s.replace(/~~([^~]+)~~/g, '$1'); + return s; +} + +/** + * Lightweight Markdown → HTML (no dependency). + * Covers headings, emphasis, links, images, lists, quotes, fences, hr, paragraphs. + * @param {string} source + * @returns {string} + */ +export function renderMarkdown(source) { + const text = String(source ?? '').replace(/\r\n?/g, '\n'); + if (!text.trim()) return ''; + + const lines = text.split('\n'); + const html = []; + let i = 0; + let inUl = false; + let inOl = false; + let inBlockquote = false; + + const closeLists = () => { + if (inUl) { + html.push(''); + inUl = false; + } + if (inOl) { + html.push(''); + inOl = false; + } + }; + const closeQuote = () => { + if (inBlockquote) { + html.push(''); + inBlockquote = false; + } + }; + const closeBlocks = () => { + closeLists(); + closeQuote(); + }; + + while (i < lines.length) { + const line = lines[i]; + + // fenced code + const fence = line.match(/^```([\w-]*)\s*$/); + if (fence) { + closeBlocks(); + const lang = fence[1] || ''; + const buf = []; + i += 1; + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + buf.push(lines[i]); + i += 1; + } + i += 1; // closing ``` + html.push( + `
${escapeHtml(buf.join('\n'))}
` + ); + continue; + } + + // hr + if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + closeBlocks(); + html.push('
'); + i += 1; + continue; + } + + // heading + const heading = line.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + closeBlocks(); + const level = heading[1].length; + html.push(`${inlineMarkdown(heading[2].trim())}`); + i += 1; + continue; + } + + // blockquote + if (/^>\s?/.test(line)) { + closeLists(); + if (!inBlockquote) { + html.push('
'); + inBlockquote = true; + } + html.push(`

${inlineMarkdown(line.replace(/^>\s?/, ''))}

`); + i += 1; + continue; + } + if (inBlockquote) closeQuote(); + + // unordered list + const ul = line.match(/^\s*[-*+]\s+(.*)$/); + if (ul) { + closeQuote(); + if (inOl) { + html.push(''); + inOl = false; + } + if (!inUl) { + html.push('
    '); + inUl = true; + } + html.push(`
  • ${inlineMarkdown(ul[1])}
  • `); + i += 1; + continue; + } + + // ordered list + const ol = line.match(/^\s*\d+\.\s+(.*)$/); + if (ol) { + closeQuote(); + if (inUl) { + html.push('
'); + inUl = false; + } + if (!inOl) { + html.push('
    '); + inOl = true; + } + html.push(`
  1. ${inlineMarkdown(ol[1])}
  2. `); + i += 1; + continue; + } + + closeLists(); + + // blank → paragraph break + if (!line.trim()) { + i += 1; + continue; + } + + // paragraph (merge consecutive non-blank non-special lines) + const para = [line]; + i += 1; + while (i < lines.length) { + const n = lines[i]; + if ( + !n.trim() || + /^#{1,6}\s/.test(n) || + /^```/.test(n) || + /^\s*[-*+]\s+/.test(n) || + /^\s*\d+\.\s+/.test(n) || + /^>\s?/.test(n) || + /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(n) + ) { + break; + } + para.push(n); + i += 1; + } + html.push(`

    ${inlineMarkdown(para.join(' '))}

    `); + } + + closeBlocks(); + return html.join('\n'); +} + +const TOOLS = [ + { id: 'bold', label: 'B', title: 'Bold', kind: 'wrap', before: '**', after: '**', placeholder: 'bold' }, + { id: 'italic', label: 'I', title: 'Italic', kind: 'wrap', before: '*', after: '*', placeholder: 'italic' }, + { id: 'heading', label: 'H', title: 'Heading', kind: 'prefix', prefix: '## ' }, + { id: 'quote', label: '>', title: 'Quote', kind: 'prefix', prefix: '> ' }, + { id: 'code', label: '`', title: 'Inline code', kind: 'wrap', before: '`', after: '`', placeholder: 'code' }, + { id: 'link', label: 'Link', title: 'Link', kind: 'link' }, + { id: 'ul', label: 'List', title: 'Bullet list', kind: 'prefix', prefix: '- ' }, + { id: 'ol', label: '1.', title: 'Numbered list', kind: 'prefix', prefix: '1. ' }, +]; + +const MODES = [ + { id: 'edit', label: '编辑' }, + { id: 'split', label: '分栏' }, + { id: 'preview', label: '预览' }, +]; + +/** + * MarkdownEditor — markdown-first editor with toolbar + live preview. + * + * @param {object} [deps] + * @param {string} [deps.value] + * @param {'edit'|'split'|'preview'} [deps.mode] default `split` + * @param {number|string} [deps.height] editor body height (default 280) + * @param {string} [deps.placeholder] + * @param {boolean} [deps.toolbar] + * @param {(value:string)=>void} [deps.onChange] + * @param {(mode:string)=>void} [deps.onModeChange] + */ +export class MarkdownEditor extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.value = deps.value ?? ''; + this.state.data.mode = deps.mode === 'edit' || deps.mode === 'preview' ? deps.mode : 'split'; + this.state.data.height = deps.height ?? 280; + this.state.data.placeholder = deps.placeholder ?? 'Write Markdown…'; + this.state.data.toolbar = deps.toolbar !== false; + this._onChange = deps.onChange || null; + this._onModeChange = deps.onModeChange || null; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + getValue() { + return String(this.state.data.value ?? ''); + } + + setValue(value, { refresh = true } = {}) { + const next = String(value ?? ''); + this.state.data.value = next; + if (refresh && this._mounted) this.refresh(); + else if (this._mounted) this._syncPreview(); + return this; + } + + setMode(mode) { + if (mode !== 'edit' && mode !== 'split' && mode !== 'preview') return this; + this.state.data.mode = mode; + this._onModeChange?.(mode); + if (this._mounted) this.refresh(); + return this; + } + + focus() { + this.ids().gdMdInput?.focus(); + return this; + } + + _emit(value) { + this.state.data.value = value; + this._onChange?.(value); + this._syncPreview(); + } + + _applyTool(tool) { + const ta = this.ids().gdMdInput; + if (!ta) return; + let next = ta.value; + if (tool.kind === 'wrap') { + next = wrapSelection(ta, tool.before, tool.after, tool.placeholder); + } else if (tool.kind === 'prefix') { + next = prefixLines(ta, tool.prefix); + } else if (tool.kind === 'link') { + const selected = ta.value.slice(ta.selectionStart, ta.selectionEnd); + const label = selected || 'link'; + next = wrapSelection(ta, '[', '](https://)', label); + } + this._emit(next); + } + + _syncMode() { + const root = this.ids().gdMdRoot; + if (!root) return; + root.dataset.mode = this.state.data.mode; + root.classList.toggle('gd-md-editor--edit', this.state.data.mode === 'edit'); + root.classList.toggle('gd-md-editor--split', this.state.data.mode === 'split'); + root.classList.toggle('gd-md-editor--preview', this.state.data.mode === 'preview'); + + for (const m of MODES) { + const btn = this.ids()[`gdMdMode_${m.id}`]; + if (!btn) continue; + btn.classList.toggle('gd-md-editor__mode--active', m.id === this.state.data.mode); + btn.setAttribute('aria-pressed', m.id === this.state.data.mode ? 'true' : 'false'); + } + + const edit = this.ids().gdMdEdit; + const preview = this.ids().gdMdPreviewPane; + if (edit) { + edit.hidden = this.state.data.mode === 'preview'; + } + if (preview) { + preview.hidden = this.state.data.mode === 'edit'; + } + } + + _syncPreview() { + const pane = this.ids().gdMdPreview; + if (!pane) return; + const html = renderMarkdown(this.state.data.value); + pane.innerHTML = html || '

    Nothing to preview

    '; + } + + _syncInput() { + const ta = this.ids().gdMdInput; + if (!ta) return; + if (typeof document !== 'undefined' && document.activeElement === ta) return; + if (ta.value !== this.state.data.value) ta.value = this.state.data.value; + } + + _syncHeight() { + const body = this.ids().gdMdBody; + if (!body) return; + const h = this.state.data.height; + body.style.height = typeof h === 'number' ? `${h}px` : String(h); + } + + render() { + const { value, placeholder, toolbar, height } = this.state.data; + + this.ensureShell( + (ids) => ids.gdMdRoot, + ({ div, textarea, button }) => { + div( + { + id: 'gdMdRoot', + className: 'gd-md-editor gd-md-editor--split', + attrs: { 'data-mode': 'split' }, + }, + div({ id: 'gdMdToolbar', className: 'gd-md-editor__toolbar' }), + div( + { + id: 'gdMdBody', + className: 'gd-md-editor__body', + style: { height: typeof height === 'number' ? `${height}px` : String(height) }, + }, + div( + { id: 'gdMdEdit', className: 'gd-md-editor__edit' }, + textarea({ + id: 'gdMdInput', + className: 'gd-md-editor__input gd-textarea', + attrs: { + spellcheck: 'true', + autocomplete: 'off', + 'aria-label': 'Markdown source', + }, + placeholder, + value: value, + onInput: (e) => { + this._emit(e.currentTarget.value); + }, + }) + ), + div( + { + id: 'gdMdPreviewPane', + className: 'gd-md-editor__preview-pane', + }, + div({ + id: 'gdMdPreview', + className: 'gd-md-editor__preview', + attrs: { role: 'document', 'aria-label': 'Markdown preview' }, + }) + ) + ) + ); + + // silence unused — toolbar filled after shell + void button; + } + ); + + const bar = this.ids().gdMdToolbar; + if (bar && !bar.dataset.ready) { + bar.dataset.ready = '1'; + if (toolbar) { + const formatDom = el('div', { className: 'gd-md-editor__tools' }).dom(); + for (const tool of TOOLS) { + formatDom.appendChild( + el('button', { + type: 'button', + className: cx( + 'gd-btn', + 'gd-btn--text', + 'gd-btn--sm', + 'gd-md-editor__tool', + tool.id === 'bold' && 'gd-md-editor__tool--bold', + tool.id === 'italic' && 'gd-md-editor__tool--italic' + ), + text: tool.label, + title: tool.title, + attrs: { 'aria-label': tool.title, 'data-tool': tool.id }, + onClick: (e) => { + e.preventDefault(); + this._applyTool(tool); + }, + }).dom() + ); + } + bar.appendChild(formatDom); + } + + const modesEl = el('div', { + className: 'gd-md-editor__modes', + attrs: { role: 'group', 'aria-label': 'View mode' }, + }); + const modesDom = modesEl.dom(); + for (const m of MODES) { + const btnEl = el('button', { + type: 'button', + className: cx('gd-btn', 'gd-btn--default', 'gd-btn--sm', 'gd-md-editor__mode'), + text: m.label, + attrs: { 'aria-pressed': 'false', 'data-mode': m.id }, + onClick: (e) => { + e.preventDefault(); + this.setMode(m.id); + }, + }); + const node = btnEl.dom(); + modesDom.appendChild(node); + } + bar.appendChild(modesDom); + } + + // Mode buttons live outside ensureShell ids — resolve from toolbar each render. + this.state.ref.id = this.state.ref.id || {}; + for (const m of MODES) { + const found = bar?.querySelector?.(`[data-mode="${m.id}"]`); + if (found) this.state.ref.id[`gdMdMode_${m.id}`] = found; + } + + this._syncHeight(); + this._syncInput(); + this._syncMode(); + this._syncPreview(); + } +} + +/** + * @param {string} value + * @param {HTMLElement|string} host + * @param {object} [options] + */ +export function createMarkdownEditor(value, host, options = {}) { + const ed = new MarkdownEditor({ ...options, value }); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) { + void ed.mount(elHost); + } + return ed; +} diff --git a/src/pyouter/webui/gd5/ui/menu.js b/src/pyouter/webui/gd5/ui/menu.js new file mode 100644 index 0000000..35845a7 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/menu.js @@ -0,0 +1,103 @@ +import { cx, el, omit } from './util.js'; + +/** + * Menu — vertical or horizontal nav menu with optional submenus (antd Menu). + * @param {object} [props] + * @param {Array<{key:string,label:string,children?:array,disabled?:boolean,danger?:boolean,type?:'divider'|'group'}>} [props.items] + * @param {'vertical'|'horizontal'|'inline'} [props.mode] + * @param {string} [props.selectedKey] + * @param {string[]} [props.openKeys] + * @param {function} [props.onClick] + * @param {function} [props.onOpenChange] + */ +export function Menu(props = {}) { + const { + items = [], + mode = 'vertical', + selectedKey, + openKeys = [], + onClick, + onOpenChange, + className, + } = props; + + const openSet = new Set(openKeys); + + const renderItems = (list, depth = 0) => + list.flatMap((it) => { + if (it.type === 'divider') { + return [el('div', { className: 'gd-menu__divider', key: it.key || `d-${depth}` })]; + } + if (it.type === 'group') { + return [ + el('div', { + className: 'gd-menu__group-title', + text: it.label, + key: it.key || it.label, + }), + ...renderItems(it.children || [], depth), + ]; + } + const hasKids = Array.isArray(it.children) && it.children.length > 0; + const opened = openSet.has(it.key); + if (hasKids) { + return [ + el( + 'div', + { className: 'gd-menu__submenu', key: it.key }, + el('button', { + type: 'button', + className: cx('gd-menu__item', 'gd-menu__submenu-title'), + text: `${it.label}${mode === 'horizontal' ? '' : opened ? ' ▾' : ' ▸'}`, + disabled: !!it.disabled, + onClick: () => { + const next = new Set(openSet); + if (opened) next.delete(it.key); + else next.add(it.key); + onOpenChange?.([...next]); + }, + }), + (opened || mode === 'horizontal') + ? el( + 'div', + { className: cx('gd-menu__sub', mode === 'horizontal' && 'gd-menu__sub--popup') }, + ...renderItems(it.children, depth + 1) + ) + : null + ), + ]; + } + return [ + el('button', { + type: 'button', + key: it.key, + className: cx( + 'gd-menu__item', + selectedKey === it.key && 'gd-menu__item--selected', + it.danger && 'gd-menu__item--danger' + ), + text: it.label, + disabled: !!it.disabled, + onClick: () => onClick?.({ key: it.key, item: it }), + }), + ]; + }); + + return el( + 'nav', + { + ...omit(props, [ + 'items', + 'mode', + 'selectedKey', + 'openKeys', + 'onClick', + 'onOpenChange', + 'className', + ]), + className: cx('gd-menu', `gd-menu--${mode}`, className), + role: 'menu', + }, + ...renderItems(items) + ); +} diff --git a/src/pyouter/webui/gd5/ui/navigation.js b/src/pyouter/webui/gd5/ui/navigation.js new file mode 100644 index 0000000..77a26cd --- /dev/null +++ b/src/pyouter/webui/gd5/ui/navigation.js @@ -0,0 +1,195 @@ +import { cx, el, omit } from './util.js'; +import { Button } from './button.js'; + +/** + * Navbar — row of page links (inside a SiteHeader). + * @param {object} [props] + * @param {Array<{key?:string,label:string,href?:string,active?:boolean,onClick?:function}>} [props.items] + */ +export function Navbar(props = {}, ...children) { + const { items = [], className } = props; + return el( + 'nav', + { + ...omit(props, ['items', 'className']), + className: cx('gd-navbar', className), + }, + ...(children.length + ? children + : items.map((it) => + el('a', { + className: cx('gd-navbar__link', it.active && 'gd-navbar__link--active'), + text: it.label, + href: it.href || '#', + onClick: it.onClick + ? (e) => { + e.preventDefault(); + it.onClick(e); + } + : undefined, + }) + )) + ); +} + +/** + * SiteHeader — top strip containing brand + Navbar + extra. + */ +export function SiteHeader(props = {}, ...children) { + const { brand, extra, className, sticky = false } = props; + return el( + 'header', + { + ...omit(props, ['brand', 'extra', 'className', 'sticky']), + className: cx('gd-site-header', sticky && 'gd-sticky', className), + }, + brand != null ? el('div', { className: 'gd-site-header__brand', text: String(brand) }) : null, + ...children, + extra || null + ); +} + +/** + * BottomNav — mobile tab bar. + * @param {object} [props] + * @param {Array<{key:string,label:string,icon?:string,active?:boolean,onClick?:function}>} [props.items] + */ +export function BottomNav(props = {}) { + const { items = [], className } = props; + return el( + 'nav', + { + ...omit(props, ['items', 'className']), + className: cx('gd-bottom-nav', className), + attrs: { 'aria-label': 'Bottom navigation' }, + }, + ...items.map((it) => + el('button', { + type: 'button', + className: cx('gd-bottom-nav__item', it.active && 'gd-bottom-nav__item--active'), + onClick: it.onClick, + }, + it.icon ? el('span', { className: 'gd-bottom-nav__icon', text: it.icon }) : null, + el('span', { className: 'gd-bottom-nav__label', text: it.label }) + ) + ) + ); +} + +/** + * Sidebar — left navigation column. + */ +export function Sidebar(props = {}, ...children) { + const { items = [], title, className, width = 220 } = props; + return el( + 'aside', + { + ...omit(props, ['items', 'title', 'className', 'width']), + className: cx('gd-sidebar', className), + style: { width: typeof width === 'number' ? `${width}px` : width }, + }, + title != null ? el('div', { className: 'gd-sidebar__title', text: title }) : null, + el( + 'div', + { className: 'gd-sidebar__nav' }, + ...(children.length + ? children + : items.map((it) => + el('a', { + className: cx('gd-sidebar__link', it.active && 'gd-sidebar__link--active'), + text: it.label, + href: it.href || '#', + onClick: it.onClick + ? (e) => { + e.preventDefault(); + it.onClick(e); + } + : undefined, + }) + )) + ) + ); +} + +/** + * Toolbar — action row. + */ +export function Toolbar(props = {}, ...children) { + const { className, extra } = props; + return el( + 'div', + { + ...omit(props, ['className', 'extra']), + className: cx('gd-toolbar', className), + role: 'toolbar', + }, + el('div', { className: 'gd-toolbar__actions' }, ...children), + extra || null + ); +} + +/** + * HamburgerButton — three-line trigger for a nav drawer. + */ +export function HamburgerButton(props = {}) { + const { className, label = 'Menu', onClick } = props; + return el('button', { + ...omit(props, ['className', 'label']), + type: 'button', + className: cx('gd-hamburger', className), + attrs: { 'aria-label': label }, + onClick, + }, + el('span'), + el('span'), + el('span') + ); +} + +/** + * Scrollspy — “On this page” list; mark active via `activeKey`. + */ +export function Scrollspy(props = {}) { + const { items = [], activeKey, onChange, className, title = 'On this page' } = props; + return el( + 'nav', + { + ...omit(props, ['items', 'activeKey', 'onChange', 'className', 'title']), + className: cx('gd-scrollspy', className), + attrs: { 'aria-label': title }, + }, + el('div', { className: 'gd-scrollspy__title', text: title }), + ...items.map((it) => + el('a', { + className: cx('gd-scrollspy__link', it.key === activeKey && 'gd-scrollspy__link--active'), + text: it.label, + href: it.href || `#${it.key}`, + onClick: (e) => { + if (onChange) { + e.preventDefault(); + onChange(it.key); + } + }, + }) + ) + ); +} + +/** + * SplitButton — primary action + overflow menu trigger. + */ +export function SplitButton(props = {}) { + const { text = 'Action', onClick, onMenuClick, className, type = 'primary' } = props; + return el( + 'div', + { className: cx('gd-split-btn', className) }, + Button({ type, text, onClick, className: 'gd-split-btn__main' }), + Button({ + type, + text: '▾', + className: 'gd-split-btn__caret', + onClick: onMenuClick, + attrs: { 'aria-label': 'More actions' }, + }) + ); +} diff --git a/src/pyouter/webui/gd5/ui/overlay.js b/src/pyouter/webui/gd5/ui/overlay.js new file mode 100644 index 0000000..6252778 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/overlay.js @@ -0,0 +1,176 @@ +import { cx, el, omit } from './util.js'; + +/** Scrim — translucent backdrop layer. */ +export function Scrim(props = {}) { + const { className, visible = true, onClick } = props; + return el('div', { + ...omit(props, ['className', 'visible']), + className: cx('gd-scrim', visible && 'gd-scrim--visible', className), + onClick, + }); +} + +/** + * Tooltip — anchored hint (CSS hover / focus). + * Wrap a trigger; show `title` text. + */ +export function Tooltip(props = {}, ...children) { + const { title, placement = 'top', className } = props; + return el( + 'span', + { + ...omit(props, ['title', 'placement', 'className']), + className: cx('gd-tooltip', `gd-tooltip--${placement}`, className), + }, + ...children, + el('span', { className: 'gd-tooltip__bubble', role: 'tooltip', text: title }) + ); +} + +/** + * Popover — richer anchored panel (visibility controlled). + * @param {object} [props] + * @param {boolean} [props.open] + * @param {any} [props.content] + * @param {string} [props.title] + */ +export function Popover(props = {}, ...children) { + const { open = false, content, title, className, placement = 'bottom' } = props; + return el( + 'span', + { + ...omit(props, ['open', 'content', 'title', 'className', 'placement']), + className: cx('gd-popover', `gd-popover--${placement}`, open && 'gd-popover--open', className), + }, + ...children, + open + ? el( + 'div', + { className: 'gd-popover__panel', role: 'dialog' }, + title != null ? el('div', { className: 'gd-popover__title', text: title }) : null, + el('div', { className: 'gd-popover__body' }, content) + ) + : null + ); +} + +/** + * DropdownMenu — list of actions under a trigger. + * @param {object} [props] + * @param {boolean} [props.open] + * @param {Array<{key?:string,label:string,danger?:boolean,disabled?:boolean,onClick?:function,divider?:boolean}>} [props.items] + */ +export function DropdownMenu(props = {}, ...children) { + const { open = false, items = [], className, onOpenChange } = props; + return el( + 'span', + { + ...omit(props, ['open', 'items', 'className', 'onOpenChange']), + className: cx('gd-dropdown', open && 'gd-dropdown--open', className), + }, + ...children, + open + ? el( + 'div', + { className: 'gd-dropdown__menu', role: 'menu' }, + ...items.map((it, i) => + it.divider + ? el('div', { className: 'gd-dropdown__divider', key: `d-${i}` }) + : el('button', { + type: 'button', + role: 'menuitem', + className: cx( + 'gd-dropdown__item', + it.danger && 'gd-dropdown__item--danger' + ), + text: it.label, + disabled: !!it.disabled, + onClick: (e) => { + it.onClick?.(e); + onOpenChange?.(false); + }, + }) + ) + ) + : null + ); +} + +/** + * HoverCard — rich preview on hover (CSS :hover). + */ +export function HoverCard(props = {}, ...children) { + const { content, className, title } = props; + return el( + 'span', + { + ...omit(props, ['content', 'className', 'title']), + className: cx('gd-hover-card', className), + }, + ...children, + el( + 'div', + { className: 'gd-hover-card__panel', role: 'tooltip' }, + title != null ? el('div', { className: 'gd-hover-card__title', text: title }) : null, + el('div', { className: 'gd-hover-card__body' }, content) + ) + ); +} + +/** + * ContextMenu — right-click menu host. + * Pass open + position {x,y} + items. + */ +export function ContextMenu(props = {}) { + const { open = false, x = 0, y = 0, items = [], onClose, className } = props; + if (!open) return el('div', { className: 'gd-context-menu gd-context-menu--hidden' }); + return el( + 'div', + { + ...omit(props, ['open', 'x', 'y', 'items', 'onClose', 'className']), + className: cx('gd-context-menu', className), + style: { left: `${x}px`, top: `${y}px` }, + role: 'menu', + }, + ...items.map((it, i) => + it.divider + ? el('div', { className: 'gd-dropdown__divider', key: `cd-${i}` }) + : el('button', { + type: 'button', + role: 'menuitem', + className: cx('gd-dropdown__item', it.danger && 'gd-dropdown__item--danger'), + text: it.label, + disabled: !!it.disabled, + onClick: (e) => { + it.onClick?.(e); + onClose?.(); + }, + }) + ) + ); +} + +/** + * OverflowMenu — three-dots / kebab trigger + menu. + * @param {'horizontal'|'vertical'|'hamburger'} [props.variant] + */ +export function OverflowMenu(props = {}) { + const { + variant = 'horizontal', + open = false, + items = [], + onOpenChange, + className, + } = props; + const glyph = variant === 'vertical' ? '⋮' : variant === 'hamburger' ? '☰' : '⋯'; + return DropdownMenu( + { open, items, onOpenChange, className: cx('gd-overflow-menu', className) }, + el('button', { + type: 'button', + className: 'gd-overflow-menu__trigger', + text: glyph, + attrs: { 'aria-label': 'More' }, + onClick: () => onOpenChange?.(!open), + }) + ); +} diff --git a/src/pyouter/webui/gd5/ui/pagination.js b/src/pyouter/webui/gd5/ui/pagination.js new file mode 100644 index 0000000..b8ab4f3 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/pagination.js @@ -0,0 +1,70 @@ +import { cx, el, omit } from './util.js'; + +/** + * Pagination — numbered page buttons. + * @param {object} [props] + * @param {number} [props.current] + * @param {number} [props.total] total pages + * @param {function} [props.onChange] + */ +export function Pagination(props = {}) { + const { current = 1, total = 1, onChange, className } = props; + const pages = Array.from({ length: Math.max(1, total) }, (_, i) => i + 1); + return el( + 'nav', + { + ...omit(props, ['current', 'total', 'onChange', 'className']), + className: cx('gd-pagination', className), + attrs: { 'aria-label': 'Pagination' }, + }, + el('button', { + type: 'button', + className: 'gd-pagination__btn', + text: '‹', + disabled: current <= 1, + onClick: () => onChange?.(current - 1), + }), + ...pages.map((p) => + el('button', { + type: 'button', + className: cx('gd-pagination__btn', p === current && 'gd-pagination__btn--active'), + text: String(p), + onClick: () => onChange?.(p), + }) + ), + el('button', { + type: 'button', + className: 'gd-pagination__btn', + text: '›', + disabled: current >= total, + onClick: () => onChange?.(current + 1), + }) + ); +} + +/** + * PageControl — dot pagination (carousel-style). + * @param {object} [props] + * @param {number} [props.count] + * @param {number} [props.current] 0-based + * @param {function} [props.onChange] + */ +export function PageControl(props = {}) { + const { count = 3, current = 0, onChange, className } = props; + return el( + 'div', + { + ...omit(props, ['count', 'current', 'onChange', 'className']), + className: cx('gd-page-control', className), + role: 'tablist', + }, + ...Array.from({ length: count }, (_, i) => + el('button', { + type: 'button', + className: cx('gd-page-control__dot', i === current && 'gd-page-control__dot--active'), + attrs: { 'aria-label': `Page ${i + 1}` }, + onClick: () => onChange?.(i), + }) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/progress.js b/src/pyouter/webui/gd5/ui/progress.js new file mode 100644 index 0000000..0180dba --- /dev/null +++ b/src/pyouter/webui/gd5/ui/progress.js @@ -0,0 +1,121 @@ +import { cx, el, omit } from './util.js'; + +/** + * ProgressBar — determinate or indeterminate. + * @param {object} [props] + * @param {number} [props.percent] 0–100; omit for indeterminate + * @param {'sm'|'md'|'lg'} [props.size] + * @param {boolean} [props.showInfo] + */ +export function ProgressBar(props = {}) { + const { percent, size = 'md', showInfo = false, className } = props; + const indeterminate = percent == null; + const pct = Math.max(0, Math.min(100, Number(percent) || 0)); + return el( + 'div', + { + ...omit(props, ['percent', 'size', 'showInfo', 'className']), + className: cx('gd-progress', `gd-progress--${size}`, className), + role: 'progressbar', + attrs: indeterminate + ? { 'aria-valuemin': '0', 'aria-valuemax': '100' } + : { + 'aria-valuenow': String(pct), + 'aria-valuemin': '0', + 'aria-valuemax': '100', + }, + }, + el( + 'div', + { className: 'gd-progress__track' }, + el('div', { + className: cx('gd-progress__bar', indeterminate && 'gd-progress__bar--indeterminate'), + style: indeterminate ? undefined : { width: `${pct}%` }, + }) + ), + showInfo && !indeterminate + ? el('span', { className: 'gd-progress__info', text: `${Math.round(pct)}%` }) + : null + ); +} + +/** + * ProgressRing — circular progress (SVG). + * @param {object} [props] + * @param {number} [props.percent] + * @param {number} [props.size] px + * @param {number} [props.strokeWidth] + */ +export function ProgressRing(props = {}) { + const { percent = 0, size = 48, strokeWidth = 4, className } = props; + const pct = Math.max(0, Math.min(100, Number(percent) || 0)); + const r = (size - strokeWidth) / 2; + const c = 2 * Math.PI * r; + const offset = c - (pct / 100) * c; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', `0 0 ${size} ${size}`); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('class', 'gd-progress-ring__svg'); + + const track = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + track.setAttribute('class', 'gd-progress-ring__track'); + track.setAttribute('cx', String(size / 2)); + track.setAttribute('cy', String(size / 2)); + track.setAttribute('r', String(r)); + track.setAttribute('fill', 'none'); + track.setAttribute('stroke-width', String(strokeWidth)); + + const bar = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + bar.setAttribute('class', 'gd-progress-ring__bar'); + bar.setAttribute('cx', String(size / 2)); + bar.setAttribute('cy', String(size / 2)); + bar.setAttribute('r', String(r)); + bar.setAttribute('fill', 'none'); + bar.setAttribute('stroke-width', String(strokeWidth)); + bar.setAttribute('stroke-dasharray', String(c)); + bar.setAttribute('stroke-dashoffset', String(offset)); + bar.setAttribute('transform', `rotate(-90 ${size / 2} ${size / 2})`); + + svg.appendChild(track); + svg.appendChild(bar); + const svgEl = { e: svg, dom: () => svg }; + + return el( + 'div', + { + ...omit(props, ['percent', 'size', 'strokeWidth', 'className']), + className: cx('gd-progress-ring', className), + role: 'progressbar', + attrs: { + 'aria-valuenow': String(pct), + 'aria-valuemin': '0', + 'aria-valuemax': '100', + }, + style: { width: `${size}px`, height: `${size}px` }, + }, + svgEl, + el('span', { className: 'gd-progress-ring__label', text: `${Math.round(pct)}%` }) + ); +} + +/** Meter — capacity / level gauge. */ +export function Meter(props = {}) { + const { value = 0, min = 0, max = 100, low, high, optimum, className, label } = props; + return el( + 'div', + { className: cx('gd-meter', className) }, + label != null ? el('div', { className: 'gd-meter__label', text: label }) : null, + el('meter', { + ...omit(props, ['className', 'label']), + className: 'gd-meter__control', + value, + min, + max, + ...(low != null ? { low } : {}), + ...(high != null ? { high } : {}), + ...(optimum != null ? { optimum } : {}), + }) + ); +} diff --git a/src/pyouter/webui/gd5/ui/radio.js b/src/pyouter/webui/gd5/ui/radio.js new file mode 100644 index 0000000..e307b3e --- /dev/null +++ b/src/pyouter/webui/gd5/ui/radio.js @@ -0,0 +1,66 @@ +import { cx, el, omit } from './util.js'; + +/** + * Radio — single choice in a named group. + * @param {object} [props] + * @param {string} [props.label] + * @param {string} [props.name] + * @param {string|number} [props.value] + * @param {boolean} [props.checked] + */ +export function Radio(props = {}) { + const { label, className, checked, disabled, name, value, onChange, id } = props; + return el( + 'label', + { + className: cx('gd-radio', disabled && 'gd-radio--disabled', className), + }, + el('input', { + ...omit(props, ['label', 'className', 'text']), + type: 'radio', + className: 'gd-radio__input', + name, + value, + checked: !!checked, + disabled: !!disabled, + onChange, + id, + }), + el('span', { className: 'gd-radio__dot', attrs: { 'aria-hidden': 'true' } }), + label != null ? el('span', { className: 'gd-radio__label', text: label }) : null + ); +} + +/** + * RadioGroup — convenience wrapper. + * @param {object} [props] + * @param {Array<{value:string,label:string,disabled?:boolean}>} [props.options] + * @param {string} [props.value] + * @param {string} [props.name] + * @param {function} [props.onChange] + */ +export function RadioGroup(props = {}) { + const { options = [], value, name = 'gd-radio', className, onChange, direction = 'vertical' } = + props; + return el( + 'div', + { + className: cx( + 'gd-radio-group', + direction === 'horizontal' && 'gd-radio-group--horizontal', + className + ), + role: 'radiogroup', + }, + ...options.map((opt) => + Radio({ + name, + value: opt.value, + label: opt.label, + disabled: opt.disabled, + checked: value === opt.value, + onChange: (e) => onChange?.(e.target.value, e), + }) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/rate.js b/src/pyouter/webui/gd5/ui/rate.js new file mode 100644 index 0000000..806d2d8 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/rate.js @@ -0,0 +1,48 @@ +import { cx, el, omit } from './util.js'; + +/** + * Rate — star rating (antd Rate). + * @param {object} [props] + * @param {number} [props.value] + * @param {number} [props.count] + * @param {boolean} [props.allowClear] + * @param {boolean} [props.disabled] + * @param {function} [props.onChange] + */ +export function Rate(props = {}) { + const { + value = 0, + count = 5, + allowClear = true, + disabled = false, + onChange, + className, + character = '★', + } = props; + + return el( + 'div', + { + ...omit(props, ['value', 'count', 'allowClear', 'disabled', 'onChange', 'className', 'character']), + className: cx('gd-rate', disabled && 'gd-rate--disabled', className), + role: 'radiogroup', + attrs: { 'aria-label': 'Rating' }, + }, + ...Array.from({ length: count }, (_, i) => { + const n = i + 1; + const active = n <= value; + return el('button', { + type: 'button', + className: cx('gd-rate__star', active && 'gd-rate__star--active'), + text: character, + disabled: !!disabled, + attrs: { 'aria-label': `${n} star${n > 1 ? 's' : ''}` }, + onClick: () => { + if (disabled) return; + if (allowClear && value === n) onChange?.(0); + else onChange?.(n); + }, + }); + }) + ); +} diff --git a/src/pyouter/webui/gd5/ui/result.js b/src/pyouter/webui/gd5/ui/result.js new file mode 100644 index 0000000..98d9fc3 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/result.js @@ -0,0 +1,111 @@ +import { cx, el, omit } from './util.js'; +import { Button } from './button.js'; + +/** + * Result — success / error / info page block (antd Result). + * @param {object} [props] + * @param {'success'|'error'|'info'|'warning'|'404'|'403'|'500'} [props.status] + * @param {string} [props.title] + * @param {string} [props.subTitle] + * @param {any} [props.extra] + */ +export function Result(props = {}, ...children) { + const { + status = 'info', + title, + subTitle, + extra, + className, + icon, + } = props; + + const defaultIcon = { + success: '✓', + error: '✕', + info: 'ℹ', + warning: '!', + '404': '404', + '403': '403', + '500': '500', + }[status] || 'ℹ'; + + return el( + 'div', + { + ...omit(props, ['status', 'title', 'subTitle', 'extra', 'className', 'icon']), + className: cx('gd-result', `gd-result--${status}`, className), + }, + el('div', { + className: 'gd-result__icon', + text: icon != null ? String(icon) : defaultIcon, + }), + title != null ? el('div', { className: 'gd-result__title', text: title }) : null, + subTitle != null ? el('div', { className: 'gd-result__subtitle', text: subTitle }) : null, + children.length ? el('div', { className: 'gd-result__body' }, ...children) : null, + extra != null ? el('div', { className: 'gd-result__extra' }, extra) : null + ); +} + +/** + * Popconfirm — confirm popover on an action (antd Popconfirm). + * Controlled via `open`. + */ +export function Popconfirm(props = {}, ...children) { + const { + open = false, + title = 'Are you sure?', + okText = 'OK', + cancelText = 'Cancel', + onConfirm, + onCancel, + onOpenChange, + className, + } = props; + + return el( + 'span', + { + ...omit(props, [ + 'open', + 'title', + 'okText', + 'cancelText', + 'onConfirm', + 'onCancel', + 'onOpenChange', + 'className', + ]), + className: cx('gd-popconfirm', open && 'gd-popconfirm--open', className), + }, + ...children, + open + ? el( + 'div', + { className: 'gd-popconfirm__panel', role: 'dialog' }, + el('div', { className: 'gd-popconfirm__title', text: title }), + el( + 'div', + { className: 'gd-popconfirm__actions' }, + Button({ + type: 'default', + size: 'sm', + text: cancelText, + onClick: () => { + onCancel?.(); + onOpenChange?.(false); + }, + }), + Button({ + type: 'primary', + size: 'sm', + text: okText, + onClick: () => { + onConfirm?.(); + onOpenChange?.(false); + }, + }) + ) + ) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/skeleton.js b/src/pyouter/webui/gd5/ui/skeleton.js new file mode 100644 index 0000000..e5b59ff --- /dev/null +++ b/src/pyouter/webui/gd5/ui/skeleton.js @@ -0,0 +1,41 @@ +import { cx, el, omit } from './util.js'; + +/** + * Skeleton — placeholder shimmer for loading layouts. + * @param {object} [props] + * @param {'text'|'title'|'avatar'|'button'|'image'|'block'} [props.variant] + * @param {number|string} [props.width] + * @param {number|string} [props.height] + * @param {boolean} [props.active] + */ +export function Skeleton(props = {}) { + const { variant = 'text', width, height, active = true, className, style } = props; + const dim = { ...(typeof style === 'object' ? style : {}) }; + if (width != null) dim.width = typeof width === 'number' ? `${width}px` : width; + if (height != null) dim.height = typeof height === 'number' ? `${height}px` : height; + + return el('div', { + ...omit(props, ['variant', 'width', 'height', 'active', 'className', 'style']), + className: cx( + 'gd-skeleton', + `gd-skeleton--${variant}`, + active && 'gd-skeleton--active', + className + ), + style: dim, + attrs: { 'aria-hidden': 'true' }, + }); +} + +/** Skeleton.Paragraph — a few text lines. */ +export function SkeletonParagraph(props = {}) { + const { rows = 3, className } = props; + const widths = ['100%', '92%', '78%', '88%', '64%']; + return el( + 'div', + { className: cx('gd-skeleton-paragraph', className) }, + ...Array.from({ length: rows }, (_, i) => + Skeleton({ variant: 'text', width: widths[i % widths.length] }) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/space.js b/src/pyouter/webui/gd5/ui/space.js new file mode 100644 index 0000000..60600d5 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/space.js @@ -0,0 +1,44 @@ +import { cx, el, omit } from './util.js'; + +/** + * Flex stack / row — antd Space analogue. + * + * @param {object} [props] + * @param {'horizontal'|'vertical'} [props.direction] + * @param {'sm'|'md'|'lg'} [props.size] + * @param {'start'|'center'|'end'} [props.align] + * @param {'start'|'center'|'end'|'between'} [props.justify] + */ +export function Space(props = {}, ...children) { + const { + direction = 'horizontal', + size = 'md', + align, + justify, + className, + } = props; + + return el( + 'div', + { + ...omit(props, ['direction', 'size', 'align', 'justify', 'className']), + className: cx( + 'gd-space', + direction === 'vertical' && 'gd-space--vertical', + `gd-space--gap-${size}`, + align && `gd-space--align-${align}`, + justify && `gd-space--justify-${justify}`, + className + ), + }, + ...children + ); +} + +export function Divider(props = {}) { + const { type = 'horizontal', className } = props; + return el('hr', { + ...omit(props, ['type', 'className']), + className: cx('gd-divider', type === 'vertical' && 'gd-divider--vertical', className), + }); +} diff --git a/src/pyouter/webui/gd5/ui/steps.js b/src/pyouter/webui/gd5/ui/steps.js new file mode 100644 index 0000000..1e3429f --- /dev/null +++ b/src/pyouter/webui/gd5/ui/steps.js @@ -0,0 +1,83 @@ +import { cx, el, omit } from './util.js'; + +/** + * Steps — checkout / wizard stage indicators. + * @param {object} [props] + * @param {Array<{title:string,description?:string}>} [props.items] + * @param {number} [props.current] 0-based + * @param {'horizontal'|'vertical'} [props.direction] + */ +export function Steps(props = {}) { + const { items = [], current = 0, direction = 'horizontal', className } = props; + return el( + 'div', + { + ...omit(props, ['items', 'current', 'direction', 'className']), + className: cx( + 'gd-steps', + direction === 'vertical' && 'gd-steps--vertical', + className + ), + }, + ...items.map((item, i) => { + const status = i < current ? 'finish' : i === current ? 'process' : 'wait'; + return el( + 'div', + { className: cx('gd-steps__item', `gd-steps__item--${status}`) }, + el('div', { className: 'gd-steps__indicator', text: String(i + 1) }), + el( + 'div', + { className: 'gd-steps__content' }, + el('div', { className: 'gd-steps__title', text: item.title }), + item.description + ? el('div', { className: 'gd-steps__desc', text: item.description }) + : null + ) + ); + }) + ); +} + +/** + * QuantityStepper — increment / decrement numeric value. + * @param {object} [props] + * @param {number} [props.value] + * @param {number} [props.min] + * @param {number} [props.max] + * @param {number} [props.step] + * @param {function} [props.onChange] + */ +export function QuantityStepper(props = {}) { + const { value = 0, min = 0, max = Infinity, step = 1, onChange, className, disabled } = + props; + const dec = () => { + const next = Math.max(min, value - step); + if (next !== value) onChange?.(next); + }; + const inc = () => { + const next = Math.min(max, value + step); + if (next !== value) onChange?.(next); + }; + return el( + 'div', + { + ...omit(props, ['value', 'min', 'max', 'step', 'onChange', 'className']), + className: cx('gd-qty-stepper', className), + }, + el('button', { + type: 'button', + className: 'gd-qty-stepper__btn', + text: '−', + disabled: disabled || value <= min, + onClick: dec, + }), + el('span', { className: 'gd-qty-stepper__value', text: String(value) }), + el('button', { + type: 'button', + className: 'gd-qty-stepper__btn', + text: '+', + disabled: disabled || value >= max, + onClick: inc, + }) + ); +} diff --git a/src/pyouter/webui/gd5/ui/table.js b/src/pyouter/webui/gd5/ui/table.js new file mode 100644 index 0000000..9f92f30 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/table.js @@ -0,0 +1,95 @@ +import { cx, el, omit } from './util.js'; + +/** + * Table / DataTable — rows & columns with optional sort headers. + * @param {object} [props] + * @param {Array<{key:string,title:string,sortable?:boolean}>} [props.columns] + * @param {Array} [props.dataSource] + * @param {string} [props.sortKey] + * @param {'asc'|'desc'} [props.sortOrder] + * @param {function} [props.onSort] + * @param {function} [props.rowKey] + */ +export function Table(props = {}) { + const { + columns = [], + dataSource = [], + sortKey, + sortOrder, + onSort, + rowKey = (row, i) => row.key ?? i, + className, + } = props; + + return el( + 'div', + { + ...omit(props, [ + 'columns', + 'dataSource', + 'sortKey', + 'sortOrder', + 'onSort', + 'rowKey', + 'className', + ]), + className: cx('gd-table-wrap', className), + }, + el( + 'table', + { className: 'gd-table' }, + el( + 'thead', + {}, + el( + 'tr', + {}, + ...columns.map((col) => + el('th', { + className: cx( + 'gd-table__th', + col.sortable && 'gd-table__th--sortable', + sortKey === col.key && 'gd-table__th--sorted' + ), + text: + col.title + + (col.sortable && sortKey === col.key + ? sortOrder === 'asc' + ? ' ↑' + : ' ↓' + : ''), + onClick: col.sortable + ? () => { + const next = + sortKey === col.key && sortOrder === 'asc' ? 'desc' : 'asc'; + onSort?.(col.key, next); + } + : undefined, + }) + ) + ) + ), + el( + 'tbody', + {}, + ...dataSource.map((row, i) => + el( + 'tr', + { key: String(rowKey(row, i)), className: 'gd-table__tr' }, + ...columns.map((col) => + el('td', { + className: 'gd-table__td', + text: + row[col.key] != null + ? String(typeof row[col.key] === 'object' ? '' : row[col.key]) + : '', + }, typeof row[col.key] === 'object' && row[col.key]?.dom ? row[col.key] : null) + ) + ) + ) + ) + ) + ); +} + +export const DataTable = Table; diff --git a/src/pyouter/webui/gd5/ui/tabs.js b/src/pyouter/webui/gd5/ui/tabs.js new file mode 100644 index 0000000..56ececb --- /dev/null +++ b/src/pyouter/webui/gd5/ui/tabs.js @@ -0,0 +1,78 @@ +import { cx, el, omit } from './util.js'; + +/** + * Tabs — label row + one active panel. + * Controlled via `activeKey` + `onChange`. + * @param {object} [props] + * @param {Array<{key:string,label:string,content?:any,disabled?:boolean}>} [props.items] + * @param {string} [props.activeKey] + * @param {function} [props.onChange] + */ +export function Tabs(props = {}) { + const { items = [], activeKey, onChange, className } = props; + const key = activeKey ?? items[0]?.key; + const active = items.find((it) => it.key === key) || items[0]; + + return el( + 'div', + { + ...omit(props, ['items', 'activeKey', 'onChange', 'className']), + className: cx('gd-tabs', className), + }, + el( + 'div', + { className: 'gd-tabs__list', role: 'tablist' }, + ...items.map((it) => + el('button', { + type: 'button', + role: 'tab', + className: cx('gd-tabs__tab', it.key === key && 'gd-tabs__tab--active'), + text: it.label, + disabled: !!it.disabled, + attrs: { + 'aria-selected': it.key === key ? 'true' : 'false', + }, + onClick: () => onChange?.(it.key), + }) + ) + ), + el( + 'div', + { className: 'gd-tabs__panel', role: 'tabpanel' }, + active?.content ?? null + ) + ); +} + +/** + * ToggleGroup / SegmentedControl — connected exclusive options. + * @param {object} [props] + * @param {Array<{value:string,label:string,disabled?:boolean}>} [props.options] + * @param {string} [props.value] + * @param {function} [props.onChange] + */ +export function ToggleGroup(props = {}) { + const { options = [], value, onChange, className, size = 'md' } = props; + return el( + 'div', + { + ...omit(props, ['options', 'value', 'onChange', 'className', 'size']), + className: cx('gd-toggle-group', `gd-toggle-group--${size}`, className), + role: 'group', + }, + ...options.map((opt) => + el('button', { + type: 'button', + className: cx( + 'gd-toggle-group__item', + opt.value === value && 'gd-toggle-group__item--active' + ), + text: opt.label, + disabled: !!opt.disabled, + onClick: () => onChange?.(opt.value), + }) + ) + ); +} + +export const SegmentedControl = ToggleGroup; diff --git a/src/pyouter/webui/gd5/ui/text-extra.js b/src/pyouter/webui/gd5/ui/text-extra.js new file mode 100644 index 0000000..ca843e4 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/text-extra.js @@ -0,0 +1,127 @@ +import { cx, el, omit } from './util.js'; + +/** + * Truncate — ellipsis / line-clamp text. + * @param {object} [props] + * @param {string} [props.text] + * @param {number} [props.lines] 1 = single-line ellipsis + */ +export function Truncate(props = {}, ...children) { + const { text, lines = 1, className } = props; + const kids = children.length ? children : text != null ? [text] : []; + return el( + 'span', + { + ...omit(props, ['text', 'lines', 'className']), + className: cx( + 'gd-truncate', + lines > 1 ? 'gd-truncate--clamp' : 'gd-truncate--ellipsis', + className + ), + style: lines > 1 ? { WebkitLineClamp: String(lines) } : undefined, + }, + ...kids + ); +} + +/** + * PasswordInput — sign-in form eye toggle pattern. + */ +export function PasswordInput(props = {}) { + const { visible = false, onToggleVisible, className, ...rest } = props; + return el( + 'div', + { className: cx('gd-password', className) }, + el('input', { + ...rest, + type: visible ? 'text' : 'password', + className: cx('gd-input', 'gd-password__input'), + }), + el('button', { + type: 'button', + className: 'gd-password__eye', + text: visible ? 'Hide' : 'Show', + onClick: onToggleVisible, + }) + ); +} + +/** + * OrDivider — “OR” rule used in sign-in forms. + */ +export function OrDivider(props = {}) { + const { text = 'OR', className } = props; + return el( + 'div', + { className: cx('gd-or-divider', className) }, + el('span', { className: 'gd-or-divider__line' }), + el('span', { className: 'gd-or-divider__text', text }), + el('span', { className: 'gd-or-divider__line' }) + ); +} + +/** + * SignInForm — labeled anatomy of a login form (composition). + */ +export function SignInForm(props = {}) { + const { + className, + email, + password, + passwordVisible, + onEmail, + onPassword, + onToggleVisible, + onSubmit, + onContinueWith, + providers = ['Google', 'GitHub'], + } = props; + + return el( + 'form', + { + className: cx('gd-sign-in', className), + onSubmit: (e) => { + e.preventDefault(); + onSubmit?.({ email, password }); + }, + }, + el('label', { className: 'gd-field' }, + el('span', { className: 'gd-field__label', text: 'Email' }), + el('input', { + type: 'email', + className: 'gd-input', + value: email, + placeholder: 'you@company.com', + onInput: (e) => onEmail?.(e.target.value), + }) + ), + el('label', { className: 'gd-field' }, + el('span', { className: 'gd-field__label', text: 'Password' }), + PasswordInput({ + value: password, + visible: passwordVisible, + onToggleVisible, + onInput: (e) => onPassword?.(e.target.value), + }) + ), + el('button', { + type: 'submit', + className: 'gd-btn gd-btn--primary gd-btn--md gd-btn--block', + text: 'Sign in', + }), + OrDivider({}), + el( + 'div', + { className: 'gd-sign-in__providers' }, + ...providers.map((p) => + el('button', { + type: 'button', + className: 'gd-btn gd-btn--default gd-btn--md gd-btn--block', + text: `Continue with ${p}`, + onClick: () => onContinueWith?.(p), + }) + ) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/theme.js b/src/pyouter/webui/gd5/ui/theme.js new file mode 100644 index 0000000..700b625 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/theme.js @@ -0,0 +1,92 @@ +/** + * Built-in UI themes: orange (default), blue, green. + * + * import { setTheme, getTheme, themes } from '../ui/index.js'; + * setTheme('blue'); // document.documentElement[data-gd-theme] + * setTheme('green', rootEl); // scope to an element + */ + +/** @typedef {'orange'|'blue'|'green'} GdThemeName */ + +/** @type {Record} */ +export const themes = { + orange: { + label: 'Orange', + primary: '#e86a17', + bg: '#ffffff', + }, + blue: { + label: 'Blue', + primary: '#2f6fed', + bg: '#f3f6fb', + }, + green: { + label: 'Green', + primary: '#2f8f5b', + bg: '#f3f8f4', + }, +}; + +export const THEME_ATTR = 'data-gd-theme'; + +/** @type {GdThemeName} */ +let currentTheme = 'orange'; + +/** + * @param {string} name + * @returns {name is GdThemeName} + */ +export function isThemeName(name) { + return Object.prototype.hasOwnProperty.call(themes, name); +} + +/** + * Apply a named theme. Default target is . + * @param {GdThemeName|string} name + * @param {HTMLElement|Document} [target] + * @returns {GdThemeName} + */ +export function setTheme(name, target) { + const theme = isThemeName(name) ? name : 'orange'; + const el = + target && target.nodeType === 9 + ? /** @type {Document} */ (target).documentElement + : target || (typeof document !== 'undefined' ? document.documentElement : null); + + if (el && el.setAttribute) { + el.setAttribute(THEME_ATTR, theme); + } + currentTheme = theme; + return theme; +} + +/** + * @param {HTMLElement|Document} [target] + * @returns {GdThemeName} + */ +export function getTheme(target) { + const el = + target && target.nodeType === 9 + ? /** @type {Document} */ (target).documentElement + : target || (typeof document !== 'undefined' ? document.documentElement : null); + const fromDom = el?.getAttribute?.(THEME_ATTR); + if (isThemeName(fromDom)) return fromDom; + return currentTheme; +} + +/** + * Resolve ConfigProvider / setTheme input to a theme name or token map. + * @param {string|{name?:string,algorithm?:string,token?:object}|null|undefined} theme + * @returns {{ name?: GdThemeName, token?: object }} + */ +export function resolveThemeInput(theme) { + if (theme == null || theme === '') return { name: undefined, token: undefined }; + if (typeof theme === 'string') { + return { name: isThemeName(theme) ? theme : undefined, token: undefined }; + } + const name = theme.name || theme.algorithm; + return { + name: isThemeName(name) ? name : undefined, + token: theme.token || undefined, + }; +} diff --git a/src/pyouter/webui/gd5/ui/timeline.js b/src/pyouter/webui/gd5/ui/timeline.js new file mode 100644 index 0000000..f68feb9 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/timeline.js @@ -0,0 +1,38 @@ +import { cx, el, omit } from './util.js'; + +/** + * Timeline — vertical dated events. + * @param {object} [props] + * @param {Array<{key?:string,time?:string,title:string,description?:string,color?:string}>} [props.items] + */ +export function Timeline(props = {}) { + const { items = [], className } = props; + return el( + 'div', + { + ...omit(props, ['items', 'className']), + className: cx('gd-timeline', className), + }, + ...items.map((item, i) => + el( + 'div', + { + className: cx('gd-timeline__item', item.color && `gd-timeline__item--${item.color}`), + key: item.key || `tl-${i}`, + }, + el('div', { className: 'gd-timeline__rail' }, el('span', { className: 'gd-timeline__dot' })), + el( + 'div', + { className: 'gd-timeline__body' }, + item.time + ? el('div', { className: 'gd-timeline__time', text: item.time }) + : null, + el('div', { className: 'gd-timeline__title', text: item.title }), + item.description + ? el('div', { className: 'gd-timeline__desc', text: item.description }) + : null + ) + ) + ) + ); +} diff --git a/src/pyouter/webui/gd5/ui/todo.js b/src/pyouter/webui/gd5/ui/todo.js new file mode 100644 index 0000000..3dfd842 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/todo.js @@ -0,0 +1,274 @@ +import { Component } from '../engine/index.js'; +import { cx, el, loadUi } from './util.js'; + +/** + * @typedef {'pending'|'active'|'done'} TodoStatus + * @typedef {{ + * key?: string|number, + * text?: string, + * title?: string, + * status?: TodoStatus|'in_progress'|'completed'|'todo', + * disabled?: boolean, + * }} TodoItemInput + */ + +/** + * Normalize status aliases. + * @param {string} [status] + * @returns {TodoStatus} + */ +export function normalizeTodoStatus(status) { + const s = String(status || 'pending').toLowerCase(); + if (s === 'active' || s === 'in_progress' || s === 'current' || s === 'running') return 'active'; + if (s === 'done' || s === 'completed' || s === 'complete' || s === 'finished') return 'done'; + return 'pending'; +} + +/** + * @param {TodoItemInput[]|string[]} items + * @returns {Array<{key:string,text:string,status:TodoStatus,disabled:boolean,raw:any}>} + */ +export function normalizeTodoItems(items = []) { + return (items || []).map((item, i) => { + if (item == null || typeof item === 'string' || typeof item === 'number') { + return { + key: `todo-${i}`, + text: String(item ?? ''), + status: 'pending', + disabled: false, + raw: item, + }; + } + return { + key: String(item.key ?? item.id ?? `todo-${i}`), + text: String(item.text ?? item.title ?? item.label ?? ''), + status: normalizeTodoStatus(item.status), + disabled: !!item.disabled, + raw: item, + }; + }); +} + +/** + * Count done / total for header badge. + * @param {Array<{status:TodoStatus}>} items + */ +export function countTodoProgress(items = []) { + const total = items.length; + const done = items.filter((it) => it.status === 'done').length; + return { done, total }; +} + +/** + * Cycle pending → active → done → pending (handy for demos). + * @param {TodoStatus} status + * @returns {TodoStatus} + */ +export function nextTodoStatus(status) { + const s = normalizeTodoStatus(status); + if (s === 'pending') return 'active'; + if (s === 'active') return 'done'; + return 'pending'; +} + +function statusIcon(status) { + if (status === 'active') { + return el( + 'span', + { + className: 'gd-todo__icon gd-todo__icon--active', + attrs: { 'aria-hidden': 'true' }, + }, + el('span', { className: 'gd-todo__arrow' }) + ); + } + if (status === 'done') { + return el( + 'span', + { + className: 'gd-todo__icon gd-todo__icon--done', + attrs: { 'aria-hidden': 'true' }, + }, + el('span', { className: 'gd-todo__check' }) + ); + } + return el('span', { + className: 'gd-todo__icon gd-todo__icon--pending', + attrs: { 'aria-hidden': 'true' }, + }); +} + +/** + * TodoList view — mountable Component (prefer calling `TodoList(props)` in trees). + */ +export class TodoListView extends Component { + constructor(deps = {}) { + super(deps); + this.state.data.title = deps.title ?? 'To-dos'; + this.state.data.items = normalizeTodoItems(deps.items); + this.state.data.showProgress = deps.showProgress !== false; + this.state.data.className = deps.className || ''; + // Bind so inline handlers can use this.setItems / this.getItems + this._onItemClick = + typeof deps.onItemClick === 'function' ? deps.onItemClick.bind(this) : null; + } + + async mount(host, params = {}) { + await loadUi(); + return super.mount(host, params); + } + + /** + * @param {TodoItemInput[]|string[]} items + */ + setItems(items) { + this.setData({ items: normalizeTodoItems(items) }, { refresh: !!this._mounted }); + return this; + } + + getItems() { + return this.state.data.items; + } + + getProgress() { + return countTodoProgress(this.state.data.items); + } + + setTitle(title) { + this.setData({ title: String(title ?? '') }, { refresh: !!this._mounted }); + return this; + } + + _renderItem(item, index) { + const interactive = typeof this._onItemClick === 'function' && !item.disabled; + const body = [statusIcon(item.status), el('span', { className: 'gd-todo__text', text: item.text })]; + return el( + 'li', + { + key: item.key, + className: cx( + 'gd-todo__item', + `gd-todo__item--${item.status}`, + item.disabled && 'gd-todo__item--disabled' + ), + attrs: { + role: 'listitem', + 'data-status': item.status, + 'aria-current': item.status === 'active' ? 'step' : undefined, + }, + }, + interactive + ? el( + 'button', + { + type: 'button', + className: 'gd-todo__row gd-todo__row--interactive', + onClick: (e) => { + e.preventDefault(); + this._onItemClick?.(item, index); + }, + }, + ...body + ) + : el('div', { className: 'gd-todo__row' }, ...body) + ); + } + + render() { + const { title, items, showProgress, className } = this.state.data; + const { done, total } = countTodoProgress(items); + + this.ensureShell( + (ids) => ids.gdTodoRoot, + ({ div, ul, span }) => { + div( + { + id: 'gdTodoRoot', + className: cx('gd-todo', className), + attrs: { role: 'list', 'aria-label': title }, + }, + div( + { className: 'gd-todo__header' }, + span({ id: 'gdTodoTitle', className: 'gd-todo__title', text: title }), + showProgress + ? span({ + id: 'gdTodoProgress', + className: 'gd-todo__progress', + text: `${done}/${total}`, + attrs: { 'aria-label': `${done} of ${total} completed` }, + }) + : null + ), + ul({ id: 'gdTodoList', className: 'gd-todo__list' }) + ); + } + ); + + const root = this.ids().gdTodoRoot; + const titleEl = this.ids().gdTodoTitle; + const progressEl = this.ids().gdTodoProgress; + const list = this.ids().gdTodoList; + if (!root || !list) return; + + if (titleEl) titleEl.innerText = title; + if (typeof root.setAttribute === 'function') { + root.setAttribute('aria-label', title); + } + + if (progressEl) { + progressEl.innerText = `${done}/${total}`; + if (typeof progressEl.setAttribute === 'function') { + progressEl.setAttribute('aria-label', `${done} of ${total} completed`); + } + } + + list.textContent = ''; + for (let i = 0; i < items.length; i++) { + list.appendChild(this._renderItem(items[i], i).dom()); + } + } +} + +/** + * TodoList — Cursor-style to-dos (like Button: call in the tree, no pre-`new` on `this`). + * + * ```js + * div( + * { id: 'root' }, + * TodoList({ + * items: [...], + * onItemClick(item, index) { + * this.setItems(this.getItems().map(...)); // `this` is the list + * }, + * }) + * ) + * ``` + * + * Statuses: `pending` · `active` · `done` + * + * @param {object} [props] + * @returns {TodoListView} + */ +export function TodoList(props = {}) { + return new TodoListView(props); +} + +/** + * @param {object} [options] + * @param {HTMLElement|string} [host] + */ +export function createTodoList(options = {}, host) { + const list = TodoList(options); + const elHost = + typeof host === 'string' + ? typeof document !== 'undefined' + ? document.getElementById(host) + : null + : host; + if (elHost) void list.mount(elHost); + return list; +} + +/** Alias */ +export const Todos = TodoList; +export const Todo = TodoList; diff --git a/src/pyouter/webui/gd5/ui/tree-select.js b/src/pyouter/webui/gd5/ui/tree-select.js new file mode 100644 index 0000000..1d006ce --- /dev/null +++ b/src/pyouter/webui/gd5/ui/tree-select.js @@ -0,0 +1,78 @@ +import { cx, el, omit } from './util.js'; + +/** + * TreeSelect — select a node from a tree (antd TreeSelect, simplified). + * @param {object} [props] + * @param {Array<{value:string,title:string,children?:array}>} [props.treeData] + * @param {string} [props.value] + * @param {boolean} [props.open] + * @param {function} [props.onChange] + * @param {function} [props.onOpenChange] + */ +export function TreeSelect(props = {}) { + const { + treeData = [], + value, + open = false, + onChange, + onOpenChange, + placeholder = 'Please select', + className, + } = props; + + const findTitle = (nodes, v) => { + for (const n of nodes) { + if (n.value === v) return n.title; + if (n.children) { + const t = findTitle(n.children, v); + if (t) return t; + } + } + return null; + }; + + const label = value != null ? findTitle(treeData, value) || value : placeholder; + + const renderNodes = (nodes, depth = 0) => + nodes.flatMap((n) => [ + el('button', { + type: 'button', + className: cx( + 'gd-tree-select__option', + n.value === value && 'gd-tree-select__option--active' + ), + style: { paddingLeft: `${12 + depth * 14}px` }, + text: n.title, + onClick: () => { + onChange?.(n.value, n); + onOpenChange?.(false); + }, + }), + ...(n.children ? renderNodes(n.children, depth + 1) : []), + ]); + + return el( + 'div', + { + ...omit(props, [ + 'treeData', + 'value', + 'open', + 'onChange', + 'onOpenChange', + 'placeholder', + 'className', + ]), + className: cx('gd-tree-select', open && 'gd-tree-select--open', className), + }, + el('button', { + type: 'button', + className: 'gd-tree-select__trigger', + text: String(label), + onClick: () => onOpenChange?.(!open), + }), + open + ? el('div', { className: 'gd-tree-select__dropdown' }, ...renderNodes(treeData)) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/tree.js b/src/pyouter/webui/gd5/ui/tree.js new file mode 100644 index 0000000..2a05cdd --- /dev/null +++ b/src/pyouter/webui/gd5/ui/tree.js @@ -0,0 +1,222 @@ +import { cx, el, omit } from './util.js'; + +/** + * Walk treeData and return keys from root to `key` (inclusive), or []. + * @param {Array<{key:string,children?:array}>} treeData + * @param {string} key + * @returns {string[]} + */ +export function treePathKeys(treeData, key) { + const walk = (nodes, trail) => { + for (const n of nodes || []) { + const next = [...trail, n.key]; + if (n.key === key) return next; + if (n.children?.length) { + const found = walk(n.children, next); + if (found) return found; + } + } + return null; + }; + return walk(treeData, []) || []; +} + +/** + * Compute next expandedKeys after toggling `key`. + * Accordion: keep only root→key when opening; keep ancestors when closing. + * @param {object} opts + * @param {Array} opts.treeData + * @param {Set|string[]} opts.expandedKeys + * @param {string} opts.key + * @param {boolean} [opts.accordion] + * @returns {string[]} + */ +export function nextTreeExpandedKeys({ + treeData, + expandedKeys, + key, + accordion = false, +}) { + const expanded = + expandedKeys instanceof Set + ? expandedKeys + : new Set(expandedKeys || []); + const open = expanded.has(key); + + if (accordion) { + const path = treePathKeys(treeData, key); + if (!path.length) return [...expanded]; + return open ? path.slice(0, -1) : path; + } + + const next = new Set(expanded); + if (open) next.delete(key); + else next.add(key); + return [...next]; +} + +function toKeySet(expandedKeys) { + return expandedKeys instanceof Set + ? new Set(expandedKeys) + : new Set(expandedKeys || []); +} + +/** + * Tree / OutlineView — expandable rows with guide lines. + * Self-repaints on expand/select so it works under ensureShell. + * + * @param {object} [props] + * @param {Array<{key:string,title:string,children?:array}>} [props.treeData] + * @param {Set|string[]} [props.expandedKeys] + * @param {function} [props.onExpand] + * @param {string} [props.selectedKey] + * @param {function} [props.onSelect] + * @param {boolean} [props.accordion] 手风琴:只保留根→当前节点路径 + */ +export function Tree(props = {}) { + const { + treeData = [], + expandedKeys, + onExpand, + selectedKey: selectedKeyProp, + onSelect, + accordion = false, + className, + } = props; + + let expanded = toKeySet(expandedKeys); + let selectedKey = selectedKeyProp; + + const root = el('div', { + ...omit(props, [ + 'treeData', + 'expandedKeys', + 'onExpand', + 'selectedKey', + 'onSelect', + 'accordion', + 'className', + ]), + className: cx('gd-tree', accordion && 'gd-tree--accordion', className), + role: 'tree', + }); + + const paint = () => { + const host = root.dom(); + while (host.firstChild) host.removeChild(host.firstChild); + for (const n of treeData) { + host.appendChild(renderNode(n).dom()); + } + }; + + const applyExpand = (key) => { + const next = nextTreeExpandedKeys({ + treeData, + expandedKeys: expanded, + key, + accordion, + }); + expanded = new Set(next); + onExpand?.(next); + paint(); + }; + + const renderNode = (node) => { + const hasKids = Array.isArray(node.children) && node.children.length > 0; + const open = expanded.has(node.key); + + const twist = hasKids + ? el('button', { + type: 'button', + className: cx('gd-tree__twist', open && 'gd-tree__twist--open'), + attrs: { 'aria-label': open ? 'Collapse' : 'Expand' }, + onClick: (e) => { + e.stopPropagation(); + applyExpand(node.key); + }, + }) + : el('span', { className: 'gd-tree__twist gd-tree__twist--leaf' }); + + const selectNode = () => { + selectedKey = node.key; + onSelect?.(node.key, node); + if (!accordion) { + paint(); + return; + } + if (hasKids) { + applyExpand(node.key); + } else { + expanded = new Set(treePathKeys(treeData, node.key).slice(0, -1)); + onExpand?.([...expanded]); + paint(); + } + }; + + const label = el('span', { + className: 'gd-tree__label', + text: node.title, + }); + + const row = el( + 'div', + { + className: cx( + 'gd-tree__row', + selectedKey === node.key && 'gd-tree__row--selected' + ), + onClick: selectNode, + }, + twist, + label + ); + + if (hasKids && open) { + return el( + 'div', + { + className: 'gd-tree__node', + attrs: { + role: 'treeitem', + 'aria-expanded': 'true', + }, + }, + row, + el( + 'div', + { className: 'gd-tree__children', role: 'group' }, + ...node.children.map((c) => renderNode(c)) + ) + ); + } + + return el( + 'div', + { + className: cx('gd-tree__node', !hasKids && 'gd-tree__node--leaf'), + attrs: { + role: 'treeitem', + 'aria-expanded': hasKids ? 'false' : undefined, + }, + }, + row + ); + }; + + paint(); + return root; +} + +export const OutlineView = Tree; + +/** DisclosureTriangle — standalone twisty control (arrow). */ +export function DisclosureTriangle(props = {}) { + const { open = false, onClick, className } = props; + return el('button', { + ...omit(props, ['open', 'className']), + type: 'button', + className: cx('gd-tree__twist', open && 'gd-tree__twist--open', className), + onClick, + attrs: { 'aria-expanded': open ? 'true' : 'false' }, + }); +} diff --git a/src/pyouter/webui/gd5/ui/typography.js b/src/pyouter/webui/gd5/ui/typography.js new file mode 100644 index 0000000..0f273a5 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/typography.js @@ -0,0 +1,47 @@ +import { cx, el, omit } from './util.js'; + +const TITLE_TAGS = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4', 5: 'h5' }; + +/** + * @param {object} [props] + * @param {1|2|3|4|5} [props.level] + * @param {string} [props.text] + */ +export function Title(props = {}, ...children) { + const { level = 3, className, text } = props; + const tag = TITLE_TAGS[level] || 'h3'; + const kids = children.length ? children : text != null ? [text] : []; + return el( + tag, + { + ...omit(props, ['level', 'className', 'text']), + className: cx('gd-title', `gd-title--${level}`, className), + }, + ...kids + ); +} + +/** + * @param {object} [props] + * @param {'default'|'secondary'|'muted'|'danger'} [props.type] + * @param {boolean} [props.code] + * @param {boolean} [props.small] + */ +export function Text(props = {}, ...children) { + const { type = 'default', code, small, className, text, as = 'p' } = props; + const kids = children.length ? children : text != null ? [text] : []; + return el( + as, + { + ...omit(props, ['type', 'code', 'small', 'className', 'text', 'as']), + className: cx( + 'gd-text', + type !== 'default' && `gd-text--${type}`, + code && 'gd-text--code', + small && 'gd-text--small', + className + ), + }, + ...kids + ); +} diff --git a/src/pyouter/webui/gd5/ui/upload.js b/src/pyouter/webui/gd5/ui/upload.js new file mode 100644 index 0000000..2bbb279 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/upload.js @@ -0,0 +1,173 @@ +import { cx, el, omit } from './util.js'; +import { Button } from './button.js'; + +/** + * Upload — file picker with file list (antd Upload, simplified). + * @param {object} [props] + * @param {Array<{uid:string,name:string,status?:string}>} [props.fileList] + * @param {boolean} [props.multiple] + * @param {string} [props.accept] + * @param {function} [props.onChange] ({ fileList, file }) + * @param {function} [props.onRemove] + */ +export function Upload(props = {}) { + const { + fileList = [], + multiple = false, + accept, + onChange, + onRemove, + className, + text = 'Click to upload', + disabled, + } = props; + + const inputId = props.id || `gd-upload-${Math.random().toString(36).slice(2, 8)}`; + + return el( + 'div', + { + ...omit(props, [ + 'fileList', + 'multiple', + 'accept', + 'onChange', + 'onRemove', + 'className', + 'text', + 'disabled', + ]), + className: cx('gd-upload', className), + }, + el( + 'label', + { className: cx('gd-upload__trigger', disabled && 'gd-upload__trigger--disabled'), attrs: { for: inputId } }, + Button({ type: 'default', text, disabled: !!disabled }), + el('input', { + id: inputId, + type: 'file', + className: 'gd-upload__input', + multiple: !!multiple, + accept, + disabled: !!disabled, + onChange: (e) => { + const files = Array.from(e.target.files || []); + const next = [ + ...fileList, + ...files.map((f, i) => ({ + uid: `${Date.now()}-${i}`, + name: f.name, + status: 'done', + originFileObj: f, + })), + ]; + onChange?.({ fileList: next, file: next[next.length - 1] }); + e.target.value = ''; + }, + }) + ), + fileList.length + ? el( + 'ul', + { className: 'gd-upload__list' }, + ...fileList.map((f) => + el( + 'li', + { className: 'gd-upload__item', key: f.uid }, + el('span', { className: 'gd-upload__name', text: f.name }), + el('button', { + type: 'button', + className: 'gd-upload__remove', + text: '×', + onClick: () => { + onRemove?.(f); + onChange?.({ + fileList: fileList.filter((x) => x.uid !== f.uid), + file: f, + }); + }, + }) + ) + ) + ) + : null + ); +} + +/** + * Mentions — textarea with @ trigger suggestions (simplified antd Mentions). + * @param {object} [props] + * @param {string} [props.value] + * @param {string[]} [props.options] + * @param {boolean} [props.open] + * @param {function} [props.onChange] + * @param {function} [props.onOpenChange] + * @param {function} [props.onSelect] + */ +export function Mentions(props = {}) { + const { + value = '', + options = [], + open = false, + onChange, + onOpenChange, + onSelect, + className, + placeholder = 'Type @ to mention…', + } = props; + + const at = value.lastIndexOf('@'); + const query = at >= 0 ? value.slice(at + 1).split(/\s/)[0].toLowerCase() : ''; + const filtered = + at >= 0 + ? options.filter((o) => String(o).toLowerCase().includes(query)) + : []; + + return el( + 'div', + { + ...omit(props, [ + 'value', + 'options', + 'open', + 'onChange', + 'onOpenChange', + 'onSelect', + 'className', + 'placeholder', + ]), + className: cx('gd-mentions', open && filtered.length && 'gd-mentions--open', className), + }, + el('textarea', { + className: 'gd-textarea gd-mentions__input', + value, + placeholder, + onInput: (e) => { + const v = e.target.value; + onChange?.(v); + onOpenChange?.(v.includes('@')); + }, + }), + open && filtered.length + ? el( + 'div', + { className: 'gd-mentions__list', role: 'listbox' }, + ...filtered.map((name) => + el('button', { + type: 'button', + className: 'gd-mentions__option', + text: `@${name}`, + onClick: () => { + const before = value.slice(0, at); + const after = value.slice(at + 1 + query.length); + const next = `${before}@${name} ${after}`; + onChange?.(next); + onSelect?.(name); + onOpenChange?.(false); + }, + }) + ) + ) + : null + ); +} diff --git a/src/pyouter/webui/gd5/ui/util.js b/src/pyouter/webui/gd5/ui/util.js new file mode 100644 index 0000000..097a20c --- /dev/null +++ b/src/pyouter/webui/gd5/ui/util.js @@ -0,0 +1,66 @@ +import { D, adoptStyles } from '../engine/index.js'; + +/** @type {Promise|null} */ +let stylesPromise = null; + +/** + * Load gd5 UI kit stylesheet once (co-located CSS). + * Apps can also `` `src/ui/css/ui.css` for zero FOUC. + */ +export function loadUi() { + if (!stylesPromise) { + stylesPromise = adoptStyles(new URL('./css/ui.css', import.meta.url)).then(async () => { + const jelly = await import('./jelly.js'); + jelly.ensureJelly(); + jelly.applyRememberedJelly(); + }); + } + return stylesPromise; +} + +/** Kick off style load from primitives (non-blocking). */ +export function ensureUiStyles() { + void loadUi(); +} + +/** + * @param {...(string|false|null|undefined|Array)} parts + */ +export function cx(...parts) { + const out = []; + const walk = (list) => { + for (const part of list) { + if (!part) continue; + if (Array.isArray(part)) walk(part); + else out.push(String(part)); + } + }; + walk(parts); + return out.join(' ').replace(/\s+/g, ' ').trim(); +} + +/** + * Create a real-DOM node via a fresh D (safe as child of another tree). + * @param {string} tag + * @param {object} [props] + * @param {...any} children + */ +export function el(tag, props, ...children) { + ensureUiStyles(); + const d = new D(); + return d.el(tag, props || {}, ...children); +} + +/** + * Strip UI-specific props before forwarding to DOM. + * @param {object} props + * @param {string[]} keys + */ +export function omit(props, keys) { + const skip = new Set(keys); + const out = {}; + for (const k in props) { + if (!skip.has(k)) out[k] = props[k]; + } + return out; +} diff --git a/src/pyouter/webui/gd5/ui/watermark.js b/src/pyouter/webui/gd5/ui/watermark.js new file mode 100644 index 0000000..e52fa51 --- /dev/null +++ b/src/pyouter/webui/gd5/ui/watermark.js @@ -0,0 +1,45 @@ +import { cx, el, omit } from './util.js'; + +/** + * Watermark — tiled text overlay (antd Watermark, simplified). + * @param {object} [props] + * @param {string|string[]} [props.content] + * @param {number} [props.gap] px between tiles + * @param {number} [props.fontSize] + * @param {string} [props.color] + */ +export function Watermark(props = {}, ...children) { + const { + content = 'gd5', + gap = 120, + fontSize = 14, + color = 'rgba(0,0,0,0.08)', + className, + rotate = -22, + } = props; + + const text = Array.isArray(content) ? content.join(' ') : String(content); + const svg = encodeURIComponent( + `` + + `${text}` + ); + + return el( + 'div', + { + ...omit(props, ['content', 'gap', 'fontSize', 'color', 'className', 'rotate']), + className: cx('gd-watermark', className), + }, + el('div', { + className: 'gd-watermark__layer', + style: { + backgroundImage: `url("data:image/svg+xml,${svg}")`, + backgroundSize: `${gap}px ${gap}px`, + }, + attrs: { 'aria-hidden': 'true' }, + }), + el('div', { className: 'gd-watermark__content' }, ...children) + ); +} diff --git a/src/pyouter/webui/labels.js b/src/pyouter/webui/labels.js new file mode 100644 index 0000000..d0c6c0f --- /dev/null +++ b/src/pyouter/webui/labels.js @@ -0,0 +1,42 @@ +/** Shared Chinese labels for the pyouter web ui. */ +export const STATUS_LABEL = { + idle: "空闲", + pending: "等待", + running: "运行中", + success: "成功", + error: "失败", + cancelled: "已取消", +}; + +export const KIND_LABEL = { + router: "路由", + function: "函数", + object: "对象", + web: "接口", + docker: "命令", + compose: "编排", + ssh: "远程", + cluster: "集群", +}; + +export const LAYER_LABEL = { + docker: "容器", + ssh: "主机", + cluster: "集群", + server: "服务", +}; + +export const UI_LABEL = { + directory: "目录", + logs: "日志", + map: "路由图", + here: "你在这里", + local: "本机", + search: "搜索路由路径", + expand: "全部展开", + collapse: "全部折叠", + run: "执行", + stop: "停止", + copy: "复制命令", + copied: "已复制命令", +}; diff --git a/src/pyouter/webui/labels.py b/src/pyouter/webui/labels.py new file mode 100644 index 0000000..96d1529 --- /dev/null +++ b/src/pyouter/webui/labels.py @@ -0,0 +1,22 @@ +"""Labels embedded in the HTML boot payload for tests and the page shell.""" + +BOOT_LABELS = { + "directory": "目录", + "logs": "日志", + "map": "路由图", + "here": "你在这里", + "local": "本机", + "kinds": { + "router": "路由", + "function": "函数", + "object": "对象", + "web": "接口", + "docker": "命令", + "compose": "编排", + "ssh": "远程", + "cluster": "集群", + "container": "容器", + "host": "主机", + "service": "服务", + }, +} diff --git a/src/pyouter/webui/store.js b/src/pyouter/webui/store.js new file mode 100644 index 0000000..c99039f --- /dev/null +++ b/src/pyouter/webui/store.js @@ -0,0 +1,351 @@ +import { KIND_LABEL, LAYER_LABEL, STATUS_LABEL, UI_LABEL } from "./labels.js"; + +const BOOT = + typeof window !== "undefined" && window.__PYOUTER_BOOT__ + ? window.__PYOUTER_BOOT__ + : {}; + +export const state = { + tree: null, + nodes: {}, + selected: "", + seq: 0, + filter: "", + expanded: new Set(), + script: BOOT.script || "pyouter", + mode: BOOT.mode || "app", + here: "", + focus: BOOT.focus || "", + live: false, +}; + +const listeners = new Set(); + +export function subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function notify() { + for (const fn of [...listeners]) fn(state); +} + +export function runtimeMode() { + return state.mode === "runtime"; +} + +export function overlaps(left, right) { + if (left === right) return true; + if (left === "" || right === "") return true; + return left.startsWith(right + ".") || right.startsWith(left + "."); +} + +export function nodeState(path) { + return ( + state.nodes[path] || { + status: "idle", + logs: [], + error: null, + started_at: null, + finished_at: null, + owner: null, + } + ); +} + +export function findNode(node, path) { + if (!node) return null; + if (node.path === path) return node; + for (const child of node.children || []) { + const found = findNode(child, path); + if (found) return found; + } + return null; +} + +export function layerKind(node) { + if (!node) return ""; + if (node.layer && LAYER_LABEL[node.layer]) return node.layer; + return ""; +} + +export function httpLabel(node) { + if (!node || node.kind !== "web" || !node.rule) return ""; + return ((node.method || "") + " " + node.rule).trim(); +} + +export function kindText(node) { + if (!node) return ""; + const layer = layerKind(node); + if (layer) return LAYER_LABEL[layer]; + const http = httpLabel(node); + if (http) return http; + if (node.route_type === "web" && node.kind === "router") return "服务"; + if (node.route_type === "docker" && node.kind === "router") return "容器"; + if (node.route_type === "compose" && node.kind === "router") return "编排"; + if (node.route_type === "ssh" && node.kind === "router") return "主机"; + if (node.route_type === "cluster" && node.kind === "router") return "集群"; + return KIND_LABEL[node.kind] || node.kind || ""; +} + +export function kindName(node) { + if (!node) return ""; + const layer = layerKind(node); + if (layer) return LAYER_LABEL[layer]; + if (node.kind === "web") return KIND_LABEL.web; + if (node.kind === "docker") return KIND_LABEL.docker; + if (node.kind === "ssh") return KIND_LABEL.ssh; + if (node.route_type === "web" && node.kind === "router") return "服务"; + if (node.route_type === "docker" && node.kind === "router") return "容器"; + if (node.route_type === "compose" && node.kind === "router") return "编排"; + if (node.route_type === "ssh" && node.kind === "router") return "主机"; + if (node.route_type === "cluster" && node.kind === "router") return "集群"; + return KIND_LABEL[node.kind] || node.kind || ""; +} + +export function statusDot(status) { + if (status === "running") return "processing"; + if (status === "pending") return "away"; + if (status === "success") return "success"; + if (status === "error") return "error"; + if (status === "cancelled") return "warning"; + return "offline"; +} + +export function tagColor(status) { + if (status === "success") return "primary"; + if (status === "error") return "danger"; + return "default"; +} + +export function textMatch(node, filter) { + const blob = ( + (node.path || "") + + " " + + (node.name || "") + + " " + + (node.target || "") + ).toLowerCase(); + return blob.includes(filter); +} + +export function subtreeVisible(node, filter) { + if (!filter || textMatch(node, filter)) return true; + return (node.children || []).some((child) => subtreeVisible(child, filter)); +} + +export function expandAll(node, into = state.expanded) { + if (!node) return into; + if ((node.children || []).length) into.add(node.path); + for (const child of node.children || []) expandAll(child, into); + return into; +} + +export function collectExpanded(node, into = []) { + if (!node) return into; + if ((node.children || []).length && state.expanded.has(node.path)) { + into.push(node.path || "__root__"); + } + for (const child of node.children || []) collectExpanded(child, into); + return into; +} + +export function toTreeData(node, filter = "") { + if (!node) return []; + const normalized = (filter || "").trim().toLowerCase(); + + const convert = (item) => { + const children = item.children || []; + const showAll = !normalized || textMatch(item, normalized); + const kids = showAll + ? children + : children.filter((child) => subtreeVisible(child, normalized)); + if (normalized && !showAll && !kids.length) return null; + const status = nodeState(item.path).status; + const kind = kindText(item); + const mark = + status && status !== "idle" + ? ` · ${STATUS_LABEL[status] || status}` + : kind + ? ` · ${kind}` + : ""; + const key = item.path === "" ? "__root__" : item.path; + const entry = { + key, + path: item.path, + title: (item.name || "(root)") + mark, + node: item, + }; + if (kids.length) { + entry.children = kids.map(convert).filter(Boolean); + } + return entry; + }; + + const root = convert(node); + return root ? [root] : []; +} + +export function formatTime(ts) { + if (!ts) return ""; + const date = new Date(ts * 1000); + const pad = (value) => String(value).padStart(2, "0"); + return ( + pad(date.getHours()) + + ":" + + pad(date.getMinutes()) + + ":" + + pad(date.getSeconds()) + ); +} + +export function formatDuration(start, end) { + if (!start) return "—"; + const ms = ((end || Date.now() / 1000) - start) * 1000; + if (ms < 1000) return Math.max(0, Math.round(ms)) + " ms"; + if (ms < 60000) return (ms / 1000).toFixed(2) + " s"; + const seconds = Math.floor(ms / 1000); + return Math.floor(seconds / 60) + " m " + (seconds % 60) + " s"; +} + +export function shellQuote(value) { + if (/^[A-Za-z0-9._+/-]+$/.test(value)) return value; + return '"' + value + '"'; +} + +export function commandFor(path) { + const script = shellQuote(state.script || "app.py"); + if (!path) return "python " + script + " --web"; + return "python " + script + " " + shellQuote(path); +} + +export function activeOwner(path) { + for (const [key, node] of Object.entries(state.nodes)) { + if (node.status !== "running" && node.status !== "pending") continue; + if (!overlaps(key, path)) continue; + return node.owner != null ? node.owner : key; + } + return null; +} + +export function childStats(path) { + const stats = { + running: 0, + pending: 0, + success: 0, + error: 0, + cancelled: 0, + }; + for (const [key, node] of Object.entries(state.nodes)) { + if (key === path) continue; + if (path !== "" && !key.startsWith(path + ".")) continue; + if (stats[node.status] != null) stats[node.status] += 1; + } + return stats; +} + +export function applyEvent(event) { + const node = + state.nodes[event.path] || { + status: "idle", + logs: [], + error: null, + started_at: null, + finished_at: null, + owner: null, + }; + state.nodes[event.path] = node; + if (event.type === "reset") { + node.logs = []; + node.error = null; + node.started_at = null; + node.finished_at = null; + node.owner = null; + } else if (event.type === "status") { + node.status = event.status; + node.error = event.error; + node.started_at = event.started_at; + node.finished_at = event.finished_at; + node.owner = event.owner; + } else if (event.type === "log") { + node.logs = node.logs || []; + node.logs.push({ + ts: event.ts, + stream: event.stream, + message: event.message, + }); + } +} + +export function applySnap(snap) { + state.tree = snap.tree; + state.mode = snap.mode || state.mode; + state.here = snap.here || ""; + if (snap.script) state.script = snap.script; + if (snap.focus != null && snap.focus !== "") state.focus = snap.focus; +} + +export async function fetchSnapshot() { + const snap = await (await fetch("/api/snapshot")).json(); + applySnap(snap); + state.nodes = snap.nodes || {}; + state.seq = snap.seq || 0; + state.live = true; + notify(); + return snap; +} + +export async function pollEvents() { + try { + const response = await fetch("/api/events?since=" + state.seq); + const data = await response.json(); + if (data.gap) { + await fetchSnapshot(); + return; + } + if (data.events && data.events.length) { + state.seq = data.seq; + data.events.forEach(applyEvent); + notify(); + } else { + state.seq = data.seq; + } + state.live = true; + } catch (_error) { + state.live = false; + notify(); + } +} + +export async function runPath(path) { + const response = await fetch("/api/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data.ok) { + throw new Error(data.error || "执行失败"); + } + return data; +} + +export async function stopPath(path) { + const response = await fetch("/api/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data.ok) { + throw new Error(data.error || "无法停止"); + } + return data; +} + +export function selectPath(path) { + state.selected = path || ""; + notify(); +} + +export { KIND_LABEL, LAYER_LABEL, STATUS_LABEL, UI_LABEL, BOOT }; diff --git a/src/tests/test_web.py b/src/tests/test_web.py index a08dd2f..149304e 100644 --- a/src/tests/test_web.py +++ b/src/tests/test_web.py @@ -218,7 +218,15 @@ def test_page_and_snapshot(self): self.assertIn("目录", html) self.assertIn("日志", html) self.assertIn("路由图", html) + self.assertIn("/webui/app.js", html) + self.assertIn("gd5", html) self.assertIn('"focus": "job.sync"', html) + status, raw = self._get("/webui/app.js") + self.assertEqual(status, 200) + self.assertIn("HomePage", raw.decode("utf-8")) + status, raw = self._get("/webui/gd5/ui/index.js") + self.assertEqual(status, 200) + self.assertIn("Tree", raw.decode("utf-8")) status, raw = self._get("/api/snapshot") snap = json.loads(raw.decode("utf-8")) self.assertEqual(status, 200)