@openpen/module-api 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/LICENSE +21 -0
- package/README.md +99 -0
- package/package.json +53 -0
- package/src/define-module.ts +55 -0
- package/src/host/color-utils.ts +18 -0
- package/src/host/event-bus.ts +37 -0
- package/src/host/host-commands.ts +9 -0
- package/src/host/index.ts +53 -0
- package/src/host/module-context-registry.ts +34 -0
- package/src/host/passthrough.ts +10 -0
- package/src/host/popup-anchor.ts +14 -0
- package/src/host/slot-entries.ts +9 -0
- package/src/host/stroke-store.ts +20 -0
- package/src/host/stroke-style.ts +15 -0
- package/src/index.ts +106 -0
- package/src/inject-keys.ts +143 -0
- package/src/locale.ts +52 -0
- package/src/slots.ts +135 -0
- package/src/types/contributions.ts +298 -0
- package/src/types/control-bar-layout.ts +174 -0
- package/src/types/module.ts +225 -0
- package/src/types/notification.ts +40 -0
- package/src/types/tool.ts +82 -0
- package/src/uikit/components/.gitkeep +0 -0
- package/src/uikit/components/AppBanner.vue +131 -0
- package/src/uikit/components/AppButton.vue +135 -0
- package/src/uikit/components/AppDialog.vue +393 -0
- package/src/uikit/components/AppPopover.vue +268 -0
- package/src/uikit/components/AppSegmented.vue +126 -0
- package/src/uikit/components/AppSelect.vue +235 -0
- package/src/uikit/components/AppSlider.vue +204 -0
- package/src/uikit/components/AppTabs.vue +163 -0
- package/src/uikit/components/AppToggle.vue +96 -0
- package/src/uikit/components/AppTooltip.vue +118 -0
- package/src/uikit/components/DialogHost.vue +260 -0
- package/src/uikit/composables/dialog-internal-keys.ts +17 -0
- package/src/uikit/composables/useDialog.ts +141 -0
- package/src/uikit/composables/useDialogPluginComponent.ts +27 -0
- package/src/uikit/index.ts +79 -0
- package/src/uikit/internal.ts +12 -0
- package/src/uikit/primitives.ts +150 -0
- package/src/uikit/tokens.css +205 -0
- package/src/use-module-context.ts +50 -0
- package/src/validation.ts +31 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
import type { LocaleMap } from '../locale'
|
|
4
|
+
|
|
5
|
+
// ── Schema ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export const GroupInsetSchema = z.object({
|
|
8
|
+
enabled: z.boolean(),
|
|
9
|
+
/** Optional CSS color override for the inset background; defaults to --openpen-color-control-group. */
|
|
10
|
+
color: z.string().optional(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const LayoutGroupSchema = z.object({
|
|
14
|
+
id: z.string().regex(/^[a-z][a-z0-9-]*$/, 'Group id must be kebab-case'),
|
|
15
|
+
items: z.array(z.string()),
|
|
16
|
+
separator: z.enum(['auto', 'always', 'never']).optional(),
|
|
17
|
+
/**
|
|
18
|
+
* Optional inset rendering for this group. When `enabled: true`, the group
|
|
19
|
+
* MUST render with a visible bg + border + padding container (the design's
|
|
20
|
+
* "grouped tools" look) AND its inner items MUST shrink so the group's
|
|
21
|
+
* outer height matches an unwrapped 36px button — i.e. enabling inset
|
|
22
|
+
* MUST NOT increase control-bar height.
|
|
23
|
+
*/
|
|
24
|
+
inset: GroupInsetSchema.optional(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const ControlBarLayoutSchema = z.object({
|
|
28
|
+
version: z.literal(1),
|
|
29
|
+
groups: z
|
|
30
|
+
.array(LayoutGroupSchema)
|
|
31
|
+
.refine((groups) => groups.some((g) => g.id === 'default'), {
|
|
32
|
+
message: "Layout must contain a 'default' group",
|
|
33
|
+
})
|
|
34
|
+
.refine(
|
|
35
|
+
(groups) => {
|
|
36
|
+
const ids = groups.map((g) => g.id)
|
|
37
|
+
return new Set(ids).size === ids.length
|
|
38
|
+
},
|
|
39
|
+
{ message: 'Group ids must be unique' },
|
|
40
|
+
)
|
|
41
|
+
.refine(
|
|
42
|
+
(groups) => {
|
|
43
|
+
const allItems = groups.flatMap((g) => g.items)
|
|
44
|
+
return new Set(allItems).size === allItems.length
|
|
45
|
+
},
|
|
46
|
+
{ message: 'Item ids must not appear in multiple groups' },
|
|
47
|
+
),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// ── TypeScript types ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface GroupInset {
|
|
53
|
+
enabled: boolean
|
|
54
|
+
/** Optional CSS color override for the inset background; defaults to --openpen-color-control-group. */
|
|
55
|
+
color?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LayoutGroup {
|
|
59
|
+
id: string
|
|
60
|
+
items: string[]
|
|
61
|
+
separator?: 'auto' | 'always' | 'never'
|
|
62
|
+
/** When `enabled: true`, render the group with the visible "grouped tools" container. */
|
|
63
|
+
inset?: GroupInset
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ControlBarLayout {
|
|
67
|
+
version: 1
|
|
68
|
+
groups: LayoutGroup[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Contribution type ─────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export interface ControlBarContribution {
|
|
74
|
+
/** Item id — MUST be globally unique across all modules. */
|
|
75
|
+
id: string
|
|
76
|
+
/** Vue component rendered for this item. */
|
|
77
|
+
component: Component
|
|
78
|
+
/**
|
|
79
|
+
* Preferred group id on first install.
|
|
80
|
+
* MUST NOT be 'default' (reserved).
|
|
81
|
+
* When omitted, item falls into the 'default' group.
|
|
82
|
+
*/
|
|
83
|
+
defaultGroup?: string
|
|
84
|
+
/**
|
|
85
|
+
* Hints used when host auto-creates the defaultGroup.
|
|
86
|
+
* Ignored if the group already exists in the layout.
|
|
87
|
+
*/
|
|
88
|
+
groupHint?: {
|
|
89
|
+
separator?: 'auto' | 'always' | 'never'
|
|
90
|
+
label?: string | LocaleMap
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Default layout ────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** Baseline layout used on first launch: a single empty `'default'` group. */
|
|
97
|
+
export const DEFAULT_CONTROL_BAR_LAYOUT: ControlBarLayout = {
|
|
98
|
+
version: 1,
|
|
99
|
+
groups: [{ id: 'default', items: [], separator: 'auto' }],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Reconcile helpers ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Apply L3a non-destructive repairs to a parsed layout:
|
|
106
|
+
* - Ensure 'default' group exists
|
|
107
|
+
* - Replace invalid separator values with 'auto'
|
|
108
|
+
* Returns the (possibly mutated copy) and whether any repair was made.
|
|
109
|
+
*
|
|
110
|
+
* @internal Host-only utility; not part of the plugin author API.
|
|
111
|
+
*/
|
|
112
|
+
export function repairLayoutL3a(layout: ControlBarLayout): {
|
|
113
|
+
layout: ControlBarLayout
|
|
114
|
+
repaired: boolean
|
|
115
|
+
} {
|
|
116
|
+
let repaired = false
|
|
117
|
+
const groups = layout.groups.map((g) => {
|
|
118
|
+
if (g.separator !== undefined && !['auto', 'always', 'never'].includes(g.separator)) {
|
|
119
|
+
repaired = true
|
|
120
|
+
return { ...g, separator: 'auto' as const }
|
|
121
|
+
}
|
|
122
|
+
return g
|
|
123
|
+
})
|
|
124
|
+
if (!groups.some((g) => g.id === 'default')) {
|
|
125
|
+
groups.push({ id: 'default', items: [], separator: 'auto' })
|
|
126
|
+
repaired = true
|
|
127
|
+
}
|
|
128
|
+
return { layout: { ...layout, groups }, repaired }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Apply L3b potentially-destructive repairs:
|
|
133
|
+
* - De-duplicate item ids across groups (keep last occurrence)
|
|
134
|
+
* - Trim ghost ids when count ≥ 20 (noop — ghost trimming is a UX action)
|
|
135
|
+
* Returns the repaired layout and whether any repair was made.
|
|
136
|
+
*
|
|
137
|
+
* @internal Host-only utility; not part of the plugin author API.
|
|
138
|
+
*/
|
|
139
|
+
export function repairLayoutL3b(
|
|
140
|
+
layout: ControlBarLayout,
|
|
141
|
+
knownItemIds: Set<string>,
|
|
142
|
+
): { layout: ControlBarLayout; repaired: boolean } {
|
|
143
|
+
let repaired = false
|
|
144
|
+
const seen = new Set<string>()
|
|
145
|
+
// Process groups in reverse so the last occurrence wins.
|
|
146
|
+
const reversedGroups = [...layout.groups].reverse()
|
|
147
|
+
const deduped = reversedGroups.map((g) => {
|
|
148
|
+
const items = g.items.filter((id) => {
|
|
149
|
+
if (seen.has(id)) {
|
|
150
|
+
repaired = true
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
seen.add(id)
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
156
|
+
return { ...g, items }
|
|
157
|
+
})
|
|
158
|
+
const groups = deduped.reverse()
|
|
159
|
+
|
|
160
|
+
// Count ghosts across layout
|
|
161
|
+
const totalItems = groups.flatMap((g) => g.items)
|
|
162
|
+
const ghostCount = totalItems.filter((id) => !knownItemIds.has(id)).length
|
|
163
|
+
if (ghostCount >= 20) {
|
|
164
|
+
repaired = true
|
|
165
|
+
// Remove ghosts from all groups
|
|
166
|
+
const cleaned = groups.map((g) => ({
|
|
167
|
+
...g,
|
|
168
|
+
items: g.items.filter((id) => knownItemIds.has(id)),
|
|
169
|
+
}))
|
|
170
|
+
return { layout: { ...layout, groups: cleaned }, repaired }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { layout: { ...layout, groups }, repaired }
|
|
174
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
import type { ModuleContributions } from './contributions'
|
|
3
|
+
import type { NotifyPayload, NotifyHandle } from './notification'
|
|
4
|
+
import type { LocaleMap } from '../locale'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Public contract that every OpenPen module — first-party (`src/core/modules/*`)
|
|
8
|
+
* and third-party plugin alike — must satisfy.
|
|
9
|
+
*
|
|
10
|
+
* Modules express their behaviour entirely via `contributes` and
|
|
11
|
+
* (optionally) `setup`. They never touch host-app internals directly:
|
|
12
|
+
* cross-module communication goes through declared contribution slots
|
|
13
|
+
* and the domain event bus.
|
|
14
|
+
*/
|
|
15
|
+
export interface OpenPenModule {
|
|
16
|
+
/** Globally unique kebab-case id. Subject to {@link import('../validation').MODULE_ID_RE}. */
|
|
17
|
+
id: string
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* i18n-independent display metadata. Used as a fallback layer in the
|
|
21
|
+
* Settings UI when the module's `contributes.locales` entries are not
|
|
22
|
+
* registered (e.g. the module is disabled and its contributions are not
|
|
23
|
+
* wired into the host). The runtime i18n contribution is always preferred
|
|
24
|
+
* as the primary source; this field is only consulted on a cache miss.
|
|
25
|
+
*
|
|
26
|
+
* Values are `LocaleMap` (`Record<string, string>`) keyed by BCP-47 tag.
|
|
27
|
+
* Resolution follows the same order as `resolveLabel`: exact tag → language
|
|
28
|
+
* prefix → `'en'` fallback → first declared value.
|
|
29
|
+
*/
|
|
30
|
+
metadata?: {
|
|
31
|
+
name: LocaleMap
|
|
32
|
+
description?: LocaleMap
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* SemVer string. **Required for plugins** — the host validator
|
|
37
|
+
* rejects any non-built-in module whose `version` is missing or
|
|
38
|
+
* empty with a `'missing-version'` ValidationError, so a plugin
|
|
39
|
+
* with no version never finishes loading.
|
|
40
|
+
*
|
|
41
|
+
* Built-in modules (those bundled with the host under
|
|
42
|
+
* `src/core/modules/`) MAY omit it and inherit the host version;
|
|
43
|
+
* the type stays optional only to accommodate that case.
|
|
44
|
+
*/
|
|
45
|
+
version?: string
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Minimum OpenPen app version this module requires (semver).
|
|
49
|
+
*
|
|
50
|
+
* Validated at load time by the host: a plugin whose `minAppVersion`
|
|
51
|
+
* exceeds the running host version is skipped with a console warning.
|
|
52
|
+
* Set this to `"1.0.0"` (or omit it) for broad v1.x compatibility.
|
|
53
|
+
*/
|
|
54
|
+
minAppVersion?: string
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One-shot initialisation, called after manifest validation and
|
|
58
|
+
* before contributions are wired into the host. Use this for things
|
|
59
|
+
* that need to happen exactly once, like seeding default settings or
|
|
60
|
+
* subscribing to long-running event streams.
|
|
61
|
+
*/
|
|
62
|
+
setup?: (ctx: ModuleSetupContext) => void | Promise<void>
|
|
63
|
+
|
|
64
|
+
/** At least one field on this object is required (enforced by `defineModule`). */
|
|
65
|
+
contributes?: ModuleContributions
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Zod schema for this module's user-facing settings. The host renders
|
|
69
|
+
* the settings form from this schema and persists the parsed value
|
|
70
|
+
* under `config.json.modules[id]`. The same schema validates writes
|
|
71
|
+
* made via `ctx.updateSettings()`; writes that fail validation reject
|
|
72
|
+
* with the underlying `ZodError`.
|
|
73
|
+
*
|
|
74
|
+
* Modules that intend to call `ctx.updateSettings()` MUST declare
|
|
75
|
+
* this field; calling `updateSettings` without a schema throws at
|
|
76
|
+
* runtime so the missing contract surfaces during development.
|
|
77
|
+
*/
|
|
78
|
+
settingsSchema?: z.ZodType
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Settings schema version. Increment when the schema's shape changes
|
|
82
|
+
* in a way that needs migration (renames, removals, or restructuring).
|
|
83
|
+
* Pure additive changes (new keys with `.default()` values) do not
|
|
84
|
+
* require a bump — Zod's defaults fill in on parse.
|
|
85
|
+
*
|
|
86
|
+
* Defaults to `1` if omitted. The host persists the version under
|
|
87
|
+
* `config.json.moduleMeta[id].schemaVersion` (separate from the data
|
|
88
|
+
* blob) so plugins never see this sentinel in their settings object.
|
|
89
|
+
*/
|
|
90
|
+
settingsVersion?: number
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Optional migration hook run before `settingsSchema` parses persisted
|
|
94
|
+
* data. Called when the stored `schemaVersion` is less than the
|
|
95
|
+
* module's `settingsVersion`. Receives the stored version + raw data
|
|
96
|
+
* blob; returns the migrated blob, which is then validated by
|
|
97
|
+
* `settingsSchema` and persisted at the new version.
|
|
98
|
+
*
|
|
99
|
+
* Use this for renames, removals, or restructuring. For pure additive
|
|
100
|
+
* changes (new keys), Zod's `.default()` covers it — no migrate hook
|
|
101
|
+
* needed.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* migrate(stored, data) {
|
|
105
|
+
* if (stored < 2 && 'oldName' in data) {
|
|
106
|
+
* data.newName = data.oldName
|
|
107
|
+
* delete data.oldName
|
|
108
|
+
* }
|
|
109
|
+
* return data
|
|
110
|
+
* }
|
|
111
|
+
*/
|
|
112
|
+
migrate?: (
|
|
113
|
+
storedVersion: number,
|
|
114
|
+
data: Record<string, unknown>
|
|
115
|
+
) => Record<string, unknown>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Runtime context handed to `setup()`. Intentionally narrow — modules
|
|
120
|
+
* that need more capabilities should declare them via contribution
|
|
121
|
+
* slots so the dependency is visible at validation time.
|
|
122
|
+
*/
|
|
123
|
+
export interface ModuleSetupContext {
|
|
124
|
+
/** This module's id, for logging / error messages. */
|
|
125
|
+
moduleId: string
|
|
126
|
+
/** Currently active locale, e.g. 'zh-TW'. Read-only. */
|
|
127
|
+
locale: string
|
|
128
|
+
/**
|
|
129
|
+
* Returns this module's current settings — a live in-memory snapshot
|
|
130
|
+
* already validated by `settingsSchema`. Synchronous and zero-I/O:
|
|
131
|
+
* the host hydrates the cache before `setup()` is called and updates
|
|
132
|
+
* it in-place after every successful `updateSettings()`.
|
|
133
|
+
*
|
|
134
|
+
* Generic `T` should match the inferred type of your `settingsSchema`
|
|
135
|
+
* (use `z.infer<typeof MySchema>` at the call site).
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* const settings = ctx.getSettings<z.infer<typeof MySchema>>()
|
|
139
|
+
*/
|
|
140
|
+
getSettings: <T = unknown>() => T
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Shallow-merges `patch` into this module's settings, validates the
|
|
144
|
+
* result against `settingsSchema` (Zod), and persists to `config.json`.
|
|
145
|
+
* Resolves once the write is durable; rejects with the underlying
|
|
146
|
+
* `ZodError` if the merged object fails validation (no I/O happens
|
|
147
|
+
* on validation failure).
|
|
148
|
+
*
|
|
149
|
+
* The in-memory snapshot returned by `getSettings()` is updated
|
|
150
|
+
* before the Promise resolves; subscribers registered via
|
|
151
|
+
* `onSettingsChange()` then fire with the new snapshot.
|
|
152
|
+
*
|
|
153
|
+
* Note on nested objects: the merge is shallow. Updating a nested
|
|
154
|
+
* object replaces it entirely — spread the existing nested value
|
|
155
|
+
* yourself if you only want to change one inner field:
|
|
156
|
+
*
|
|
157
|
+
* ```ts
|
|
158
|
+
* const cur = ctx.getSettings<MySettings>()
|
|
159
|
+
* await ctx.updateSettings({ colors: { ...cur.colors, primary: '#0f0' } })
|
|
160
|
+
* ```
|
|
161
|
+
*
|
|
162
|
+
* Throws synchronously if the module did not declare `settingsSchema`.
|
|
163
|
+
*/
|
|
164
|
+
updateSettings: <T = unknown>(patch: Partial<T>) => Promise<void>
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Subscribe to settings changes for this module. The callback fires
|
|
168
|
+
* after every successful `updateSettings()` — including writes that
|
|
169
|
+
* originated from the host's settings panel — with the fresh,
|
|
170
|
+
* already-validated snapshot.
|
|
171
|
+
*
|
|
172
|
+
* Returns an unsubscribe function. The host also calls it
|
|
173
|
+
* automatically when the module is rolled back, so manual cleanup
|
|
174
|
+
* is only needed if you want to stop listening sooner than
|
|
175
|
+
* module dispose.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* const stop = ctx.onSettingsChange<MySettings>((s) => {
|
|
179
|
+
* redrawWithColor(s.primary)
|
|
180
|
+
* })
|
|
181
|
+
*/
|
|
182
|
+
onSettingsChange: <T = unknown>(cb: (settings: T) => void) => () => void
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Invoke one of THIS module's main-side handlers (declared via
|
|
186
|
+
* `contributes.mainHandlers`). The moduleId is captured by the host
|
|
187
|
+
* so plugins cannot reach into other modules' handlers — use this
|
|
188
|
+
* helper rather than the lower-level `openPenApi.moduleCall`.
|
|
189
|
+
*
|
|
190
|
+
* Returns whatever the handler returns (typed by the caller).
|
|
191
|
+
*/
|
|
192
|
+
callMain: <T = unknown>(action: string, payload?: unknown) => Promise<T>
|
|
193
|
+
/**
|
|
194
|
+
* Register a cleanup callback. Called in reverse registration order
|
|
195
|
+
* when the module is unloaded (hot-reload, user-disable, or app quit).
|
|
196
|
+
* Use this to cancel timers, remove listeners, or release resources
|
|
197
|
+
* that `setup()` acquired.
|
|
198
|
+
*/
|
|
199
|
+
onDispose: (fn: () => void | Promise<void>) => void
|
|
200
|
+
/**
|
|
201
|
+
* Shows a short-lived toast notification in the overlay window.
|
|
202
|
+
* Visibility and position are controlled by the user's settings.
|
|
203
|
+
* Returns a NotifyHandle for early dismissal.
|
|
204
|
+
*/
|
|
205
|
+
notify: (payload: NotifyPayload) => NotifyHandle
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Resolves an i18n key for this module to the current-locale string.
|
|
209
|
+
*
|
|
210
|
+
* The key is automatically namespaced as `${moduleId}.${key}`, matching
|
|
211
|
+
* the dictionary structure registered via `contributes.locales` (the host
|
|
212
|
+
* merges each locale dict into the global vue-i18n instance under the
|
|
213
|
+
* module's id namespace).
|
|
214
|
+
*
|
|
215
|
+
* Aligned with VS Code's `vscode.l10n.t()` convention:
|
|
216
|
+
* ```typescript
|
|
217
|
+
* ctx.t('notif.start')
|
|
218
|
+
* ctx.t('notif.greeting', { name: 'Alice' }) // dict entry: 'Hello {name}'
|
|
219
|
+
* ```
|
|
220
|
+
*
|
|
221
|
+
* When a key has no matching translation the namespaced key string is
|
|
222
|
+
* returned (vue-i18n's default missing-key behaviour).
|
|
223
|
+
*/
|
|
224
|
+
t: (key: string, params?: Record<string, unknown>) => string
|
|
225
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notification.ts — NotifyPayload and NotifyHandle type definitions.
|
|
3
|
+
*
|
|
4
|
+
* Used by callers of ctx.notify(payload); NotificationService implements
|
|
5
|
+
* the host-side logic.
|
|
6
|
+
*
|
|
7
|
+
* Runtime messages (notify / status text, etc.) are always resolved to a
|
|
8
|
+
* plain string via ctx.t(key) before being passed in. LocaleMap is reserved
|
|
9
|
+
* for module manifest metadata (name, description, contribution label),
|
|
10
|
+
* following the VS Code package.nls.json / JetBrains plugin.xml convention.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Input payload for ctx.notify().
|
|
15
|
+
*
|
|
16
|
+
* **Overlay-window only.** Toasts render via the host's
|
|
17
|
+
* NotificationLayer, which is mounted exclusively in the overlay
|
|
18
|
+
* window. Calls made while the overlay is not the active context
|
|
19
|
+
* silently no-op (no error, no queue persistence). See
|
|
20
|
+
* `docs/reference/notify-api.md` → "When does the toast appear?"
|
|
21
|
+
* for guidance on choosing notify() versus your own UI surface.
|
|
22
|
+
*/
|
|
23
|
+
export interface NotifyPayload {
|
|
24
|
+
/** Required. Already-resolved plain string; use ctx.t(key) for i18n. */
|
|
25
|
+
message: string
|
|
26
|
+
/** Subtitle text. Already-resolved plain string, e.g. "Press again to exit". */
|
|
27
|
+
description?: string
|
|
28
|
+
/** Inline SVG string, same convention as ToolContribution.icon. */
|
|
29
|
+
icon?: string
|
|
30
|
+
/** Semantic colour variant. Defaults to 'default'. */
|
|
31
|
+
variant?: 'default' | 'success' | 'warning' | 'danger'
|
|
32
|
+
/** Auto-dismiss delay in milliseconds. Defaults to 1800. */
|
|
33
|
+
duration?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Return value of ctx.notify(), allowing early dismissal. */
|
|
37
|
+
export interface NotifyHandle {
|
|
38
|
+
/** Immediately closes this notification. */
|
|
39
|
+
dismiss(): void
|
|
40
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public Tool / Stroke / Point primitives shared by host code and
|
|
3
|
+
* every module / plugin. Declared inside @openpen/module-api so
|
|
4
|
+
* third parties don't need to import host internals to type their
|
|
5
|
+
* `canvas.tools` contributions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Point {
|
|
9
|
+
x: number
|
|
10
|
+
y: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Solid color or linear gradient between two stops. */
|
|
14
|
+
export type StrokeColor =
|
|
15
|
+
| string
|
|
16
|
+
| { type: 'linear'; from: string; to: string }
|
|
17
|
+
|
|
18
|
+
export interface StrokeStyle {
|
|
19
|
+
color: StrokeColor
|
|
20
|
+
lineWidth: number
|
|
21
|
+
lineCap: CanvasLineCap
|
|
22
|
+
lineJoin: CanvasLineJoin
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Stroke {
|
|
26
|
+
id: string
|
|
27
|
+
/** Id of the tool that produced this stroke (matches a ToolContribution.id). */
|
|
28
|
+
tool: string
|
|
29
|
+
points: Point[]
|
|
30
|
+
style: StrokeStyle
|
|
31
|
+
/**
|
|
32
|
+
* Tool-specific extra state. Tools MAY store arbitrary keys here during
|
|
33
|
+
* `onPointerDown` / `onPointerMove` / `onPointerUp`; those keys survive
|
|
34
|
+
* into `renderStroke(stroke, canvasCtx)` calls so the renderer can read
|
|
35
|
+
* them back.
|
|
36
|
+
*
|
|
37
|
+
* The host treats extra keys as `unknown` — type-cast at the read site is
|
|
38
|
+
* the plugin author's responsibility (TypeScript cannot infer the shape).
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // Store during onPointerUp:
|
|
42
|
+
* const stroke: Stroke = { id, tool: 'my-tool', points, style, opacity: 0.5 }
|
|
43
|
+
*
|
|
44
|
+
* // Read back in renderStroke:
|
|
45
|
+
* const opacity = (stroke.opacity as number) ?? 1
|
|
46
|
+
* canvasCtx.globalAlpha = opacity
|
|
47
|
+
*/
|
|
48
|
+
[key: string]: unknown
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PointerModifiers {
|
|
52
|
+
shiftKey?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Tool {
|
|
56
|
+
onPointerDown(
|
|
57
|
+
canvasCtx: CanvasRenderingContext2D,
|
|
58
|
+
point: Point,
|
|
59
|
+
style: StrokeStyle
|
|
60
|
+
): void
|
|
61
|
+
/**
|
|
62
|
+
* Returns `true` when the engine should perform a full redraw
|
|
63
|
+
* (e.g. stroke-eraser removed an existing stroke). Otherwise `void`.
|
|
64
|
+
*/
|
|
65
|
+
onPointerMove(
|
|
66
|
+
canvasCtx: CanvasRenderingContext2D,
|
|
67
|
+
point: Point,
|
|
68
|
+
modifiers?: PointerModifiers
|
|
69
|
+
): void | boolean
|
|
70
|
+
/**
|
|
71
|
+
* Returns the finished `Stroke` to push into the store, or `null`
|
|
72
|
+
* to discard (e.g. an eraser that doesn't accumulate state).
|
|
73
|
+
*/
|
|
74
|
+
onPointerUp(
|
|
75
|
+
canvasCtx: CanvasRenderingContext2D,
|
|
76
|
+
point: Point,
|
|
77
|
+
modifiers?: PointerModifiers
|
|
78
|
+
): Stroke | null
|
|
79
|
+
/** When true, canvas-engine clears + redraws all strokes before each move. */
|
|
80
|
+
needsPreviewRedraw?: boolean
|
|
81
|
+
renderPreview?(canvasCtx: CanvasRenderingContext2D): void
|
|
82
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* AppBanner — High-level wrapper (Layer 1).
|
|
4
|
+
*
|
|
5
|
+
* Presents info / warning / success / error status messages using the
|
|
6
|
+
* --openpen-color-state-* design tokens. The icon is auto-rendered based on
|
|
7
|
+
* the variant; message content goes in the default slot; optional inline action
|
|
8
|
+
* buttons (e.g. restart / dismiss) go in the `#actions` slot.
|
|
9
|
+
*
|
|
10
|
+
* Inline mode (compact single-line) is enabled via the `inline` prop and is
|
|
11
|
+
* suited for form validation feedback and status lines inside dialogs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type BannerVariant = 'info' | 'warning' | 'success' | 'error'
|
|
15
|
+
|
|
16
|
+
withDefaults(defineProps<{
|
|
17
|
+
variant: BannerVariant
|
|
18
|
+
/** Compact single-line layout: smaller padding, vertically centered. */
|
|
19
|
+
inline?: boolean
|
|
20
|
+
}>(), { inline: false })
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div
|
|
25
|
+
:class="['app-banner', `app-banner-${variant}`, { 'app-banner-inline': inline }]"
|
|
26
|
+
:role="variant === 'error' ? 'alert' : 'status'"
|
|
27
|
+
>
|
|
28
|
+
<span class="app-banner-icon" aria-hidden="true">
|
|
29
|
+
<!-- info -->
|
|
30
|
+
<svg v-if="variant === 'info'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
31
|
+
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
32
|
+
</svg>
|
|
33
|
+
<!-- warning -->
|
|
34
|
+
<svg v-else-if="variant === 'warning'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
35
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
36
|
+
</svg>
|
|
37
|
+
<!-- success -->
|
|
38
|
+
<svg v-else-if="variant === 'success'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
39
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
|
40
|
+
</svg>
|
|
41
|
+
<!-- error -->
|
|
42
|
+
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
43
|
+
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
|
|
44
|
+
</svg>
|
|
45
|
+
</span>
|
|
46
|
+
|
|
47
|
+
<span class="app-banner-body"><slot /></span>
|
|
48
|
+
|
|
49
|
+
<span v-if="$slots.actions" class="app-banner-actions">
|
|
50
|
+
<slot name="actions" />
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
.app-banner {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: flex-start;
|
|
59
|
+
gap: 10px;
|
|
60
|
+
padding: 10px 14px;
|
|
61
|
+
border-radius: var(--openpen-radius-md, 10px);
|
|
62
|
+
border: 1px solid;
|
|
63
|
+
font-size: 13px;
|
|
64
|
+
line-height: 1.55;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.app-banner-inline {
|
|
68
|
+
padding: 7px 12px;
|
|
69
|
+
align-items: center;
|
|
70
|
+
font-size: 12px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.app-banner-icon {
|
|
74
|
+
flex-shrink: 0;
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
margin-top: 1px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.app-banner-inline .app-banner-icon { margin-top: 0; }
|
|
81
|
+
|
|
82
|
+
.app-banner-body {
|
|
83
|
+
flex: 1;
|
|
84
|
+
min-width: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.app-banner-actions {
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 6px;
|
|
91
|
+
flex-shrink: 0;
|
|
92
|
+
margin-left: auto;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Variants */
|
|
96
|
+
.app-banner-info {
|
|
97
|
+
background: var(--openpen-color-state-info-bg);
|
|
98
|
+
border-color: var(--openpen-color-state-info-border);
|
|
99
|
+
color: var(--openpen-color-state-info-text);
|
|
100
|
+
}
|
|
101
|
+
.app-banner-info .app-banner-icon {
|
|
102
|
+
color: var(--openpen-color-state-info-icon);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.app-banner-warning {
|
|
106
|
+
background: var(--openpen-color-state-warning-bg);
|
|
107
|
+
border-color: var(--openpen-color-state-warning-border);
|
|
108
|
+
color: var(--openpen-color-state-warning-text);
|
|
109
|
+
}
|
|
110
|
+
.app-banner-warning .app-banner-icon {
|
|
111
|
+
color: var(--openpen-color-state-warning-icon);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.app-banner-success {
|
|
115
|
+
background: var(--openpen-color-state-success-bg);
|
|
116
|
+
border-color: var(--openpen-color-state-success-border);
|
|
117
|
+
color: var(--openpen-color-state-success-text);
|
|
118
|
+
}
|
|
119
|
+
.app-banner-success .app-banner-icon {
|
|
120
|
+
color: var(--openpen-color-state-success-icon);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.app-banner-error {
|
|
124
|
+
background: var(--openpen-color-state-error-bg);
|
|
125
|
+
border-color: var(--openpen-color-state-error-border);
|
|
126
|
+
color: var(--openpen-color-state-error-text);
|
|
127
|
+
}
|
|
128
|
+
.app-banner-error .app-banner-icon {
|
|
129
|
+
color: var(--openpen-color-state-error-icon);
|
|
130
|
+
}
|
|
131
|
+
</style>
|