@openpen/module-api 1.0.0 → 1.1.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/README.md +9 -2
- package/package.json +9 -3
- package/packages/module-api/CHANGELOG.md +27 -0
- package/src/cursors.ts +369 -0
- package/src/index.ts +25 -0
- package/src/inject-keys.ts +11 -0
- package/src/types/contributions.ts +37 -2
- package/src/uikit/components/AppSegmented.vue +2 -0
- package/src/uikit/components/DialogHost.vue +13 -1
package/README.md
CHANGED
|
@@ -21,8 +21,11 @@ npm install @openpen/module-api zod
|
|
|
21
21
|
import { defineModule, z } from '@openpen/module-api'
|
|
22
22
|
|
|
23
23
|
export default defineModule({
|
|
24
|
-
id: 'my-module',
|
|
25
|
-
|
|
24
|
+
id: '@scope/my-module',
|
|
25
|
+
version: '0.1.0',
|
|
26
|
+
metadata: {
|
|
27
|
+
name: { en: 'My Module', 'zh-Hant': '我的模組' },
|
|
28
|
+
},
|
|
26
29
|
settingsSchema: z.object({
|
|
27
30
|
enabled: z.boolean().default(true),
|
|
28
31
|
}),
|
|
@@ -44,9 +47,13 @@ export default defineModule({
|
|
|
44
47
|
| Method | Description |
|
|
45
48
|
|--------|-------------|
|
|
46
49
|
| `ctx.getSettings<T>()` | Read this module's parsed settings (requires `settingsSchema` to be declared) |
|
|
50
|
+
| `ctx.updateSettings(patch)` | Persist a partial update to this module's settings |
|
|
51
|
+
| `ctx.onSettingsChange<T>(cb)` | Subscribe to settings changes; returns an unsubscribe function |
|
|
47
52
|
| `ctx.callMain(action, payload?)` | Call this module's main-side handler |
|
|
48
53
|
| `ctx.onDispose(fn)` | Register a cleanup callback (called in reverse order when the module is unloaded) |
|
|
49
54
|
| `ctx.notify(payload)` | Show a short-lived toast in the overlay window; returns `NotifyHandle` for early `dismiss()` |
|
|
55
|
+
| `ctx.t(key, params?)` | Resolve an i18n key against this module's `contributes.locales` for the current host locale |
|
|
56
|
+
| `ctx.locale` | The host's current locale (e.g. `'en'`, `'zh-Hant'`) |
|
|
50
57
|
|
|
51
58
|
```typescript
|
|
52
59
|
import { defineModule } from '@openpen/module-api'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpen/module-api",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "OpenPen Module API — public contract for built-in modules and third-party plugins",
|
|
5
5
|
"author": "navishachiku <navishachiku@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/openpen-platform/openpen#readme",
|
|
@@ -42,9 +42,15 @@
|
|
|
42
42
|
"node": ">=20",
|
|
43
43
|
"npm": ">=9"
|
|
44
44
|
},
|
|
45
|
-
"keywords": [
|
|
45
|
+
"keywords": [
|
|
46
|
+
"openpen",
|
|
47
|
+
"plugin",
|
|
48
|
+
"module",
|
|
49
|
+
"sdk"
|
|
50
|
+
],
|
|
46
51
|
"dependencies": {
|
|
47
|
-
"
|
|
52
|
+
"dompurify": "^3.4.5",
|
|
53
|
+
"reka-ui": "~2.9.7"
|
|
48
54
|
},
|
|
49
55
|
"peerDependencies": {
|
|
50
56
|
"vue": "^3.5.0",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.1.0](https://github.com/openpen-platform/openpen/compare/v1.0.0...v1.1.0) (2026-05-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **cursors:** DOM overlay cursor system + plugin SDK doc gaps ([2f77192](https://github.com/openpen-platform/openpen/commit/2f771927fb94586b0ceef58e07fcb5220ed40c02))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **control-bar:** pause auto-collapse timer while any dialog is open ([#1](https://github.com/openpen-platform/openpen/issues/1)) ([add84ff](https://github.com/openpen-platform/openpen/commit/add84ff89f1ad5135c7371fd7d4bfd64b1ef2395))
|
|
14
|
+
|
|
15
|
+
## 1.0.0 (2026-05-14)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* initial open source release ([f58abd7](https://github.com/openpen-platform/openpen/commit/f58abd7c4f3e12be9c88cdad23c61c82f9db7a7f))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **settings:** persist single Color Mode change to disk ([db9e6dd](https://github.com/openpen-platform/openpen/commit/db9e6dda0b9e584f349fb31da4349bdfaec0a84a))
|
|
26
|
+
|
|
27
|
+
## Changelog
|
package/src/cursors.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor compilation + sanitisation helpers shared by host runtime and
|
|
3
|
+
* plugin SDK.
|
|
4
|
+
*
|
|
5
|
+
* Three closure layers gate every cursor entry before it reaches
|
|
6
|
+
* `v-html`: R1–R12 shape validation here, DOMPurify content sanitisation
|
|
7
|
+
* inside compileCursor, and a deep-clone + freeze in the contribution
|
|
8
|
+
* store. Removing any one of them re-opens an attack surface the others
|
|
9
|
+
* cannot close.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import DOMPurify from 'dompurify'
|
|
13
|
+
import type {
|
|
14
|
+
CursorContribution,
|
|
15
|
+
CursorSpec,
|
|
16
|
+
Hotspot,
|
|
17
|
+
PngCursorSpec,
|
|
18
|
+
SvgCursorSpec,
|
|
19
|
+
} from './types/contributions'
|
|
20
|
+
|
|
21
|
+
export interface CompiledCursor {
|
|
22
|
+
/** Sanitised SVG / HTML fragment to mount as `innerHTML` on the cursor element. */
|
|
23
|
+
svgMarkup: string
|
|
24
|
+
/** Hotspot offset, in CSS pixels relative to the markup's top-left. */
|
|
25
|
+
hotspot: Hotspot
|
|
26
|
+
/** CSS keyword fallback retained for legacy callers. */
|
|
27
|
+
fallbackCssKeyword: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CursorResolutionContext {
|
|
31
|
+
/**
|
|
32
|
+
* Hostname-encoded plugin id (`@acme/x` → `acme.x`) used to assemble
|
|
33
|
+
* `openpen-plugin://<hostname>/<path>` URLs for relative-path specs.
|
|
34
|
+
* Undefined for built-in modules — path-form specs without a hostname
|
|
35
|
+
* resolve to `null` (host falls back to its default cursor).
|
|
36
|
+
*/
|
|
37
|
+
pluginHostname?: string
|
|
38
|
+
/** Injection point for tests; defaults to global `fetch`. */
|
|
39
|
+
fetchImpl?: typeof fetch
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* W3C CSS UI Level 3 cursor keywords. The legacy `cursor: string` form
|
|
44
|
+
* MUST match one of these (case-insensitive after trim); everything else
|
|
45
|
+
* is rejected by R2 / R12.
|
|
46
|
+
*/
|
|
47
|
+
export const SAFE_CURSOR_KEYWORDS: ReadonlySet<string> = new Set([
|
|
48
|
+
'auto', 'default', 'none',
|
|
49
|
+
'context-menu', 'help', 'pointer', 'progress', 'wait',
|
|
50
|
+
'cell', 'crosshair', 'text', 'vertical-text',
|
|
51
|
+
'alias', 'copy', 'move', 'no-drop', 'not-allowed', 'grab', 'grabbing',
|
|
52
|
+
'e-resize', 'n-resize', 'ne-resize', 'nw-resize',
|
|
53
|
+
's-resize', 'se-resize', 'sw-resize', 'w-resize',
|
|
54
|
+
'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize',
|
|
55
|
+
'col-resize', 'row-resize', 'all-scroll',
|
|
56
|
+
'zoom-in', 'zoom-out',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const FORBIDDEN_CSS_LOAD_PATTERN = /url\s*\(|image-set\s*\(|-webkit-image-set\s*\(/i
|
|
60
|
+
|
|
61
|
+
const DEFAULT_FALLBACK_KEYWORD = 'crosshair'
|
|
62
|
+
|
|
63
|
+
export function isSafeRelativePath(p: unknown): p is string {
|
|
64
|
+
if (typeof p !== 'string' || p.length === 0) return false
|
|
65
|
+
// Bare `:` catches scheme prefixes without `://` (e.g. `data:`).
|
|
66
|
+
if (p.includes(':')) return false
|
|
67
|
+
if (p.startsWith('/')) return false
|
|
68
|
+
for (const seg of p.split('/')) {
|
|
69
|
+
if (seg === '' || seg === '..') return false
|
|
70
|
+
}
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function pluginHostname(moduleId: string): string {
|
|
75
|
+
return moduleId.replace(/^@/, '').replace(/\//g, '.')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function pluginAssetUrl(moduleId: string, relativePath: string): string {
|
|
79
|
+
if (!isSafeRelativePath(relativePath)) {
|
|
80
|
+
throw new Error(`Unsafe asset path: ${relativePath}`)
|
|
81
|
+
}
|
|
82
|
+
return `openpen-plugin://${pluginHostname(moduleId)}/${relativePath}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sanitise SVG markup before it reaches `v-html`. Rationale for each
|
|
87
|
+
* flag: `USE_PROFILES: { svg: true }` covers script tags, on* handlers,
|
|
88
|
+
* and javascript: hrefs; `FORBID_TAGS: ['foreignObject']` closes the
|
|
89
|
+
* mXSS escape hatch the SVG profile alone does not; turning off
|
|
90
|
+
* `ALLOW_UNKNOWN_PROTOCOLS` blocks `<use href="data:...">` and friends.
|
|
91
|
+
*/
|
|
92
|
+
export function sanitizeSvgMarkup(raw: string): string {
|
|
93
|
+
return DOMPurify.sanitize(raw, {
|
|
94
|
+
USE_PROFILES: { svg: true },
|
|
95
|
+
FORBID_TAGS: ['foreignObject'],
|
|
96
|
+
// Cursor SVGs use presentation attributes (fill/stroke/etc.), never
|
|
97
|
+
// inline `style`. Forbidding it strips legacy `expression()` CSS
|
|
98
|
+
// smuggling vectors (S7) without touching anything we draw.
|
|
99
|
+
FORBID_ATTR: ['style'],
|
|
100
|
+
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveHotspot(spec: SvgCursorSpec | PngCursorSpec): Hotspot {
|
|
105
|
+
const h = spec.hotspot
|
|
106
|
+
if (!h || typeof h !== 'object') return { x: 0, y: 0 }
|
|
107
|
+
const x = typeof h.x === 'number' && Number.isFinite(h.x) ? h.x : 0
|
|
108
|
+
const y = typeof h.y === 'number' && Number.isFinite(h.y) ? h.y : 0
|
|
109
|
+
return { x, y }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveFallback(spec: SvgCursorSpec | PngCursorSpec): string {
|
|
113
|
+
return typeof spec.fallback === 'string' && spec.fallback.length > 0
|
|
114
|
+
? spec.fallback
|
|
115
|
+
: DEFAULT_FALLBACK_KEYWORD
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function escapeHtmlAttr(s: string): string {
|
|
119
|
+
return s
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/"/g, '"')
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Convert a frozen, shape-validated CursorContribution.cursor into render
|
|
128
|
+
* data ready for `v-html`. Returns `null` for legacy keyword strings
|
|
129
|
+
* (caller falls back to the host default cursor) and for any spec the
|
|
130
|
+
* sanitiser empties out completely.
|
|
131
|
+
*
|
|
132
|
+
* Async because path-form SVG specs are fetched through `openpen-plugin://`
|
|
133
|
+
* at compile time; inline + PNG branches resolve synchronously inside the
|
|
134
|
+
* returned Promise.
|
|
135
|
+
*/
|
|
136
|
+
export async function compileCursor(
|
|
137
|
+
spec: CursorSpec,
|
|
138
|
+
context: CursorResolutionContext = {},
|
|
139
|
+
): Promise<CompiledCursor | null> {
|
|
140
|
+
if (typeof spec === 'string') return null
|
|
141
|
+
if (!spec || typeof spec !== 'object') return null
|
|
142
|
+
|
|
143
|
+
if ('svg' in spec && typeof spec.svg === 'string') {
|
|
144
|
+
const hotspot = resolveHotspot(spec)
|
|
145
|
+
const fallbackCssKeyword = resolveFallback(spec)
|
|
146
|
+
const trimmed = spec.svg.trim()
|
|
147
|
+
|
|
148
|
+
if (trimmed.startsWith('<')) {
|
|
149
|
+
const sanitised = sanitizeSvgMarkup(trimmed)
|
|
150
|
+
if (!sanitised) return null
|
|
151
|
+
return { svgMarkup: sanitised, hotspot, fallbackCssKeyword }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!context.pluginHostname || !isSafeRelativePath(trimmed)) return null
|
|
155
|
+
const url = `openpen-plugin://${context.pluginHostname}/${trimmed}`
|
|
156
|
+
const fetchImpl = context.fetchImpl
|
|
157
|
+
?? (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : undefined)
|
|
158
|
+
if (!fetchImpl) return null
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const resp = await fetchImpl(url)
|
|
162
|
+
if (!resp.ok) return null
|
|
163
|
+
const raw = await resp.text()
|
|
164
|
+
const sanitised = sanitizeSvgMarkup(raw)
|
|
165
|
+
if (!sanitised) return null
|
|
166
|
+
return { svgMarkup: sanitised, hotspot, fallbackCssKeyword }
|
|
167
|
+
} catch {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if ('png' in spec && typeof spec.png === 'string') {
|
|
173
|
+
const hotspot = resolveHotspot(spec)
|
|
174
|
+
const fallbackCssKeyword = resolveFallback(spec)
|
|
175
|
+
const trimmed = spec.png.trim()
|
|
176
|
+
if (!context.pluginHostname || !isSafeRelativePath(trimmed)) return null
|
|
177
|
+
const url = escapeHtmlAttr(`openpen-plugin://${context.pluginHostname}/${trimmed}`)
|
|
178
|
+
const svgMarkup =
|
|
179
|
+
`<img src="${url}" alt="" style="display:block;max-width:64px;max-height:64px;" />`
|
|
180
|
+
return { svgMarkup, hotspot, fallbackCssKeyword }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type CursorSanitizeRule =
|
|
187
|
+
| 'R1' | 'R2' | 'R3' | 'R4' | 'R5' | 'R6'
|
|
188
|
+
| 'R7' | 'R8' | 'R9' | 'R10' | 'R11' | 'R12'
|
|
189
|
+
|
|
190
|
+
export interface CursorSanitizeDiagnostic {
|
|
191
|
+
/** Contribution id, or `'<unknown>'` if `id` was missing / invalid. */
|
|
192
|
+
id: string
|
|
193
|
+
rule: CursorSanitizeRule
|
|
194
|
+
reason: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface CursorSanitizeOptions {
|
|
198
|
+
/**
|
|
199
|
+
* Built-in modules (host bundle) cannot use path forms because they
|
|
200
|
+
* have no `openpen-plugin://<hostname>` namespace. R7 / R10 reject
|
|
201
|
+
* such contributions for built-ins.
|
|
202
|
+
*/
|
|
203
|
+
isBuiltin?: boolean
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface CursorSanitizeResult {
|
|
207
|
+
cleaned: CursorContribution[]
|
|
208
|
+
diagnostics: CursorSanitizeDiagnostic[]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Shape-validate raw cursor contributions before they reach the
|
|
213
|
+
* contribution-store. This is the R1–R12 gate; SVG markup content is
|
|
214
|
+
* sanitised later inside {@link compileCursor} via DOMPurify.
|
|
215
|
+
*/
|
|
216
|
+
export function sanitizeCursorContributions(
|
|
217
|
+
cursors: unknown,
|
|
218
|
+
options: CursorSanitizeOptions = {},
|
|
219
|
+
): CursorSanitizeResult {
|
|
220
|
+
const cleaned: CursorContribution[] = []
|
|
221
|
+
const diagnostics: CursorSanitizeDiagnostic[] = []
|
|
222
|
+
|
|
223
|
+
if (!Array.isArray(cursors)) return { cleaned, diagnostics }
|
|
224
|
+
|
|
225
|
+
for (const raw of cursors) {
|
|
226
|
+
if (raw === null || typeof raw !== 'object') {
|
|
227
|
+
diagnostics.push({ id: '<unknown>', rule: 'R1', reason: 'contribution is not an object' })
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const entry = raw as { id?: unknown; cursor?: unknown }
|
|
232
|
+
const id = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : '<unknown>'
|
|
233
|
+
|
|
234
|
+
if (id === '<unknown>') {
|
|
235
|
+
diagnostics.push({ id, rule: 'R1', reason: 'missing or empty `id`' })
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { cursor } = entry
|
|
240
|
+
|
|
241
|
+
if (typeof cursor === 'string') {
|
|
242
|
+
const trimmedLower = cursor.trim().toLowerCase()
|
|
243
|
+
if (FORBIDDEN_CSS_LOAD_PATTERN.test(trimmedLower)) {
|
|
244
|
+
diagnostics.push({
|
|
245
|
+
id,
|
|
246
|
+
rule: 'R12',
|
|
247
|
+
reason: 'string contains url() / image-set() / -webkit-image-set(); loads external resource',
|
|
248
|
+
})
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
if (!SAFE_CURSOR_KEYWORDS.has(trimmedLower)) {
|
|
252
|
+
diagnostics.push({
|
|
253
|
+
id,
|
|
254
|
+
rule: 'R2',
|
|
255
|
+
reason: `string '${trimmedLower}' is not in the safe CSS cursor keyword whitelist`,
|
|
256
|
+
})
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
cleaned.push({ id, cursor: trimmedLower })
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
|
|
264
|
+
diagnostics.push({ id, rule: 'R1', reason: 'cursor field is neither string nor plain object' })
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const cursorObj = cursor as Record<string, unknown>
|
|
269
|
+
const hasSvg = Object.prototype.hasOwnProperty.call(cursorObj, 'svg')
|
|
270
|
+
const hasPng = Object.prototype.hasOwnProperty.call(cursorObj, 'png')
|
|
271
|
+
|
|
272
|
+
if (!hasSvg && !hasPng) {
|
|
273
|
+
diagnostics.push({ id, rule: 'R3', reason: 'cursor object has neither `svg` nor `png` key' })
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
if (hasSvg && hasPng) {
|
|
277
|
+
diagnostics.push({ id, rule: 'R4', reason: 'cursor object has both `svg` and `png` keys (ambiguous)' })
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (hasSvg) {
|
|
282
|
+
const svg = cursorObj.svg
|
|
283
|
+
if (typeof svg !== 'string') {
|
|
284
|
+
diagnostics.push({ id, rule: 'R5', reason: '`svg` value is not a string' })
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
const trimmed = svg.trim()
|
|
288
|
+
const isInline = trimmed.startsWith('<')
|
|
289
|
+
if (!isInline) {
|
|
290
|
+
if (!isSafeRelativePath(trimmed)) {
|
|
291
|
+
diagnostics.push({
|
|
292
|
+
id,
|
|
293
|
+
rule: 'R6',
|
|
294
|
+
reason: '`svg` value is non-inline and not a safe relative path',
|
|
295
|
+
})
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
if (options.isBuiltin) {
|
|
299
|
+
diagnostics.push({
|
|
300
|
+
id,
|
|
301
|
+
rule: 'R7',
|
|
302
|
+
reason: 'built-in modules cannot use path-form `svg` (no openpen-plugin hostname)',
|
|
303
|
+
})
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (hasPng) {
|
|
310
|
+
const png = cursorObj.png
|
|
311
|
+
if (typeof png !== 'string') {
|
|
312
|
+
diagnostics.push({ id, rule: 'R8', reason: '`png` value is not a string' })
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
if (!isSafeRelativePath(png.trim())) {
|
|
316
|
+
diagnostics.push({
|
|
317
|
+
id,
|
|
318
|
+
rule: 'R9',
|
|
319
|
+
reason: '`png` value is not a safe relative path',
|
|
320
|
+
})
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (options.isBuiltin) {
|
|
324
|
+
diagnostics.push({
|
|
325
|
+
id,
|
|
326
|
+
rule: 'R10',
|
|
327
|
+
reason: 'built-in modules cannot use `png` (no openpen-plugin hostname)',
|
|
328
|
+
})
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (cursorObj.hotspot !== undefined) {
|
|
334
|
+
const h = cursorObj.hotspot as Record<string, unknown> | null
|
|
335
|
+
const valid =
|
|
336
|
+
h !== null &&
|
|
337
|
+
typeof h === 'object' &&
|
|
338
|
+
typeof h.x === 'number' && Number.isFinite(h.x) && h.x >= 0 &&
|
|
339
|
+
typeof h.y === 'number' && Number.isFinite(h.y) && h.y >= 0
|
|
340
|
+
if (!valid) {
|
|
341
|
+
diagnostics.push({
|
|
342
|
+
id,
|
|
343
|
+
rule: 'R11',
|
|
344
|
+
reason: '`hotspot` must be `{ x: number ≥ 0, y: number ≥ 0 }`',
|
|
345
|
+
})
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Output a strictly-allowlisted clone — only known primitive fields
|
|
351
|
+
// pass through. Anything else (function / symbol / getter / circular
|
|
352
|
+
// reference attached as an extra key) is dropped here so the
|
|
353
|
+
// downstream `structuredClone` in the contribution-store cannot
|
|
354
|
+
// throw and trigger a module-wide rollback.
|
|
355
|
+
const cleanedCursor: SvgCursorSpec | PngCursorSpec = hasSvg
|
|
356
|
+
? { svg: (cursorObj.svg as string).trim() }
|
|
357
|
+
: { png: (cursorObj.png as string).trim() }
|
|
358
|
+
if (cursorObj.hotspot !== undefined) {
|
|
359
|
+
const h = cursorObj.hotspot as { x: number; y: number }
|
|
360
|
+
cleanedCursor.hotspot = { x: h.x, y: h.y }
|
|
361
|
+
}
|
|
362
|
+
if (typeof cursorObj.fallback === 'string') {
|
|
363
|
+
cleanedCursor.fallback = cursorObj.fallback
|
|
364
|
+
}
|
|
365
|
+
cleaned.push({ id, cursor: cleanedCursor })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { cleaned, diagnostics }
|
|
369
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,10 @@ export type {
|
|
|
63
63
|
SettingsTabContribution,
|
|
64
64
|
SettingsPanelContribution,
|
|
65
65
|
CursorContribution,
|
|
66
|
+
CursorSpec,
|
|
67
|
+
SvgCursorSpec,
|
|
68
|
+
PngCursorSpec,
|
|
69
|
+
Hotspot,
|
|
66
70
|
StatusContribution,
|
|
67
71
|
ModalContribution,
|
|
68
72
|
TrayMenuContribution,
|
|
@@ -89,9 +93,30 @@ export {
|
|
|
89
93
|
CONTROL_BAR_ANIMATING_KEY,
|
|
90
94
|
HOST_DIALOG_OPEN_COUNT_KEY,
|
|
91
95
|
POPOVER_PLACEMENT_HINT_KEY,
|
|
96
|
+
ACTIVE_TOOL_KEY,
|
|
92
97
|
} from './inject-keys'
|
|
93
98
|
export type { StrokeStyleContext, SnapEdge, ModalManager, PopoverPlacementHint } from './inject-keys'
|
|
94
99
|
|
|
100
|
+
// Cursor compilation + sanitisation (used by host runtime; helpers
|
|
101
|
+
// re-exported so plugins can validate cursor specs in their own tests).
|
|
102
|
+
export {
|
|
103
|
+
compileCursor,
|
|
104
|
+
sanitizeSvgMarkup,
|
|
105
|
+
sanitizeCursorContributions,
|
|
106
|
+
isSafeRelativePath,
|
|
107
|
+
pluginHostname,
|
|
108
|
+
pluginAssetUrl,
|
|
109
|
+
SAFE_CURSOR_KEYWORDS,
|
|
110
|
+
} from './cursors'
|
|
111
|
+
export type {
|
|
112
|
+
CompiledCursor,
|
|
113
|
+
CursorResolutionContext,
|
|
114
|
+
CursorSanitizeRule,
|
|
115
|
+
CursorSanitizeDiagnostic,
|
|
116
|
+
CursorSanitizeOptions,
|
|
117
|
+
CursorSanitizeResult,
|
|
118
|
+
} from './cursors'
|
|
119
|
+
|
|
95
120
|
// Re-export zod so modules don't need a separate import path.
|
|
96
121
|
export { z } from 'zod'
|
|
97
122
|
|
package/src/inject-keys.ts
CHANGED
|
@@ -141,3 +141,14 @@ export type PopoverPlacementHint = 'top' | 'bottom' | 'left' | 'right'
|
|
|
141
141
|
|
|
142
142
|
export const POPOVER_PLACEMENT_HINT_KEY: InjectionKey<Readonly<Ref<PopoverPlacementHint>>> =
|
|
143
143
|
Symbol('openpen:popoverPlacementHint')
|
|
144
|
+
|
|
145
|
+
// ── Active Tool ───────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The currently active tool id. Provided by ControlBar.vue as `readonly(activeToolId)`.
|
|
149
|
+
* Module tool-button components inject this for reactive active-state without
|
|
150
|
+
* depending on the event-bus timing (event bus is fire-and-forget; this ref is
|
|
151
|
+
* always current regardless of when the component mounts).
|
|
152
|
+
*/
|
|
153
|
+
export const ACTIVE_TOOL_KEY: InjectionKey<Readonly<Ref<string>>> =
|
|
154
|
+
Symbol('openpen:activeTool')
|
|
@@ -161,10 +161,45 @@ export interface SettingsPanelContribution {
|
|
|
161
161
|
component: Component
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** Hotspot offset in CSS pixels relative to the cursor image's top-left. */
|
|
165
|
+
export interface Hotspot {
|
|
166
|
+
x: number
|
|
167
|
+
y: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Vector cursor: inline SVG markup OR a plugin-internal relative path
|
|
172
|
+
* to a `.svg` file. URL-shaped strings, absolute paths, and `..` segments
|
|
173
|
+
* are rejected at contribution-validation time.
|
|
174
|
+
*/
|
|
175
|
+
export interface SvgCursorSpec {
|
|
176
|
+
svg: string
|
|
177
|
+
hotspot?: Hotspot
|
|
178
|
+
/** CSS keyword fallback for legacy / non-overlay contexts. Defaults to `'crosshair'`. */
|
|
179
|
+
fallback?: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Raster cursor: plugin-internal relative path to a `.png` file. Inline
|
|
184
|
+
* form is not supported — PNG payload comes through `openpen-plugin://`.
|
|
185
|
+
*/
|
|
186
|
+
export interface PngCursorSpec {
|
|
187
|
+
png: string
|
|
188
|
+
hotspot?: Hotspot
|
|
189
|
+
fallback?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Cursor specification. The legacy `string` form must be one of the
|
|
194
|
+
* whitelisted CSS keywords (see `SAFE_CURSOR_KEYWORDS` in cursors.ts);
|
|
195
|
+
* any string containing `url(` / `image-set(` / `-webkit-image-set(` is
|
|
196
|
+
* rejected to keep the asset trust boundary closed.
|
|
197
|
+
*/
|
|
198
|
+
export type CursorSpec = string | SvgCursorSpec | PngCursorSpec
|
|
199
|
+
|
|
164
200
|
export interface CursorContribution {
|
|
165
201
|
id: string
|
|
166
|
-
|
|
167
|
-
cursor: string
|
|
202
|
+
cursor: CursorSpec
|
|
168
203
|
}
|
|
169
204
|
|
|
170
205
|
export interface StatusContribution {
|
|
@@ -21,6 +21,7 @@ interface Option {
|
|
|
21
21
|
value: string
|
|
22
22
|
label: string
|
|
23
23
|
icon?: string
|
|
24
|
+
testid?: string
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
defineProps<{
|
|
@@ -60,6 +61,7 @@ function onValueChange(val: unknown) {
|
|
|
60
61
|
<button
|
|
61
62
|
class="app-seg-btn"
|
|
62
63
|
:class="{ active: modelValue === opt.value }"
|
|
64
|
+
:data-testid="opt.testid"
|
|
63
65
|
>
|
|
64
66
|
<span
|
|
65
67
|
v-if="opt.icon"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* falls back gracefully (no mutual exclusion, no animating-guard) — it still
|
|
19
19
|
* renders and resolves correctly, just without those host integrations.
|
|
20
20
|
*/
|
|
21
|
-
import { computed, nextTick, provide, ref, watch } from 'vue'
|
|
21
|
+
import { computed, inject, nextTick, provide, ref, watch } from 'vue'
|
|
22
22
|
import AppDialog from './AppDialog.vue'
|
|
23
23
|
// _activeRequest MUST come from the package barrel — see AppPopover.vue header
|
|
24
24
|
// comment. The runtime build externalises '@openpen/module-api/uikit' so host
|
|
@@ -27,11 +27,17 @@ import AppDialog from './AppDialog.vue'
|
|
|
27
27
|
// callers to write to a different ref than DialogHost watches (dialog never
|
|
28
28
|
// opens in production).
|
|
29
29
|
import { _activeRequest, _resolveActive } from '@openpen/module-api/uikit'
|
|
30
|
+
import { HOST_DIALOG_OPEN_COUNT_KEY } from '@openpen/module-api'
|
|
30
31
|
import { DIALOG_PLUGIN_HANDLE_KEY } from '../composables/dialog-internal-keys'
|
|
31
32
|
import type { DialogPluginComponentHandle } from '../composables/useDialogPluginComponent'
|
|
32
33
|
|
|
33
34
|
const MODAL_ID = '__openpen_imperative_dialog__'
|
|
34
35
|
|
|
36
|
+
// Increment/decrement the host-level dialog-open counter so ControlBar can
|
|
37
|
+
// pause the auto-collapse timer while any imperative dialog is visible.
|
|
38
|
+
// Falls back to a local ref when not provided (settings window has no host bar).
|
|
39
|
+
const dialogOpenCount = inject(HOST_DIALOG_OPEN_COUNT_KEY, ref(0))
|
|
40
|
+
|
|
35
41
|
// Whether the dialog is open: true when there is an active request.
|
|
36
42
|
const isOpen = ref(false)
|
|
37
43
|
|
|
@@ -47,6 +53,9 @@ watch(_activeRequest, (req) => {
|
|
|
47
53
|
if (req.kind === 'prompt') {
|
|
48
54
|
promptValue.value = req.opts.defaultValue ?? ''
|
|
49
55
|
}
|
|
56
|
+
if (!isOpen.value) {
|
|
57
|
+
dialogOpenCount.value++
|
|
58
|
+
}
|
|
50
59
|
isOpen.value = true
|
|
51
60
|
if (req.kind === 'prompt') {
|
|
52
61
|
nextTick(() => {
|
|
@@ -54,6 +63,9 @@ watch(_activeRequest, (req) => {
|
|
|
54
63
|
})
|
|
55
64
|
}
|
|
56
65
|
} else {
|
|
66
|
+
if (isOpen.value) {
|
|
67
|
+
dialogOpenCount.value = Math.max(0, dialogOpenCount.value - 1)
|
|
68
|
+
}
|
|
57
69
|
isOpen.value = false
|
|
58
70
|
}
|
|
59
71
|
})
|