lecodes-web-canvas 2.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/package.json +26 -0
- package/src/contract.d.ts +16 -0
- package/src/core/SceneNode.ts +109 -0
- package/src/core/hitTest.ts +30 -0
- package/src/core/text.ts +259 -0
- package/src/core/util/colors.ts +30 -0
- package/src/core/util/genericFamilies.ts +30 -0
- package/src/core/util/geometry.ts +121 -0
- package/src/core/util/svgSrc.ts +141 -0
- package/src/host/BrowserHost.ts +132 -0
- package/src/host/HeadlessHost.ts +64 -0
- package/src/host/Host.ts +85 -0
- package/src/layout/CuiPaint.gen.ts +121 -0
- package/src/layout/LayoutEngine.ts +78 -0
- package/src/layout/creatorUi/loadCreatorUi.ts +87 -0
- package/src/layout/creatorUi/paint-read.ts +131 -0
- package/src/layout/creatorUi/readGradient.ts +62 -0
- package/src/paint/Canvas2DPainter.ts +258 -0
- package/src/paint/Painter.ts +62 -0
- package/src/paint/RecordingPainter.ts +101 -0
- package/src/paint/paintTree.ts +314 -0
- package/src/runtime/TreeUI.ts +822 -0
- package/src/serialize/serializeRenderTree.ts +150 -0
- package/src/types.ts +299 -0
- package/src/vite-env.d.ts +1 -0
- package/src/web/BrowserUI.ts +312 -0
- package/src/web/chrome.ts +12 -0
- package/src/web/renderer.ts +192 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// The TreeUI of a browser page: the painted picture, and the few things a browser shows better than
|
|
2
|
+
// a painter — the way a native host keeps a platform view for them:
|
|
3
|
+
//
|
|
4
|
+
// under the picture a native view (a plugin's web half: a map, a camera) and a video. The
|
|
5
|
+
// painter leaves a window where the node is (`clearRoundRect`), so what the
|
|
6
|
+
// app paints AFTER the node — a button over the map — is over it.
|
|
7
|
+
// over the picture the field being edited: a live <input> / <textarea> in the place of its
|
|
8
|
+
// node, for as long as it has the focus — the caret, the selection, the
|
|
9
|
+
// clipboard and the input method are the browser's. The painter leaves the
|
|
10
|
+
// field's text out meanwhile.
|
|
11
|
+
//
|
|
12
|
+
// Both are placed from the render tree every time it changes: the box of the node, moved by the
|
|
13
|
+
// transforms above it (a screen in a transition carries its field along), cut by the clips above it.
|
|
14
|
+
import type { WasmModule } from "lecodes-web-runtime/src/gen/wasm"
|
|
15
|
+
import type { WebViewFactory, WebViewInstanceHandle } from "lecodes-web-shared/src/ui/nativeView"
|
|
16
|
+
import { getPlayer } from "lecodes-web-shared/src/utils/mediaplayer"
|
|
17
|
+
import { TREE_DEST_NATIVE } from "lecodes-sdk/ui/tree"
|
|
18
|
+
import { hitTest } from "../core/hitTest"
|
|
19
|
+
import type { Host } from "../host/Host"
|
|
20
|
+
import type { PaintOptions } from "../paint/paintTree"
|
|
21
|
+
import { TreeUI, type TreeUIOptions } from "../runtime/TreeUI"
|
|
22
|
+
import type { Rect, RenderNode } from "../types"
|
|
23
|
+
|
|
24
|
+
const FIELD_STYLE = `
|
|
25
|
+
.lc-field { position: absolute; left: 0; top: 0; margin: 0; border: none; outline: none; background: none; resize: none; box-sizing: border-box; transform-origin: 0 0; pointer-events: auto; overflow: hidden; -webkit-appearance: none; appearance: none; }
|
|
26
|
+
.lc-field::placeholder { color: var(--lc-placeholder, #999); opacity: 1; }
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
const hex = (v: number | undefined, fallback: string) => (v === undefined ? fallback : "#" + (v >>> 0).toString(16).padStart(8, "0"))
|
|
30
|
+
const isSurface = (n: { type: string }) => n.type === "native" || n.type === "video"
|
|
31
|
+
|
|
32
|
+
type Matrix = [number, number, number, number, number, number]
|
|
33
|
+
const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0]
|
|
34
|
+
/** `m` then `n` in the coordinate system `m` made (the painter's `transform`). */
|
|
35
|
+
const multiply = (m: Matrix, n: Matrix): Matrix => [
|
|
36
|
+
m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
|
|
37
|
+
m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
|
|
38
|
+
m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5],
|
|
39
|
+
]
|
|
40
|
+
const intersect = (a: Rect | null, b: Rect): Rect => {
|
|
41
|
+
if (!a) return b
|
|
42
|
+
const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y)
|
|
43
|
+
return { x, y, width: Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x), height: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where a node is: its box, the transform of everything above it, the clip of everything above it. */
|
|
47
|
+
type Place = { node: RenderNode, matrix: Matrix, clip: Rect | null }
|
|
48
|
+
|
|
49
|
+
export class BrowserUI extends TreeUI {
|
|
50
|
+
private readonly doc: Document
|
|
51
|
+
private readonly factories = new Map<string, WebViewFactory>()
|
|
52
|
+
private readonly versions = new Map<string, number>()
|
|
53
|
+
private readonly views = new Map<number, { name: string, params: string, handle: WebViewInstanceHandle }>()
|
|
54
|
+
/** The wrapper of a native node's view, by node id. */
|
|
55
|
+
private readonly wrappers = new Map<number, HTMLElement>()
|
|
56
|
+
/** What is under the picture now, by node id (a wrapper, or a video's own element). */
|
|
57
|
+
private readonly shown = new Map<number, HTMLElement>()
|
|
58
|
+
/** The fullscreen native destination (openView kind 3), null when none. */
|
|
59
|
+
private destination: { viewId: number, el: HTMLElement } | null = null
|
|
60
|
+
private field: { id: number, el: HTMLInputElement | HTMLTextAreaElement } | null = null
|
|
61
|
+
private placedFor: unknown = null
|
|
62
|
+
|
|
63
|
+
/** `under` and `over` are the host's layers below and above the painted canvas. */
|
|
64
|
+
constructor(M: WasmModule, host: Host, private readonly under: HTMLElement, private readonly over: HTMLElement, options: TreeUIOptions = {}) {
|
|
65
|
+
super(M, host, options)
|
|
66
|
+
this.doc = under.ownerDocument
|
|
67
|
+
const style = this.doc.createElement("style")
|
|
68
|
+
style.textContent = FIELD_STYLE
|
|
69
|
+
over.appendChild(style)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the page shows itself: the painter leaves it out. */
|
|
73
|
+
paintOptions(): PaintOptions { return { surfaces: true, editing: this.field?.id } }
|
|
74
|
+
|
|
75
|
+
// ---- native views: the plugins' web halves -----------------------------------------------------------
|
|
76
|
+
/** `version` = the contract version the half was generated from (1 for one written by hand). */
|
|
77
|
+
registerView(name: string, factory: WebViewFactory, version = 1): void { this.factories.set(name, factory); this.versions.set(name, version) }
|
|
78
|
+
/** A view the next bundle does not have (its plugin is not among the app's): unsupported again. */
|
|
79
|
+
unregisterView(name: string): void { this.factories.delete(name); this.versions.delete(name) }
|
|
80
|
+
isViewSupported(name: string): boolean { return this.factories.has(name) }
|
|
81
|
+
viewVersion(name: string): number { return this.versions.get(name) ?? 0 }
|
|
82
|
+
|
|
83
|
+
private ensureView(viewId: number, name: string, params: string): WebViewInstanceHandle | null {
|
|
84
|
+
const live = this.views.get(viewId)
|
|
85
|
+
// A new project world restarts the SDK's view ids from 1: another name or other params under a
|
|
86
|
+
// live id is an instance of the world that left.
|
|
87
|
+
if (live && (live.name !== name || live.params !== params)) this.destroyView(viewId)
|
|
88
|
+
else if (live) return live.handle
|
|
89
|
+
const factory = this.factories.get(name)
|
|
90
|
+
if (!factory) return null
|
|
91
|
+
let parsed: unknown = {}
|
|
92
|
+
try { parsed = params ? JSON.parse(params) : {} } catch { /* the runtime's JSON */ }
|
|
93
|
+
// An event's payload crosses as the value it is: the app and this host are the same JavaScript.
|
|
94
|
+
const handle = factory(parsed, { emit: (event, data) => this.M.jsb.viewEvent(viewId, event, data) }, this.doc)
|
|
95
|
+
handle.el.style.cssText += ";position:absolute;inset:0;width:100%;height:100%;"
|
|
96
|
+
this.views.set(viewId, { name, params, handle })
|
|
97
|
+
return handle
|
|
98
|
+
}
|
|
99
|
+
private destroyView(viewId: number): void {
|
|
100
|
+
const live = this.views.get(viewId)
|
|
101
|
+
if (!live) return
|
|
102
|
+
this.views.delete(viewId)
|
|
103
|
+
live.handle.destroy?.()
|
|
104
|
+
live.handle.el.remove()
|
|
105
|
+
}
|
|
106
|
+
/** `args` is the app's own array: a half reads it, never keeps or changes it. */
|
|
107
|
+
viewCall(viewId: number, method: string, args: unknown, onComplete: (result?: unknown) => void, onReject: (message: string) => void): void {
|
|
108
|
+
const live = this.views.get(viewId)
|
|
109
|
+
if (!live?.handle.call) { onReject(`NativeView call "${method}": no live instance or no call handler (viewId ${viewId})`); return }
|
|
110
|
+
try {
|
|
111
|
+
Promise.resolve(live.handle.call(method, Array.isArray(args) ? args : [])).then(
|
|
112
|
+
(result) => { if (result === undefined) onComplete(); else onComplete(result) },
|
|
113
|
+
(e) => onReject(e instanceof Error ? e.message : String(e)),
|
|
114
|
+
)
|
|
115
|
+
} catch (e) { onReject(e instanceof Error ? e.message : String(e)) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A native destination is a surface: it stays where it is until the runtime drops it (a screen
|
|
119
|
+
// may still be coming in over it), under every screen.
|
|
120
|
+
override openView(kind: number, node: number, id: number, viewName: string, paramsJson: string, above: boolean): void {
|
|
121
|
+
super.openView(kind, node, id, viewName, paramsJson, above)
|
|
122
|
+
if (kind !== TREE_DEST_NATIVE) return
|
|
123
|
+
this.dropDestination()
|
|
124
|
+
const handle = this.ensureView(id, viewName, paramsJson)
|
|
125
|
+
if (!handle) return
|
|
126
|
+
const el = this.doc.createElement("div")
|
|
127
|
+
el.style.cssText = "position:absolute;inset:0;pointer-events:auto;"
|
|
128
|
+
el.appendChild(handle.el)
|
|
129
|
+
this.under.prepend(el)
|
|
130
|
+
this.destination = { viewId: id, el }
|
|
131
|
+
}
|
|
132
|
+
override dropView(kind: number, node: number, id: number): void {
|
|
133
|
+
super.dropView(kind, node, id)
|
|
134
|
+
// …unless the one on screen now is that very view, presented again
|
|
135
|
+
if (kind === TREE_DEST_NATIVE && this.destination?.viewId === id && this.destKind !== TREE_DEST_NATIVE) this.dropDestination()
|
|
136
|
+
}
|
|
137
|
+
private dropDestination(): void {
|
|
138
|
+
this.destination?.el.remove()
|
|
139
|
+
this.destination = null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
override releaseNode(id: number): void {
|
|
143
|
+
const n = this.get(id)
|
|
144
|
+
if (this.field?.id === id) this.unmountField()
|
|
145
|
+
super.releaseNode(id)
|
|
146
|
+
this.shown.get(id)?.remove()
|
|
147
|
+
this.shown.delete(id)
|
|
148
|
+
this.wrappers.delete(id)
|
|
149
|
+
if (n?.type === "native" && n.viewId) this.destroyView(n.viewId)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- the field being edited ---------------------------------------------------------------------------
|
|
153
|
+
override focusInput(id: number): void {
|
|
154
|
+
super.focusInput(id)
|
|
155
|
+
if (this.focusedId() !== id || this.field?.id === id) return
|
|
156
|
+
this.unmountField()
|
|
157
|
+
const node = this.get(id)
|
|
158
|
+
if (!node) return
|
|
159
|
+
const el = this.doc.createElement(node.type === "textarea" ? "textarea" : "input")
|
|
160
|
+
el.className = "lc-field"
|
|
161
|
+
el.setAttribute("autocomplete", "off")
|
|
162
|
+
el.value = node.value
|
|
163
|
+
el.addEventListener("input", () => { if (this.focusedId() === id) this.setInputValue(id, el.value) })
|
|
164
|
+
el.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" && el.tagName === "INPUT") { e.preventDefault(); this.submitInput() } })
|
|
165
|
+
// the browser took the focus away (another window, a tab key): the app's field lost it too
|
|
166
|
+
el.addEventListener("blur", () => { if (this.field?.el === el && this.focusedId() === id) this.blurInput() })
|
|
167
|
+
this.over.appendChild(el)
|
|
168
|
+
this.field = { id, el }
|
|
169
|
+
this.placedFor = null // placed with the next frame; focused now, inside the gesture that asked
|
|
170
|
+
this.place()
|
|
171
|
+
el.focus({ preventScroll: true })
|
|
172
|
+
}
|
|
173
|
+
override blurInput(): void {
|
|
174
|
+
super.blurInput()
|
|
175
|
+
this.unmountField()
|
|
176
|
+
}
|
|
177
|
+
private unmountField(): void {
|
|
178
|
+
const field = this.field
|
|
179
|
+
if (!field) return
|
|
180
|
+
this.field = null
|
|
181
|
+
field.el.remove()
|
|
182
|
+
this.placedFor = null
|
|
183
|
+
}
|
|
184
|
+
override setPropertyString(id: number, prop: string, value: string | null): void {
|
|
185
|
+
super.setPropertyString(id, prop, value)
|
|
186
|
+
// a value the CODE set while the field is edited
|
|
187
|
+
if (prop === "value" && this.field?.id === id) { const v = this.get(id)?.value ?? ""; if (this.field.el.value !== v) this.field.el.value = v }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- placing ------------------------------------------------------------------------------------------
|
|
191
|
+
/** The nodes the page shows itself, where the frame has them. */
|
|
192
|
+
private find(): Place[] {
|
|
193
|
+
const found: Place[] = []
|
|
194
|
+
const editing = this.field?.id
|
|
195
|
+
const walk = (node: RenderNode, above: Matrix, clip: Rect | null) => {
|
|
196
|
+
let matrix = above
|
|
197
|
+
const t = node.style.transform
|
|
198
|
+
if (t) {
|
|
199
|
+
// the painter's: the node's matrix about its transform origin (its centre by default)
|
|
200
|
+
const o = node.style.transformOrigin
|
|
201
|
+
const cx = node.rect.x + (o ? (o.xPercent ? node.rect.width * o.x / 100 : o.x) : node.rect.width / 2)
|
|
202
|
+
const cy = node.rect.y + (o ? (o.yPercent ? node.rect.height * o.y / 100 : o.y) : node.rect.height / 2)
|
|
203
|
+
matrix = multiply(multiply(multiply(above, [1, 0, 0, 1, cx, cy]), t as unknown as Matrix), [1, 0, 0, 1, -cx, -cy])
|
|
204
|
+
}
|
|
205
|
+
if (isSurface(node) || node.id === editing) found.push({ node, matrix, clip })
|
|
206
|
+
const inner = node.clip ? intersect(clip, node.rect) : clip
|
|
207
|
+
for (const child of node.children) walk(child, matrix, inner)
|
|
208
|
+
}
|
|
209
|
+
for (const layer of this.frame()) walk(layer.tree, IDENTITY, null)
|
|
210
|
+
return found
|
|
211
|
+
}
|
|
212
|
+
private static put(el: HTMLElement, place: Place): void {
|
|
213
|
+
const { rect } = place.node
|
|
214
|
+
const s = el.style
|
|
215
|
+
s.position = "absolute"
|
|
216
|
+
s.left = "0"
|
|
217
|
+
s.top = "0"
|
|
218
|
+
s.width = rect.width + "px"
|
|
219
|
+
s.height = rect.height + "px"
|
|
220
|
+
s.transformOrigin = "0 0"
|
|
221
|
+
s.transform = `matrix(${place.matrix.join(",")}) translate(${rect.x}px,${rect.y}px)`
|
|
222
|
+
const c = place.clip
|
|
223
|
+
// the clip is in the frame's coordinates, the element's clip-path in its own box
|
|
224
|
+
s.clipPath = c ? `inset(${Math.max(0, c.y - rect.y)}px ${Math.max(0, rect.x + rect.width - (c.x + c.width))}px ${Math.max(0, rect.y + rect.height - (c.y + c.height))}px ${Math.max(0, c.x - rect.x)}px)` : ""
|
|
225
|
+
}
|
|
226
|
+
private surfaceElement(node: RenderNode): HTMLElement | null {
|
|
227
|
+
const n = this.get(node.id)
|
|
228
|
+
if (!n) return null
|
|
229
|
+
if (n.type === "video") {
|
|
230
|
+
const player = getPlayer(n.playerId)
|
|
231
|
+
return player instanceof HTMLVideoElement ? player : null
|
|
232
|
+
}
|
|
233
|
+
if (!n.viewId) return null
|
|
234
|
+
const handle = this.ensureView(n.viewId, n.viewName ?? "", n.viewParams ?? "")
|
|
235
|
+
if (!handle) return null
|
|
236
|
+
let wrapper = this.wrappers.get(n.id)
|
|
237
|
+
if (!wrapper) { wrapper = this.doc.createElement("div"); this.wrappers.set(n.id, wrapper) }
|
|
238
|
+
if (handle.el.parentElement !== wrapper) wrapper.replaceChildren(handle.el)
|
|
239
|
+
return wrapper
|
|
240
|
+
}
|
|
241
|
+
private styleField(el: HTMLInputElement | HTMLTextAreaElement, node: RenderNode): void {
|
|
242
|
+
const s = node.style
|
|
243
|
+
const st = el.style
|
|
244
|
+
const [pl, pt, pr, pb] = node.padding ?? [0, 0, 0, 0]
|
|
245
|
+
st.padding = `${pt}px ${pr}px ${pb}px ${pl}px`
|
|
246
|
+
st.color = hex(s.color, "#000000ff")
|
|
247
|
+
st.caretColor = st.color
|
|
248
|
+
const family = s.fontFamily ?? "Roboto"
|
|
249
|
+
st.fontFamily = this.host.resolveFontFamily?.(family) ?? family
|
|
250
|
+
st.fontSize = (s.fontSize ?? 14) + "px"
|
|
251
|
+
st.fontWeight = String(s.fontWeight ?? 400)
|
|
252
|
+
st.fontStyle = s.fontStyle ?? "normal"
|
|
253
|
+
st.letterSpacing = (s.letterSpacing ?? 0) + "px"
|
|
254
|
+
// a textarea's lines are the node's; an input is one line, centred in the field by the browser
|
|
255
|
+
st.lineHeight = el.tagName === "TEXTAREA" && s.lineHeight !== undefined ? s.lineHeight + "px" : "normal"
|
|
256
|
+
st.textAlign = s.textAlign === "center" ? "center" : s.textAlign === "end" ? "right" : "left"
|
|
257
|
+
st.borderRadius = "0"
|
|
258
|
+
el.placeholder = s.placeholder ?? ""
|
|
259
|
+
st.setProperty("--lc-placeholder", hex(s.placeholderColor, "#999999ff"))
|
|
260
|
+
if (el instanceof HTMLInputElement) { const type = s.inputType ?? "text"; if (el.type !== type) el.type = type }
|
|
261
|
+
}
|
|
262
|
+
/** After the paint: what the page shows itself goes where the frame has it. */
|
|
263
|
+
place(): void {
|
|
264
|
+
const frame = this.frame()
|
|
265
|
+
if (frame === this.placedFor) return
|
|
266
|
+
this.placedFor = frame
|
|
267
|
+
const seen = new Set<number>()
|
|
268
|
+
for (const place of this.find()) {
|
|
269
|
+
const { node } = place
|
|
270
|
+
if (node.id === this.field?.id) {
|
|
271
|
+
this.styleField(this.field.el, node)
|
|
272
|
+
BrowserUI.put(this.field.el, place)
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
const el = this.surfaceElement(node)
|
|
276
|
+
if (!el) continue
|
|
277
|
+
seen.add(node.id)
|
|
278
|
+
if (el.parentElement !== this.under) this.under.appendChild(el)
|
|
279
|
+
el.style.display = "block"
|
|
280
|
+
el.style.opacity = ""
|
|
281
|
+
el.style.pointerEvents = "auto" // where the picture has a window onto it
|
|
282
|
+
if (el instanceof HTMLVideoElement) el.style.objectFit = node.style.objectFit ?? "contain"
|
|
283
|
+
BrowserUI.put(el, place)
|
|
284
|
+
this.shown.set(node.id, el)
|
|
285
|
+
}
|
|
286
|
+
// what the frame no longer shows (a page that left, a node taken out): kept, not seen
|
|
287
|
+
for (const [id, el] of this.shown) if (!seen.has(id)) { el.style.display = "none"; this.shown.delete(id) }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- whose the pointer is --------------------------------------------------------------------------------
|
|
291
|
+
/** Whether the point is a surface's: the topmost thing there that takes input is a native view
|
|
292
|
+
* (or the native destination, with nothing of the UI over it) — the event is that view's. */
|
|
293
|
+
surfaceAt(x: number, y: number): boolean {
|
|
294
|
+
const layers = this.frame()
|
|
295
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
296
|
+
const layer = layers[i]!
|
|
297
|
+
if (layer.leaving) continue
|
|
298
|
+
const hit = hitTest(layer.tree, x, y, (n) => n.type === "native" || (this.get(n.id)?.interactive ?? false) || (this.get(n.id)?.editable ?? false) || !!n.scroll)
|
|
299
|
+
if (hit) return hit.type === "native"
|
|
300
|
+
if (layer.backdrop) return false
|
|
301
|
+
}
|
|
302
|
+
return this.destination !== null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
dispose(): void {
|
|
306
|
+
this.unmountField()
|
|
307
|
+
for (const id of [...this.views.keys()]) this.destroyView(id)
|
|
308
|
+
this.dropDestination()
|
|
309
|
+
for (const el of this.shown.values()) if (!(el instanceof HTMLVideoElement)) el.remove()
|
|
310
|
+
this.shown.clear()
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// What the browser renderer shows of the host's own: the toast strip.
|
|
2
|
+
|
|
3
|
+
/** A transient toast strip at the bottom of the root (duration in ms; ≤ 0 = 2 s). */
|
|
4
|
+
export function showToast(doc: Document, root: HTMLElement, message: string, duration: number) {
|
|
5
|
+
const el = doc.createElement("div")
|
|
6
|
+
el.textContent = message
|
|
7
|
+
el.style.cssText =
|
|
8
|
+
"position:absolute;left:50%;bottom:48px;transform:translateX(-50%);max-width:80%;padding:10px 16px;border-radius:8px;" +
|
|
9
|
+
"background:rgba(30,30,30,0.92);color:#fff;font:13px/1.4 system-ui,sans-serif;z-index:30;pointer-events:none;"
|
|
10
|
+
root.appendChild(el)
|
|
11
|
+
setTimeout(() => el.remove(), duration > 0 ? duration : 2000)
|
|
12
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The canvas renderer in the browser: the TreeUI (the runtime's HostUI table) painted to a 2D canvas
|
|
2
|
+
// that lies over the scene canvases, with a layer under it for the native views and the videos and
|
|
3
|
+
// a layer over it for the field being edited (./BrowserUI.ts). The host calls `render()` after each
|
|
4
|
+
// layout pass.
|
|
5
|
+
//
|
|
6
|
+
// The canvas is the host's: it sizes the backing store (device px, the host's page), the renderer
|
|
7
|
+
// only paints. Nothing here draws a keyboard or a device — the page is the app's window.
|
|
8
|
+
//
|
|
9
|
+
// POINTERS. The painted canvas takes no pointer itself (`pointer-events: none`): an event lands on
|
|
10
|
+
// what is under the pointer in the page — a native view, a scene canvas, the container — and the
|
|
11
|
+
// renderer meets it on its way down, at the container, in the capture phase. There the render tree
|
|
12
|
+
// says whose the point is. The UI's: the event goes to the TreeUI's routing (logical px), and
|
|
13
|
+
// stops before a view under the picture hears of it. A native view's: it goes on to the view.
|
|
14
|
+
import type { WasmModule } from "lecodes-web-runtime/src/gen/wasm"
|
|
15
|
+
import type { CanvasHost } from "lecodes-web-shared/src/canvas/createCanvasHost"
|
|
16
|
+
import { getUrl } from "lecodes-web-shared/src/utils/fetch"
|
|
17
|
+
import { viewScale } from "lecodes-web-shared/src/ui/viewScale"
|
|
18
|
+
import { BrowserHost } from "../host/BrowserHost"
|
|
19
|
+
import { Canvas2DPainter } from "../paint/Canvas2DPainter"
|
|
20
|
+
import { BrowserUI } from "./BrowserUI"
|
|
21
|
+
import { showToast } from "./chrome"
|
|
22
|
+
|
|
23
|
+
/** Hold this long before a press counts as a long press (the runtime asks the node's listener). */
|
|
24
|
+
const LONG_PRESS_MS = 500
|
|
25
|
+
|
|
26
|
+
export type SafeAreaEdge = "top" | "right" | "bottom" | "left"
|
|
27
|
+
/** Insets in logical px; `bars` = the edges that are exact-height system bars (`comfort-*` adds its
|
|
28
|
+
* knob past a bar instead of flooring against it). */
|
|
29
|
+
export type SafeArea = { top: number, right: number, bottom: number, left: number, bars?: readonly SafeAreaEdge[] }
|
|
30
|
+
|
|
31
|
+
export type CanvasRendererOptions = {
|
|
32
|
+
M: WasmModule
|
|
33
|
+
/** The canvas to paint on, over the scene canvases. */
|
|
34
|
+
canvas: HTMLCanvasElement
|
|
35
|
+
canvasHost?: CanvasHost
|
|
36
|
+
/** What the window loses to the on-screen keyboard, in logical px. */
|
|
37
|
+
keyboardHeight?: () => number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a page fires for one press besides its pointer events: stopped with them on the way to a
|
|
41
|
+
* view under the picture. */
|
|
42
|
+
const FOLLOWERS = ["mousedown", "mouseup", "click", "dblclick", "contextmenu", "touchstart", "touchmove", "touchend"] as const
|
|
43
|
+
|
|
44
|
+
export const createCanvasRenderer = (root: HTMLElement, options: CanvasRendererOptions) => {
|
|
45
|
+
const doc = root.ownerDocument
|
|
46
|
+
const { canvas } = options
|
|
47
|
+
canvas.style.display = "block"
|
|
48
|
+
canvas.style.pointerEvents = "none"
|
|
49
|
+
root.style.touchAction = "none"
|
|
50
|
+
const context = canvas.getContext("2d")!
|
|
51
|
+
|
|
52
|
+
const layer = () => {
|
|
53
|
+
const el = doc.createElement("div")
|
|
54
|
+
el.style.cssText = "position:absolute;inset:0;overflow:hidden;pointer-events:none;"
|
|
55
|
+
return el
|
|
56
|
+
}
|
|
57
|
+
const under = layer()
|
|
58
|
+
const over = layer()
|
|
59
|
+
root.insertBefore(under, canvas)
|
|
60
|
+
canvas.after(over)
|
|
61
|
+
|
|
62
|
+
// Uploaded-asset image srcs ({ _id }) resolve to blob urls through the fetch store; UIImage(canvas)
|
|
63
|
+
// surfaces through the canvas host.
|
|
64
|
+
const host = new BrowserHost({ resolveAssetUrl: (id) => getUrl(id), canvasSurfaces: options.canvasHost })
|
|
65
|
+
const ui = new BrowserUI(options.M, host, under, over, {
|
|
66
|
+
keyboardHeight: options.keyboardHeight ?? (() => 0),
|
|
67
|
+
onToast: (message, duration) => showToast(doc, root, message, duration),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// The painter bakes the resolution factor into its transform: a new one when the factor moves.
|
|
71
|
+
let factor = 1
|
|
72
|
+
let painter = new Canvas2DPainter(context, factor)
|
|
73
|
+
// A canvas that took a new size was WIPED: paint once even if nothing changed.
|
|
74
|
+
let wiped = true
|
|
75
|
+
const render = () => {
|
|
76
|
+
if (wiped || ui.needsPaint()) {
|
|
77
|
+
wiped = false
|
|
78
|
+
ui.paint(painter, ui.paintOptions())
|
|
79
|
+
}
|
|
80
|
+
ui.place()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The viewport in logical px and the device px per logical px; the canvas already has the size. */
|
|
84
|
+
const resize = (width: number, height: number, nextFactor: number) => {
|
|
85
|
+
if (width <= 0 || height <= 0) return
|
|
86
|
+
if (nextFactor !== factor) { factor = nextFactor; painter = new Canvas2DPainter(context, factor) }
|
|
87
|
+
wiped = true
|
|
88
|
+
ui.resize(width, height)
|
|
89
|
+
}
|
|
90
|
+
const setSafeArea = (area: SafeArea) => {
|
|
91
|
+
ui.setSafeArea([area.top, area.right, area.bottom, area.left], area.bars ?? [])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- pointers (logical px: clientX and the rect are VISUAL px under a CSS zoom) ----
|
|
95
|
+
const local = (e: { clientX: number, clientY: number }): [number, number] => {
|
|
96
|
+
const rect = root.getBoundingClientRect()
|
|
97
|
+
const scale = viewScale(root)
|
|
98
|
+
return [(e.clientX - rect.left) / scale, (e.clientY - rect.top) / scale]
|
|
99
|
+
}
|
|
100
|
+
const inField = (e: Event) => e.target instanceof Node && over.contains(e.target)
|
|
101
|
+
const toView = (e: Event) => e.target instanceof Node && under.contains(e.target)
|
|
102
|
+
/** The event is the UI's: a view under the picture does not hear of it. */
|
|
103
|
+
const keep = (e: Event) => { if (toView(e)) e.stopPropagation() }
|
|
104
|
+
|
|
105
|
+
/** The pointers the UI has (down on it, until they are up): theirs is every event that follows. */
|
|
106
|
+
const held = new Set<number>()
|
|
107
|
+
const longPress = new Map<number, number>()
|
|
108
|
+
const clearLongPress = (id: number) => { const t = longPress.get(id); if (t !== undefined) { clearTimeout(t); longPress.delete(id) } }
|
|
109
|
+
|
|
110
|
+
const onDown = (e: PointerEvent) => {
|
|
111
|
+
if (inField(e)) return // the field being edited: the browser's own caret and selection
|
|
112
|
+
const [x, y] = local(e)
|
|
113
|
+
if (ui.surfaceAt(x, y)) return
|
|
114
|
+
keep(e)
|
|
115
|
+
held.add(e.pointerId)
|
|
116
|
+
try { root.setPointerCapture(e.pointerId) } catch { /* detached */ }
|
|
117
|
+
ui.pointerDown(e.pointerId, x, y)
|
|
118
|
+
clearLongPress(e.pointerId)
|
|
119
|
+
longPress.set(e.pointerId, window.setTimeout(() => { longPress.delete(e.pointerId); ui.longPress(e.pointerId, x, y) }, LONG_PRESS_MS))
|
|
120
|
+
}
|
|
121
|
+
// A mouse move is also the cursor's hover (button or not): stored here, resolved once per frame
|
|
122
|
+
// (TreeUI.tickHover). A finger hovers nothing.
|
|
123
|
+
const onMove = (e: PointerEvent) => {
|
|
124
|
+
const [x, y] = local(e)
|
|
125
|
+
if (held.has(e.pointerId)) { keep(e); ui.pointerMove(e.pointerId, x, y) }
|
|
126
|
+
if (e.pointerType !== "mouse") return
|
|
127
|
+
if (inField(e) || (!held.has(e.pointerId) && ui.surfaceAt(x, y))) ui.pointerLeave()
|
|
128
|
+
else ui.pointerHover(x, y)
|
|
129
|
+
}
|
|
130
|
+
const onLeave = (e: PointerEvent) => { if (e.pointerType === "mouse") ui.pointerLeave() }
|
|
131
|
+
const onUp = (e: PointerEvent) => {
|
|
132
|
+
if (!held.delete(e.pointerId)) return
|
|
133
|
+
keep(e)
|
|
134
|
+
clearLongPress(e.pointerId)
|
|
135
|
+
const [x, y] = local(e)
|
|
136
|
+
ui.pointerUp(e.pointerId, x, y)
|
|
137
|
+
}
|
|
138
|
+
const onCancel = (e: PointerEvent) => {
|
|
139
|
+
if (!held.delete(e.pointerId)) return
|
|
140
|
+
clearLongPress(e.pointerId)
|
|
141
|
+
ui.pointerCancel(e.pointerId)
|
|
142
|
+
}
|
|
143
|
+
const onWheel = (e: WheelEvent) => {
|
|
144
|
+
if (inField(e)) return
|
|
145
|
+
const [x, y] = local(e)
|
|
146
|
+
if (ui.surfaceAt(x, y)) return
|
|
147
|
+
keep(e)
|
|
148
|
+
const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? ui.height : 1
|
|
149
|
+
if (ui.wheel(x, y, e.deltaX * k, e.deltaY * k)) e.preventDefault()
|
|
150
|
+
}
|
|
151
|
+
// What follows a press the UI took: not the view's either. A mouse press on the UI moves no
|
|
152
|
+
// focus and selects nothing (the field being edited keeps its caret while a button is pressed).
|
|
153
|
+
const onFollower = (e: Event) => {
|
|
154
|
+
if (inField(e)) return
|
|
155
|
+
const point = "clientX" in e ? (e as MouseEvent) : (e as TouchEvent).changedTouches?.[0]
|
|
156
|
+
if (!point) return
|
|
157
|
+
const [x, y] = local(point)
|
|
158
|
+
if (ui.surfaceAt(x, y)) return
|
|
159
|
+
keep(e)
|
|
160
|
+
if (e.type === "mousedown") e.preventDefault()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
root.addEventListener("pointerdown", onDown, true)
|
|
164
|
+
root.addEventListener("pointermove", onMove, true)
|
|
165
|
+
root.addEventListener("pointerleave", onLeave)
|
|
166
|
+
root.addEventListener("pointerup", onUp, true)
|
|
167
|
+
root.addEventListener("pointercancel", onCancel, true)
|
|
168
|
+
root.addEventListener("wheel", onWheel, { passive: false, capture: true })
|
|
169
|
+
for (const type of FOLLOWERS) root.addEventListener(type, onFollower, true)
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
ui,
|
|
173
|
+
render,
|
|
174
|
+
resize,
|
|
175
|
+
setSafeArea,
|
|
176
|
+
destroy() {
|
|
177
|
+
for (const id of longPress.keys()) clearLongPress(id)
|
|
178
|
+
root.removeEventListener("pointerdown", onDown, true)
|
|
179
|
+
root.removeEventListener("pointermove", onMove, true)
|
|
180
|
+
root.removeEventListener("pointerleave", onLeave)
|
|
181
|
+
root.removeEventListener("pointerup", onUp, true)
|
|
182
|
+
root.removeEventListener("pointercancel", onCancel, true)
|
|
183
|
+
root.removeEventListener("wheel", onWheel, true)
|
|
184
|
+
for (const type of FOLLOWERS) root.removeEventListener(type, onFollower, true)
|
|
185
|
+
ui.dispose()
|
|
186
|
+
under.remove()
|
|
187
|
+
over.remove()
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type CanvasRenderer = ReturnType<typeof createCanvasRenderer>
|