aihand 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -2
- package/dist/chunk-2NTK7H4W.js +10 -0
- package/dist/chunk-3X4FTHLC.cjs +369 -0
- package/dist/chunk-BXVNR4E2.js +399 -0
- package/dist/chunk-C7DGE6MY.cjs +1456 -0
- package/dist/chunk-DUUCVLC3.cjs +254 -0
- package/dist/chunk-FAHI53KO.cjs +125 -0
- package/dist/chunk-G7KVJ7NF.js +369 -0
- package/dist/chunk-GNEUSRGP.js +52 -0
- package/dist/chunk-IGNEAOLT.cjs +130 -0
- package/dist/chunk-IS5XFUDB.js +125 -0
- package/dist/chunk-JLYC76XL.js +2448 -0
- package/dist/chunk-KQOABC2O.cjs +52 -0
- package/dist/chunk-OVMK33AC.cjs +104 -0
- package/dist/chunk-OWYK2IGV.js +250 -0
- package/dist/chunk-PQSQN4CN.js +126 -0
- package/dist/chunk-QF6AG3M5.cjs +410 -0
- package/dist/chunk-QSAMLXML.js +1456 -0
- package/dist/chunk-VEKYRKPF.cjs +399 -0
- package/dist/chunk-Y6H7W7PI.cjs +2451 -0
- package/dist/chunk-YKSYW77R.js +410 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/chunk-ZJQRNIK7.js +104 -0
- package/dist/cli-3J7EYI6G.cjs +651 -0
- package/dist/cli-FIJLKAGI.js +649 -0
- package/dist/cli-JQEIE7RQ.js +120 -0
- package/dist/cli-K3OS2QQH.cjs +122 -0
- package/dist/cli-OSYG6LJD.cjs +89 -0
- package/dist/cli-TXRW5PG6.js +89 -0
- package/dist/cli.cjs +81 -0
- package/dist/cli.js +81 -0
- package/dist/config-5KEQLN6L.cjs +13 -0
- package/dist/config-PJPYKDLQ.js +13 -0
- package/dist/graph-IH56SCPK.js +8 -0
- package/dist/graph-ZUXXCJ5A.cjs +8 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +461 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +479 -0
- package/dist/locate-5XFSXJ5J.cjs +15 -0
- package/dist/locate-NKSUGL3A.js +15 -0
- package/dist/refactor-5FWSZIBN.cjs +19 -0
- package/dist/refactor-BOB3SZSA.js +19 -0
- package/dist/scan-4R7GQG2W.cjs +9 -0
- package/dist/scan-VF54GAAX.js +9 -0
- package/dist/ui/probe/server.cjs +505 -0
- package/dist/ui/probe/server.js +507 -0
- package/dist/vite.cjs +12 -0
- package/dist/vite.d.cts +12 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +12 -0
- package/package.json +82 -9
- package/src/cli.ts +107 -0
- package/src/index.ts +54 -0
- package/src/read/cli.ts +650 -0
- package/src/read/compact.ts +286 -0
- package/src/read/config.ts +62 -0
- package/src/read/graph.ts +182 -0
- package/src/read/index.ts +12 -0
- package/src/read/inject.ts +121 -0
- package/src/read/locate.ts +104 -0
- package/src/read/panel.ts +335 -0
- package/src/read/pipeline.ts +78 -0
- package/src/read/refactor.ts +576 -0
- package/src/read/render.ts +1118 -0
- package/src/read/scan.ts +61 -0
- package/src/read/seam.ts +0 -0
- package/src/read/security.ts +171 -0
- package/src/read/signals.ts +333 -0
- package/src/read/state.ts +71 -0
- package/src/read/stategraph.ts +205 -0
- package/src/read/types.ts +162 -0
- package/src/read/vite.ts +77 -0
- package/src/ui/babel/line-profiler.ts +197 -0
- package/src/ui/babel/source-loc.ts +68 -0
- package/src/ui/bridge/cdp-bridge.ts +138 -0
- package/src/ui/bridge/compile-probe.ts +80 -0
- package/src/ui/bridge/transport.ts +26 -0
- package/src/ui/bridge/vite-bridge.ts +116 -0
- package/src/ui/client/client-patch.ts +899 -0
- package/src/ui/client/client.ts +2562 -0
- package/src/ui/core/action.ts +747 -0
- package/src/ui/core/candidates.ts +348 -0
- package/src/ui/core/canvas.ts +305 -0
- package/src/ui/core/check.ts +34 -0
- package/src/ui/core/compact.ts +314 -0
- package/src/ui/core/detail.ts +244 -0
- package/src/ui/core/diff.ts +253 -0
- package/src/ui/core/emit.ts +198 -0
- package/src/ui/core/knob-exec.ts +137 -0
- package/src/ui/core/perf.ts +254 -0
- package/src/ui/core/types.ts +164 -0
- package/src/ui/core/util.ts +221 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/probe/cli.ts +139 -0
- package/src/ui/probe/server.ts +468 -0
- package/src/ui/self/act.ts +47 -0
- package/src/ui/self/discover.ts +101 -0
- package/src/ui/self/grow.ts +121 -0
- package/src/ui/self/install.ts +100 -0
- package/src/ui/self/probe.ts +105 -0
- package/src/ui/self/screen-hook.ts +44 -0
- package/src/ui/self/self.ts +48 -0
- package/src/ui/self/store-refs.ts +123 -0
- package/src/ui/self/store-schema.ts +65 -0
- package/src/ui/self/synth.ts +37 -0
- package/src/ui/server/cli.ts +102 -0
- package/src/ui/server/dispatch.ts +276 -0
- package/src/ui/server/help-text.ts +237 -0
- package/src/ui/server/knob-schema.ts +87 -0
- package/src/ui/server/plugin.ts +1151 -0
- package/src/vite.ts +39 -0
- package/index.js +0 -2
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// DOM 命中的纯判定原语 —— aihand write/read 路的命中地基。
|
|
3
|
+
//
|
|
4
|
+
// 全是浏览器原生 DOM API(document/getBoundingClientRect/elementFromPoint),零依赖。
|
|
5
|
+
// 这 64 行同时被 read 侧(/screen 列 knobs、/click text= 匹配、miss 时的可点击清单)和
|
|
6
|
+
// write 侧(doClick 的 elementFromPoint 命中测试)消费 —— 同进程内读写必须共用一份,否则
|
|
7
|
+
// 立刻产生 read-says-clickable / write-can't 的不一致(reachable 注释里钉死的 bug)。
|
|
8
|
+
//
|
|
9
|
+
// teact 的 domHitSuite 也有一份逻辑等价的 candidates(测试侧,jsdom 采集命中轨迹)。那是
|
|
10
|
+
// 跨仓的两份、不同进程、不同时演化,没有运行时耦合 —— 故各持一份,而非跨包运行时依赖
|
|
11
|
+
// (后者会把 aihand 发布后强行拉一个会撞 npm 名的 teact,必炸)。改这里时记得 teact 那份。
|
|
12
|
+
|
|
13
|
+
export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
|
|
14
|
+
|
|
15
|
+
// aria-labelledby: an element names itself by POINTING at other elements (one or more IDs),
|
|
16
|
+
// whose visible text, concatenated, is the name. This is the TOP signal in the W3C accessible-
|
|
17
|
+
// name algorithm — higher than aria-label — and is how real third-party frontends name custom
|
|
18
|
+
// widgets (a role=textbox whose label is a separate visible <div id> above it). Zero app
|
|
19
|
+
// cooperation: the association is already in the DOM. We resolve each id to its element's text
|
|
20
|
+
// (getDirectText first, then full textContent), join with a space, skipping dangling ids.
|
|
21
|
+
function labelledByText(el: Element): string {
|
|
22
|
+
const ids = (el.getAttribute('aria-labelledby') || '').trim()
|
|
23
|
+
if (!ids)
|
|
24
|
+
return ''
|
|
25
|
+
const out: string[] = []
|
|
26
|
+
// aria-labelledby is a space-separated ID token list (HTML spec). split on whitespace,
|
|
27
|
+
// drop empties — no regex on structured text, this is a flat token list, not syntax.
|
|
28
|
+
for (const id of ids.split(' ').map(s => s.trim()).filter(Boolean)) {
|
|
29
|
+
const ref = el.ownerDocument?.getElementById(id)
|
|
30
|
+
if (!ref)
|
|
31
|
+
continue
|
|
32
|
+
const t = (getDirectText(ref) || (ref.textContent || '').trim()).trim()
|
|
33
|
+
if (t)
|
|
34
|
+
out.push(t)
|
|
35
|
+
}
|
|
36
|
+
return out.join(' ')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A form control names itself by its associated <label> — `<label for=id>` pointing at it OR a
|
|
40
|
+
// wrapping <label>. Native, standard, present without app cooperation (how every accessible form
|
|
41
|
+
// works). HTMLInputElement/Textarea/Select expose `.labels` (a live NodeList of BOTH kinds), so
|
|
42
|
+
// we read it directly — no id-into-selector escaping, no regex. Concatenate the labels' visible
|
|
43
|
+
// text. Returns '' if the control type has no `.labels` (e.g. a contenteditable div).
|
|
44
|
+
function associatedLabelText(el: Element): string {
|
|
45
|
+
const labels = (el as HTMLInputElement).labels
|
|
46
|
+
if (!labels || labels.length === 0)
|
|
47
|
+
return ''
|
|
48
|
+
const out: string[] = []
|
|
49
|
+
for (const lab of Array.from(labels)) {
|
|
50
|
+
const t = (getDirectText(lab) || (lab.textContent || '').trim()).trim()
|
|
51
|
+
if (t)
|
|
52
|
+
out.push(t)
|
|
53
|
+
}
|
|
54
|
+
return out.join(' ')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// An icon-only button carries no text/aria/title, but its icon's identity IS its meaning, and
|
|
58
|
+
// every icon library stamps that identity into the <svg> class — lucide emits `lucide ellipsis`
|
|
59
|
+
// → class "lucide lucide-ellipsis", feather "feather feather-trash", heroicons similar. The
|
|
60
|
+
// fingerprint that distinguishes an icon-name token from a utility class (w-3.5, text-gray-500)
|
|
61
|
+
// is the PAIR: a bare library token (`lucide`) AND a `lucide-<name>` token co-occur on the same
|
|
62
|
+
// svg. Utility classes never form that prefix/prefix-name pair. So we find any token of the form
|
|
63
|
+
// `<p>-<name>` whose prefix `<p>` is itself a standalone token on the element, and read `<name>`
|
|
64
|
+
// (kebab→space). This is a browser-native, zero-app-cooperation signal — the icon library emits
|
|
65
|
+
// it for everyone. It is the WEAKEST label (an icon name is cruder than a real word), so it sits
|
|
66
|
+
// at the very bottom of elementLabel's chain, below every accname signal: a title-bearing icon
|
|
67
|
+
// button still shows its title, never "search". Returns '' if no icon-library class is present.
|
|
68
|
+
function iconNameOf(el: Element): string {
|
|
69
|
+
const svg = el.tagName === 'svg' ? el : el.querySelector('svg')
|
|
70
|
+
if (!svg)
|
|
71
|
+
return ''
|
|
72
|
+
const tokens = (svg.getAttribute('class') || '').split(' ').map(s => s.trim()).filter(Boolean)
|
|
73
|
+
const bare = new Set(tokens.filter(t => !t.includes('-')))
|
|
74
|
+
for (const t of tokens) {
|
|
75
|
+
const dash = t.indexOf('-')
|
|
76
|
+
if (dash <= 0)
|
|
77
|
+
continue
|
|
78
|
+
const prefix = t.slice(0, dash)
|
|
79
|
+
if (bare.has(prefix)) // `lucide` + `lucide-ellipsis` → the icon-library fingerprint
|
|
80
|
+
return t.slice(dash + 1).split('-').join(' ') // trash-2 → "trash 2", chevron-down → "chevron down"
|
|
81
|
+
}
|
|
82
|
+
return ''
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Direct text children only — skip nested element text (a button's icon-span <title>,
|
|
86
|
+
// a badge count) so the label is the element's own words, not its descendants'.
|
|
87
|
+
export function getDirectText(el: Element): string {
|
|
88
|
+
let text = ''
|
|
89
|
+
for (const node of el.childNodes) {
|
|
90
|
+
if (node.nodeType === 3) { // TEXT_NODE
|
|
91
|
+
const t = node.textContent?.trim()
|
|
92
|
+
if (t)
|
|
93
|
+
text += (text ? ' ' : '') + t
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return text
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The ONE label of an element — what /screen lists it as AND what /click matches text= on.
|
|
100
|
+
// Both reading (knobs) and writing (find target) go through this, so the projection can
|
|
101
|
+
// never name a control the click can't find (the read-says-clickable, write-can't bug).
|
|
102
|
+
//
|
|
103
|
+
// The fallback chain is the W3C accessible-name computation (accname) — the SAME algorithm
|
|
104
|
+
// every screen reader uses to name an element — restricted to its load-bearing signals. This
|
|
105
|
+
// is what makes aihand universal: it extracts names from browser-native, standardized signals
|
|
106
|
+
// that arbitrary third-party frontends already emit for accessibility, with ZERO cooperation
|
|
107
|
+
// from the app under test. The order is accname's priority order:
|
|
108
|
+
// 1. aria-labelledby — element points at other elements whose text is its name (TOP signal,
|
|
109
|
+
// how custom widgets like a role=textbox name themselves by a visible sibling label).
|
|
110
|
+
// 2. aria-label — the inline name.
|
|
111
|
+
// 3. getDirectText — the element's own words (a button's label text).
|
|
112
|
+
// 4. title — icon buttons carry their word here, not in text.
|
|
113
|
+
// 5. textContent — full subtree text when own text is empty.
|
|
114
|
+
// If still empty and it's a form control, fall to its native associations:
|
|
115
|
+
// 6. <label for> / wrapping <label> — the standard way a form field is named.
|
|
116
|
+
// 7. placeholder — the visible gray-text name of an input.
|
|
117
|
+
// 8. value (safeValue) — last resort; the control's content is its only human-readable word
|
|
118
|
+
// (textContent is always empty for form controls). Through safeValue so a password/token
|
|
119
|
+
// field's label is its redaction, never the plaintext secret.
|
|
120
|
+
// A clickable CONTAINER with empty own text often wraps the control holding its name (a sidebar
|
|
121
|
+
// row is a div wrapping a pointer-events-none <input value=…>), so we descend to the first form
|
|
122
|
+
// control and run the same native-association chain on it.
|
|
123
|
+
// 9. icon name (iconNameOf) — the FINAL, weakest fallback: an icon-only button with no accname
|
|
124
|
+
// signal at all (a `<MoreHorizontal/>` menu trigger) takes its name from the icon library's
|
|
125
|
+
// svg class (lucide-ellipsis → "ellipsis"). Below everything else so a real name always wins.
|
|
126
|
+
// 空白归一化:HTML 把 `74 comments` 渲成含 U+00A0(不间断空格)的文本,但人看到的、人输入
|
|
127
|
+
// 到 text= 的是普通空格 `74 comments`。JS 的 `\s` 不含 U+00A0,故 includes 直接漏匹配(live 实测
|
|
128
|
+
// HN 评论链接 `no element for 74 comments`,而 clickable 列表里明明列着它)。把所有 Unicode 空白
|
|
129
|
+
// (nbsp/各种宽度空格/制表/换行)折叠成单个普通空格 + trim,读侧(label 显示)写侧(text= 匹配)同源:
|
|
130
|
+
// /screen 列 `74 comments`、pickByText 比较 `74 comments`,普通空格直接命中。这是空白字符的字节
|
|
131
|
+
// 归一(非对结构化语法做 pattern match),正则在此正当。\s 加 补 JS 的盲点。
|
|
132
|
+
function normWs(s: string): string {
|
|
133
|
+
return s.replace(/[\s ]+/g, ' ').trim()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function elementLabel(el: Element): string {
|
|
137
|
+
return normWs(rawLabel(el))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function rawLabel(el: Element): string {
|
|
141
|
+
const text = (labelledByText(el) || el.getAttribute('aria-label') || getDirectText(el) || el.getAttribute('title') || (el.textContent || '').trim()).trim()
|
|
142
|
+
if (text)
|
|
143
|
+
return text
|
|
144
|
+
const tag = el.tagName
|
|
145
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
146
|
+
return controlLabel(el)
|
|
147
|
+
const ctrl = el.querySelector('input, textarea, select')
|
|
148
|
+
if (ctrl) {
|
|
149
|
+
const cl = controlLabel(ctrl)
|
|
150
|
+
if (cl)
|
|
151
|
+
return cl
|
|
152
|
+
}
|
|
153
|
+
return iconNameOf(el)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The name of a form control reached either as the element itself or by descending into a
|
|
157
|
+
// clickable container that wraps it. Full accname order on the control: aria-labelledby →
|
|
158
|
+
// aria-label → <label> → title → placeholder → value. (own/textContent are always empty for
|
|
159
|
+
// form controls, so they're skipped.) Through safeValue so a secret field's name is its
|
|
160
|
+
// redaction, never the plaintext value.
|
|
161
|
+
function controlLabel(el: Element): string {
|
|
162
|
+
return (labelledByText(el) || el.getAttribute('aria-label') || associatedLabelText(el) || el.getAttribute('title') || el.getAttribute('placeholder') || safeValue(el)).trim()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isVisible(el: Element): boolean {
|
|
166
|
+
const r = el.getBoundingClientRect()
|
|
167
|
+
return r.width > 0 && r.height > 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A name that marks a value as a secret to never echo in plaintext: API keys, tokens,
|
|
171
|
+
// passwords. The substring signals (no regex) that gate redaction. This is the ONE predicate
|
|
172
|
+
// over a *name string* — shared by the DOM-element reader (isSensitive, action.ts: input's
|
|
173
|
+
// type/name/aria) AND the domain-field reader (collectScreen/diffScreen: a `store.field` key).
|
|
174
|
+
// Both routes echo values; the redaction must hang off this single key-level predicate or a
|
|
175
|
+
// different endpoint leaks what another redacts (sessionToken was plaintext in /screen's domain
|
|
176
|
+
// block because only the DOM route was guarded — same hole, second consumer).
|
|
177
|
+
// Substrings that mark a name (form-field name/aria, domain key, OR an HTTP header name) as
|
|
178
|
+
// secret-bearing. `authorization`/`cookie`/`set-cookie` carry credentials but contain none of
|
|
179
|
+
// the token/key/secret words, so they're listed explicitly — a request-header dump (/network
|
|
180
|
+
// ?full) leaks `Authorization: Bearer sk-…` exactly the way the domain block leaked sessionToken.
|
|
181
|
+
const SECRET_HINTS = ['password', 'api key', 'apikey', 'api-key', 'api_key', 'secret', 'token', 'private key', 'authorization', 'cookie']
|
|
182
|
+
|
|
183
|
+
// Substrings that look secret-bearing by `token` but are plainly a COUNT, not a credential. A
|
|
184
|
+
// credential is a single `token` (authToken/githubToken/sessionToken/syncToken); the plural
|
|
185
|
+
// `tokens` and `tokenCount` are LLM token quantities (maxTokensK, showTokenCount). Without this,
|
|
186
|
+
// `'maxtokensk'.includes('token')` redacts a harmless number — and a redacted config value is
|
|
187
|
+
// strictly worse for a reader (it can't see the setting) than an exposed count is risky (it isn't).
|
|
188
|
+
const SECRET_EXEMPTIONS = ['tokens', 'tokencount']
|
|
189
|
+
|
|
190
|
+
export function isSecretKey(name: string): boolean {
|
|
191
|
+
const lower = name.toLowerCase()
|
|
192
|
+
if (SECRET_EXEMPTIONS.some(e => lower.includes(e)))
|
|
193
|
+
return false
|
|
194
|
+
return SECRET_HINTS.some(h => lower.includes(h))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A domain field that names an in-flight operation — generating, loading, a pending request.
|
|
198
|
+
// While such a field is truthy the app is mid-async; the DOM may fall silent (waiting on the
|
|
199
|
+
// network for the first token) yet the state machine has NOT settled. settle uses this to tell a
|
|
200
|
+
// real resting state from a mid-flight lull: "DOM quiet" ∧ "no busy field truthy" = actually done.
|
|
201
|
+
// This is the dual of isSecretKey — a key-level semantic classifier, generic over any app's
|
|
202
|
+
// __AIPEEK_SCREEN__ (it never hard-codes a field like 流式中). Bilingual: an app may name the
|
|
203
|
+
// field in Chinese (流式中/加载中/思考中/请求中) or English (isLoading/streaming/pending/busy).
|
|
204
|
+
// Strong markers: unambiguous in-progress words. A key containing one is busy no matter what — they
|
|
205
|
+
// never collide with a neutral noun, so no exemption applies to them.
|
|
206
|
+
const BUSY_STRONG = ['loading', 'streaming', 'pending', 'busy', 'fetching', 'inflight', 'in-flight', 'generating', 'waiting', '加载中', '生成中', '思考中', '流式中', '请求中', '发送中']
|
|
207
|
+
|
|
208
|
+
// Weak markers fire only after exemptions clear. '中' and bare '-ing' are real in-progress signals
|
|
209
|
+
// (…中 / streaming) but also land inside neutral nouns (中文 / setting), so the exemptions below
|
|
210
|
+
// suppress the false hits. Strong markers above bypass this gate entirely.
|
|
211
|
+
const BUSY_WEAK = ['中', 'ing']
|
|
212
|
+
const BUSY_EXEMPTIONS = ['中文', '中间', '居中', '集中', 'setting', 'heading', 'string', 'rating', 'meeting', 'missing', 'training', 'ring', 'ceiling', 'sibling']
|
|
213
|
+
|
|
214
|
+
export function isBusyKey(name: string): boolean {
|
|
215
|
+
const lower = name.toLowerCase()
|
|
216
|
+
if (BUSY_STRONG.some(h => lower.includes(h)))
|
|
217
|
+
return true
|
|
218
|
+
if (BUSY_EXEMPTIONS.some(e => lower.includes(e)))
|
|
219
|
+
return false
|
|
220
|
+
return BUSY_WEAK.some(h => lower.includes(h))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// True when a domain entry asserts work is in flight: an in-progress-named key holding a NON-EMPTY
|
|
224
|
+
// value. (`流式中: true` yes; `流式中: false` no; `模型: "Sonnet"` no — not an in-progress key.)
|
|
225
|
+
// The empty-collection gate is load-bearing, not cosmetic: a busy-named key can hold a container
|
|
226
|
+
// whose EMPTINESS is the rest state — `typing: {}` (nobody typing), `replyingTo: {}` (not replying),
|
|
227
|
+
// `pendingReqs: []` (none pending). An empty {} / [] is truthy in JS, so without this gate every
|
|
228
|
+
// such key reads as perpetually busy and settle never terminates. The value, not the name, is the
|
|
229
|
+
// honest in-flight signal; the gate makes "named busy but holding nothing" collapse to rest.
|
|
230
|
+
export function isBusyState(key: string, value: unknown): boolean {
|
|
231
|
+
if (!isBusyKey(key))
|
|
232
|
+
return false
|
|
233
|
+
if (value === false || value === null || value === undefined || value === 0 || value === '')
|
|
234
|
+
return false
|
|
235
|
+
if (Array.isArray(value))
|
|
236
|
+
return value.length > 0
|
|
237
|
+
if (typeof value === 'object')
|
|
238
|
+
return Object.keys(value as object).length > 0
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Length-preserving redaction of a value (so "empty vs set / roughly how long" stays
|
|
243
|
+
// observable without exposing the secret). The single masking shape, shared by every reader
|
|
244
|
+
// that echoes a value under a secret-named key — DOM input, domain field, HTTP header.
|
|
245
|
+
export function redactSecretValue(value: string): string {
|
|
246
|
+
return value ? `‹redacted ${value.length} chars›` : value
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A field whose live value must never be echoed: API keys, tokens, passwords. `.value`
|
|
250
|
+
// returns the real string even for type=password (masking is visual only), so reading it
|
|
251
|
+
// out in /dom, /query, /screen or as a label would leak the secret in plaintext. We redact
|
|
252
|
+
// instead. Signals (substring, no regex): type=password, autocomplete=*-password /
|
|
253
|
+
// one-time-code, or a name/placeholder/aria-label hinting a secret. Lives here next to the
|
|
254
|
+
// redaction primitives because elementLabel (this file) now reads value too — both the label
|
|
255
|
+
// and the state suffix must pass through the same one chokepoint or one re-leaks the other.
|
|
256
|
+
export function isSensitive(el: Element): boolean {
|
|
257
|
+
if ((el as HTMLInputElement).type === 'password')
|
|
258
|
+
return true
|
|
259
|
+
const ac = (el.getAttribute('autocomplete') || '').toLowerCase()
|
|
260
|
+
if (ac.includes('password') || ac === 'one-time-code')
|
|
261
|
+
return true
|
|
262
|
+
const hint = `${el.getAttribute('name') || ''} ${el.getAttribute('placeholder') || ''} ${el.getAttribute('aria-label') || ''}`
|
|
263
|
+
return isSecretKey(hint)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The value to show for a field: real value, or a length-preserving redaction for secrets
|
|
267
|
+
// (so "is it empty / roughly how long" stays observable without exposing the secret).
|
|
268
|
+
export function safeValue(el: Element): string {
|
|
269
|
+
const v = (el as HTMLInputElement).value ?? ''
|
|
270
|
+
return isSensitive(el) ? redactSecretValue(v) : v
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// text → element is a RANKING problem, not a filter. The old `filter(includes).find(reachable)`
|
|
274
|
+
// collapsed three orthogonal axes into "substring + DOM order", and each lost axis revived a
|
|
275
|
+
// real miss class seen in live driving:
|
|
276
|
+
// 1. EXACTNESS — a tab button labelled exactly "外观" vs the whole nav strip whose text
|
|
277
|
+
// *contains* "外观". Pure substring + DOM-order let the giant container win. So an exact
|
|
278
|
+
// label beats a prefix/suffix beats a mere substring.
|
|
279
|
+
// 2. REACHABILITY — a knob that's clickable now beats one merely boxed (covered/off-screen).
|
|
280
|
+
// 3. TIGHTNESS — at equal exactness, the SMALLEST element wins: the leaf <button> over the
|
|
281
|
+
// <div> wrapping it, the icon control over its tooltip-portal ancestor. Drop this and a
|
|
282
|
+
// substring match hands the click to the biggest enclosing box.
|
|
283
|
+
// Removing any one axis brings back a miss class — this 3-tuple is the minimal complete order.
|
|
284
|
+
// matchScore returns null for a non-match; higher tuple (lexicographic, then larger = better)
|
|
285
|
+
// is the better target. Pure & deterministic so teact's domHitSuite can mirror it 1:1.
|
|
286
|
+
interface MatchScore { exact: number, reach: number, tight: number }
|
|
287
|
+
|
|
288
|
+
function matchScore(el: Element, lowerText: string): MatchScore | null {
|
|
289
|
+
if (!isVisible(el))
|
|
290
|
+
return null
|
|
291
|
+
const label = elementLabel(el).toLowerCase()
|
|
292
|
+
if (!label.includes(lowerText))
|
|
293
|
+
return null
|
|
294
|
+
const exact = label === lowerText ? 2 : (label.startsWith(lowerText) || label.endsWith(lowerText)) ? 1 : 0
|
|
295
|
+
const r = el.getBoundingClientRect()
|
|
296
|
+
return { exact, reach: reachable(el) ? 1 : 0, tight: -(r.width * r.height) }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Lexicographic compare of two scores: exact, then reach, then tight (all larger = better).
|
|
300
|
+
function scoreBetter(a: MatchScore, b: MatchScore): boolean {
|
|
301
|
+
if (a.exact !== b.exact) return a.exact > b.exact
|
|
302
|
+
if (a.reach !== b.reach) return a.reach > b.reach
|
|
303
|
+
return a.tight > b.tight
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// argmax of matchScore over a candidate set — the one element a text= should resolve to.
|
|
307
|
+
export function pickByText(candidates: Iterable<Element>, text: string): Element | null {
|
|
308
|
+
// text= 输入同样归一化:label 已是普通空格,输入侧也折叠(用户粘贴带 nbsp 的文本时两侧仍同源)。
|
|
309
|
+
const lower = normWs(text).toLowerCase()
|
|
310
|
+
let best: Element | null = null
|
|
311
|
+
let bestScore: MatchScore | null = null
|
|
312
|
+
for (const el of candidates) {
|
|
313
|
+
const s = matchScore(el, lower)
|
|
314
|
+
if (s && (!bestScore || scoreBetter(s, bestScore))) {
|
|
315
|
+
best = el
|
|
316
|
+
bestScore = s
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return best
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// One judgment of "can a real click land on this element *right now*", shared by the read
|
|
323
|
+
// side (/screen knobs, findElement text-match, the miss clickable-list) and the write side
|
|
324
|
+
// (doClick hit-tests via elementFromPoint). They used to disagree: readers only checked
|
|
325
|
+
// isVisible (has a box), but a boxed element can be covered by an overlay, pointer-events:
|
|
326
|
+
// none, or scrolled out of the viewport — so /screen listed knobs that /click then couldn't
|
|
327
|
+
// hit. reachable is the write-side truth: a mouse at the element's center hits this element
|
|
328
|
+
// or something in its own subtree chain. isVisible stays the cheap precondition.
|
|
329
|
+
export function reachable(el: Element): boolean {
|
|
330
|
+
if (!isVisible(el))
|
|
331
|
+
return false
|
|
332
|
+
// No hit-testing available (jsdom) → fall back to the box check; real browsers always
|
|
333
|
+
// have elementFromPoint, so this degrades only in tests, never in the live inspector.
|
|
334
|
+
if (typeof document.elementFromPoint !== 'function')
|
|
335
|
+
return true
|
|
336
|
+
const r = el.getBoundingClientRect()
|
|
337
|
+
const x = r.left + r.width / 2
|
|
338
|
+
const y = r.top + r.height / 2
|
|
339
|
+
// Off-viewport (scrolled away) → elementFromPoint can't reach it → not clickable now.
|
|
340
|
+
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight)
|
|
341
|
+
return false
|
|
342
|
+
const hit = document.elementFromPoint(x, y)
|
|
343
|
+
if (!hit)
|
|
344
|
+
return false
|
|
345
|
+
// Hit lands inside this element's own chain (it, a descendant, or an ancestor that the
|
|
346
|
+
// click still routes to) — same containment test doClick uses to decide "landed on us".
|
|
347
|
+
return el.contains(hit) || hit.contains(el)
|
|
348
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// 界面 → 等价草图。唯一标准:肉眼在屏幕上看到什么矩形,就在字符网格上画那个矩形 —— 像人手画
|
|
3
|
+
// wireframe。代替视觉喂 AI,不走截图/像素逆向。
|
|
4
|
+
//
|
|
5
|
+
// 这是一台「抽象 UI 渲染引擎」:把真实视口几何降采样到有上限(MAX_COLS×MAX_ROWS)的字符网格。
|
|
6
|
+
// 位置+尺寸就是布局:三栏=三个并排矩形;bot 消息=左侧框,user 消息=右侧框,日期=居中无框;
|
|
7
|
+
// 高消息=高框,矮消息=矮框。
|
|
8
|
+
//
|
|
9
|
+
// 两步正交分离:
|
|
10
|
+
// ① collectCanvas(浏览器,读 DOM)—— 见 client.ts,带副作用、读真实 DOM + 视口可见性 + 几何。
|
|
11
|
+
// 它把每个框降采样成「相对父框内部原点」的整数网格矩形 {col,row,w,h},烘进 Box 树。
|
|
12
|
+
// ② renderCanvas(纯函数,本文件)—— Box 树 → 字符串,零 DOM、零像素坐标,teact 钉死契约。
|
|
13
|
+
// 只消费离散整数坐标,画到可变字符网格。零位置数学。
|
|
14
|
+
//
|
|
15
|
+
// 单递归 + 几何停止:collector 对每个框问「降采样后框宽塞得下文字吗?能 → 填文字前缀(超了 …);
|
|
16
|
+
// 不能且有内部结构 → 递归画子框」。三档(窄=label/中=正文/大=容器)从框宽涌现,不硬编码。
|
|
17
|
+
// 人画草图不给每个词画框 —— 纯文本段 drawBorder=false,只有真矩形(栏/气泡/按钮)画框。
|
|
18
|
+
|
|
19
|
+
const MAX_COLS = 160 // 字符画布列宽上限(画布物理边界)
|
|
20
|
+
const MAX_ROWS = 80 // 字符画布行高上限(cap 防巨页炸满)
|
|
21
|
+
|
|
22
|
+
export type Side = 'left' | 'center' | 'right' // 仅作框内 label 对齐提示;摆位本身靠 col/row
|
|
23
|
+
export type BoxKind = 'frame' | 'bubble' | 'knob' | 'text'
|
|
24
|
+
|
|
25
|
+
// 一个画出来的矩形。collector 已把一切降采样成「相对父框内部原点」的整数网格坐标 —— renderer
|
|
26
|
+
// 零位置数学,只按 col/row/w/h 画框 + 写 label + 递归子框。
|
|
27
|
+
export interface Box {
|
|
28
|
+
kind: BoxKind
|
|
29
|
+
col: number // 相对父框内部原点的 x 字符偏移 (>=0)
|
|
30
|
+
row: number // 相对父框内部原点的 y 行偏移 (>=0)
|
|
31
|
+
w: number // 字符宽,含 2 列边框 (>=1)
|
|
32
|
+
h: number // 行高,含 2 行边框 (>=1)
|
|
33
|
+
label: string // 已截断的文字/图标/landmark 名,或 ''
|
|
34
|
+
morphism: string // ' → store.{ field=to }' / ''
|
|
35
|
+
drawBorder: boolean // true → 画 ┌┐└┘─│;false → 纯 label 块(文本段/日期)
|
|
36
|
+
children: Box[] // 子框,坐标相对本框内部
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Node = Box // 别名,client.ts 的 import 仍解析
|
|
40
|
+
|
|
41
|
+
// @kN 可寻址旋钮 ref:单一权威源。按 renderKnobs 同款栏序遍历(root 直接 frame 子分组、栏内
|
|
42
|
+
// 递归收 knob),给每个 kind==='knob' 的 Box 依次编 @k1 @k2…。四投影都读这一份映射 → 号必一致。
|
|
43
|
+
// 缺省不传 refs 时四投影逐字节同旧(纯增量旁路)。
|
|
44
|
+
export function numberKnobs(root: Box): Map<Box, string> {
|
|
45
|
+
const refs = new Map<Box, string>()
|
|
46
|
+
let n = 0
|
|
47
|
+
const collect = (box: Box, acc: Box[]): void => {
|
|
48
|
+
if (box.kind === 'knob')
|
|
49
|
+
acc.push(box)
|
|
50
|
+
for (const c of box.children)
|
|
51
|
+
collect(c, acc)
|
|
52
|
+
}
|
|
53
|
+
const columns = root.children.filter(c => c.kind === 'frame')
|
|
54
|
+
const cols = columns.length ? columns : [root]
|
|
55
|
+
for (const col of cols) {
|
|
56
|
+
const knobs: Box[] = []
|
|
57
|
+
collect(col, knobs)
|
|
58
|
+
for (const k of knobs)
|
|
59
|
+
refs.set(k, `@k${++n}`)
|
|
60
|
+
}
|
|
61
|
+
return refs
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 该框的 ref 前缀(有号则 '@k3 ',否则 '')。
|
|
65
|
+
function refPrefix(box: Box, refs?: Map<Box, string>): string {
|
|
66
|
+
const r = refs?.get(box)
|
|
67
|
+
return r ? `${r} ` : ''
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 显示宽度:CJK/全角字符占 2 列,其余 1 列。JS .length 是 code unit 数,CJK 算 1 —— 直接
|
|
71
|
+
// padEnd 会让「群聊」比「AI 对话」视觉更短,列对不齐,AI 读错结构。代替视觉必须按真实占列。
|
|
72
|
+
function displayWidth(s: string): number {
|
|
73
|
+
let w = 0
|
|
74
|
+
for (const ch of s) {
|
|
75
|
+
const c = ch.codePointAt(0)!
|
|
76
|
+
const wide = (c >= 0x1100 && c <= 0x115F) // Hangul Jamo
|
|
77
|
+
|| (c >= 0x2E80 && c <= 0xA4CF) // CJK 部首…彝文(含〔〕「」、汉字、假名)
|
|
78
|
+
|| (c >= 0xAC00 && c <= 0xD7A3) // Hangul 音节
|
|
79
|
+
|| (c >= 0xF900 && c <= 0xFAFF) // CJK 兼容表意
|
|
80
|
+
|| (c >= 0xFE30 && c <= 0xFE4F) // CJK 兼容形式
|
|
81
|
+
|| (c >= 0xFF00 && c <= 0xFF60) // 全角 ASCII
|
|
82
|
+
|| (c >= 0xFFE0 && c <= 0xFFE6) // 全角符号
|
|
83
|
+
|| (c >= 0x20000 && c <= 0x3FFFD) // CJK 扩展 B+
|
|
84
|
+
w += wide ? 2 : 1
|
|
85
|
+
}
|
|
86
|
+
return w
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 截到恰好 width 显示列:在宽字边界停下(不切半个 CJK),被截则末尾留位放 …。
|
|
90
|
+
// 纯字符遍历,禁正则。width 不足放 … 时退化成尽量塞。
|
|
91
|
+
function clipDisplay(s: string, width: number): string {
|
|
92
|
+
if (displayWidth(s) <= width)
|
|
93
|
+
return s
|
|
94
|
+
if (width <= 1)
|
|
95
|
+
return '…'.slice(0, width)
|
|
96
|
+
let w = 0
|
|
97
|
+
let out = ''
|
|
98
|
+
for (const ch of s) {
|
|
99
|
+
const cw = displayWidth(ch)
|
|
100
|
+
if (w + cw > width - 1) // 留 1 列给 …
|
|
101
|
+
break
|
|
102
|
+
out += ch
|
|
103
|
+
w += cw
|
|
104
|
+
}
|
|
105
|
+
return out + '…'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clampInt(v: number, lo: number, hi: number): number {
|
|
109
|
+
return v < lo ? lo : v > hi ? hi : v
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 去尾空格,纯字符扫描(禁正则)。
|
|
113
|
+
function trimEnd(s: string): string {
|
|
114
|
+
let end = s.length
|
|
115
|
+
while (end > 0 && s[end - 1] === ' ')
|
|
116
|
+
end--
|
|
117
|
+
return s.slice(0, end)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 把字符串写进网格,从 (row,col) 起逐字铺。CJK 占两格:左格放字、右格放 '' 哨兵并跳 2,
|
|
121
|
+
// 绝不切半个宽字。越界即停。
|
|
122
|
+
function writeText(grid: string[][], row: number, col: number, s: string): void {
|
|
123
|
+
const cols = grid[0]?.length ?? 0
|
|
124
|
+
if (row < 0 || row >= grid.length)
|
|
125
|
+
return
|
|
126
|
+
let c = col
|
|
127
|
+
for (const ch of s) {
|
|
128
|
+
if (c >= cols)
|
|
129
|
+
break
|
|
130
|
+
if (displayWidth(ch) === 2) {
|
|
131
|
+
if (c + 1 >= cols)
|
|
132
|
+
break
|
|
133
|
+
grid[row][c] = ch
|
|
134
|
+
grid[row][c + 1] = ''
|
|
135
|
+
c += 2
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
grid[row][c] = ch
|
|
139
|
+
c += 1
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// label 装得下态射就拼上(宽框行内显态射),否则只 label(窄框态射降级到尾部图例)。
|
|
145
|
+
// 有 @kN ref 时前缀 '@k3 ':ref 是寻址锚,比文字更重要 —— 框宽不够时由 clipDisplay 截 label
|
|
146
|
+
// 尾部(前缀在头部天然保留)。
|
|
147
|
+
function composeLabel(box: Box, innerW: number, refs?: Map<Box, string>): string {
|
|
148
|
+
const pre = refPrefix(box, refs)
|
|
149
|
+
if (box.morphism && displayWidth(pre + box.label + box.morphism) <= innerW)
|
|
150
|
+
return pre + box.label + box.morphism
|
|
151
|
+
return pre + box.label
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 一个框是否会把态射行内渲出来 —— 与 composeLabel 同判据,collectLegend 据此挑漏网的。
|
|
155
|
+
function morphismShownInline(box: Box): boolean {
|
|
156
|
+
const innerW = box.drawBorder ? box.w - 2 : box.w
|
|
157
|
+
return displayWidth(box.label + box.morphism) <= innerW
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 把一个框画进网格。originCol/originRow = 本框左上角的绝对网格坐标。
|
|
161
|
+
function paint(grid: string[][], box: Box, originCol: number, originRow: number, refs?: Map<Box, string>): void {
|
|
162
|
+
const rows = grid.length
|
|
163
|
+
const cols = grid[0]?.length ?? 0
|
|
164
|
+
if (rows === 0 || cols === 0)
|
|
165
|
+
return
|
|
166
|
+
const c0 = clampInt(originCol, 0, cols - 1)
|
|
167
|
+
const r0 = clampInt(originRow, 0, rows - 1)
|
|
168
|
+
const c1 = clampInt(c0 + Math.max(1, box.w) - 1, 0, cols - 1) // 含右边框列
|
|
169
|
+
const r1 = clampInt(r0 + Math.max(1, box.h) - 1, 0, rows - 1) // 含下边框行
|
|
170
|
+
|
|
171
|
+
// 太薄画不成框(内宽<2 或内高<1)→ 降级:不画框,有 label 当裸文本铺。
|
|
172
|
+
const drawable = box.drawBorder && (c1 - c0) >= 2 && (r1 - r0) >= 1
|
|
173
|
+
if (drawable) {
|
|
174
|
+
for (let c = c0; c <= c1; c++) {
|
|
175
|
+
grid[r0][c] = c === c0 ? '┌' : c === c1 ? '┐' : '─'
|
|
176
|
+
grid[r1][c] = c === c0 ? '└' : c === c1 ? '┘' : '─'
|
|
177
|
+
}
|
|
178
|
+
for (let r = r0 + 1; r < r1; r++) {
|
|
179
|
+
grid[r][c0] = '│'
|
|
180
|
+
grid[r][c1] = '│'
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 内部原点:有框则缩进 1 让 child.col=0 贴内壁;无框则就在框原点。
|
|
185
|
+
const ic = drawable ? c0 + 1 : c0
|
|
186
|
+
const ir = drawable ? r0 + 1 : r0
|
|
187
|
+
const innerW = drawable ? (c1 - c0 - 1) : (c1 - c0 + 1)
|
|
188
|
+
|
|
189
|
+
// label 行必须落在内部:有框时严格在下边框之上(ir<r1),无框时就在框内(ir<=r1)。
|
|
190
|
+
const labelRowOk = drawable ? ir < r1 : ir <= r1
|
|
191
|
+
if (box.label && innerW >= 1 && labelRowOk)
|
|
192
|
+
writeText(grid, ir, ic, clipDisplay(composeLabel(box, innerW, refs), innerW))
|
|
193
|
+
|
|
194
|
+
for (const child of box.children)
|
|
195
|
+
paint(grid, child, ic + child.col, ir + child.row, refs)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 态射图例:递归全树,拼成尾部块。两类入图例:
|
|
199
|
+
// ① 所有有 ref 的 knob(带 @kN)—— 让「看图例即得全部可寻址号」,即便其框已行内显出。
|
|
200
|
+
// ② 非 knob 但有态射且没被行内渲出的框 —— 窄图标 store 跃迁框太小写不下也不能丢。
|
|
201
|
+
// 缺省无 refs 时退化成旧行为(只挑漏网的)。
|
|
202
|
+
function collectLegend(root: Box, refs?: Map<Box, string>): string {
|
|
203
|
+
const lines: string[] = []
|
|
204
|
+
const walk = (box: Box): void => {
|
|
205
|
+
const ref = refs?.get(box)
|
|
206
|
+
if (ref)
|
|
207
|
+
lines.push(`${ref} ${box.label ? `[${box.label}]` : `<${box.kind}>`}${box.morphism}`)
|
|
208
|
+
else if (box.morphism && !morphismShownInline(box))
|
|
209
|
+
lines.push(`${box.label ? `[${box.label}]` : `<${box.kind}>`}${box.morphism}`)
|
|
210
|
+
for (const c of box.children)
|
|
211
|
+
walk(c)
|
|
212
|
+
}
|
|
213
|
+
walk(root)
|
|
214
|
+
if (lines.length === 0)
|
|
215
|
+
return ''
|
|
216
|
+
return ['┄ 控制面板态射 ┄', ...lines].join('\n')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function renderCanvas(root: Box, refs?: Map<Box, string>): string {
|
|
220
|
+
const rows = Math.min(MAX_ROWS, Math.max(1, root.h))
|
|
221
|
+
const cols = Math.min(MAX_COLS, Math.max(1, root.w))
|
|
222
|
+
const grid: string[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ' '))
|
|
223
|
+
paint(grid, root, 0, 0, refs)
|
|
224
|
+
const body = grid.map(r => trimEnd(r.join(''))).join('\n')
|
|
225
|
+
const legend = collectLegend(root, refs)
|
|
226
|
+
return legend ? `${body}\n\n${legend}` : body
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── 同源派生:下面三个纯函数消费同一棵 Box 树,渲成 B/C/D 三种文本形态。 ───────────────
|
|
230
|
+
// 与 renderCanvas(A=ASCII)同源、零手写。几何全在 Box 的 col/row/w/h 里,各形态只是不同投影:
|
|
231
|
+
// B 语义布局树 —— 几何编码进关系词(左/中/右 + 大/中/小),缩进表嵌套。
|
|
232
|
+
// C 关系式 —— 几何展平成 above/below/left-of/right-of/inside 三元组。
|
|
233
|
+
// D 旋钮表 —— 只投影可操作面(kind=knob)+ 态射,按顶层栏分组。
|
|
234
|
+
|
|
235
|
+
// 一个框相对父内部宽的水平位置档:left/center/right(纯 col/w 算术,不读 DOM)。
|
|
236
|
+
function colBand(box: Box, parentInnerW: number): Side {
|
|
237
|
+
const center = box.col + box.w / 2
|
|
238
|
+
if (center < parentInnerW / 3)
|
|
239
|
+
return 'left'
|
|
240
|
+
if (center > (parentInnerW * 2) / 3)
|
|
241
|
+
return 'right'
|
|
242
|
+
return 'center'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 尺寸档:按字符面积粗分大/中/小(给 AI「视觉体量」直觉,Q7 类题靠这个)。
|
|
246
|
+
function sizeBand(box: Box): string {
|
|
247
|
+
const area = box.w * box.h
|
|
248
|
+
if (area >= 600)
|
|
249
|
+
return '大'
|
|
250
|
+
if (area >= 80)
|
|
251
|
+
return '中'
|
|
252
|
+
return '小'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const SIDE_CN: Record<Side, string> = { left: '左', center: '中', right: '右' }
|
|
256
|
+
|
|
257
|
+
// B:语义布局树。每行 = 缩进 + kind 记号 + (@kN) + label + (位置/尺寸/态射)标注。
|
|
258
|
+
export function renderSemanticTree(root: Box, refs?: Map<Box, string>): string {
|
|
259
|
+
const lines: string[] = ['语义布局树(几何→关系词;缩进=嵌套):']
|
|
260
|
+
const walk = (box: Box, depth: number, parentInnerW: number): void => {
|
|
261
|
+
if (depth > 0) {
|
|
262
|
+
const tags: string[] = []
|
|
263
|
+
const band = colBand(box, parentInnerW)
|
|
264
|
+
if (band !== 'center' || box.kind === 'bubble')
|
|
265
|
+
tags.push(SIDE_CN[band])
|
|
266
|
+
if (box.kind === 'frame' || box.kind === 'bubble')
|
|
267
|
+
tags.push(sizeBand(box))
|
|
268
|
+
const mark = box.kind === 'knob' ? '●' : box.kind === 'frame' ? '▢' : box.kind === 'bubble' ? '◷' : '·'
|
|
269
|
+
const text = box.label || `<${box.kind}>`
|
|
270
|
+
const tagStr = tags.length ? ` (${tags.join('/')})` : ''
|
|
271
|
+
lines.push(`${' '.repeat(depth)}${mark} ${refPrefix(box, refs)}${text}${tagStr}${box.morphism}`)
|
|
272
|
+
}
|
|
273
|
+
const innerW = box.drawBorder ? Math.max(1, box.w - 2) : box.w
|
|
274
|
+
for (const c of box.children)
|
|
275
|
+
walk(c, depth + 1, innerW)
|
|
276
|
+
}
|
|
277
|
+
walk(root, 0, Math.max(1, root.w))
|
|
278
|
+
return lines.join('\n')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// D:控制论旋钮表。只投影 kind=knob(可操作面)+ 态射,按顶层栏(root 的直接 frame 子)分组。
|
|
282
|
+
// 每行带 @kN(可寻址号);此栏序遍历与 numberKnobs 同款 → 号与编号权威源天然一致。
|
|
283
|
+
export function renderKnobs(root: Box, refs?: Map<Box, string>): string {
|
|
284
|
+
const lines: string[] = ['控制论投影:能拨的旋钮(可点,@kN 可寻址)+ 态射。布局=旋钮所在栏。']
|
|
285
|
+
const collectKnobs = (box: Box, acc: Box[]): void => {
|
|
286
|
+
if (box.kind === 'knob')
|
|
287
|
+
acc.push(box)
|
|
288
|
+
for (const c of box.children)
|
|
289
|
+
collectKnobs(c, acc)
|
|
290
|
+
}
|
|
291
|
+
// 顶层栏 = root 的直接 frame 子(并排栏)。每栏内收集旋钮。
|
|
292
|
+
const columns = root.children.filter(c => c.kind === 'frame')
|
|
293
|
+
const cols = columns.length ? columns : [root]
|
|
294
|
+
for (const col of cols) {
|
|
295
|
+
const knobs: Box[] = []
|
|
296
|
+
collectKnobs(col, knobs)
|
|
297
|
+
if (knobs.length === 0)
|
|
298
|
+
continue
|
|
299
|
+
const colName = col.label || `<${col.kind}>`
|
|
300
|
+
lines.push(`\n[${colName} 栏]`)
|
|
301
|
+
for (const k of knobs)
|
|
302
|
+
lines.push(` ${refPrefix(k, refs)}${k.label || `<${k.kind}>`}${k.morphism}`)
|
|
303
|
+
}
|
|
304
|
+
return lines.join('\n')
|
|
305
|
+
}
|