glass-easel-devtools-agent 0.9.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/src/backend.ts ADDED
@@ -0,0 +1,233 @@
1
+ /* eslint-disable arrow-body-style */
2
+
3
+ import * as glassEasel from 'glass-easel'
4
+ import parser from 'postcss-selector-parser'
5
+ import { selectorSpecificity, compare as selectorCompare } from '@csstools/selector-specificity'
6
+ import { tokenize, TokenType, stringify } from '@csstools/css-tokenizer'
7
+ import { backendUnsupported } from './utils'
8
+
9
+ export type BoundingClientRect = glassEasel.BoundingClientRect
10
+
11
+ export const getBoundingClientRect = (
12
+ ctx: glassEasel.GeneralBackendContext,
13
+ elem: glassEasel.GeneralBackendElement,
14
+ ): Promise<BoundingClientRect> => {
15
+ return new Promise<BoundingClientRect>((resolve) => {
16
+ if (ctx.mode === glassEasel.BackendMode.Domlike) {
17
+ const { left, top, width, height } = (
18
+ elem.getBoundingClientRect as () => BoundingClientRect
19
+ )()
20
+ resolve({ left, top, width, height })
21
+ } else if (!elem.getBoundingClientRect) {
22
+ backendUnsupported('Element#getBoundingClientRect')
23
+ } else {
24
+ elem.getBoundingClientRect((rect) => {
25
+ resolve(rect)
26
+ })
27
+ }
28
+ })
29
+ }
30
+
31
+ export const getAllComputedStyles = (
32
+ ctx: glassEasel.GeneralBackendContext,
33
+ elem: glassEasel.GeneralBackendElement,
34
+ ): Promise<{ properties: { name: string; value: string }[] }> => {
35
+ return new Promise((resolve) => {
36
+ let properties: { name: string; value: string }[] = []
37
+ if (ctx.mode === glassEasel.BackendMode.Domlike) {
38
+ if (typeof ctx.getAllComputedStyles === 'function') {
39
+ ctx.getAllComputedStyles(elem as glassEasel.domlikeBackend.Element, (ret) => {
40
+ properties = ret.properties
41
+ resolve({ properties })
42
+ })
43
+ } else {
44
+ backendUnsupported('Context#getAllComputedStyles')
45
+ }
46
+ } else {
47
+ if ('getAllComputedStyles' in elem) {
48
+ ;(elem as glassEasel.backend.Element).getAllComputedStyles!((ret) => {
49
+ properties = ret.properties
50
+ resolve({ properties })
51
+ })
52
+ } else {
53
+ backendUnsupported('Element#getAllComputedStyles')
54
+ }
55
+ }
56
+ })
57
+ }
58
+
59
+ export const getBoxModel = (
60
+ ctx: glassEasel.GeneralBackendContext,
61
+ elem: glassEasel.GeneralBackendElement,
62
+ ): Promise<{
63
+ margin: BoundingClientRect
64
+ border: BoundingClientRect
65
+ padding: BoundingClientRect
66
+ content: BoundingClientRect
67
+ }> => {
68
+ const parsePx = (v: string): number => {
69
+ if (!v.endsWith('px')) return NaN
70
+ return Number(v.slice(0, -2))
71
+ }
72
+ return new Promise<{
73
+ margin: BoundingClientRect
74
+ border: BoundingClientRect
75
+ padding: BoundingClientRect
76
+ content: BoundingClientRect
77
+ }>((resolve) => {
78
+ if ('getBoxModel' in elem) {
79
+ // if there is `getBoxModel` call, use it
80
+ elem.getBoxModel!((ret) => {
81
+ resolve(ret)
82
+ })
83
+ } else {
84
+ // otherwise, use `getAllComputedStyles` to emulate
85
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises, promise/catch-or-return, promise/always-return
86
+ Promise.resolve().then(async () => {
87
+ const border = await getBoundingClientRect(ctx, elem)
88
+ const cs = await getAllComputedStyles(ctx, elem)
89
+ let marginLeft = NaN
90
+ let marginTop = NaN
91
+ let marginRight = NaN
92
+ let marginBottom = NaN
93
+ let paddingLeft = NaN
94
+ let paddingTop = NaN
95
+ let paddingRight = NaN
96
+ let paddingBottom = NaN
97
+ let borderLeft = NaN
98
+ let borderTop = NaN
99
+ let borderRight = NaN
100
+ let borderBottom = NaN
101
+ cs.properties.forEach(({ name, value }) => {
102
+ if (name === 'margin-left') marginLeft = parsePx(value)
103
+ if (name === 'margin-top') marginTop = parsePx(value)
104
+ if (name === 'margin-right') marginRight = parsePx(value)
105
+ if (name === 'margin-bottom') marginBottom = parsePx(value)
106
+ if (name === 'padding-left') paddingLeft = parsePx(value)
107
+ if (name === 'padding-top') paddingTop = parsePx(value)
108
+ if (name === 'padding-right') paddingRight = parsePx(value)
109
+ if (name === 'padding-bottom') paddingBottom = parsePx(value)
110
+ if (name === 'border-left-width') borderLeft = parsePx(value)
111
+ if (name === 'border-top-width') borderTop = parsePx(value)
112
+ if (name === 'border-right-width') borderRight = parsePx(value)
113
+ if (name === 'border-bottom-width') borderBottom = parsePx(value)
114
+ })
115
+ const margin = {
116
+ left: border.left - marginLeft,
117
+ top: border.top - marginTop,
118
+ width: border.width + marginLeft + marginRight,
119
+ height: border.height + marginTop + marginBottom,
120
+ }
121
+ const padding = {
122
+ left: border.left + borderLeft,
123
+ top: border.top + borderTop,
124
+ width: border.width - borderLeft - borderRight,
125
+ height: border.height - borderTop - borderBottom,
126
+ }
127
+ const content = {
128
+ left: padding.left + paddingLeft,
129
+ top: padding.top + paddingTop,
130
+ width: padding.width - paddingLeft - paddingRight,
131
+ height: padding.height - paddingTop - paddingBottom,
132
+ }
133
+ resolve({
134
+ margin,
135
+ border,
136
+ padding,
137
+ content,
138
+ })
139
+ })
140
+ }
141
+ })
142
+ }
143
+
144
+ export const getMatchedRules = (
145
+ ctx: glassEasel.GeneralBackendContext,
146
+ elem: glassEasel.GeneralBackendElement,
147
+ ): Promise<{
148
+ inline: glassEasel.CSSProperty[]
149
+ inlineText?: string
150
+ rules: glassEasel.CSSRule[]
151
+ crossOriginFailing?: boolean
152
+ }> => {
153
+ const parseNameValueStr = (cssText: string) => {
154
+ const ret: { name: string; value: string }[] = []
155
+ const tokens = tokenize({ css: cssText })
156
+ if (tokens.length === 0) return null
157
+ let nameStart = 0
158
+ for (let i = 0; i < tokens.length; i += 1) {
159
+ const t = tokens[i]
160
+ if (t[0] === TokenType.Colon) {
161
+ const name = stringify(...tokens.slice(nameStart, i))
162
+ i += 1
163
+ const valueStart = i
164
+ for (; i < tokens.length; i += 1) {
165
+ const t = tokens[i]
166
+ if (t[0] === TokenType.Semicolon) break
167
+ }
168
+ const value = stringify(...tokens.slice(valueStart, i))
169
+ ret.push({ name, value })
170
+ nameStart = i + 1
171
+ }
172
+ }
173
+ return ret
174
+ }
175
+ const calcRuleWeight = (rules: glassEasel.CSSRule[]): glassEasel.CSSRule[] => {
176
+ const rulesWithSelector = rules.map((rule) => {
177
+ if (rule.propertyText) {
178
+ rule.properties = parseNameValueStr(rule.propertyText) ?? rule.properties
179
+ }
180
+ const ps = parser().astSync(rule.selector)
181
+ const specificity = selectorSpecificity(ps)
182
+ return [specificity, rule] as const
183
+ })
184
+ rulesWithSelector.sort(([aSpec, aRule], [bSpec, bRule]) => {
185
+ const highBitsDiff = (aRule.weightHighBits || 0) - (bRule.weightHighBits || 0)
186
+ if (highBitsDiff !== 0) return highBitsDiff
187
+ if (aRule.weightLowBits !== undefined || bRule.weightLowBits !== undefined) {
188
+ const lowBitsDiff = (aRule.weightLowBits ?? -1) - (bRule.weightLowBits ?? -1)
189
+ if (lowBitsDiff !== 0) return lowBitsDiff
190
+ }
191
+ const selDiff = selectorCompare(aSpec, bSpec)
192
+ if (selDiff !== 0) return selDiff
193
+ const sheetDiff = aRule.sheetIndex - bRule.sheetIndex
194
+ if (sheetDiff !== 0) return sheetDiff
195
+ const ruleDiff = aRule.ruleIndex - bRule.ruleIndex
196
+ return ruleDiff
197
+ })
198
+ return rulesWithSelector.map(([_sel, rule]) => rule).reverse()
199
+ }
200
+ return new Promise((resolve) => {
201
+ if (ctx.mode === glassEasel.BackendMode.Domlike) {
202
+ if (typeof ctx.getMatchedRules === 'function') {
203
+ try {
204
+ ctx.getMatchedRules(elem as glassEasel.domlikeBackend.Element, (ret) => {
205
+ ret.rules = calcRuleWeight(ret.rules)
206
+ if (ret.inlineText) {
207
+ ret.inline = parseNameValueStr(ret.inlineText) ?? ret.inline
208
+ }
209
+ resolve(ret)
210
+ })
211
+ } catch (err) {
212
+ // this may throw when reading cross-origin stylesheets
213
+ resolve({
214
+ inline: [],
215
+ rules: [],
216
+ crossOriginFailing: true,
217
+ })
218
+ }
219
+ } else {
220
+ backendUnsupported('Context#getMatchedRules')
221
+ }
222
+ } else {
223
+ if ('getMatchedRules' in elem) {
224
+ ;(elem as glassEasel.backend.Element).getMatchedRules!((ret) => {
225
+ ret.rules = calcRuleWeight(ret.rules)
226
+ resolve(ret)
227
+ })
228
+ } else {
229
+ backendUnsupported('Element#getMatchedRules')
230
+ }
231
+ }
232
+ })
233
+ }
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type * as glassEasel from 'glass-easel'
2
+ import type {
3
+ AgentEventKind,
4
+ AgentRequestKind,
5
+ AgentRecvMessage,
6
+ AgentSendMessage,
7
+ } from './protocol/index'
8
+ import { MountPointsManager } from './mount_point'
9
+ import { overlayCompDef, OverlayManager } from './overlay'
10
+ import { debug } from './utils'
11
+
12
+ export * as protocol from './protocol/index'
13
+
14
+ export interface MessageChannel {
15
+ send(data: AgentSendMessage): void
16
+ recv(listener: (data: AgentRecvMessage) => void): void
17
+ }
18
+
19
+ export class Connection {
20
+ private messageChannel: MessageChannel
21
+ private requestHandlers = Object.create(null) as Record<string, (detail: any) => Promise<any>>
22
+ readonly overlayManager: OverlayManager
23
+
24
+ constructor(messageChannel: MessageChannel) {
25
+ this.messageChannel = messageChannel
26
+ messageChannel.recv((data) => {
27
+ if (data.kind === 'request') {
28
+ debug(`recv request ${data.id}`, data.name, data.detail)
29
+ this.recvRequest(data.id, data.name, data.detail)
30
+ }
31
+ })
32
+ this.overlayManager = new OverlayManager()
33
+ }
34
+
35
+ private recvRequest(id: number, name: string, detail: unknown) {
36
+ const handler = this.requestHandlers[name]
37
+ if (!handler) {
38
+ const data: AgentSendMessage = {
39
+ kind: 'error',
40
+ id,
41
+ message: 'invalid request name',
42
+ }
43
+ this.messageChannel.send(data)
44
+ return
45
+ }
46
+ handler
47
+ .call(this, detail)
48
+ .then((ret: unknown) => {
49
+ debug(`send response ${id}`, ret)
50
+ const data: AgentSendMessage = { kind: 'response', id, detail: ret }
51
+ this.messageChannel.send(data)
52
+ return undefined
53
+ })
54
+ .catch((err) => {
55
+ const data: AgentSendMessage = {
56
+ kind: 'error',
57
+ id,
58
+ message: err instanceof Error ? err.message : '',
59
+ stack: err instanceof Error ? err.stack : '',
60
+ }
61
+ this.messageChannel.send(data)
62
+ })
63
+ }
64
+
65
+ setRequestHandler<T extends keyof AgentRequestKind>(
66
+ name: T,
67
+ handler: (detail: AgentRequestKind[T]['request']) => Promise<AgentRequestKind[T]['response']>,
68
+ ) {
69
+ this.requestHandlers[name] = handler
70
+ }
71
+
72
+ sendEvent<T extends keyof AgentEventKind>(name: T, detail: AgentEventKind[T]['detail']) {
73
+ debug('send event', name, detail)
74
+ const data: AgentSendMessage = { kind: 'event', name, detail }
75
+ this.messageChannel.send(data)
76
+ }
77
+
78
+ getOverlayComponent(mp: glassEasel.GeneralBackendContext) {
79
+ return this.overlayManager.get(mp).asInstanceOf(overlayCompDef)!
80
+ }
81
+ }
82
+
83
+ class InspectorDevToolsImpl implements glassEasel.InspectorDevTools {
84
+ private conn: Connection
85
+ private mountPoints: MountPointsManager
86
+ private enabled = false
87
+
88
+ constructor(conn: Connection) {
89
+ this.conn = conn
90
+ this.mountPoints = new MountPointsManager(conn)
91
+ conn.setRequestHandler('DOM.enable', async () => {
92
+ if (this.enabled) return
93
+ this.enabled = true
94
+ })
95
+ }
96
+
97
+ // eslint-disable-next-line class-methods-use-this
98
+ addMountPoint(root: glassEasel.Element, env: glassEasel.MountPointEnv): void {
99
+ debug('attach mount point', root)
100
+ this.mountPoints.attach(root, env)
101
+ }
102
+
103
+ // eslint-disable-next-line class-methods-use-this
104
+ removeMountPoint(root: glassEasel.GeneralComponent): void {
105
+ debug('detach mount point', root)
106
+ this.mountPoints.detach(root)
107
+ }
108
+ }
109
+
110
+ export const getDevTools = (channel: MessageChannel): glassEasel.DevTools => {
111
+ const connection = new Connection(channel)
112
+ return {
113
+ inspector: new InspectorDevToolsImpl(connection),
114
+ }
115
+ }