@taboo-avalanche/andesite-compiler 1.0.0
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/dist/compile.d.ts +24 -0
- package/dist/compile.js +2076 -0
- package/package.json +30 -0
- package/src/compile.ts +142 -0
- package/src/rasterize/puppeteer.ts +2257 -0
- package/tsconfig.json +7 -0
- package/tsup.config.ts +13 -0
|
@@ -0,0 +1,2257 @@
|
|
|
1
|
+
import puppeteer, { type Browser, type Page, type ElementHandle } from 'puppeteer'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
import * as path from 'path'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_PROGRESS_STEPS,
|
|
6
|
+
HUD_ANCHORS,
|
|
7
|
+
type ButtonStateDecl,
|
|
8
|
+
type ButtonLabelDecl,
|
|
9
|
+
type LabelDecl,
|
|
10
|
+
type TextInputCompletionDecl,
|
|
11
|
+
type TextInputDecl,
|
|
12
|
+
type SlotDecl,
|
|
13
|
+
type ProgressDecl,
|
|
14
|
+
type SpriteDecl,
|
|
15
|
+
type ScrollViewDecl,
|
|
16
|
+
type HudAnchor,
|
|
17
|
+
type HudMountDecl,
|
|
18
|
+
type ViewStackDecl,
|
|
19
|
+
} from '@taboo-avalanche/andesite'
|
|
20
|
+
|
|
21
|
+
// 普通面板/按钮导出 4x 源图,再通过 .gf 逻辑尺寸按原始尺寸显示,避免高 GUI scale 放大后发糊。
|
|
22
|
+
// 倍率越高越清晰但 mcpack/显存按平方增长;链路倍率无关(origin 取 PNG 像素,logical 取 CSS 尺寸)。
|
|
23
|
+
// 进度条不走截图,仍在 renderProgressPng 中按 CSS 像素直接绘制。
|
|
24
|
+
const RASTER_SCALE = 4
|
|
25
|
+
|
|
26
|
+
interface PanelRaster {
|
|
27
|
+
id: string
|
|
28
|
+
mountId?: string
|
|
29
|
+
texturePath: string
|
|
30
|
+
/** 面板边框盒(本体)在页面内的坐标与尺寸;导入器据此把控件定位到与 dev 一致的位置。 */
|
|
31
|
+
x: number
|
|
32
|
+
y: number
|
|
33
|
+
width: number
|
|
34
|
+
height: number
|
|
35
|
+
/** 贴图相对边框盒的四向外扩量(box-shadow 投影);仅外扩>0 时存在,导入器据此外扩渲染。 */
|
|
36
|
+
overflow?: VisualOverflow
|
|
37
|
+
defaultVisible?: boolean
|
|
38
|
+
png: Uint8Array
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ButtonRaster {
|
|
42
|
+
id: string
|
|
43
|
+
mountId?: string
|
|
44
|
+
defaultTexturePath: string
|
|
45
|
+
pressedTexturePath: string
|
|
46
|
+
x: number
|
|
47
|
+
y: number
|
|
48
|
+
width: number
|
|
49
|
+
height: number
|
|
50
|
+
pressDuration: number
|
|
51
|
+
clickable: boolean
|
|
52
|
+
layer?: number
|
|
53
|
+
defaultVisible?: boolean
|
|
54
|
+
states: ButtonStateDecl[]
|
|
55
|
+
labels: ButtonLabelDecl[]
|
|
56
|
+
defaultPng: Uint8Array
|
|
57
|
+
pressedPng: Uint8Array
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DomBox {
|
|
61
|
+
x: number
|
|
62
|
+
y: number
|
|
63
|
+
width: number
|
|
64
|
+
height: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RepeaterCellBox extends DomBox {
|
|
68
|
+
index: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AndesiteRasterResult {
|
|
72
|
+
kind: 'menu' | 'hud'
|
|
73
|
+
// 页面整体包围盒(内容根 px),供运行时居中摆放
|
|
74
|
+
width: number
|
|
75
|
+
height: number
|
|
76
|
+
mounts: HudMountDecl[]
|
|
77
|
+
viewStacks: ViewStackDecl[]
|
|
78
|
+
panels: PanelRaster[]
|
|
79
|
+
buttons: ButtonRaster[]
|
|
80
|
+
labels: LabelDecl[]
|
|
81
|
+
inputs: TextInputDecl[]
|
|
82
|
+
slots: SlotDecl[]
|
|
83
|
+
progresses: ProgressDecl[]
|
|
84
|
+
sprites: SpriteDecl[]
|
|
85
|
+
scrollViews: ScrollViewDecl[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PUPPETEER_LAUNCH_ARGS = [
|
|
89
|
+
`--force-device-scale-factor=${RASTER_SCALE}`,
|
|
90
|
+
'--no-sandbox',
|
|
91
|
+
'--disable-setuid-sandbox',
|
|
92
|
+
'--disable-dev-shm-usage',
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
export interface RasterizeOptions {
|
|
96
|
+
/** 为 true 时输出浏览器控制台与请求失败日志 */
|
|
97
|
+
verbose?: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 启动可复用的 Puppeteer 实例,供 build-all 多页栅格化共用,避免每页重复 launch。
|
|
102
|
+
*/
|
|
103
|
+
export async function launchRasterBrowser(): Promise<Browser> {
|
|
104
|
+
return puppeteer.launch({ headless: true, args: PUPPETEER_LAUNCH_ARGS })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 在已有 Browser 上栅格化单个页面(独立 Tab,用毕关闭 Page)。
|
|
109
|
+
*/
|
|
110
|
+
export async function rasterizeAllInBrowser(
|
|
111
|
+
browser: Browser,
|
|
112
|
+
pageUrl: string,
|
|
113
|
+
outputDir: string,
|
|
114
|
+
options?: RasterizeOptions,
|
|
115
|
+
): Promise<AndesiteRasterResult> {
|
|
116
|
+
const page = await browser.newPage()
|
|
117
|
+
const verbose = options?.verbose === true
|
|
118
|
+
try {
|
|
119
|
+
if (verbose) {
|
|
120
|
+
page.on('console', msg => console.log('[browser console]', msg.type(), msg.text()))
|
|
121
|
+
page.on('pageerror', err => console.log('[browser error]', err.message))
|
|
122
|
+
page.on('requestfailed', req => console.log('[request failed]', req.url(), req.failure()?.errorText))
|
|
123
|
+
}
|
|
124
|
+
await page.setViewport({ width: 176, height: 222, deviceScaleFactor: RASTER_SCALE })
|
|
125
|
+
await page.goto(pageUrl, { waitUntil: 'domcontentloaded' })
|
|
126
|
+
await page.evaluate('globalThis.__name = (fn) => fn')
|
|
127
|
+
await page.waitForFunction(
|
|
128
|
+
() => document.querySelector('[data-andesite-canvas]') !== null,
|
|
129
|
+
{ timeout: 15000 },
|
|
130
|
+
)
|
|
131
|
+
const canvasSize = await page.$eval('[data-andesite-canvas]', node => {
|
|
132
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
133
|
+
return {
|
|
134
|
+
width: rect.width,
|
|
135
|
+
height: rect.height,
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
await page.setViewport({
|
|
139
|
+
width: Math.max(176, Math.ceil(canvasSize.width)),
|
|
140
|
+
height: Math.max(222, Math.ceil(canvasSize.height)),
|
|
141
|
+
deviceScaleFactor: RASTER_SCALE,
|
|
142
|
+
})
|
|
143
|
+
const kind: AndesiteRasterResult['kind'] = await page.$eval('[data-andesite-canvas]', node => {
|
|
144
|
+
return (node as HTMLElement).getAttribute('data-andesite-page-kind') === 'hud' ? 'hud' : 'menu'
|
|
145
|
+
})
|
|
146
|
+
// 页面整体包围盒:菜单以内容根居中;HUD 控件另按最近 mount 作为局部坐标基准。
|
|
147
|
+
const pageBox = await page.$eval('[data-andesite-content-root]', node => {
|
|
148
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
149
|
+
return { width: Math.round(rect.width), height: Math.round(rect.height) }
|
|
150
|
+
})
|
|
151
|
+
// 布局体检前置到栅格化之前,一次浏览器往返查两类会让控件"看着好、导出丢/错位"的隐性错误:
|
|
152
|
+
// 1) 塌陷:控件边框盒宽或高 ≤1px,几乎总是 absolute 脱离文档流反向撑父容器、或父容器高度为 0
|
|
153
|
+
// 所致。编译器对 0 尺寸控件本应按 isCollectable 跳过,但静默丢弃会让 sprites 数组凭空变空、
|
|
154
|
+
// 作者难以定位,故在此显式报错。
|
|
155
|
+
// 2) 出屏:菜单页把控件移出内容包围盒(误用 inset:0 全屏遮罩 / transform 居中 frame),AndesiteCanvas
|
|
156
|
+
// 按内容包围盒导出、引擎 anchor=center 自动居中,任何自行定位都会把内容移出导出区;不在此拦截,
|
|
157
|
+
// 这类页面会在截图阶段以难懂的 clip 宽高错误崩溃。检测用元素边框盒(getBoundingClientRect,不含
|
|
158
|
+
// box-shadow 外溢),故合法投影不误判。出屏判定仅菜单页(HUD 控件按各自 mount 局部坐标,其锚点
|
|
159
|
+
// 本就允许相对屏幕边角偏移);塌陷判定两类页面都做。
|
|
160
|
+
const layout = await page.evaluate(() => {
|
|
161
|
+
const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
162
|
+
const collapsed: string[] = []
|
|
163
|
+
const outOfBounds: string[] = []
|
|
164
|
+
const rootRect = root ? root.getBoundingClientRect() : null
|
|
165
|
+
// 允许 1px 舍入误差;只拦真正越界,不把细线/1px 装饰误判为塌陷
|
|
166
|
+
const tol = 1
|
|
167
|
+
for (const el of Array.from(document.querySelectorAll<HTMLElement>('[data-andesite-id]'))) {
|
|
168
|
+
// 视图栈稍后逐视图量尺寸并体检,不能拿默认隐藏视图的零矩形判塌陷。
|
|
169
|
+
if (el.closest('[data-andesite-viewstack]')) continue
|
|
170
|
+
// 声明 defaultVisible=false 的控件以 display:none 默认隐藏(翻页/页签的隐藏组),getBoundingClientRect 全为 0,
|
|
171
|
+
// 这是预期而非布局塌陷——跳过塌陷与出屏检测,照常栅格化(截图时临时恢复可见)。
|
|
172
|
+
if (el.getAttribute('data-andesite-default-visible') === 'false') {
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
const r = el.getBoundingClientRect()
|
|
176
|
+
const id = el.getAttribute('data-andesite-id') ?? '?'
|
|
177
|
+
if (r.width <= tol || r.height <= tol) {
|
|
178
|
+
collapsed.push(`${id} (宽=${Math.round(r.width)}, 高=${Math.round(r.height)})`)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
if (rootRect) {
|
|
182
|
+
const x = r.left - rootRect.left
|
|
183
|
+
const y = r.top - rootRect.top
|
|
184
|
+
if (x < -tol || y < -tol || x + r.width > rootRect.width + tol || y + r.height > rootRect.height + tol) {
|
|
185
|
+
outOfBounds.push(`${id} (x=${Math.round(x)}, y=${Math.round(y)}, ${Math.round(r.width)}×${Math.round(r.height)}, 页面 ${Math.round(rootRect.width)}×${Math.round(rootRect.height)})`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { collapsed, outOfBounds }
|
|
190
|
+
})
|
|
191
|
+
// 布局体检仅输出警告,不中断编译。只要浏览器能渲染,就原样烘焙;
|
|
192
|
+
// 塌陷或出屏由作者自行在预览中确认,编译器不替作者做审美判断。
|
|
193
|
+
if (layout.collapsed.length && verbose) {
|
|
194
|
+
console.warn(`[andesite-compiler] 布局塌陷警告: ${layout.collapsed.slice(0, 8).join(';')}${layout.collapsed.length > 8 ? ` 等 ${layout.collapsed.length} 个` : ''}`)
|
|
195
|
+
}
|
|
196
|
+
if (kind !== 'hud' && layout.outOfBounds.length && verbose) {
|
|
197
|
+
console.warn(`[andesite-compiler] 控件出屏警告: ${layout.outOfBounds.slice(0, 8).join(';')}${layout.outOfBounds.length > 8 ? ` 等 ${layout.outOfBounds.length} 个` : ''}`)
|
|
198
|
+
}
|
|
199
|
+
const mounts = kind === 'hud' ? await collectHudMounts(page) : []
|
|
200
|
+
const viewStacks: ViewStackDecl[] = []
|
|
201
|
+
for (const stack of await page.$$('[data-andesite-viewstack]')) {
|
|
202
|
+
const info = await stack.evaluate(node => ({ id: node.getAttribute('data-andesite-id')!, defaultView: node.getAttribute('data-andesite-default-view')! }))
|
|
203
|
+
const views: ViewStackDecl['views'] = []
|
|
204
|
+
for (const el of await stack.$$(':scope > [data-andesite-view]')) {
|
|
205
|
+
const box = await measureAndesiteBox(el)
|
|
206
|
+
const info = await el.evaluate(node => ({
|
|
207
|
+
id: node.getAttribute('data-andesite-id')!,
|
|
208
|
+
name: node.getAttribute('data-andesite-view-name')!,
|
|
209
|
+
parentView: node.parentElement?.closest('[data-andesite-view]')?.getAttribute('data-andesite-id') ?? undefined,
|
|
210
|
+
controls: Array.from(node.querySelectorAll('[data-andesite-id]'))
|
|
211
|
+
.filter(child => child.closest('[data-andesite-view]') === node && !child.hasAttribute('data-andesite-viewstack'))
|
|
212
|
+
.map(child => child.getAttribute('data-andesite-id')!),
|
|
213
|
+
}))
|
|
214
|
+
if (box.width <= 0 || box.height <= 0) throw new Error(`Collapsed AView: ${info.id}`)
|
|
215
|
+
const layer = optionalNumber(await stack.evaluate(node => node.getAttribute('data-andesite-layer')))
|
|
216
|
+
views.push({ ...info, ...box, layer, mountId: await collectHudMountId(el), controls: [info.id + '/__background', ...info.controls] })
|
|
217
|
+
}
|
|
218
|
+
viewStacks.push({ ...info, views })
|
|
219
|
+
}
|
|
220
|
+
const labels = await collectLabels(page)
|
|
221
|
+
const inputs = await rasterizeTextInputs(page, outputDir)
|
|
222
|
+
const backgroundPanels = kind === 'hud'
|
|
223
|
+
? await rasterizeHudStaticBackgrounds(page, outputDir)
|
|
224
|
+
: await rasterizeStaticBackground(page, outputDir)
|
|
225
|
+
const panels = await rasterizePanels(page, outputDir)
|
|
226
|
+
const buttons = await rasterizeButtons(page, outputDir)
|
|
227
|
+
const progresses = await rasterizeProgresses(page, outputDir)
|
|
228
|
+
const sprites = await rasterizeSprites(page, outputDir)
|
|
229
|
+
const scrollViews = await rasterizeScrollViews(page, outputDir)
|
|
230
|
+
// HUD 是 Bedrock-only,不能把 ASlot 导出成 Java chest slot。
|
|
231
|
+
const slots = kind === 'hud' ? [] : await collectSlots(page)
|
|
232
|
+
// 所有视图都必须参与体检,非默认视图不得静默丢控件或以零尺寸导出。
|
|
233
|
+
const exported = [...panels, ...buttons, ...labels, ...inputs, ...progresses, ...sprites, ...scrollViews, ...slots]
|
|
234
|
+
const exportedIds = new Set(exported.map(control => control.id))
|
|
235
|
+
for (const stack of viewStacks) for (const view of stack.views) {
|
|
236
|
+
view.controls = view.controls.filter(id => exportedIds.has(id))
|
|
237
|
+
for (const control of exported.filter(control => view.controls.includes(control.id))) {
|
|
238
|
+
if (control.width <= 0 || control.height <= 0) throw new Error(`Collapsed AView control: ${control.id}`)
|
|
239
|
+
if (kind !== 'hud' && (control.x < -1 || control.y < -1 || control.x + control.width > pageBox.width + 1 || control.y + control.height > pageBox.height + 1)) {
|
|
240
|
+
throw new Error(`AView control outside page: ${control.id}`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { kind, width: pageBox.width, height: pageBox.height, mounts, viewStacks, panels: [...backgroundPanels, ...panels], buttons, labels, inputs, slots, progresses, sprites, scrollViews }
|
|
245
|
+
} finally {
|
|
246
|
+
await page.close()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 单页便捷入口:内部 launch + 栅格化 + close。
|
|
252
|
+
*/
|
|
253
|
+
export async function rasterizeAll(pageUrl: string, outputDir: string, options?: RasterizeOptions): Promise<AndesiteRasterResult> {
|
|
254
|
+
const browser = await launchRasterBrowser()
|
|
255
|
+
try {
|
|
256
|
+
return await rasterizeAllInBrowser(browser, pageUrl, outputDir, options)
|
|
257
|
+
} finally {
|
|
258
|
+
await browser.close()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function collectHudMounts(page: Page): Promise<HudMountDecl[]> {
|
|
263
|
+
const elements = await page.$$('[data-andesite-hud-mount]')
|
|
264
|
+
const results: HudMountDecl[] = []
|
|
265
|
+
for (const el of elements) {
|
|
266
|
+
const mount = await el.evaluate(node => {
|
|
267
|
+
const target = node as HTMLElement
|
|
268
|
+
return {
|
|
269
|
+
id: target.getAttribute('data-andesite-id') ?? '',
|
|
270
|
+
x: target.getAttribute('data-andesite-x') ?? '0',
|
|
271
|
+
y: target.getAttribute('data-andesite-y') ?? '0',
|
|
272
|
+
anchor: target.getAttribute('data-andesite-anchor') ?? 'top-left',
|
|
273
|
+
offsetX: target.getAttribute('data-andesite-offset-x'),
|
|
274
|
+
offsetY: target.getAttribute('data-andesite-offset-y'),
|
|
275
|
+
scale: target.getAttribute('data-andesite-scale'),
|
|
276
|
+
layer: target.getAttribute('data-andesite-layer'),
|
|
277
|
+
visibleByDefault: target.getAttribute('data-andesite-visible-by-default'),
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
const offsetX = optionalNumber(mount.offsetX)
|
|
281
|
+
const offsetY = optionalNumber(mount.offsetY)
|
|
282
|
+
const scale = optionalNumber(mount.scale)
|
|
283
|
+
const layer = optionalNumber(mount.layer)
|
|
284
|
+
const visibleByDefault = mount.visibleByDefault == null || mount.visibleByDefault === ''
|
|
285
|
+
? undefined
|
|
286
|
+
: mount.visibleByDefault === 'true' || mount.visibleByDefault === '1'
|
|
287
|
+
results.push({
|
|
288
|
+
id: mount.id,
|
|
289
|
+
x: clampPercent(Number(mount.x)),
|
|
290
|
+
y: clampPercent(Number(mount.y)),
|
|
291
|
+
anchor: normalizeHudAnchor(mount.anchor),
|
|
292
|
+
...(offsetX == null ? {} : { offsetX }),
|
|
293
|
+
...(offsetY == null ? {} : { offsetY }),
|
|
294
|
+
...(scale == null ? {} : { scale }),
|
|
295
|
+
...(layer == null ? {} : { layer }),
|
|
296
|
+
...(visibleByDefault == null ? {} : { visibleByDefault }),
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
return results
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeHudAnchor(value: string): HudAnchor {
|
|
303
|
+
return (HUD_ANCHORS as readonly string[]).includes(value) ? (value as HudAnchor) : 'top-left'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function optionalNumber(value: string | null): number | undefined {
|
|
307
|
+
if (value == null || value === '') {
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
const number = Number(value)
|
|
311
|
+
return Number.isFinite(number) ? number : undefined
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function collectButtonClickable(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
|
|
315
|
+
if (!target) {
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
return await target.evaluate((node: Element) => node.getAttribute('data-andesite-clickable') !== 'false')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function clampPercent(value: number): number {
|
|
322
|
+
if (!Number.isFinite(value)) {
|
|
323
|
+
return 0
|
|
324
|
+
}
|
|
325
|
+
return Math.max(0, Math.min(100, value))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function rasterizeStaticBackground(page: Page, outputDir: string): Promise<PanelRaster[]> {
|
|
329
|
+
const root = await page.$('[data-andesite-content-root]')
|
|
330
|
+
if (!root) {
|
|
331
|
+
return []
|
|
332
|
+
}
|
|
333
|
+
const box = await measureAndesiteBox(root)
|
|
334
|
+
if (box.width <= 0 || box.height <= 0) {
|
|
335
|
+
return []
|
|
336
|
+
}
|
|
337
|
+
const texturePath = 'textures/__page_background.png'
|
|
338
|
+
const screenshot = await withHiddenAndesiteControls(page, async () => {
|
|
339
|
+
return await root.screenshot({ type: 'png', omitBackground: true })
|
|
340
|
+
})
|
|
341
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
342
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
343
|
+
fs.writeFileSync(outputPath, screenshot)
|
|
344
|
+
return [{ id: '__page_background', texturePath, x: box.x, y: box.y, width: box.width, height: box.height, png: screenshot }]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function rasterizeHudStaticBackgrounds(page: Page, outputDir: string): Promise<PanelRaster[]> {
|
|
348
|
+
const elements = await page.$$('[data-andesite-hud-mount]')
|
|
349
|
+
const results: PanelRaster[] = []
|
|
350
|
+
for (const el of elements) {
|
|
351
|
+
const mountId = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
352
|
+
if (mountId === '') {
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const rect = await el.evaluate((node: Element) => {
|
|
357
|
+
const bounds = (node as HTMLElement).getBoundingClientRect()
|
|
358
|
+
return {
|
|
359
|
+
width: Math.round(bounds.width),
|
|
360
|
+
height: Math.round(bounds.height),
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const safeMountId = mountId.replace(/[:\/.-]/g, '_') || 'default'
|
|
368
|
+
const id = `__hud_mount_${safeMountId}_background`
|
|
369
|
+
const texturePath = `textures/${id}.png`
|
|
370
|
+
// 原位截图,不用克隆 overlay:克隆成 fixed 会破坏 mount 内 flex stretch 布局,
|
|
371
|
+
// 说明条这类撑满全宽的普通 div 在克隆里收缩成内容宽,导致背景图右半缺失。
|
|
372
|
+
// withIsolatedCapture 原位保留布局,仅隐藏独立 A* 控件、清理祖先背景。
|
|
373
|
+
const screenshot = await withIsolatedCapture(page, el, async () => {
|
|
374
|
+
return await el.screenshot({ type: 'png', omitBackground: true })
|
|
375
|
+
})
|
|
376
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
377
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
378
|
+
fs.writeFileSync(outputPath, screenshot)
|
|
379
|
+
results.push({ id, mountId, texturePath, x: 0, y: 0, width: rect.width, height: rect.height, png: screenshot })
|
|
380
|
+
}
|
|
381
|
+
return results
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async function isInsideScrollView(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
|
|
386
|
+
if (!target) {
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
return await target.evaluate((node: Element) => node.closest('[data-andesite-type="scroll-view"]') != null)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function isInsideRepeater(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
|
|
393
|
+
if (!target) {
|
|
394
|
+
return false
|
|
395
|
+
}
|
|
396
|
+
return await target.evaluate((node: Element) => node.closest('[data-andesite-type="repeater"]') != null)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function measureBoxRelativeToScrollChild(
|
|
400
|
+
target: Awaited<ReturnType<Page['$']>>,
|
|
401
|
+
scrollEl: Awaited<ReturnType<Page['$']>>,
|
|
402
|
+
): Promise<DomBox> {
|
|
403
|
+
return await measureBoxRelativeToAncestor(target, scrollEl)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function measureBoxRelativeToAncestor(
|
|
407
|
+
target: Awaited<ReturnType<Page['$']>>,
|
|
408
|
+
ancestor: Awaited<ReturnType<Page['$']>>,
|
|
409
|
+
): Promise<DomBox> {
|
|
410
|
+
if (!target || !ancestor) {
|
|
411
|
+
return { x: 0, y: 0, width: 0, height: 0 }
|
|
412
|
+
}
|
|
413
|
+
return await target.evaluate((node: Element, ancestorNode: Element) => {
|
|
414
|
+
const ancestorRect = (ancestorNode as HTMLElement).getBoundingClientRect()
|
|
415
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
416
|
+
return {
|
|
417
|
+
x: Math.round(rect.left - ancestorRect.left),
|
|
418
|
+
y: Math.round(rect.top - ancestorRect.top),
|
|
419
|
+
width: Math.round(rect.width),
|
|
420
|
+
height: Math.round(rect.height),
|
|
421
|
+
}
|
|
422
|
+
}, ancestor)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function rasterizeScrollViews(page: Page, outputDir: string): Promise<ScrollViewDecl[]> {
|
|
426
|
+
const elements = await page.$$('[data-andesite-type="scroll-view"]')
|
|
427
|
+
const results: ScrollViewDecl[] = []
|
|
428
|
+
for (const scrollEl of elements) {
|
|
429
|
+
if (!await isCollectableAndesiteElement(scrollEl)) {
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
const id = await scrollEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
433
|
+
const mountId = await collectHudMountId(scrollEl)
|
|
434
|
+
const box = await measureAndesiteBox(scrollEl)
|
|
435
|
+
const defaultVisible = await scrollEl.evaluate(
|
|
436
|
+
(node: Element) => node.getAttribute('data-andesite-default-visible') !== 'false',
|
|
437
|
+
)
|
|
438
|
+
const scrollPanels: ScrollViewDecl['panels'] = []
|
|
439
|
+
const panelEls = await scrollEl.$$('[data-andesite-type="panel"]')
|
|
440
|
+
for (const panelEl of panelEls) {
|
|
441
|
+
if (!await isCollectableAndesiteElement(panelEl)) {
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
if (await isInsideRepeater(panelEl)) {
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
const panelId = await panelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
448
|
+
const panelBox = await measureBoxRelativeToScrollChild(panelEl, scrollEl)
|
|
449
|
+
const texturePath = `textures/${panelId}.png`
|
|
450
|
+
const screenshot = await withUnclippedScrollCapture(page, panelEl, async () => {
|
|
451
|
+
return await withIsolatedCapture(page, panelEl, async () => panelEl.screenshot({ type: 'png', omitBackground: true }))
|
|
452
|
+
})
|
|
453
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
454
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
455
|
+
fs.writeFileSync(outputPath, screenshot)
|
|
456
|
+
scrollPanels.push({
|
|
457
|
+
id: panelId,
|
|
458
|
+
texture: texturePath,
|
|
459
|
+
x: panelBox.x,
|
|
460
|
+
y: panelBox.y,
|
|
461
|
+
width: panelBox.width,
|
|
462
|
+
height: panelBox.height,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
const scrollButtons: ScrollViewDecl['buttons'] = []
|
|
466
|
+
const buttonEls = await scrollEl.$$('[data-andesite-type="button"]')
|
|
467
|
+
for (const btnEl of buttonEls) {
|
|
468
|
+
if (!await isCollectableAndesiteElement(btnEl)) {
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
if (await isInsideRepeater(btnEl)) {
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
const btnId = await btnEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
475
|
+
const btnBox = await measureBoxRelativeToScrollChild(btnEl, scrollEl)
|
|
476
|
+
const texturePath = `textures/${btnId}.png`
|
|
477
|
+
const defaultPng = await withUnclippedScrollCapture(page, btnEl, async () => {
|
|
478
|
+
return await withIsolatedCapture(page, btnEl, async () => {
|
|
479
|
+
await setButtonCaptureState(btnEl, 'default', true)
|
|
480
|
+
return await captureButtonPng(page, btnEl)
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
const pressedPng = await withUnclippedScrollCapture(page, btnEl, async () => {
|
|
484
|
+
return await withIsolatedCapture(page, btnEl, async () => {
|
|
485
|
+
await setButtonCaptureState(btnEl, 'pressed', true)
|
|
486
|
+
return await captureButtonPng(page, btnEl)
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
const atlas = await combinePngsVertically(page, [defaultPng, pressedPng])
|
|
490
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
491
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
492
|
+
fs.writeFileSync(outputPath, atlas)
|
|
493
|
+
await resetButtonPreviewState(btnEl)
|
|
494
|
+
scrollButtons.push({
|
|
495
|
+
id: btnId,
|
|
496
|
+
defaultTexture: texturePath,
|
|
497
|
+
pressedTexture: texturePath,
|
|
498
|
+
x: btnBox.x,
|
|
499
|
+
y: btnBox.y,
|
|
500
|
+
width: btnBox.width,
|
|
501
|
+
height: btnBox.height,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
const scrollLabels: ScrollViewDecl['labels'] = []
|
|
505
|
+
const labelEls = await scrollEl.$$('[data-andesite-type="label"]')
|
|
506
|
+
for (const labelEl of labelEls) {
|
|
507
|
+
if (!await isCollectableAndesiteElement(labelEl)) {
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
if (await isInsideRepeater(labelEl)) {
|
|
511
|
+
continue
|
|
512
|
+
}
|
|
513
|
+
const labelBox = await measureBoxRelativeToScrollChild(labelEl, scrollEl)
|
|
514
|
+
const labelId = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
515
|
+
const text = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-text') ?? '')
|
|
516
|
+
const size = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
|
|
517
|
+
const align = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
|
|
518
|
+
const color = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
|
|
519
|
+
scrollLabels.push({
|
|
520
|
+
id: labelId,
|
|
521
|
+
text,
|
|
522
|
+
x: labelBox.x,
|
|
523
|
+
y: labelBox.y,
|
|
524
|
+
width: labelBox.width,
|
|
525
|
+
height: labelBox.height,
|
|
526
|
+
size: size as ScrollViewDecl['labels'][0]['size'],
|
|
527
|
+
align: align as ScrollViewDecl['labels'][0]['align'],
|
|
528
|
+
color,
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
const scrollSlots: ScrollViewDecl['slots'] = []
|
|
532
|
+
const slotEls = await scrollEl.$$('[data-andesite-type="slot"]')
|
|
533
|
+
for (const slotEl of slotEls) {
|
|
534
|
+
if (!await isCollectableAndesiteElement(slotEl)) {
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (await isInsideRepeater(slotEl)) {
|
|
538
|
+
continue
|
|
539
|
+
}
|
|
540
|
+
const slotId = await slotEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
541
|
+
const slotMountId = await collectHudMountId(slotEl)
|
|
542
|
+
const slotIndex = await slotEl.evaluate((node: Element) => node.getAttribute('data-andesite-slot-index') ?? '')
|
|
543
|
+
const slotBox = await measureBoxRelativeToScrollChild(slotEl, scrollEl)
|
|
544
|
+
scrollSlots.push({
|
|
545
|
+
id: slotId,
|
|
546
|
+
mountId: slotMountId,
|
|
547
|
+
slot: slotIndex === '' ? scrollSlots.length : Number(slotIndex),
|
|
548
|
+
x: slotBox.x,
|
|
549
|
+
y: slotBox.y,
|
|
550
|
+
width: slotBox.width,
|
|
551
|
+
height: slotBox.height,
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
const scrollRepeaters = await rasterizeScrollRepeaters(page, scrollEl, outputDir)
|
|
555
|
+
const contentFromDom = await scrollEl.evaluate((node: Element) => {
|
|
556
|
+
const content = node.querySelector<HTMLElement>('[data-andesite-scroll-content]')
|
|
557
|
+
return content ? Math.round(content.scrollHeight) : 0
|
|
558
|
+
})
|
|
559
|
+
const contentHeight = Math.max(
|
|
560
|
+
contentFromDom,
|
|
561
|
+
box.height,
|
|
562
|
+
scrollPanels.reduce((max, panel) => Math.max(max, panel.y + panel.height), 0),
|
|
563
|
+
scrollButtons.reduce((max, btn) => Math.max(max, btn.y + btn.height), 0),
|
|
564
|
+
scrollLabels.reduce((max, label) => Math.max(max, label.y + label.height), 0),
|
|
565
|
+
scrollSlots.reduce((max, slot) => Math.max(max, slot.y + slot.height), 0),
|
|
566
|
+
scrollRepeaters.reduce((max, repeater) => Math.max(max, repeater.y + repeater.contentHeight), 0),
|
|
567
|
+
)
|
|
568
|
+
results.push({
|
|
569
|
+
id,
|
|
570
|
+
mountId,
|
|
571
|
+
x: box.x,
|
|
572
|
+
y: box.y,
|
|
573
|
+
width: box.width,
|
|
574
|
+
height: box.height,
|
|
575
|
+
contentHeight,
|
|
576
|
+
defaultVisible,
|
|
577
|
+
panels: scrollPanels,
|
|
578
|
+
buttons: scrollButtons,
|
|
579
|
+
labels: scrollLabels,
|
|
580
|
+
slots: scrollSlots,
|
|
581
|
+
repeaters: scrollRepeaters,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
return results
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function rasterizeScrollRepeaters(
|
|
588
|
+
page: Page,
|
|
589
|
+
scrollEl: Awaited<ReturnType<Page['$']>>,
|
|
590
|
+
outputDir: string,
|
|
591
|
+
): Promise<ScrollViewDecl['repeaters']> {
|
|
592
|
+
if (!scrollEl) {
|
|
593
|
+
return []
|
|
594
|
+
}
|
|
595
|
+
const repeaterEls = await scrollEl.$$('[data-andesite-type="repeater"]')
|
|
596
|
+
const results: ScrollViewDecl['repeaters'] = []
|
|
597
|
+
for (const repeaterEl of repeaterEls) {
|
|
598
|
+
if (!await isCollectableAndesiteElement(repeaterEl)) {
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
const id = await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
602
|
+
const source = await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-source') ?? id)
|
|
603
|
+
const repeaterBox = await measureBoxRelativeToScrollChild(repeaterEl, scrollEl)
|
|
604
|
+
const cellBoxes = await collectRepeaterCellBoxes(repeaterEl)
|
|
605
|
+
const declaredColumns = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-columns')))
|
|
606
|
+
const declaredItemWidth = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-item-width')))
|
|
607
|
+
const declaredItemHeight = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-item-height')))
|
|
608
|
+
const declaredMaxItems = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-max-items')))
|
|
609
|
+
const declaredRowGap = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-row-gap')))
|
|
610
|
+
const declaredColumnGap = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-column-gap')))
|
|
611
|
+
const columns = Math.max(1, Math.floor(declaredColumns ?? inferRepeaterColumns(cellBoxes)))
|
|
612
|
+
const itemWidth = Math.max(1, Math.round(declaredItemWidth ?? cellBoxes[0]?.width ?? repeaterBox.width))
|
|
613
|
+
const itemHeight = Math.max(1, Math.round(declaredItemHeight ?? cellBoxes[0]?.height ?? repeaterBox.height))
|
|
614
|
+
const maxItems = declaredMaxItems ?? 0
|
|
615
|
+
const rowGap = Math.max(0, Math.round(declaredRowGap ?? inferRepeaterRowGap(cellBoxes, columns)))
|
|
616
|
+
const columnGap = Math.max(0, Math.round(declaredColumnGap ?? inferRepeaterColumnGap(cellBoxes, columns)))
|
|
617
|
+
const safeColumns = Math.max(1, Math.floor(columns))
|
|
618
|
+
const maxRows = maxItems > 0 ? Math.ceil(maxItems / safeColumns) : 1
|
|
619
|
+
const contentHeight = Math.max(itemHeight, maxRows * itemHeight + Math.max(0, maxRows - 1) * rowGap)
|
|
620
|
+
const templatePanels: ScrollViewDecl['panels'] = []
|
|
621
|
+
const panelEls = await repeaterEl.$$('[data-andesite-type="panel"]')
|
|
622
|
+
for (const panelEl of panelEls) {
|
|
623
|
+
if (!await isCollectableAndesiteElement(panelEl) || !await isInsideRepeaterFirstRow(panelEl, columns)) {
|
|
624
|
+
continue
|
|
625
|
+
}
|
|
626
|
+
const panelId = await panelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
627
|
+
const panelBox = await measureBoxRelativeToAncestor(panelEl, repeaterEl)
|
|
628
|
+
const texturePath = `textures/${panelId}.png`
|
|
629
|
+
const screenshot = await withUnclippedScrollCapture(page, panelEl, async () => {
|
|
630
|
+
return await withIsolatedCapture(page, panelEl, async () => panelEl.screenshot({ type: 'png', omitBackground: true }))
|
|
631
|
+
})
|
|
632
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
633
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
634
|
+
fs.writeFileSync(outputPath, screenshot)
|
|
635
|
+
templatePanels.push({
|
|
636
|
+
id: panelId,
|
|
637
|
+
texture: texturePath,
|
|
638
|
+
x: panelBox.x,
|
|
639
|
+
y: panelBox.y,
|
|
640
|
+
width: panelBox.width,
|
|
641
|
+
height: panelBox.height,
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
const templateButtons: ScrollViewDecl['buttons'] = []
|
|
645
|
+
const buttonEls = await repeaterEl.$$('[data-andesite-type="button"]')
|
|
646
|
+
for (const btnEl of buttonEls) {
|
|
647
|
+
if (!await isCollectableAndesiteElement(btnEl) || !await isInsideRepeaterFirstRow(btnEl, columns)) {
|
|
648
|
+
continue
|
|
649
|
+
}
|
|
650
|
+
const btnId = await btnEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
651
|
+
const btnBox = await measureBoxRelativeToAncestor(btnEl, repeaterEl)
|
|
652
|
+
const texturePath = `textures/${btnId}.png`
|
|
653
|
+
const defaultPng = await withUnclippedScrollCapture(page, btnEl, async () => {
|
|
654
|
+
return await withIsolatedCapture(page, btnEl, async () => {
|
|
655
|
+
await setButtonCaptureState(btnEl, 'default', true)
|
|
656
|
+
return await captureButtonPng(page, btnEl)
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
const pressedPng = await withUnclippedScrollCapture(page, btnEl, async () => {
|
|
660
|
+
return await withIsolatedCapture(page, btnEl, async () => {
|
|
661
|
+
await setButtonCaptureState(btnEl, 'pressed', true)
|
|
662
|
+
return await captureButtonPng(page, btnEl)
|
|
663
|
+
})
|
|
664
|
+
})
|
|
665
|
+
const atlas = await combinePngsVertically(page, [defaultPng, pressedPng])
|
|
666
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
667
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
668
|
+
fs.writeFileSync(outputPath, atlas)
|
|
669
|
+
await resetButtonPreviewState(btnEl)
|
|
670
|
+
templateButtons.push({
|
|
671
|
+
id: btnId,
|
|
672
|
+
defaultTexture: texturePath,
|
|
673
|
+
pressedTexture: texturePath,
|
|
674
|
+
x: btnBox.x,
|
|
675
|
+
y: btnBox.y,
|
|
676
|
+
width: btnBox.width,
|
|
677
|
+
height: btnBox.height,
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
const templateLabels: ScrollViewDecl['labels'] = []
|
|
681
|
+
const labelEls = await repeaterEl.$$('[data-andesite-type="label"]')
|
|
682
|
+
for (const labelEl of labelEls) {
|
|
683
|
+
if (!await isCollectableAndesiteElement(labelEl) || !await isInsideRepeaterFirstRow(labelEl, columns)) {
|
|
684
|
+
continue
|
|
685
|
+
}
|
|
686
|
+
const labelBox = await measureBoxRelativeToAncestor(labelEl, repeaterEl)
|
|
687
|
+
const labelId = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
688
|
+
const text = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-text') ?? '')
|
|
689
|
+
const size = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
|
|
690
|
+
const align = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
|
|
691
|
+
const color = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
|
|
692
|
+
templateLabels.push({
|
|
693
|
+
id: labelId,
|
|
694
|
+
text,
|
|
695
|
+
x: labelBox.x,
|
|
696
|
+
y: labelBox.y,
|
|
697
|
+
width: labelBox.width,
|
|
698
|
+
height: labelBox.height,
|
|
699
|
+
size: size as ScrollViewDecl['labels'][0]['size'],
|
|
700
|
+
align: align as ScrollViewDecl['labels'][0]['align'],
|
|
701
|
+
color,
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
const templateSlots: ScrollViewDecl['slots'] = []
|
|
705
|
+
const slotEls = await repeaterEl.$$('[data-andesite-type="slot"]')
|
|
706
|
+
for (const slotEl of slotEls) {
|
|
707
|
+
if (!await isCollectableAndesiteElement(slotEl)) {
|
|
708
|
+
continue
|
|
709
|
+
}
|
|
710
|
+
const slotId = await slotEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
711
|
+
const slotMountId = await collectHudMountId(slotEl)
|
|
712
|
+
const slotMeta = await slotEl.evaluate((node: Element) => {
|
|
713
|
+
const declared = node.getAttribute('data-andesite-slot-index') ?? ''
|
|
714
|
+
const cell = node.closest('[data-andesite-repeater-cell]')
|
|
715
|
+
return {
|
|
716
|
+
slot: declared !== '' ? declared : cell?.getAttribute('data-andesite-repeater-slot') ?? '',
|
|
717
|
+
itemIndex: cell?.getAttribute('data-andesite-repeater-cell') ?? '',
|
|
718
|
+
virtual: node.getAttribute('data-andesite-virtual') === 'true',
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
const slotBox = await measureBoxRelativeToAncestor(slotEl, repeaterEl)
|
|
722
|
+
templateSlots.push({
|
|
723
|
+
id: slotId,
|
|
724
|
+
mountId: slotMountId,
|
|
725
|
+
slot: slotMeta.slot === '' ? templateSlots.length : Number(slotMeta.slot),
|
|
726
|
+
...(slotMeta.itemIndex === '' ? {} : { itemIndex: Number(slotMeta.itemIndex) }),
|
|
727
|
+
...(slotMeta.virtual ? { virtual: true } : {}),
|
|
728
|
+
x: slotBox.x,
|
|
729
|
+
y: slotBox.y,
|
|
730
|
+
width: slotBox.width,
|
|
731
|
+
height: slotBox.height,
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
results.push({
|
|
735
|
+
id,
|
|
736
|
+
source,
|
|
737
|
+
x: repeaterBox.x,
|
|
738
|
+
y: repeaterBox.y,
|
|
739
|
+
width: Math.max(repeaterBox.width, columns * itemWidth + Math.max(0, columns - 1) * columnGap),
|
|
740
|
+
height: itemHeight,
|
|
741
|
+
columns,
|
|
742
|
+
itemWidth,
|
|
743
|
+
itemHeight,
|
|
744
|
+
...(maxItems > 0 ? { maxItems: Math.floor(maxItems) } : {}),
|
|
745
|
+
contentHeight,
|
|
746
|
+
rowGap,
|
|
747
|
+
columnGap,
|
|
748
|
+
panels: templatePanels,
|
|
749
|
+
buttons: templateButtons,
|
|
750
|
+
labels: templateLabels,
|
|
751
|
+
slots: templateSlots,
|
|
752
|
+
})
|
|
753
|
+
}
|
|
754
|
+
return results
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function collectRepeaterCellBoxes(repeaterEl: Awaited<ReturnType<Page['$']>>): Promise<RepeaterCellBox[]> {
|
|
758
|
+
if (!repeaterEl) {
|
|
759
|
+
return []
|
|
760
|
+
}
|
|
761
|
+
const cells = await repeaterEl.$$('[data-andesite-repeater-cell]')
|
|
762
|
+
const boxes: RepeaterCellBox[] = []
|
|
763
|
+
for (const cell of cells) {
|
|
764
|
+
const index = await cell.evaluate((node: Element) => Number((node as HTMLElement).dataset.andesiteRepeaterCell ?? '-1'))
|
|
765
|
+
if (!Number.isFinite(index) || index < 0) {
|
|
766
|
+
continue
|
|
767
|
+
}
|
|
768
|
+
const box = await measureBoxRelativeToAncestor(cell, repeaterEl)
|
|
769
|
+
boxes.push({ index, ...box })
|
|
770
|
+
}
|
|
771
|
+
boxes.sort((a, b) => a.index - b.index)
|
|
772
|
+
return boxes
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function inferRepeaterColumns(cells: RepeaterCellBox[]): number {
|
|
776
|
+
if (cells.length <= 1) {
|
|
777
|
+
return 1
|
|
778
|
+
}
|
|
779
|
+
const first = cells[0]
|
|
780
|
+
const firstRowY = first.y
|
|
781
|
+
return Math.max(1, cells.filter(cell => Math.abs(cell.y - firstRowY) <= 1).length)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function inferRepeaterColumnGap(cells: RepeaterCellBox[], columns: number): number {
|
|
785
|
+
if (columns <= 1 || cells.length <= 1) {
|
|
786
|
+
return 0
|
|
787
|
+
}
|
|
788
|
+
const firstRow = cells.slice(0, columns).sort((a, b) => a.x - b.x)
|
|
789
|
+
if (firstRow.length <= 1) {
|
|
790
|
+
return 0
|
|
791
|
+
}
|
|
792
|
+
return firstRow[1].x - firstRow[0].x - firstRow[0].width
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function inferRepeaterRowGap(cells: RepeaterCellBox[], columns: number): number {
|
|
796
|
+
if (cells.length <= columns) {
|
|
797
|
+
return 0
|
|
798
|
+
}
|
|
799
|
+
const first = cells[0]
|
|
800
|
+
const secondRow = cells.find(cell => cell.index >= columns)
|
|
801
|
+
if (!secondRow) {
|
|
802
|
+
return 0
|
|
803
|
+
}
|
|
804
|
+
return secondRow.y - first.y - first.height
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function isInsideRepeaterFirstRow(target: Awaited<ReturnType<Page['$']>>, columns: number): Promise<boolean> {
|
|
808
|
+
if (!target) {
|
|
809
|
+
return false
|
|
810
|
+
}
|
|
811
|
+
return await target.evaluate((node: Element, columnCount: number) => {
|
|
812
|
+
const cell = node.closest('[data-andesite-repeater-cell]') as HTMLElement | null
|
|
813
|
+
if (!cell) {
|
|
814
|
+
return false
|
|
815
|
+
}
|
|
816
|
+
const index = Number(cell.getAttribute('data-andesite-repeater-cell') ?? '-1')
|
|
817
|
+
return Number.isFinite(index) && index >= 0 && index < Math.max(1, Math.floor(columnCount))
|
|
818
|
+
}, columns)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function rasterizePanels(page: Page, outputDir: string): Promise<PanelRaster[]> {
|
|
822
|
+
const elements = await page.$$('[data-andesite-type="panel"], [data-andesite-view]')
|
|
823
|
+
const results: PanelRaster[] = []
|
|
824
|
+
for (const el of elements) {
|
|
825
|
+
if (await isInsideScrollView(el)) {
|
|
826
|
+
continue
|
|
827
|
+
}
|
|
828
|
+
const id = await el.evaluate((node: Element) => (node.getAttribute('data-andesite-id') ?? '') + (node.hasAttribute('data-andesite-view') ? '/__background' : ''))
|
|
829
|
+
const mountId = await collectHudMountId(el)
|
|
830
|
+
const defaultVisible = await readDefaultVisible(el)
|
|
831
|
+
const box = await measureAndesiteBox(el)
|
|
832
|
+
// box-shadow 外溢(如面板底部投影)不在边框盒内,page.screenshot 按边框盒 clip 会把它裁掉。
|
|
833
|
+
// 量出四向外溢,贴图按含阴影的视觉包围盒外扩截取,保留完整投影;导出 overflow 供导入器
|
|
834
|
+
// 把控件按边框盒定位、贴图按外扩尺寸渲染——本体对齐边框盒、投影自然溢出控件框外。
|
|
835
|
+
// blur 不是像素外溢上界;面板随后会逐像素裁剪,先留足模糊尾部,避免把真实阴影截掉。
|
|
836
|
+
const estimatedOverflow = await measureShadowOverflow(el, 2)
|
|
837
|
+
const texturePath = `textures/${encodeURIComponent(id)}.png`
|
|
838
|
+
const captured = await withIsolatedCapture(page, el, async () => {
|
|
839
|
+
// clip 原点必须在预处理(目标已显示)后实时量:defaultVisible=false 的目标被单独显示时,
|
|
840
|
+
// 其在网格/flex 中的实际落位可能不同于"全部兄弟都显示"时的排版位置(隐藏兄弟不再占轨道),
|
|
841
|
+
// 用预处理前量的坐标会错位截空。这里量的是目标当前真实渲染位置。
|
|
842
|
+
// 负 clip 会让 Chromium 的实际取样原点与声明尺寸脱节;仅平移还不足以保留右/下阴影,
|
|
843
|
+
// 需用透明占位撑开文档绘制范围。保持视口不变,避免 vw、媒体查询及百分比布局重新排版。
|
|
844
|
+
const captureSpace = await el.evaluateHandle((node: Element, pad: VisualOverflow) => {
|
|
845
|
+
const html = document.documentElement
|
|
846
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
847
|
+
const state = { style: html.getAttribute('style'), x: window.scrollX, y: window.scrollY, width: rect.width, height: rect.height, spacer: document.createElement('div') }
|
|
848
|
+
const dx = Math.max(0, Math.ceil(pad.left - rect.left - state.x))
|
|
849
|
+
const dy = Math.max(0, Math.ceil(pad.top - rect.top - state.y))
|
|
850
|
+
const right = Math.ceil(Math.max(html.scrollWidth, rect.right + state.x + pad.right))
|
|
851
|
+
const bottom = Math.ceil(Math.max(html.scrollHeight, rect.bottom + state.y + pad.bottom))
|
|
852
|
+
const transform = getComputedStyle(html).transform
|
|
853
|
+
state.spacer.style.cssText = `all: initial; position: absolute; left: ${right}px; top: ${bottom}px; width: 1px; height: 1px; visibility: hidden; pointer-events: none;`
|
|
854
|
+
html.appendChild(state.spacer)
|
|
855
|
+
if (dx > 0 || dy > 0) {
|
|
856
|
+
html.style.setProperty('transition', 'none', 'important')
|
|
857
|
+
html.style.setProperty('transform', `translate(${dx}px, ${dy}px)${transform === 'none' ? '' : ` ${transform}`}`, 'important')
|
|
858
|
+
}
|
|
859
|
+
return state
|
|
860
|
+
}, estimatedOverflow)
|
|
861
|
+
try {
|
|
862
|
+
const abs = await el.evaluate((node: Element) => {
|
|
863
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
864
|
+
return { left: rect.left + window.scrollX, top: rect.top + window.scrollY, width: rect.width, height: rect.height }
|
|
865
|
+
})
|
|
866
|
+
const originalSize = await captureSpace.evaluate(state => ({ width: state.width, height: state.height }))
|
|
867
|
+
if (Math.abs(abs.width - originalSize.width) > 0.01 || Math.abs(abs.height - originalSize.height) > 0.01) {
|
|
868
|
+
throw new Error(`Panel layout changed while preparing shadow capture: ${id}`)
|
|
869
|
+
}
|
|
870
|
+
// 主动把 clip 对齐到整数 CSS 像素(左上 floor、右下 ceil),供 trimShadowOverflow 校验与反推。
|
|
871
|
+
const left = Math.floor(abs.left - estimatedOverflow.left)
|
|
872
|
+
const top = Math.floor(abs.top - estimatedOverflow.top)
|
|
873
|
+
const right = Math.ceil(abs.left + abs.width + estimatedOverflow.right)
|
|
874
|
+
const bottom = Math.ceil(abs.top + abs.height + estimatedOverflow.bottom)
|
|
875
|
+
const clip: DomBox = { x: left, y: top, width: right - left, height: bottom - top }
|
|
876
|
+
if (clip.x < 0 || clip.y < 0) {
|
|
877
|
+
throw new Error(`Panel shadow capture is outside the padded page: ${id}`)
|
|
878
|
+
}
|
|
879
|
+
const borderBoxInClip: DomBox = { x: abs.left - left, y: abs.top - top, width: abs.width, height: abs.height }
|
|
880
|
+
const png = await page.screenshot({
|
|
881
|
+
type: 'png',
|
|
882
|
+
omitBackground: true,
|
|
883
|
+
captureBeyondViewport: true,
|
|
884
|
+
clip,
|
|
885
|
+
})
|
|
886
|
+
return { png, clip, borderBoxInClip }
|
|
887
|
+
} finally {
|
|
888
|
+
try {
|
|
889
|
+
await captureSpace.evaluate(state => {
|
|
890
|
+
state.spacer.remove()
|
|
891
|
+
const html = document.documentElement
|
|
892
|
+
if (state.style === null) {
|
|
893
|
+
html.removeAttribute('style')
|
|
894
|
+
} else {
|
|
895
|
+
html.setAttribute('style', state.style)
|
|
896
|
+
}
|
|
897
|
+
window.scrollTo(state.x, state.y)
|
|
898
|
+
})
|
|
899
|
+
} finally {
|
|
900
|
+
await captureSpace.dispose()
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
})
|
|
904
|
+
// 截图后扫真实 alpha 包围盒,裁掉边框盒外的纯透明(如 overflow-hidden 裁掉的投影),反推真实 overflow。
|
|
905
|
+
const trimmed = await trimShadowOverflow(page, captured.png, captured.clip, captured.borderBoxInClip, RASTER_SCALE)
|
|
906
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
907
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
908
|
+
fs.writeFileSync(outputPath, trimmed.png)
|
|
909
|
+
const overflow = trimmed.overflow
|
|
910
|
+
const hasOverflow = overflow.left > 0 || overflow.top > 0 || overflow.right > 0 || overflow.bottom > 0
|
|
911
|
+
results.push({ id, mountId, texturePath, x: box.x, y: box.y, width: captured.borderBoxInClip.width, height: captured.borderBoxInClip.height, ...(hasOverflow ? { overflow } : {}), ...(defaultVisible == null ? {} : { defaultVisible }), png: trimmed.png })
|
|
912
|
+
}
|
|
913
|
+
return results
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function rasterizeButtons(page: Page, outputDir: string): Promise<ButtonRaster[]> {
|
|
917
|
+
const elements = await page.$$('[data-andesite-type="button"]')
|
|
918
|
+
const results: ButtonRaster[] = []
|
|
919
|
+
for (const el of elements) {
|
|
920
|
+
if (await isInsideScrollView(el)) {
|
|
921
|
+
continue
|
|
922
|
+
}
|
|
923
|
+
const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
924
|
+
const mountId = await collectHudMountId(el)
|
|
925
|
+
const defaultVisible = await readDefaultVisible(el)
|
|
926
|
+
const box = await measureAndesiteBox(el)
|
|
927
|
+
const pressDuration = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-press-duration') ?? '2'))
|
|
928
|
+
const clickable = await collectButtonClickable(el)
|
|
929
|
+
const layer = optionalNumber(await el.evaluate((node: Element) => node.getAttribute('data-andesite-layer')))
|
|
930
|
+
const statesJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-states') ?? '[]')
|
|
931
|
+
const labelsJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-labels') ?? '[]')
|
|
932
|
+
const states = JSON.parse(statesJson) as ButtonStateDecl[]
|
|
933
|
+
const labels = JSON.parse(labelsJson) as ButtonLabelDecl[]
|
|
934
|
+
const texturePath = `textures/${id}.png`
|
|
935
|
+
// 外层容器 box-shadow 外溢需在导出坐标与尺寸中体现,与截图外扩保持一致,
|
|
936
|
+
// 否则放大后的贴图按边框盒坐标摆放会偏移、投影对不齐按钮体。
|
|
937
|
+
const overflow = await measureShadowOverflow(el)
|
|
938
|
+
const frames: Uint8Array[] = []
|
|
939
|
+
// 截取默认态
|
|
940
|
+
await setButtonCaptureState(el, 'default', true)
|
|
941
|
+
if (el) {
|
|
942
|
+
const screenshot = await withIsolatedCapture(page, el, async () => {
|
|
943
|
+
await setButtonCaptureState(el, 'default', true)
|
|
944
|
+
return await captureButtonPng(page, el)
|
|
945
|
+
})
|
|
946
|
+
frames.push(screenshot)
|
|
947
|
+
}
|
|
948
|
+
// 截取按下态
|
|
949
|
+
await setButtonCaptureState(el, 'pressed', true)
|
|
950
|
+
if (el) {
|
|
951
|
+
const screenshot = await withIsolatedCapture(page, el, async () => {
|
|
952
|
+
await setButtonCaptureState(el, 'pressed', true)
|
|
953
|
+
return await captureButtonPng(page, el)
|
|
954
|
+
})
|
|
955
|
+
frames.push(screenshot)
|
|
956
|
+
}
|
|
957
|
+
if (frames.length === 2) {
|
|
958
|
+
const atlas = await combinePngsVertically(page, frames)
|
|
959
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
960
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
961
|
+
fs.writeFileSync(outputPath, atlas)
|
|
962
|
+
results.push({
|
|
963
|
+
id,
|
|
964
|
+
mountId,
|
|
965
|
+
defaultTexturePath: texturePath,
|
|
966
|
+
pressedTexturePath: texturePath,
|
|
967
|
+
x: box.x - overflow.left,
|
|
968
|
+
y: box.y - overflow.top,
|
|
969
|
+
width: box.width + overflow.left + overflow.right,
|
|
970
|
+
height: box.height + overflow.top + overflow.bottom,
|
|
971
|
+
pressDuration,
|
|
972
|
+
clickable,
|
|
973
|
+
...(layer == null ? {} : { layer }),
|
|
974
|
+
...(defaultVisible == null ? {} : { defaultVisible }),
|
|
975
|
+
states,
|
|
976
|
+
labels,
|
|
977
|
+
defaultPng: frames[0],
|
|
978
|
+
pressedPng: frames[1],
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
await resetButtonPreviewState(el)
|
|
982
|
+
}
|
|
983
|
+
return results
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function rasterizeTextInputs(page: Page, outputDir: string): Promise<TextInputDecl[]> {
|
|
987
|
+
const elements = await page.$$('[data-andesite-type="text-input"]')
|
|
988
|
+
const results: TextInputDecl[] = []
|
|
989
|
+
for (const el of elements) {
|
|
990
|
+
if (!await isCollectableAndesiteElement(el)) {
|
|
991
|
+
continue
|
|
992
|
+
}
|
|
993
|
+
const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
994
|
+
const mountId = await collectHudMountId(el)
|
|
995
|
+
const box = await measureAndesiteBox(el)
|
|
996
|
+
const value = await el.evaluate((node: Element) => node.getAttribute('data-andesite-value') ?? '')
|
|
997
|
+
const placeholder = await el.evaluate((node: Element) => node.getAttribute('data-andesite-placeholder') ?? '')
|
|
998
|
+
const maxLength = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-max-length') ?? '32'))
|
|
999
|
+
const size = await el.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
|
|
1000
|
+
const align = await el.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
|
|
1001
|
+
const color = await el.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
|
|
1002
|
+
const placeholderColor = await el.evaluate((node: Element) => node.getAttribute('data-andesite-placeholder-color') ?? '8a8a8a')
|
|
1003
|
+
const completionJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-completion') ?? '{}')
|
|
1004
|
+
const texturePath = `textures/${id}.png`
|
|
1005
|
+
const screenshot = await withIsolatedCapture(page, el, async () => {
|
|
1006
|
+
return await el.screenshot({ type: 'png', omitBackground: true })
|
|
1007
|
+
})
|
|
1008
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
1009
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
1010
|
+
fs.writeFileSync(outputPath, screenshot)
|
|
1011
|
+
results.push({
|
|
1012
|
+
id,
|
|
1013
|
+
mountId,
|
|
1014
|
+
texture: texturePath,
|
|
1015
|
+
value,
|
|
1016
|
+
placeholder,
|
|
1017
|
+
x: box.x,
|
|
1018
|
+
y: box.y,
|
|
1019
|
+
width: box.width,
|
|
1020
|
+
height: box.height,
|
|
1021
|
+
maxLength: Number.isFinite(maxLength) ? maxLength : 32,
|
|
1022
|
+
size: size as TextInputDecl['size'],
|
|
1023
|
+
align: align as TextInputDecl['align'],
|
|
1024
|
+
color,
|
|
1025
|
+
placeholderColor,
|
|
1026
|
+
completion: normalizeTextInputCompletion(completionJson),
|
|
1027
|
+
})
|
|
1028
|
+
}
|
|
1029
|
+
return results
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function normalizeTextInputCompletion(json: string): TextInputCompletionDecl {
|
|
1033
|
+
const fallback: TextInputCompletionDecl = {
|
|
1034
|
+
server: false,
|
|
1035
|
+
minChars: 0,
|
|
1036
|
+
maxItems: 0,
|
|
1037
|
+
debounceTicks: 0,
|
|
1038
|
+
}
|
|
1039
|
+
const parsed = safeJson(json)
|
|
1040
|
+
if (parsed.server !== true) {
|
|
1041
|
+
return fallback
|
|
1042
|
+
}
|
|
1043
|
+
return {
|
|
1044
|
+
server: true,
|
|
1045
|
+
minChars: nonNegativeNumber(parsed.minChars, 1),
|
|
1046
|
+
maxItems: nonNegativeNumber(parsed.maxItems, 5),
|
|
1047
|
+
debounceTicks: nonNegativeNumber(parsed.debounceTicks, 2),
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function safeJson(json: string): Partial<TextInputCompletionDecl> {
|
|
1052
|
+
try {
|
|
1053
|
+
return JSON.parse(json) as Partial<TextInputCompletionDecl>
|
|
1054
|
+
} catch {
|
|
1055
|
+
return {}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function nonNegativeNumber(value: unknown, fallback: number): number {
|
|
1060
|
+
const number = Number(value ?? fallback)
|
|
1061
|
+
return Number.isFinite(number) ? Math.max(0, number) : fallback
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function captureButtonPng(page: Page, el: Awaited<ReturnType<Page['$']>>): Promise<Uint8Array> {
|
|
1065
|
+
if (!el) {
|
|
1066
|
+
return new Uint8Array()
|
|
1067
|
+
}
|
|
1068
|
+
const cloneHandle = await el.evaluateHandle((node: Element) => {
|
|
1069
|
+
const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1070
|
+
const html = document.documentElement
|
|
1071
|
+
const body = document.body
|
|
1072
|
+
// 声明 defaultVisible=false 的按钮以 display:none 默认隐藏,需先恢复 display 才能量出真实尺寸,
|
|
1073
|
+
// 克隆到 overlay 前再还原,避免污染实时页面。
|
|
1074
|
+
const selfHidden = (node as HTMLElement).getAttribute('data-andesite-default-visible') === 'false' && (node as HTMLElement).style.display === 'none'
|
|
1075
|
+
if (selfHidden) {
|
|
1076
|
+
(node as HTMLElement).style.display = ''
|
|
1077
|
+
}
|
|
1078
|
+
if (root) {
|
|
1079
|
+
root.dataset.andesiteButtonCaptureVisibility = root.style.visibility
|
|
1080
|
+
root.style.visibility = 'hidden'
|
|
1081
|
+
// 显式 visibility:visible 的状态层可穿透祖先 visibility;opacity 隔离整棵原树,克隆不受影响。
|
|
1082
|
+
root.dataset.andesiteButtonCaptureOpacity = root.style.opacity
|
|
1083
|
+
root.style.opacity = '0'
|
|
1084
|
+
}
|
|
1085
|
+
html.dataset.andesiteButtonCaptureBackground = html.style.background
|
|
1086
|
+
html.dataset.andesiteButtonCaptureBackgroundColor = html.style.backgroundColor
|
|
1087
|
+
body.dataset.andesiteButtonCaptureBackground = body.style.background
|
|
1088
|
+
body.dataset.andesiteButtonCaptureBackgroundColor = body.style.backgroundColor
|
|
1089
|
+
html.style.background = 'transparent'
|
|
1090
|
+
html.style.backgroundColor = 'transparent'
|
|
1091
|
+
body.style.background = 'transparent'
|
|
1092
|
+
body.style.backgroundColor = 'transparent'
|
|
1093
|
+
|
|
1094
|
+
const wrapper = document.createElement('div')
|
|
1095
|
+
wrapper.dataset.andesiteButtonCaptureOverlay = 'true'
|
|
1096
|
+
wrapper.style.position = 'fixed'
|
|
1097
|
+
wrapper.style.left = '0'
|
|
1098
|
+
wrapper.style.top = '0'
|
|
1099
|
+
wrapper.style.margin = '0'
|
|
1100
|
+
wrapper.style.padding = '0'
|
|
1101
|
+
wrapper.style.background = 'transparent'
|
|
1102
|
+
wrapper.style.pointerEvents = 'none'
|
|
1103
|
+
wrapper.style.zIndex = '2147483647'
|
|
1104
|
+
|
|
1105
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
1106
|
+
const clone = node.cloneNode(true) as HTMLElement
|
|
1107
|
+
// 按钮常用 w-full / absolute 子层,独立截图时必须脱离原布局后固化测量尺寸。
|
|
1108
|
+
clone.style.position = 'relative'
|
|
1109
|
+
clone.style.left = '0'
|
|
1110
|
+
clone.style.top = '0'
|
|
1111
|
+
clone.style.right = 'auto'
|
|
1112
|
+
clone.style.bottom = 'auto'
|
|
1113
|
+
clone.style.width = `${Math.max(1, Math.round(rect.width))}px`
|
|
1114
|
+
clone.style.height = `${Math.max(1, Math.round(rect.height))}px`
|
|
1115
|
+
clone.style.margin = '0'
|
|
1116
|
+
// 外层容器 box-shadow(底座/扩散投影)溢出边框盒,克隆截图需按外扩 clip 保留,
|
|
1117
|
+
// 与面板路径一致;否则按钮底部投影在编译产物里被裁掉(dev 实时渲染不受影响)。
|
|
1118
|
+
const cloneBoxShadow = getComputedStyle(node as HTMLElement).boxShadow
|
|
1119
|
+
clone.style.boxShadow = cloneBoxShadow
|
|
1120
|
+
clone.querySelectorAll<HTMLElement>('[data-andesite-type]').forEach(controlNode => {
|
|
1121
|
+
controlNode.style.opacity = '0'
|
|
1122
|
+
})
|
|
1123
|
+
clone.querySelectorAll<HTMLElement>('[data-andesite-type="label"], [data-andesite-preview]').forEach(previewNode => {
|
|
1124
|
+
previewNode.style.visibility = 'hidden'
|
|
1125
|
+
previewNode.style.opacity = '0'
|
|
1126
|
+
})
|
|
1127
|
+
wrapper.appendChild(clone)
|
|
1128
|
+
body.appendChild(wrapper)
|
|
1129
|
+
if (selfHidden) {
|
|
1130
|
+
(node as HTMLElement).style.display = 'none'
|
|
1131
|
+
}
|
|
1132
|
+
return clone
|
|
1133
|
+
})
|
|
1134
|
+
const cloneElement = cloneHandle.asElement() as ElementHandle<HTMLElement> | null
|
|
1135
|
+
try {
|
|
1136
|
+
if (!cloneElement) {
|
|
1137
|
+
return new Uint8Array()
|
|
1138
|
+
}
|
|
1139
|
+
// 外层容器 box-shadow(底座/扩散投影)溢出边框盒,克隆元素自身 screenshot 会裁掉。
|
|
1140
|
+
// 量出四向外溢后给克隆留出 margin,再用 page.screenshot 按外扩 clip 截取,保留投影。
|
|
1141
|
+
const overflow = await measureShadowOverflow(cloneElement)
|
|
1142
|
+
if (overflow.left > 0 || overflow.top > 0 || overflow.right > 0 || overflow.bottom > 0) {
|
|
1143
|
+
await cloneElement.evaluate((node: Element, pad: { left: number; top: number }) => {
|
|
1144
|
+
const el = node as HTMLElement
|
|
1145
|
+
el.style.marginLeft = `${pad.left}px`
|
|
1146
|
+
el.style.marginTop = `${pad.top}px`
|
|
1147
|
+
}, { left: overflow.left, top: overflow.top })
|
|
1148
|
+
const box = await cloneElement.boundingBox()
|
|
1149
|
+
if (!box) {
|
|
1150
|
+
return new Uint8Array()
|
|
1151
|
+
}
|
|
1152
|
+
return await page.screenshot({
|
|
1153
|
+
type: 'png',
|
|
1154
|
+
omitBackground: true,
|
|
1155
|
+
clip: {
|
|
1156
|
+
x: Math.max(0, box.x - overflow.left),
|
|
1157
|
+
y: Math.max(0, box.y - overflow.top),
|
|
1158
|
+
width: box.width + overflow.left + overflow.right,
|
|
1159
|
+
height: box.height + overflow.top + overflow.bottom,
|
|
1160
|
+
},
|
|
1161
|
+
})
|
|
1162
|
+
}
|
|
1163
|
+
return await cloneElement.screenshot({ type: 'png', omitBackground: true })
|
|
1164
|
+
} finally {
|
|
1165
|
+
await page.evaluate(() => {
|
|
1166
|
+
document.querySelectorAll<HTMLElement>('[data-andesite-button-capture-overlay]').forEach(node => {
|
|
1167
|
+
node.remove()
|
|
1168
|
+
})
|
|
1169
|
+
const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1170
|
+
if (root) {
|
|
1171
|
+
root.style.visibility = root.dataset.andesiteButtonCaptureVisibility ?? ''
|
|
1172
|
+
delete root.dataset.andesiteButtonCaptureVisibility
|
|
1173
|
+
root.style.opacity = root.dataset.andesiteButtonCaptureOpacity ?? ''
|
|
1174
|
+
delete root.dataset.andesiteButtonCaptureOpacity
|
|
1175
|
+
}
|
|
1176
|
+
const html = document.documentElement
|
|
1177
|
+
const body = document.body
|
|
1178
|
+
html.style.background = html.dataset.andesiteButtonCaptureBackground ?? ''
|
|
1179
|
+
html.style.backgroundColor = html.dataset.andesiteButtonCaptureBackgroundColor ?? ''
|
|
1180
|
+
body.style.background = body.dataset.andesiteButtonCaptureBackground ?? ''
|
|
1181
|
+
body.style.backgroundColor = body.dataset.andesiteButtonCaptureBackgroundColor ?? ''
|
|
1182
|
+
delete html.dataset.andesiteButtonCaptureBackground
|
|
1183
|
+
delete html.dataset.andesiteButtonCaptureBackgroundColor
|
|
1184
|
+
delete body.dataset.andesiteButtonCaptureBackground
|
|
1185
|
+
delete body.dataset.andesiteButtonCaptureBackgroundColor
|
|
1186
|
+
})
|
|
1187
|
+
await cloneHandle.dispose()
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// 仅改变编译专用页面:沿目标祖先逐层选中视图,兄弟仍 display:none,保持真实布局而非把全部视图摊开。
|
|
1192
|
+
async function selectCaptureView(target: Awaited<ReturnType<Page['$']>>): Promise<void> {
|
|
1193
|
+
if (!target) return
|
|
1194
|
+
await target.evaluate(node => {
|
|
1195
|
+
const selected = new Map<Element, Element>()
|
|
1196
|
+
let view = node.closest('[data-andesite-view]')
|
|
1197
|
+
while (view) {
|
|
1198
|
+
selected.set(view.parentElement!, view)
|
|
1199
|
+
view = view.parentElement?.closest('[data-andesite-view]') ?? null
|
|
1200
|
+
}
|
|
1201
|
+
for (const stack of document.querySelectorAll('[data-andesite-viewstack]')) {
|
|
1202
|
+
for (const child of stack.querySelectorAll<HTMLElement>(':scope > [data-andesite-view]')) {
|
|
1203
|
+
const active = selected.has(stack) ? selected.get(stack) === child : child.dataset.andesiteViewName === stack.getAttribute('data-andesite-default-view')
|
|
1204
|
+
child.style.display = active ? child.dataset.andesiteViewDisplay ?? '' : 'none'
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
})
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function measureAndesiteBox(target: Awaited<ReturnType<Page['$']>>): Promise<DomBox> {
|
|
1211
|
+
if (!target) {
|
|
1212
|
+
return { x: 0, y: 0, width: 0, height: 0 }
|
|
1213
|
+
}
|
|
1214
|
+
await selectCaptureView(target)
|
|
1215
|
+
return await target.evaluate((node: Element) => {
|
|
1216
|
+
const canvas = document.querySelector<HTMLElement>('[data-andesite-canvas]')
|
|
1217
|
+
const isHud = canvas?.getAttribute('data-andesite-page-kind') === 'hud'
|
|
1218
|
+
const root = isHud
|
|
1219
|
+
? (node.closest('[data-andesite-hud-mount]') as HTMLElement | null) ?? document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1220
|
+
: document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1221
|
+
if (!root) {
|
|
1222
|
+
return { x: 0, y: 0, width: 0, height: 0 }
|
|
1223
|
+
}
|
|
1224
|
+
// 声明 defaultVisible=false 的控件以 display:none 默认隐藏,getBoundingClientRect 全为 0。
|
|
1225
|
+
// 只恢复自身 display 不够——兄弟隐藏控件仍 display:none 会改变网格/flex 轨道分配,
|
|
1226
|
+
// 父级 display:none 则自身仍无布局。须把页面上所有此类隐藏控件一并临时恢复,按真实排版测量后统一还原。
|
|
1227
|
+
const hiddenSiblings = Array.from(
|
|
1228
|
+
(root as HTMLElement).querySelectorAll<HTMLElement>('[data-andesite-default-visible="false"]'),
|
|
1229
|
+
).filter(n => n.style.display === 'none')
|
|
1230
|
+
hiddenSiblings.forEach(n => { n.style.display = '' })
|
|
1231
|
+
const rootRect = root.getBoundingClientRect()
|
|
1232
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
1233
|
+
hiddenSiblings.forEach(n => { n.style.display = 'none' })
|
|
1234
|
+
return {
|
|
1235
|
+
x: Math.round(rect.left - rootRect.left),
|
|
1236
|
+
y: Math.round(rect.top - rootRect.top),
|
|
1237
|
+
width: Math.round(rect.width),
|
|
1238
|
+
height: Math.round(rect.height),
|
|
1239
|
+
}
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// progress 被圆角容器包裹时取容器包围盒(与 renderProgressPng 的胶囊外形同一参照),否则取自身。
|
|
1244
|
+
async function measureProgressCapsuleBox(target: Awaited<ReturnType<Page['$']>>): Promise<DomBox> {
|
|
1245
|
+
if (!target) {
|
|
1246
|
+
return { x: 0, y: 0, width: 0, height: 0 }
|
|
1247
|
+
}
|
|
1248
|
+
return await target.evaluate((node: Element) => {
|
|
1249
|
+
const canvas = document.querySelector<HTMLElement>('[data-andesite-canvas]')
|
|
1250
|
+
const isHud = canvas?.getAttribute('data-andesite-page-kind') === 'hud'
|
|
1251
|
+
const root = isHud
|
|
1252
|
+
? (node.closest('[data-andesite-hud-mount]') as HTMLElement | null) ?? document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1253
|
+
: document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1254
|
+
if (!root) {
|
|
1255
|
+
return { x: 0, y: 0, width: 0, height: 0 }
|
|
1256
|
+
}
|
|
1257
|
+
let capsule: HTMLElement = node as HTMLElement
|
|
1258
|
+
let current: HTMLElement | null = (node as HTMLElement).parentElement
|
|
1259
|
+
while (current && current !== document.body) {
|
|
1260
|
+
const style = getComputedStyle(current)
|
|
1261
|
+
const radius = Number.parseFloat(style.borderTopLeftRadius || '0')
|
|
1262
|
+
const clipped = style.overflow === 'hidden' || style.overflowX === 'hidden' || style.overflowY === 'hidden'
|
|
1263
|
+
if (clipped && Number.isFinite(radius) && radius > 0) {
|
|
1264
|
+
capsule = current
|
|
1265
|
+
break
|
|
1266
|
+
}
|
|
1267
|
+
current = current.parentElement
|
|
1268
|
+
}
|
|
1269
|
+
const rootRect = root.getBoundingClientRect()
|
|
1270
|
+
const rect = capsule.getBoundingClientRect()
|
|
1271
|
+
return {
|
|
1272
|
+
x: Math.round(rect.left - rootRect.left),
|
|
1273
|
+
y: Math.round(rect.top - rootRect.top),
|
|
1274
|
+
width: Math.round(rect.width),
|
|
1275
|
+
height: Math.round(rect.height),
|
|
1276
|
+
}
|
|
1277
|
+
})
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// 元素边框盒不含 box-shadow 外溢;截图与导出尺寸需按视觉包围盒外扩,否则面板底部投影被裁掉。
|
|
1281
|
+
interface VisualOverflow { left: number; top: number; right: number; bottom: number }
|
|
1282
|
+
|
|
1283
|
+
async function measureShadowOverflow(target: Awaited<ReturnType<Page['$']>>, blurScale = 1): Promise<VisualOverflow> {
|
|
1284
|
+
if (!target) {
|
|
1285
|
+
return { left: 0, top: 0, right: 0, bottom: 0 }
|
|
1286
|
+
}
|
|
1287
|
+
return await target.evaluate((node: Element, blurScale: number) => {
|
|
1288
|
+
// 自身 overflow:hidden 只裁剪内容,不裁自身 box-shadow;祖先裁剪交由真实截图和 alpha 测量处理。
|
|
1289
|
+
const shadow = getComputedStyle(node as HTMLElement).boxShadow
|
|
1290
|
+
const overflow = { left: 0, top: 0, right: 0, bottom: 0 }
|
|
1291
|
+
if (!shadow || shadow === 'none') {
|
|
1292
|
+
return overflow
|
|
1293
|
+
}
|
|
1294
|
+
// 按逗号拆分层阴影,但 rgba() 内含逗号,需跳过括号内的逗号。
|
|
1295
|
+
const layers: string[] = []
|
|
1296
|
+
let depth = 0
|
|
1297
|
+
let current = ''
|
|
1298
|
+
for (const ch of shadow) {
|
|
1299
|
+
if (ch === '(') depth++
|
|
1300
|
+
if (ch === ')') depth--
|
|
1301
|
+
if (ch === ',' && depth === 0) { layers.push(current); current = ''; continue }
|
|
1302
|
+
current += ch
|
|
1303
|
+
}
|
|
1304
|
+
layers.push(current)
|
|
1305
|
+
for (const layer of layers) {
|
|
1306
|
+
if (/\binset\b/.test(layer)) continue // 内阴影不外溢
|
|
1307
|
+
const nums = layer.match(/-?\d+(?:\.\d+)?px/g)?.map(v => parseFloat(v)) ?? []
|
|
1308
|
+
const [offsetX = 0, offsetY = 0, blur = 0, spread = 0] = nums
|
|
1309
|
+
const reach = blur * blurScale + Math.max(0, spread)
|
|
1310
|
+
overflow.left = Math.max(overflow.left, -offsetX + reach - Math.min(0, spread))
|
|
1311
|
+
overflow.top = Math.max(overflow.top, -offsetY + reach - Math.min(0, spread))
|
|
1312
|
+
overflow.right = Math.max(overflow.right, offsetX + reach - Math.min(0, spread))
|
|
1313
|
+
overflow.bottom = Math.max(overflow.bottom, offsetY + reach - Math.min(0, spread))
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
left: Math.max(0, Math.ceil(overflow.left)),
|
|
1317
|
+
top: Math.max(0, Math.ceil(overflow.top)),
|
|
1318
|
+
right: Math.max(0, Math.ceil(overflow.right)),
|
|
1319
|
+
bottom: Math.max(0, Math.ceil(overflow.bottom)),
|
|
1320
|
+
}
|
|
1321
|
+
}, blurScale)
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// measureShadowOverflow 按 box-shadow 声明算理论外溢,但不知道元素自身 overflow:hidden 会把投影裁进
|
|
1325
|
+
// 边框盒(如 wish_panel 自身 rounded + overflow-hidden,投影根本渲染不出去)。结果理论外溢虚高,
|
|
1326
|
+
// clip 外扩出一块实际没有任何像素的纯透明区,导入器又按虚高 overflow 外扩控件框 → 本体在框内占比变小、
|
|
1327
|
+
// 游戏里显得图小。这里截图后扫真实 alpha 包围盒,把边框盒之外的纯透明裁掉,并用真实包围盒反推 overflow。
|
|
1328
|
+
// 更正上述裁剪原因:元素自身 overflow:hidden 不会裁掉自身投影;祖先可以裁剪投影,负 clip 则会造成取样坐标脱节。
|
|
1329
|
+
// 裁剪框 = alpha 包围盒 ∪ 边框盒(边框盒必须完整保留,圆角/透明控件也不例外),只删纯透明(alpha=0)。
|
|
1330
|
+
async function trimShadowOverflow(
|
|
1331
|
+
page: Page,
|
|
1332
|
+
png: Uint8Array,
|
|
1333
|
+
clip: DomBox,
|
|
1334
|
+
borderBoxInClip: DomBox,
|
|
1335
|
+
scale: number,
|
|
1336
|
+
): Promise<{ png: Uint8Array; overflow: VisualOverflow }> {
|
|
1337
|
+
const geometry = [
|
|
1338
|
+
clip.x, clip.y, clip.width, clip.height,
|
|
1339
|
+
borderBoxInClip.x, borderBoxInClip.y, borderBoxInClip.width, borderBoxInClip.height,
|
|
1340
|
+
scale,
|
|
1341
|
+
]
|
|
1342
|
+
if (!geometry.every(Number.isFinite) || scale <= 0 || clip.x < 0 || clip.y < 0 || clip.width <= 0 || clip.height <= 0 || borderBoxInClip.width <= 0 || borderBoxInClip.height <= 0) {
|
|
1343
|
+
throw new Error('Invalid shadow capture geometry')
|
|
1344
|
+
}
|
|
1345
|
+
// Puppeteer 会对 clip 做 CSS 像素舍入,调用方须先固定最终整数截图矩形。
|
|
1346
|
+
if (![clip.x, clip.y, clip.width, clip.height].every(Number.isInteger)) {
|
|
1347
|
+
throw new Error('Shadow capture clip must use integer CSS coordinates')
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const result = await page.evaluate(async (input) => {
|
|
1351
|
+
const image = new Image()
|
|
1352
|
+
const loaded = new Promise<void>((resolve, reject) => {
|
|
1353
|
+
image.onload = () => resolve()
|
|
1354
|
+
image.onerror = () => reject(new Error('Failed to decode shadow capture'))
|
|
1355
|
+
})
|
|
1356
|
+
image.src = `data:image/png;base64,${input.base64}`
|
|
1357
|
+
await loaded
|
|
1358
|
+
|
|
1359
|
+
const width = image.naturalWidth
|
|
1360
|
+
const height = image.naturalHeight
|
|
1361
|
+
// 不从错误的 PNG 尺寸反推 scale,否则把截图异常掩盖成合法缩放。
|
|
1362
|
+
if (width !== Math.round(input.clip.width * input.scale) || height !== Math.round(input.clip.height * input.scale)) {
|
|
1363
|
+
throw new Error(`Shadow capture PNG ${width}x${height} does not match clip ${input.clip.width}x${input.clip.height} @${input.scale}`)
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const box = input.borderBoxInClip
|
|
1367
|
+
const bx0 = box.x * input.scale
|
|
1368
|
+
const by0 = box.y * input.scale
|
|
1369
|
+
const bx1 = (box.x + box.width) * input.scale
|
|
1370
|
+
const by1 = (box.y + box.height) * input.scale
|
|
1371
|
+
if (bx0 < 0 || by0 < 0 || bx1 > width || by1 > height) {
|
|
1372
|
+
throw new Error('Border box is outside shadow capture')
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const canvas = document.createElement('canvas')
|
|
1376
|
+
canvas.width = width
|
|
1377
|
+
canvas.height = height
|
|
1378
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
1379
|
+
if (!ctx) {
|
|
1380
|
+
throw new Error('Failed to create shadow scan context')
|
|
1381
|
+
}
|
|
1382
|
+
ctx.drawImage(image, 0, 0)
|
|
1383
|
+
const pixels = ctx.getImageData(0, 0, width, height).data
|
|
1384
|
+
|
|
1385
|
+
// 从完整边框盒向外扩展(右/下用 exclusive 边界),透明控件也保留其布局尺寸。
|
|
1386
|
+
let left = Math.floor(bx0)
|
|
1387
|
+
let top = Math.floor(by0)
|
|
1388
|
+
let right = Math.ceil(bx1)
|
|
1389
|
+
let bottom = Math.ceil(by1)
|
|
1390
|
+
for (let y = 0, alphaIndex = 3; y < height; y++) {
|
|
1391
|
+
for (let x = 0; x < width; x++, alphaIndex += 4) {
|
|
1392
|
+
// 只删纯透明,保留所有低 alpha 阴影像素。
|
|
1393
|
+
if (pixels[alphaIndex] === 0) {
|
|
1394
|
+
continue
|
|
1395
|
+
}
|
|
1396
|
+
if (x < left) left = x
|
|
1397
|
+
if (y < top) top = y
|
|
1398
|
+
if (x + 1 > right) right = x + 1
|
|
1399
|
+
if (y + 1 > bottom) bottom = y + 1
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// overflow = 边框盒各边到裁剪框各边的距离(CSS px),天然非负,与裁剪后贴图尺寸自洽。
|
|
1404
|
+
const overflow = {
|
|
1405
|
+
left: (bx0 - left) / input.scale,
|
|
1406
|
+
top: (by0 - top) / input.scale,
|
|
1407
|
+
right: (right - bx1) / input.scale,
|
|
1408
|
+
bottom: (bottom - by1) / input.scale,
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (left === 0 && top === 0 && right === width && bottom === height) {
|
|
1412
|
+
return { base64: null, overflow }
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const cropped = document.createElement('canvas')
|
|
1416
|
+
cropped.width = right - left
|
|
1417
|
+
cropped.height = bottom - top
|
|
1418
|
+
const croppedCtx = cropped.getContext('2d')
|
|
1419
|
+
if (!croppedCtx) {
|
|
1420
|
+
throw new Error('Failed to create shadow crop context')
|
|
1421
|
+
}
|
|
1422
|
+
croppedCtx.imageSmoothingEnabled = false
|
|
1423
|
+
croppedCtx.drawImage(image, left, top, cropped.width, cropped.height, 0, 0, cropped.width, cropped.height)
|
|
1424
|
+
const dataUrl = cropped.toDataURL('image/png')
|
|
1425
|
+
return { base64: dataUrl.substring(dataUrl.indexOf(',') + 1), overflow }
|
|
1426
|
+
}, {
|
|
1427
|
+
base64: Buffer.from(png).toString('base64'),
|
|
1428
|
+
clip,
|
|
1429
|
+
borderBoxInClip,
|
|
1430
|
+
scale,
|
|
1431
|
+
})
|
|
1432
|
+
|
|
1433
|
+
return {
|
|
1434
|
+
png: result.base64 === null ? png : Buffer.from(result.base64, 'base64'),
|
|
1435
|
+
overflow: result.overflow,
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
async function collectHudMountId(target: Awaited<ReturnType<Page['$']>>): Promise<string | undefined> {
|
|
1440
|
+
if (!target) {
|
|
1441
|
+
return undefined
|
|
1442
|
+
}
|
|
1443
|
+
return await target.evaluate((node: Element) => {
|
|
1444
|
+
const canvas = document.querySelector<HTMLElement>('[data-andesite-canvas]')
|
|
1445
|
+
if (canvas?.getAttribute('data-andesite-page-kind') !== 'hud') {
|
|
1446
|
+
return undefined
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const mount = node.closest('[data-andesite-hud-mount]') as HTMLElement | null
|
|
1450
|
+
const mountId = mount?.getAttribute('data-andesite-id') ?? ''
|
|
1451
|
+
return mountId === '' ? undefined : mountId
|
|
1452
|
+
})
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
async function isCollectableAndesiteElement(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
|
|
1456
|
+
if (!target) {
|
|
1457
|
+
return false
|
|
1458
|
+
}
|
|
1459
|
+
const box = await measureAndesiteBox(target)
|
|
1460
|
+
if (box.width <= 0 || box.height <= 0) return false
|
|
1461
|
+
return await target.evaluate((node: Element) => {
|
|
1462
|
+
// 视图层级由 viewStacks 单独导出,采集时祖先视图已经切到当前目标。
|
|
1463
|
+
if (node.closest('[data-andesite-viewstack]') && !node.closest('[data-andesite-view]')) {
|
|
1464
|
+
return false
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// AButton 会为默认/按下态复制 children;隐藏克隆层里的 A* 控件只用于按钮贴图导出,不应重复注册为运行时控件。
|
|
1468
|
+
let current: HTMLElement | null = node as HTMLElement
|
|
1469
|
+
while (current) {
|
|
1470
|
+
const style = getComputedStyle(current)
|
|
1471
|
+
if (current.getAttribute('data-andesite-preview') === 'button-size' || current.hasAttribute('data-andesite-button-export')) {
|
|
1472
|
+
return false
|
|
1473
|
+
}
|
|
1474
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') {
|
|
1475
|
+
// 声明了 data-andesite-default-visible="false" 的控件,其 hidden 是"默认隐藏待运行时切换",
|
|
1476
|
+
// 不是要剔除——照常导出(并在结果里记 defaultVisible=false,由运行时控制初始显隐)。
|
|
1477
|
+
// 未声明该标记的 hidden 控件维持旧行为:视为真隐藏,跳过。
|
|
1478
|
+
if (current.getAttribute('data-andesite-default-visible') !== 'false') {
|
|
1479
|
+
return false
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (current.hasAttribute('data-andesite-content-root')) {
|
|
1483
|
+
break
|
|
1484
|
+
}
|
|
1485
|
+
current = current.parentElement
|
|
1486
|
+
}
|
|
1487
|
+
return true
|
|
1488
|
+
})
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// 读取控件声明的默认可见性。仅当显式标记 data-andesite-default-visible="false" 时返回 false(默认隐藏,
|
|
1492
|
+
// 待运行时切换);未标记或标记为 true 都视为默认可见,返回 undefined 以保持 andesite.json 简洁、向后兼容。
|
|
1493
|
+
async function readDefaultVisible(target: Awaited<ReturnType<Page['$']>>): Promise<boolean | undefined> {
|
|
1494
|
+
if (!target) {
|
|
1495
|
+
return undefined
|
|
1496
|
+
}
|
|
1497
|
+
const value = await target.evaluate((node: Element) => node.getAttribute('data-andesite-default-visible'))
|
|
1498
|
+
return value === 'false' ? false : undefined
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
async function withIsolatedCapture<T>(page: Page, target: Awaited<ReturnType<Page['$']>>, capture: () => Promise<T>, hideTargetLabels = true): Promise<T> {
|
|
1502
|
+
if (!target) {
|
|
1503
|
+
return await capture()
|
|
1504
|
+
}
|
|
1505
|
+
await page.evaluate((targetNode: Element, hideLabels: boolean) => {
|
|
1506
|
+
const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1507
|
+
const nodes = Array.from((root ?? document).querySelectorAll<HTMLElement>('*'))
|
|
1508
|
+
nodes.forEach(node => {
|
|
1509
|
+
node.dataset.andesiteCaptureVisibility = node.style.visibility
|
|
1510
|
+
node.dataset.andesiteCaptureOpacity = node.style.opacity
|
|
1511
|
+
node.dataset.andesiteCaptureDisplay = node.style.display
|
|
1512
|
+
node.style.visibility = 'hidden'
|
|
1513
|
+
})
|
|
1514
|
+
|
|
1515
|
+
// Puppeteer 截图的是屏幕合成结果,必须只显示当前目标,避免普通 sibling overlay 被烘进 PNG。
|
|
1516
|
+
// 目标若声明 defaultVisible=false,其初始 display 是 none(默认隐藏),须临时恢复为可见才能截到内容,
|
|
1517
|
+
// 截完由下方恢复逻辑还原。祖先链恢复其原始值即可(目标自身随后单独强制)。
|
|
1518
|
+
let current: HTMLElement | null = targetNode as HTMLElement
|
|
1519
|
+
while (current) {
|
|
1520
|
+
current.style.visibility = current.dataset.andesiteCaptureVisibility ?? ''
|
|
1521
|
+
current = current.parentElement
|
|
1522
|
+
}
|
|
1523
|
+
if (targetNode.getAttribute('data-andesite-default-visible') === 'false') {
|
|
1524
|
+
(targetNode as HTMLElement).style.visibility = 'visible'
|
|
1525
|
+
// display:none 时 visibility 不生效。目标自身恢复 display 之外,其 defaultVisible=false 的祖先
|
|
1526
|
+
// (如整组隐藏的卡片,按钮嵌在其内)也必须一并恢复,否则目标仍无布局、截不到内容。
|
|
1527
|
+
;(targetNode as HTMLElement).style.display = ''
|
|
1528
|
+
let ancestor = (targetNode as HTMLElement).parentElement
|
|
1529
|
+
while (ancestor && ancestor !== document.documentElement) {
|
|
1530
|
+
if (ancestor.getAttribute('data-andesite-default-visible') === 'false') {
|
|
1531
|
+
ancestor.style.display = ''
|
|
1532
|
+
}
|
|
1533
|
+
ancestor = ancestor.parentElement
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
targetNode.querySelectorAll<HTMLElement>('*').forEach(node => {
|
|
1537
|
+
if (node === targetNode || !node.matches('[data-andesite-type]')) {
|
|
1538
|
+
node.style.visibility = node.dataset.andesiteCaptureVisibility ?? ''
|
|
1539
|
+
}
|
|
1540
|
+
})
|
|
1541
|
+
// 按钮贴图只烘普通 DOM 装饰;嵌套的 A* 控件独立导出,必须整棵透明,避免 ASprite 子状态 visibility 穿透。
|
|
1542
|
+
targetNode.querySelectorAll<HTMLElement>('[data-andesite-type]').forEach(node => {
|
|
1543
|
+
node.style.opacity = '0'
|
|
1544
|
+
})
|
|
1545
|
+
// 控件贴图只截自身像素;祖先容器(HUD mount、content-root、预览舞台 #1a1a1a、body 等)的背景
|
|
1546
|
+
// 不能烘进 PNG,否则面板圆角/半透明边缘会被填上舞台底色(黑)。黑色舞台在 content-root 之上,
|
|
1547
|
+
// 必须一路清到 body,不能在 content-root 停下。此前仅 sprite 清理且止于 content-root,现统一。
|
|
1548
|
+
{
|
|
1549
|
+
let current = targetNode.parentElement
|
|
1550
|
+
while (current && current !== document.documentElement) {
|
|
1551
|
+
current.dataset.andesiteCaptureBackground = current.style.background
|
|
1552
|
+
current.dataset.andesiteCaptureBackgroundColor = current.style.backgroundColor
|
|
1553
|
+
current.dataset.andesiteCaptureBorderColor = current.style.borderColor
|
|
1554
|
+
current.dataset.andesiteCaptureBoxShadow = current.style.boxShadow
|
|
1555
|
+
current.dataset.andesiteCaptureOutline = current.style.outline
|
|
1556
|
+
current.style.background = 'transparent'
|
|
1557
|
+
current.style.backgroundColor = 'transparent'
|
|
1558
|
+
current.style.borderColor = 'transparent'
|
|
1559
|
+
current.style.boxShadow = 'none'
|
|
1560
|
+
current.style.outline = 'none'
|
|
1561
|
+
current = current.parentElement
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (hideLabels) {
|
|
1565
|
+
targetNode.querySelectorAll<HTMLElement>('[data-andesite-type="label"], [data-andesite-preview]').forEach(node => {
|
|
1566
|
+
node.style.visibility = 'hidden'
|
|
1567
|
+
node.style.opacity = '0'
|
|
1568
|
+
})
|
|
1569
|
+
}
|
|
1570
|
+
}, target, hideTargetLabels)
|
|
1571
|
+
try {
|
|
1572
|
+
return await capture()
|
|
1573
|
+
} finally {
|
|
1574
|
+
await page.evaluate(() => {
|
|
1575
|
+
document.querySelectorAll<HTMLElement>('[data-andesite-capture-visibility]').forEach(node => {
|
|
1576
|
+
node.style.visibility = node.dataset.andesiteCaptureVisibility ?? ''
|
|
1577
|
+
node.style.opacity = node.dataset.andesiteCaptureOpacity ?? ''
|
|
1578
|
+
if (node.dataset.andesiteCaptureDisplay != null) {
|
|
1579
|
+
node.style.display = node.dataset.andesiteCaptureDisplay
|
|
1580
|
+
delete node.dataset.andesiteCaptureDisplay
|
|
1581
|
+
}
|
|
1582
|
+
if (node.dataset.andesiteCaptureBackground != null) {
|
|
1583
|
+
node.style.background = node.dataset.andesiteCaptureBackground
|
|
1584
|
+
node.style.backgroundColor = node.dataset.andesiteCaptureBackgroundColor ?? ''
|
|
1585
|
+
node.style.borderColor = node.dataset.andesiteCaptureBorderColor ?? ''
|
|
1586
|
+
node.style.boxShadow = node.dataset.andesiteCaptureBoxShadow ?? ''
|
|
1587
|
+
node.style.outline = node.dataset.andesiteCaptureOutline ?? ''
|
|
1588
|
+
delete node.dataset.andesiteCaptureBackground
|
|
1589
|
+
delete node.dataset.andesiteCaptureBackgroundColor
|
|
1590
|
+
delete node.dataset.andesiteCaptureBorderColor
|
|
1591
|
+
delete node.dataset.andesiteCaptureBoxShadow
|
|
1592
|
+
delete node.dataset.andesiteCaptureOutline
|
|
1593
|
+
}
|
|
1594
|
+
delete node.dataset.andesiteCaptureVisibility
|
|
1595
|
+
delete node.dataset.andesiteCaptureOpacity
|
|
1596
|
+
})
|
|
1597
|
+
})
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function withUnclippedScrollCapture<T>(page: Page, target: Awaited<ReturnType<Page['$']>>, capture: () => Promise<T>): Promise<T> {
|
|
1602
|
+
if (!target) {
|
|
1603
|
+
return await capture()
|
|
1604
|
+
}
|
|
1605
|
+
await page.evaluate((targetNode: Element) => {
|
|
1606
|
+
const scrollNode = targetNode.closest<HTMLElement>('[data-andesite-type="scroll-view"]')
|
|
1607
|
+
if (!scrollNode) {
|
|
1608
|
+
return
|
|
1609
|
+
}
|
|
1610
|
+
const contentNode = scrollNode.querySelector<HTMLElement>('[data-andesite-scroll-content]')
|
|
1611
|
+
const nodes = [scrollNode, contentNode]
|
|
1612
|
+
nodes.forEach(node => {
|
|
1613
|
+
if (!node) {
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
node.dataset.andesiteScrollCaptureOverflow = node.style.overflow
|
|
1617
|
+
node.dataset.andesiteScrollCaptureOverflowX = node.style.overflowX
|
|
1618
|
+
node.dataset.andesiteScrollCaptureOverflowY = node.style.overflowY
|
|
1619
|
+
node.style.overflow = 'visible'
|
|
1620
|
+
node.style.overflowX = 'visible'
|
|
1621
|
+
node.style.overflowY = 'visible'
|
|
1622
|
+
})
|
|
1623
|
+
}, target)
|
|
1624
|
+
try {
|
|
1625
|
+
return await capture()
|
|
1626
|
+
} finally {
|
|
1627
|
+
await page.evaluate(() => {
|
|
1628
|
+
document.querySelectorAll<HTMLElement>('[data-andesite-scroll-capture-overflow]').forEach(node => {
|
|
1629
|
+
node.style.overflow = node.dataset.andesiteScrollCaptureOverflow ?? ''
|
|
1630
|
+
node.style.overflowX = node.dataset.andesiteScrollCaptureOverflowX ?? ''
|
|
1631
|
+
node.style.overflowY = node.dataset.andesiteScrollCaptureOverflowY ?? ''
|
|
1632
|
+
delete node.dataset.andesiteScrollCaptureOverflow
|
|
1633
|
+
delete node.dataset.andesiteScrollCaptureOverflowX
|
|
1634
|
+
delete node.dataset.andesiteScrollCaptureOverflowY
|
|
1635
|
+
})
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
async function withHiddenAndesiteControls<T>(page: Page, capture: () => Promise<T>, target?: Awaited<ReturnType<Page['$']>>): Promise<T> {
|
|
1641
|
+
await page.evaluate((targetNode: Element | null) => {
|
|
1642
|
+
const root = (targetNode as HTMLElement | null) ?? document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1643
|
+
if (!root) {
|
|
1644
|
+
return
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
const rootRect = root.getBoundingClientRect()
|
|
1648
|
+
const carrier = targetNode == null ? (root.firstElementChild as HTMLElement | null) ?? root : root
|
|
1649
|
+
const carrierRect = carrier.getBoundingClientRect()
|
|
1650
|
+
const overlay = carrier.cloneNode(true) as HTMLElement
|
|
1651
|
+
overlay.dataset.andesiteStaticCaptureOverlay = 'true'
|
|
1652
|
+
overlay.style.position = 'fixed'
|
|
1653
|
+
overlay.style.left = `${carrierRect.left}px`
|
|
1654
|
+
overlay.style.top = `${carrierRect.top}px`
|
|
1655
|
+
overlay.style.width = `${carrierRect.width}px`
|
|
1656
|
+
overlay.style.height = `${carrierRect.height}px`
|
|
1657
|
+
overlay.style.margin = '0'
|
|
1658
|
+
overlay.style.zIndex = '2147483647'
|
|
1659
|
+
overlay.style.pointerEvents = 'none'
|
|
1660
|
+
overlay.style.visibility = 'visible'
|
|
1661
|
+
const hiddenControls = [
|
|
1662
|
+
...(overlay.matches('[data-andesite-type], [data-andesite-preview]') ? [overlay] : []),
|
|
1663
|
+
...Array.from(overlay.querySelectorAll<HTMLElement>('[data-andesite-type], [data-andesite-preview]')),
|
|
1664
|
+
]
|
|
1665
|
+
hiddenControls.forEach(node => {
|
|
1666
|
+
// 静态背景要保留布局占位;opacity 会隐藏整棵子树,避免子节点 visibility 覆盖导致按钮被烘进背景。
|
|
1667
|
+
node.style.opacity = '0'
|
|
1668
|
+
})
|
|
1669
|
+
root.dataset.andesiteCaptureVisibility = root.style.visibility
|
|
1670
|
+
root.dataset.andesiteStaticCapture = 'true'
|
|
1671
|
+
root.style.visibility = 'hidden'
|
|
1672
|
+
|
|
1673
|
+
// 静态背景需要保留普通 DOM 装饰,但不能把按钮、Label、Panel 等独立控件重复烘进背景图。
|
|
1674
|
+
if (carrierRect.left !== rootRect.left || carrierRect.top !== rootRect.top) {
|
|
1675
|
+
const wrapper = document.createElement('div')
|
|
1676
|
+
wrapper.dataset.andesiteStaticCaptureOverlay = 'true'
|
|
1677
|
+
wrapper.style.position = 'fixed'
|
|
1678
|
+
wrapper.style.left = `${rootRect.left}px`
|
|
1679
|
+
wrapper.style.top = `${rootRect.top}px`
|
|
1680
|
+
wrapper.style.width = `${rootRect.width}px`
|
|
1681
|
+
wrapper.style.height = `${rootRect.height}px`
|
|
1682
|
+
wrapper.style.margin = '0'
|
|
1683
|
+
wrapper.style.zIndex = '2147483647'
|
|
1684
|
+
wrapper.style.pointerEvents = 'none'
|
|
1685
|
+
wrapper.appendChild(overlay)
|
|
1686
|
+
document.body.appendChild(wrapper)
|
|
1687
|
+
} else {
|
|
1688
|
+
document.body.appendChild(overlay)
|
|
1689
|
+
}
|
|
1690
|
+
}, target ?? null)
|
|
1691
|
+
// 只合成静态副本;omitBackground 不会移除预览舞台和 body 的 CSS 底色。
|
|
1692
|
+
// 预览舞台 .andesite-preview-stage 带 #1a1a1a 底色与背景图,opacity:0 不阻止其背景被合成,
|
|
1693
|
+
// 必须显式把背景清成 transparent,否则 HUD mount 背景图会带上一层深灰底。
|
|
1694
|
+
const captureStyle = await page.addStyleTag({ content: `
|
|
1695
|
+
html, body, .andesite-preview-stage, .andesite-preview-canvas-wrapper { background: transparent !important; background-image: none !important; }
|
|
1696
|
+
body > :not([data-andesite-static-capture-overlay]) { opacity: 0 !important; }
|
|
1697
|
+
` })
|
|
1698
|
+
try {
|
|
1699
|
+
return await capture()
|
|
1700
|
+
} finally {
|
|
1701
|
+
await captureStyle.evaluate(node => node.remove())
|
|
1702
|
+
await page.evaluate(() => {
|
|
1703
|
+
document.querySelectorAll<HTMLElement>('[data-andesite-capture-visibility]').forEach(node => {
|
|
1704
|
+
node.style.visibility = node.dataset.andesiteCaptureVisibility ?? ''
|
|
1705
|
+
delete node.dataset.andesiteCaptureVisibility
|
|
1706
|
+
})
|
|
1707
|
+
document.querySelectorAll<HTMLElement>('[data-andesite-static-capture]').forEach(node => {
|
|
1708
|
+
delete node.dataset.andesiteStaticCapture
|
|
1709
|
+
})
|
|
1710
|
+
document.querySelectorAll<HTMLElement>('[data-andesite-static-capture-overlay]').forEach(node => {
|
|
1711
|
+
node.remove()
|
|
1712
|
+
})
|
|
1713
|
+
})
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async function setButtonCaptureState(el: Awaited<ReturnType<Page['$']>>, state: 'default' | 'pressed', showPreview: boolean): Promise<void> {
|
|
1718
|
+
if (!el) {
|
|
1719
|
+
return
|
|
1720
|
+
}
|
|
1721
|
+
await el.evaluate((node: Element, targetState: 'default' | 'pressed', shouldShowPreview: boolean) => {
|
|
1722
|
+
const defaultNode = node.querySelector<HTMLElement>('[data-andesite-state="default"]')
|
|
1723
|
+
const pressedNode = node.querySelector<HTMLElement>('[data-andesite-state="pressed"]')
|
|
1724
|
+
const contentNode = node.querySelector<HTMLElement>('[data-andesite-preview="button-content"]')
|
|
1725
|
+
const previewNode = node.querySelector<HTMLElement>('[data-andesite-preview="button-labels"]')
|
|
1726
|
+
if (defaultNode) {
|
|
1727
|
+
defaultNode.style.visibility = 'visible'
|
|
1728
|
+
defaultNode.style.opacity = targetState === 'default' ? '1' : '0'
|
|
1729
|
+
}
|
|
1730
|
+
if (pressedNode) {
|
|
1731
|
+
pressedNode.style.visibility = 'visible'
|
|
1732
|
+
pressedNode.style.opacity = targetState === 'pressed' ? '1' : '0'
|
|
1733
|
+
}
|
|
1734
|
+
if (contentNode) {
|
|
1735
|
+
contentNode.style.visibility = 'hidden'
|
|
1736
|
+
contentNode.style.opacity = '0'
|
|
1737
|
+
}
|
|
1738
|
+
if (previewNode) {
|
|
1739
|
+
previewNode.style.visibility = shouldShowPreview ? 'visible' : 'hidden'
|
|
1740
|
+
}
|
|
1741
|
+
}, state, showPreview)
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
async function resetButtonPreviewState(el: Awaited<ReturnType<Page['$']>>): Promise<void> {
|
|
1745
|
+
if (!el) {
|
|
1746
|
+
return
|
|
1747
|
+
}
|
|
1748
|
+
await el.evaluate((node: Element) => {
|
|
1749
|
+
const defaultNode = node.querySelector<HTMLElement>('[data-andesite-state="default"]')
|
|
1750
|
+
const pressedNode = node.querySelector<HTMLElement>('[data-andesite-state="pressed"]')
|
|
1751
|
+
const contentNode = node.querySelector<HTMLElement>('[data-andesite-preview="button-content"]')
|
|
1752
|
+
if (defaultNode) {
|
|
1753
|
+
defaultNode.style.visibility = 'visible'
|
|
1754
|
+
defaultNode.style.opacity = '0'
|
|
1755
|
+
}
|
|
1756
|
+
if (pressedNode) {
|
|
1757
|
+
pressedNode.style.visibility = 'visible'
|
|
1758
|
+
pressedNode.style.opacity = '0'
|
|
1759
|
+
}
|
|
1760
|
+
if (contentNode) {
|
|
1761
|
+
contentNode.style.visibility = 'visible'
|
|
1762
|
+
contentNode.style.opacity = '1'
|
|
1763
|
+
}
|
|
1764
|
+
})
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
async function rasterizeProgresses(page: Page, outputDir: string): Promise<ProgressDecl[]> {
|
|
1768
|
+
const elements = await page.$$('[data-andesite-type="progress"]')
|
|
1769
|
+
const results: ProgressDecl[] = []
|
|
1770
|
+
for (const el of elements) {
|
|
1771
|
+
if (!await isCollectableAndesiteElement(el)) {
|
|
1772
|
+
continue
|
|
1773
|
+
}
|
|
1774
|
+
const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
1775
|
+
const mountId = await collectHudMountId(el)
|
|
1776
|
+
// 圆角容器包裹 progress 时,导出包围盒取容器(与 renderProgressPng 的胶囊外形一致),
|
|
1777
|
+
// 否则取 progress 自身;保证贴图尺寸、坐标与胶囊外形对齐。
|
|
1778
|
+
const box = await measureProgressCapsuleBox(el)
|
|
1779
|
+
const value = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-value') ?? '0'))
|
|
1780
|
+
const max = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-max') ?? '100'))
|
|
1781
|
+
const steps = Math.max(1, Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-steps') ?? `${DEFAULT_PROGRESS_STEPS}`)))
|
|
1782
|
+
const currentPercent = max <= 0 ? 0 : Math.max(0, Math.min(100, value / max * 100))
|
|
1783
|
+
const defaultIndex = Math.round(currentPercent / 100 * steps)
|
|
1784
|
+
const states: ProgressDecl['states'] = []
|
|
1785
|
+
const frames: Uint8Array[] = []
|
|
1786
|
+
const texturePath = `textures/${id}.png`
|
|
1787
|
+
for (let i = 0; i <= steps; i++) {
|
|
1788
|
+
const percent = Math.round(i * 1000 / steps) / 10
|
|
1789
|
+
const percentId = progressPercentId(percent)
|
|
1790
|
+
const screenshot = await renderProgressPng(el, percent)
|
|
1791
|
+
frames.push(screenshot)
|
|
1792
|
+
states.push({
|
|
1793
|
+
id: `p_${percentId}`,
|
|
1794
|
+
texture: texturePath,
|
|
1795
|
+
value: percent,
|
|
1796
|
+
defaultState: i === defaultIndex,
|
|
1797
|
+
})
|
|
1798
|
+
}
|
|
1799
|
+
const atlas = await combinePngsVertically(page, frames)
|
|
1800
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
1801
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
1802
|
+
fs.writeFileSync(outputPath, atlas)
|
|
1803
|
+
results.push({ id, mountId, x: box.x, y: box.y, width: box.width, height: box.height, states })
|
|
1804
|
+
}
|
|
1805
|
+
return results
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
async function rasterizeSprites(page: Page, outputDir: string): Promise<SpriteDecl[]> {
|
|
1809
|
+
const elements = await page.$$('[data-andesite-type="sprite"]')
|
|
1810
|
+
const results: SpriteDecl[] = []
|
|
1811
|
+
for (const el of elements) {
|
|
1812
|
+
if (!await isCollectableAndesiteElement(el)) {
|
|
1813
|
+
continue
|
|
1814
|
+
}
|
|
1815
|
+
const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
1816
|
+
const mountId = await collectHudMountId(el)
|
|
1817
|
+
const box = await measureAndesiteBox(el)
|
|
1818
|
+
const statesJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-states') ?? '[]')
|
|
1819
|
+
const stateDecls = JSON.parse(statesJson) as Array<{ id: string; defaultState?: boolean }>
|
|
1820
|
+
const texturePath = `textures/${id}.png`
|
|
1821
|
+
const frames: Uint8Array[] = []
|
|
1822
|
+
const states: SpriteDecl['states'] = []
|
|
1823
|
+
for (let i = 0; i < stateDecls.length; i++) {
|
|
1824
|
+
const state = stateDecls[i]
|
|
1825
|
+
const screenshot = await captureSpriteStatePng(page, el, state.id)
|
|
1826
|
+
frames.push(screenshot)
|
|
1827
|
+
states.push({
|
|
1828
|
+
id: state.id,
|
|
1829
|
+
texture: texturePath,
|
|
1830
|
+
defaultState: state.defaultState === true || (i === 0 && !stateDecls.some(item => item.defaultState === true)),
|
|
1831
|
+
})
|
|
1832
|
+
}
|
|
1833
|
+
if (frames.length === 0) {
|
|
1834
|
+
continue
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const atlas = await combinePngsVertically(page, frames)
|
|
1838
|
+
const outputPath = path.join(outputDir, texturePath)
|
|
1839
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
1840
|
+
fs.writeFileSync(outputPath, atlas)
|
|
1841
|
+
results.push({ id, mountId, x: box.x, y: box.y, width: box.width, height: box.height, states })
|
|
1842
|
+
}
|
|
1843
|
+
return results
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
async function captureSpriteStatePng(page: Page, el: Awaited<ReturnType<Page['$']>>, stateId: string): Promise<Uint8Array> {
|
|
1847
|
+
if (!el) {
|
|
1848
|
+
return new Uint8Array()
|
|
1849
|
+
}
|
|
1850
|
+
const cloneHandle = await el.evaluateHandle((node: Element, targetStateId: string) => {
|
|
1851
|
+
const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1852
|
+
const html = document.documentElement
|
|
1853
|
+
const body = document.body
|
|
1854
|
+
if (root) {
|
|
1855
|
+
root.dataset.andesiteSpriteCaptureVisibility = root.style.visibility
|
|
1856
|
+
root.style.visibility = 'hidden'
|
|
1857
|
+
// 与按钮克隆一致,阻止原页面显式可见的状态层污染透明帧。
|
|
1858
|
+
root.dataset.andesiteSpriteCaptureOpacity = root.style.opacity
|
|
1859
|
+
root.style.opacity = '0'
|
|
1860
|
+
}
|
|
1861
|
+
html.dataset.andesiteSpriteCaptureBackground = html.style.background
|
|
1862
|
+
html.dataset.andesiteSpriteCaptureBackgroundColor = html.style.backgroundColor
|
|
1863
|
+
body.dataset.andesiteSpriteCaptureBackground = body.style.background
|
|
1864
|
+
body.dataset.andesiteSpriteCaptureBackgroundColor = body.style.backgroundColor
|
|
1865
|
+
html.style.background = 'transparent'
|
|
1866
|
+
html.style.backgroundColor = 'transparent'
|
|
1867
|
+
body.style.background = 'transparent'
|
|
1868
|
+
body.style.backgroundColor = 'transparent'
|
|
1869
|
+
|
|
1870
|
+
const wrapper = document.createElement('div')
|
|
1871
|
+
wrapper.dataset.andesiteSpriteCaptureOverlay = 'true'
|
|
1872
|
+
wrapper.style.position = 'fixed'
|
|
1873
|
+
wrapper.style.left = '0'
|
|
1874
|
+
wrapper.style.top = '0'
|
|
1875
|
+
wrapper.style.margin = '0'
|
|
1876
|
+
wrapper.style.padding = '0'
|
|
1877
|
+
wrapper.style.background = 'transparent'
|
|
1878
|
+
wrapper.style.pointerEvents = 'none'
|
|
1879
|
+
wrapper.style.zIndex = '2147483647'
|
|
1880
|
+
|
|
1881
|
+
const rect = (node as HTMLElement).getBoundingClientRect()
|
|
1882
|
+
const clone = node.cloneNode(true) as HTMLElement
|
|
1883
|
+
// 状态层常用 absolute inset-0 继承父级尺寸;克隆到独立 wrapper 后必须固化尺寸,否则截图宽高会归零。
|
|
1884
|
+
clone.style.position = 'relative'
|
|
1885
|
+
clone.style.left = '0'
|
|
1886
|
+
clone.style.top = '0'
|
|
1887
|
+
clone.style.right = 'auto'
|
|
1888
|
+
clone.style.bottom = 'auto'
|
|
1889
|
+
clone.style.width = `${Math.max(1, Math.round(rect.width))}px`
|
|
1890
|
+
clone.style.height = `${Math.max(1, Math.round(rect.height))}px`
|
|
1891
|
+
clone.style.margin = '0'
|
|
1892
|
+
clone.querySelectorAll<HTMLElement>('[data-andesite-state]').forEach(stateNode => {
|
|
1893
|
+
stateNode.style.visibility = stateNode.getAttribute('data-andesite-state') === targetStateId ? 'visible' : 'hidden'
|
|
1894
|
+
})
|
|
1895
|
+
wrapper.appendChild(clone)
|
|
1896
|
+
body.appendChild(wrapper)
|
|
1897
|
+
return clone
|
|
1898
|
+
}, stateId)
|
|
1899
|
+
const cloneElement = cloneHandle.asElement()
|
|
1900
|
+
try {
|
|
1901
|
+
if (!cloneElement) {
|
|
1902
|
+
return new Uint8Array()
|
|
1903
|
+
}
|
|
1904
|
+
return await cloneElement.screenshot({ type: 'png', omitBackground: true })
|
|
1905
|
+
} finally {
|
|
1906
|
+
await page.evaluate(() => {
|
|
1907
|
+
document.querySelectorAll<HTMLElement>('[data-andesite-sprite-capture-overlay]').forEach(node => {
|
|
1908
|
+
node.remove()
|
|
1909
|
+
})
|
|
1910
|
+
const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
1911
|
+
if (root) {
|
|
1912
|
+
root.style.visibility = root.dataset.andesiteSpriteCaptureVisibility ?? ''
|
|
1913
|
+
delete root.dataset.andesiteSpriteCaptureVisibility
|
|
1914
|
+
root.style.opacity = root.dataset.andesiteSpriteCaptureOpacity ?? ''
|
|
1915
|
+
delete root.dataset.andesiteSpriteCaptureOpacity
|
|
1916
|
+
}
|
|
1917
|
+
const html = document.documentElement
|
|
1918
|
+
const body = document.body
|
|
1919
|
+
html.style.background = html.dataset.andesiteSpriteCaptureBackground ?? ''
|
|
1920
|
+
html.style.backgroundColor = html.dataset.andesiteSpriteCaptureBackgroundColor ?? ''
|
|
1921
|
+
body.style.background = body.dataset.andesiteSpriteCaptureBackground ?? ''
|
|
1922
|
+
body.style.backgroundColor = body.dataset.andesiteSpriteCaptureBackgroundColor ?? ''
|
|
1923
|
+
delete html.dataset.andesiteSpriteCaptureBackground
|
|
1924
|
+
delete html.dataset.andesiteSpriteCaptureBackgroundColor
|
|
1925
|
+
delete body.dataset.andesiteSpriteCaptureBackground
|
|
1926
|
+
delete body.dataset.andesiteSpriteCaptureBackgroundColor
|
|
1927
|
+
})
|
|
1928
|
+
await cloneHandle.dispose()
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function combinePngsVertically(page: Page, frames: Uint8Array[]): Promise<Uint8Array> {
|
|
1933
|
+
if (frames.length === 0) {
|
|
1934
|
+
return new Uint8Array()
|
|
1935
|
+
}
|
|
1936
|
+
const dataUrls = frames.map(frame => `data:image/png;base64,${Buffer.from(frame).toString('base64')}`)
|
|
1937
|
+
const dataUrl = await page.evaluate(async (sources: string[]) => {
|
|
1938
|
+
const images = await Promise.all(sources.map(source => new Promise<HTMLImageElement>((resolve, reject) => {
|
|
1939
|
+
const image = new Image()
|
|
1940
|
+
image.onload = () => resolve(image)
|
|
1941
|
+
image.onerror = () => reject(new Error(`Failed to load atlas frame: ${source.substring(0, 32)}`))
|
|
1942
|
+
image.src = source
|
|
1943
|
+
})))
|
|
1944
|
+
const width = Math.max(...images.map(image => image.width))
|
|
1945
|
+
const height = images.reduce((sum, image) => sum + image.height, 0)
|
|
1946
|
+
const canvas = document.createElement('canvas')
|
|
1947
|
+
canvas.width = width
|
|
1948
|
+
canvas.height = height
|
|
1949
|
+
const ctx = canvas.getContext('2d')!
|
|
1950
|
+
ctx.imageSmoothingEnabled = false
|
|
1951
|
+
|
|
1952
|
+
// Andesite 源 atlas 纵向排列,避免 progress 档位较多时生成过宽图片。
|
|
1953
|
+
let y = 0
|
|
1954
|
+
images.forEach(image => {
|
|
1955
|
+
ctx.drawImage(image, 0, y)
|
|
1956
|
+
y += image.height
|
|
1957
|
+
})
|
|
1958
|
+
return canvas.toDataURL('image/png')
|
|
1959
|
+
}, dataUrls)
|
|
1960
|
+
return Buffer.from(dataUrl.substring(dataUrl.indexOf(',') + 1), 'base64')
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function progressPercentId(percent: number): string {
|
|
1964
|
+
const normalized = Math.round(percent * 10) / 10
|
|
1965
|
+
return Number.isInteger(normalized) ? `${normalized}` : normalized.toFixed(1).replace('.', '_')
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
async function renderProgressPng(el: Awaited<ReturnType<Page['$']>>, percent: number): Promise<Uint8Array> {
|
|
1969
|
+
if (!el) {
|
|
1970
|
+
return new Uint8Array()
|
|
1971
|
+
}
|
|
1972
|
+
const dataUrl = await el.evaluate((node: Element, value: number) => {
|
|
1973
|
+
const target = node as HTMLElement
|
|
1974
|
+
// 圆角/边框常做在包裹 progress 的容器上(容器 overflow:hidden 把直角 fill 裁成胶囊)。
|
|
1975
|
+
// 单独栅格化 progress 会丢失容器裁剪、暴露直角;向上找最近的圆角裁剪容器,用它作胶囊外形,
|
|
1976
|
+
// 让导出贴图自带与 web 一致的圆角与边框。
|
|
1977
|
+
let capsule: HTMLElement = target
|
|
1978
|
+
let current: HTMLElement | null = target.parentElement
|
|
1979
|
+
while (current && current !== document.body) {
|
|
1980
|
+
const style = getComputedStyle(current)
|
|
1981
|
+
const radius = Number.parseFloat(style.borderTopLeftRadius || '0')
|
|
1982
|
+
const clipped = style.overflow === 'hidden' || style.overflowX === 'hidden' || style.overflowY === 'hidden'
|
|
1983
|
+
if (clipped && Number.isFinite(radius) && radius > 0) {
|
|
1984
|
+
capsule = current
|
|
1985
|
+
break
|
|
1986
|
+
}
|
|
1987
|
+
current = current.parentElement
|
|
1988
|
+
}
|
|
1989
|
+
const rect = capsule.getBoundingClientRect()
|
|
1990
|
+
const width = Math.max(1, Math.round(rect.width))
|
|
1991
|
+
const height = Math.max(1, Math.round(rect.height))
|
|
1992
|
+
const capsuleStyle = getComputedStyle(capsule)
|
|
1993
|
+
const track = target.children.item(0) as HTMLElement | null
|
|
1994
|
+
const fill = target.children.item(1) as HTMLElement | null
|
|
1995
|
+
const trackStyle = track ? getComputedStyle(track) : getComputedStyle(target)
|
|
1996
|
+
const fillStyle = fill ? getComputedStyle(fill) : trackStyle
|
|
1997
|
+
const fillWidth = Math.max(0, Math.min(width, Math.round(width * value / 100)))
|
|
1998
|
+
// 胶囊外形容器的圆角同时约束底槽与填充;填充自身圆角取容器值,保证任意进度下两端圆弧一致。
|
|
1999
|
+
const capsuleRadius = borderRadius(capsuleStyle, width, height)
|
|
2000
|
+
const capsuleBorder = Number.parseFloat(capsuleStyle.borderTopWidth || '0')
|
|
2001
|
+
const capsuleBorderColor = capsuleStyle.borderTopColor
|
|
2002
|
+
const canvas = document.createElement('canvas')
|
|
2003
|
+
canvas.width = width
|
|
2004
|
+
canvas.height = height
|
|
2005
|
+
const ctx = canvas.getContext('2d')!
|
|
2006
|
+
ctx.imageSmoothingEnabled = false
|
|
2007
|
+
// AProgress 不能依赖截图路径,否则动态进度每一帧会互相污染;Canvas 绘制时必须还原 CSS 圆角。
|
|
2008
|
+
// 底槽:优先取容器底色(无则退回 track 色),再画容器边框,与 web 叠放顺序一致。
|
|
2009
|
+
ctx.fillStyle = solidColor(capsuleStyle.backgroundColor, solidColor(trackStyle.backgroundColor, '#00000000'))
|
|
2010
|
+
fillRoundedRect(ctx, 0, 0, width, height, capsuleRadius)
|
|
2011
|
+
if (capsuleBorder > 0 && capsuleBorderColor && capsuleBorderColor !== 'rgba(0, 0, 0, 0)') {
|
|
2012
|
+
ctx.lineWidth = capsuleBorder
|
|
2013
|
+
ctx.strokeStyle = capsuleBorderColor
|
|
2014
|
+
strokeRoundedRect(ctx, capsuleBorder / 2, capsuleBorder / 2, width - capsuleBorder, height - capsuleBorder, Math.max(0, capsuleRadius - capsuleBorder / 2))
|
|
2015
|
+
}
|
|
2016
|
+
if (fillWidth > 0) {
|
|
2017
|
+
ctx.fillStyle = fillPaint(ctx, fillStyle.backgroundImage, fillStyle.backgroundColor, fillWidth, height)
|
|
2018
|
+
fillRoundedRect(ctx, 0, 0, fillWidth, height, capsuleRadius)
|
|
2019
|
+
}
|
|
2020
|
+
return canvas.toDataURL('image/png')
|
|
2021
|
+
|
|
2022
|
+
function solidColor(value: string, fallback: string): string {
|
|
2023
|
+
return value && value !== 'rgba(0, 0, 0, 0)' ? value : fallback
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function fillPaint(context: CanvasRenderingContext2D, backgroundImage: string, backgroundColor: string, fillWidth: number, fillHeight: number): string | CanvasGradient {
|
|
2027
|
+
// 解析 linear-gradient 的方向与颜色停靠点;computed style 会把方向归一成角度或省略(默认 180deg 自上而下)。
|
|
2028
|
+
const gradientMatch = backgroundImage.match(/linear-gradient\((.*)\)$/)
|
|
2029
|
+
if (gradientMatch) {
|
|
2030
|
+
const body = gradientMatch[1]
|
|
2031
|
+
const stops = parseGradientStops(body)
|
|
2032
|
+
if (stops.length >= 2) {
|
|
2033
|
+
const [x0, y0, x1, y1] = gradientAxis(body, fillWidth, fillHeight)
|
|
2034
|
+
const gradient = context.createLinearGradient(x0, y0, x1, y1)
|
|
2035
|
+
for (const stop of stops) {
|
|
2036
|
+
gradient.addColorStop(stop.offset, stop.color)
|
|
2037
|
+
}
|
|
2038
|
+
return gradient
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return solidColor(backgroundColor, '#ffffff')
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// 解析 linear-gradient 的角度/方向为 canvas 起止坐标。角度以顺时针、0deg 指向正上方为准(CSS 规范)。
|
|
2045
|
+
function gradientAxis(body: string, width: number, height: number): [number, number, number, number] {
|
|
2046
|
+
const first = body.slice(0, body.indexOf(',')).trim().toLowerCase()
|
|
2047
|
+
let deg = 180
|
|
2048
|
+
if (/^-?\d+(?:\.\d+)?deg$/.test(first)) {
|
|
2049
|
+
deg = Number.parseFloat(first)
|
|
2050
|
+
} else if (first.startsWith('to ')) {
|
|
2051
|
+
if (first.includes('top')) deg = 0
|
|
2052
|
+
else if (first.includes('bottom')) deg = 180
|
|
2053
|
+
else if (first.includes('left')) deg = 270
|
|
2054
|
+
else if (first.includes('right')) deg = 90
|
|
2055
|
+
}
|
|
2056
|
+
const rad = (deg - 90) * Math.PI / 180
|
|
2057
|
+
const dx = Math.cos(rad)
|
|
2058
|
+
const dy = Math.sin(rad)
|
|
2059
|
+
// 渐变轴长度取矩形对角在方向上的投影,保证 CSS 角到角语义。
|
|
2060
|
+
const half = Math.abs(width * dx) / 2 + Math.abs(height * dy) / 2
|
|
2061
|
+
const cx = width / 2
|
|
2062
|
+
const cy = height / 2
|
|
2063
|
+
return [cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half]
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// 解析颜色停靠点为 {color, offset};无显式百分比时按索引均摊。rgba() 内含逗号需整体匹配。
|
|
2067
|
+
function parseGradientStops(body: string): Array<{ color: string; offset: number }> {
|
|
2068
|
+
const colorRe = /(rgba?\([^)]*\))(?:\s+(\d+(?:\.\d+)?)%)?/g
|
|
2069
|
+
const stops: Array<{ color: string; offset: number }> = []
|
|
2070
|
+
let match: RegExpExecArray | null
|
|
2071
|
+
while ((match = colorRe.exec(body)) !== null) {
|
|
2072
|
+
const percent = match[2] !== undefined ? Number.parseFloat(match[2]) / 100 : -1
|
|
2073
|
+
stops.push({ color: match[1], offset: percent })
|
|
2074
|
+
}
|
|
2075
|
+
const n = stops.length
|
|
2076
|
+
// 省略角度的方向前缀不是颜色,colorRe 不会误匹配;补齐未声明的停靠点。
|
|
2077
|
+
for (let i = 0; i < n; i++) {
|
|
2078
|
+
if (stops[i].offset < 0) {
|
|
2079
|
+
stops[i].offset = n <= 1 ? 0 : i / (n - 1)
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
return stops
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function borderRadius(style: CSSStyleDeclaration, rectWidth: number, rectHeight: number): number {
|
|
2086
|
+
const value = Number.parseFloat(style.borderTopLeftRadius || style.borderRadius || '0')
|
|
2087
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
2088
|
+
return 0
|
|
2089
|
+
}
|
|
2090
|
+
return Math.min(value, rectWidth / 2, rectHeight / 2)
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function fillRoundedRect(context: CanvasRenderingContext2D, x: number, y: number, rectWidth: number, rectHeight: number, radius: number): void {
|
|
2094
|
+
roundedRectPath(context, x, y, rectWidth, rectHeight, radius)
|
|
2095
|
+
context.fill()
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function strokeRoundedRect(context: CanvasRenderingContext2D, x: number, y: number, rectWidth: number, rectHeight: number, radius: number): void {
|
|
2099
|
+
roundedRectPath(context, x, y, rectWidth, rectHeight, radius)
|
|
2100
|
+
context.stroke()
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function roundedRectPath(context: CanvasRenderingContext2D, x: number, y: number, rectWidth: number, rectHeight: number, radius: number): void {
|
|
2104
|
+
if (radius <= 0) {
|
|
2105
|
+
context.beginPath()
|
|
2106
|
+
context.rect(x, y, rectWidth, rectHeight)
|
|
2107
|
+
return
|
|
2108
|
+
}
|
|
2109
|
+
context.beginPath()
|
|
2110
|
+
context.moveTo(x + radius, y)
|
|
2111
|
+
context.lineTo(x + rectWidth - radius, y)
|
|
2112
|
+
context.quadraticCurveTo(x + rectWidth, y, x + rectWidth, y + radius)
|
|
2113
|
+
context.lineTo(x + rectWidth, y + rectHeight - radius)
|
|
2114
|
+
context.quadraticCurveTo(x + rectWidth, y + rectHeight, x + rectWidth - radius, y + rectHeight)
|
|
2115
|
+
context.lineTo(x + radius, y + rectHeight)
|
|
2116
|
+
context.quadraticCurveTo(x, y + rectHeight, x, y + rectHeight - radius)
|
|
2117
|
+
context.lineTo(x, y + radius)
|
|
2118
|
+
context.quadraticCurveTo(x, y, x + radius, y)
|
|
2119
|
+
context.closePath()
|
|
2120
|
+
}
|
|
2121
|
+
}, percent)
|
|
2122
|
+
return Buffer.from(dataUrl.substring(dataUrl.indexOf(',') + 1), 'base64')
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
async function collectLabels(page: Page): Promise<LabelDecl[]> {
|
|
2126
|
+
const elements = await page.$$('[data-andesite-type="label"]')
|
|
2127
|
+
const results: LabelDecl[] = []
|
|
2128
|
+
for (const el of elements) {
|
|
2129
|
+
if (!await isCollectableAndesiteElement(el)) {
|
|
2130
|
+
continue
|
|
2131
|
+
}
|
|
2132
|
+
if (await isInsideScrollView(el)) {
|
|
2133
|
+
continue
|
|
2134
|
+
}
|
|
2135
|
+
const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
2136
|
+
const mountId = await collectHudMountId(el)
|
|
2137
|
+
const defaultVisible = await readDefaultVisible(el)
|
|
2138
|
+
const rotatedBy = await findRotatingLabelAncestor(el)
|
|
2139
|
+
if (rotatedBy != null) {
|
|
2140
|
+
throw new Error(`[Andesite] ALabel 不支持旋转 transform:${id} 受到 ${rotatedBy} 影响。请移除 rotate-*,或把该文本改为静态 DOM 烘进背景。`)
|
|
2141
|
+
}
|
|
2142
|
+
const box = await measureAndesiteBox(el)
|
|
2143
|
+
const text = await el.evaluate((node: Element) => node.getAttribute('data-andesite-text') ?? '')
|
|
2144
|
+
const size = await el.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
|
|
2145
|
+
const align = await el.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
|
|
2146
|
+
const color = await el.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
|
|
2147
|
+
const shadowColor = await el.evaluate((node: Element) => node.getAttribute('data-andesite-shadow-color') ?? '')
|
|
2148
|
+
const shadowOffset = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-shadow-offset') ?? '1'))
|
|
2149
|
+
const shadowVerticalOffset = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-shadow-vertical-offset') ?? '1'))
|
|
2150
|
+
results.push({
|
|
2151
|
+
id, mountId, text, x: box.x, y: box.y, width: box.width, height: box.height,
|
|
2152
|
+
size: size as LabelDecl['size'],
|
|
2153
|
+
align: align as LabelDecl['align'],
|
|
2154
|
+
color,
|
|
2155
|
+
shadowColor: shadowColor || undefined,
|
|
2156
|
+
shadowOffset,
|
|
2157
|
+
shadowVerticalOffset,
|
|
2158
|
+
...(defaultVisible == null ? {} : { defaultVisible }),
|
|
2159
|
+
})
|
|
2160
|
+
}
|
|
2161
|
+
return results
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
async function findRotatingLabelAncestor(el: Awaited<ReturnType<Page['$']>>): Promise<string | null> {
|
|
2165
|
+
if (!el) {
|
|
2166
|
+
return null
|
|
2167
|
+
}
|
|
2168
|
+
return await el.evaluate((node: Element) => {
|
|
2169
|
+
const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
|
|
2170
|
+
let current: HTMLElement | null = node as HTMLElement
|
|
2171
|
+
while (current) {
|
|
2172
|
+
const style = getComputedStyle(current)
|
|
2173
|
+
// Bedrock 动态 label 只能还原轴对齐盒模型;旋转会让文本与已烘焙背景错位。
|
|
2174
|
+
if (hasNonAxisAlignedTransform(style)) {
|
|
2175
|
+
return describeNode(current)
|
|
2176
|
+
}
|
|
2177
|
+
if (current === root) {
|
|
2178
|
+
break
|
|
2179
|
+
}
|
|
2180
|
+
current = current.parentElement
|
|
2181
|
+
}
|
|
2182
|
+
return null
|
|
2183
|
+
|
|
2184
|
+
function hasNonAxisAlignedTransform(style: CSSStyleDeclaration): boolean {
|
|
2185
|
+
if (hasRotateProperty(style.getPropertyValue('rotate'))) {
|
|
2186
|
+
return true
|
|
2187
|
+
}
|
|
2188
|
+
const transform = style.transform
|
|
2189
|
+
if (transform == null || transform === '' || transform === 'none') {
|
|
2190
|
+
return false
|
|
2191
|
+
}
|
|
2192
|
+
const matrix = transform.match(/^matrix\(([^)]+)\)$/)
|
|
2193
|
+
if (matrix != null) {
|
|
2194
|
+
const values = matrix[1].split(',').map(value => Number.parseFloat(value.trim()))
|
|
2195
|
+
return values.length >= 4 && (Math.abs(values[1]) > 0.0001 || Math.abs(values[2]) > 0.0001)
|
|
2196
|
+
}
|
|
2197
|
+
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/)
|
|
2198
|
+
if (matrix3d != null) {
|
|
2199
|
+
const values = matrix3d[1].split(',').map(value => Number.parseFloat(value.trim()))
|
|
2200
|
+
return values.length >= 16 && (Math.abs(values[1]) > 0.0001 || Math.abs(values[4]) > 0.0001)
|
|
2201
|
+
}
|
|
2202
|
+
return /rotate|skew/i.test(transform)
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function hasRotateProperty(value: string): boolean {
|
|
2206
|
+
if (value == null || value === '' || value === 'none') {
|
|
2207
|
+
return false
|
|
2208
|
+
}
|
|
2209
|
+
return value.split(/\s+/).some(part => {
|
|
2210
|
+
const n = Number.parseFloat(part)
|
|
2211
|
+
return Number.isFinite(n) && Math.abs(n) > 0.0001
|
|
2212
|
+
})
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function describeNode(target: HTMLElement): string {
|
|
2216
|
+
const tag = target.tagName.toLowerCase()
|
|
2217
|
+
const id = target.id ? `#${target.id}` : ''
|
|
2218
|
+
const className = typeof target.className === 'string' && target.className !== ''
|
|
2219
|
+
? `.${target.className.trim().split(/\s+/).join('.')}`
|
|
2220
|
+
: ''
|
|
2221
|
+
return `${tag}${id}${className}`
|
|
2222
|
+
}
|
|
2223
|
+
})
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* 收集页面中所有原生 ASlot 的坐标和尺寸,不截图。
|
|
2228
|
+
*/
|
|
2229
|
+
async function collectSlots(page: Page): Promise<SlotDecl[]> {
|
|
2230
|
+
const elements = await page.$$('[data-andesite-type="slot"]')
|
|
2231
|
+
const results: SlotDecl[] = []
|
|
2232
|
+
let visibleIndex = 0
|
|
2233
|
+
for (let i = 0; i < elements.length; i++) {
|
|
2234
|
+
const el = elements[i]
|
|
2235
|
+
if (!await isCollectableAndesiteElement(el)) {
|
|
2236
|
+
continue
|
|
2237
|
+
}
|
|
2238
|
+
if (await isInsideScrollView(el)) {
|
|
2239
|
+
continue
|
|
2240
|
+
}
|
|
2241
|
+
if (await isInsideRepeater(el)) {
|
|
2242
|
+
continue
|
|
2243
|
+
}
|
|
2244
|
+
const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
|
|
2245
|
+
const mountId = await collectHudMountId(el)
|
|
2246
|
+
const slotIndex = await el.evaluate((node: Element) => node.getAttribute('data-andesite-slot-index') ?? '')
|
|
2247
|
+
const slot = slotIndex === '' ? visibleIndex : Number(slotIndex)
|
|
2248
|
+
const box = await measureAndesiteBox(el)
|
|
2249
|
+
const x = box.x
|
|
2250
|
+
const y = box.y
|
|
2251
|
+
const width = box.width
|
|
2252
|
+
const height = box.height
|
|
2253
|
+
results.push({ id, mountId, slot, x, y, width, height })
|
|
2254
|
+
visibleIndex++
|
|
2255
|
+
}
|
|
2256
|
+
return results
|
|
2257
|
+
}
|