@pie-players/pie-default-tool-loaders 0.3.67 → 0.3.68

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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Dictionary Tool Registrations
3
+ *
4
+ * Word lookup and picture (symbol) lookup, each a floating panel opened from the
5
+ * toolbar.
6
+ *
7
+ * Neither declares a universal support id. A dictionary is a granted accommodation,
8
+ * not a universal affordance: on a vocabulary item it is construct-relevant, so
9
+ * handing it to every learner by default would change what the item measures. A
10
+ * programme grants it through the PNP like any other accommodation.
11
+ *
12
+ * Both panels also accept a `term` from whatever selection affordance a host offers,
13
+ * but neither depends on one. A sighted keyboard-only learner cannot originate a text
14
+ * selection in non-editable content — Chromium does not extend one with Shift+Arrow
15
+ * there without caret browsing, an OS toggle absent on mobile — so a selection-only
16
+ * dictionary is unreachable for them. The panel's own field is the keyboard route.
17
+ */
18
+ import { createScopedVisibilityBinding, createToolElement, hasReadableText, syncButtonAndOverlayVisibility, } from "@pie-players/pie-assessment-toolkit/tools/internal";
19
+ /**
20
+ * Apply the host's per-tool render params to the panel.
21
+ *
22
+ * `lookup` wins over `endpoint` when a host supplies both, matching the element's own
23
+ * precedence: a host that already has a client should not also have to describe its
24
+ * HTTP shape. Absent both, the panel renders its unconfigured state — PIE ships no
25
+ * dictionary endpoint, because the corpus behind one is licensed per programme.
26
+ *
27
+ * An `endpoint` alone is the whole configuration for the common case: the panel calls
28
+ * it on the assessment's own session, so a route already behind that session answers
29
+ * without a token. `headers` and `credentials` are the seam for a host authorising some
30
+ * other way, and neither is required.
31
+ */
32
+ function applyLookupParams(element, toolId, toolbarContext,
33
+ /** A language variant's own corpus language; see {@link DictionaryVariantOptions}. */
34
+ lookupLanguage) {
35
+ const params = toolbarContext.getToolRenderParams?.(toolId) ?? {};
36
+ if (typeof params.endpoint === "string")
37
+ element.endpoint = params.endpoint;
38
+ if (typeof params.lookup === "function")
39
+ element.lookup = params.lookup;
40
+ if (typeof params.headers === "function")
41
+ element.headers = params.headers;
42
+ if (typeof params.credentials === "string")
43
+ element.credentials = params.credentials;
44
+ // The selection door: a gateway acting on the learner's selection requests this
45
+ // tool with the words in `term`, and the toolbar layers that over the host's own
46
+ // params. The toolbar stamps every request with a neutral `toolRequestId` — core
47
+ // names no tool's concepts — and this layer, which knows both, hands it to the panel
48
+ // as the identity of its term. That is what makes reapplying an unchanged term on a
49
+ // later sync free while a second request for the same word still re-runs.
50
+ if (typeof params.term === "string" && params.term)
51
+ element.term = params.term;
52
+ if (typeof params.toolRequestId === "string" ||
53
+ typeof params.toolRequestId === "number") {
54
+ element.termRequestId = params.toolRequestId;
55
+ }
56
+ // Resolution order: what the host named for this tool, then the capability's own
57
+ // corpus language if it is a language variant, then the toolbar's scope. A variant
58
+ // exists precisely to look words up in a language the surrounding content is not in —
59
+ // a Spanish gloss for an English passage — so its language outranks the scope's, and a
60
+ // host that names one anyway still wins.
61
+ const language = typeof params.language === "string"
62
+ ? params.language
63
+ : (lookupLanguage ?? toolbarContext.language);
64
+ if (typeof language === "string" && language)
65
+ element.language = language;
66
+ }
67
+ function renderDictionaryPanel(args) {
68
+ const { registration, context, toolbarContext } = args;
69
+ const visibility = createScopedVisibilityBinding(registration.toolId, toolbarContext);
70
+ const button = {
71
+ toolId: registration.toolId,
72
+ label: registration.name,
73
+ icon: typeof registration.icon === "function"
74
+ ? registration.icon(context)
75
+ : registration.icon,
76
+ disabled: false,
77
+ ariaLabel: toolbarContext.i18n.t(args.buttonA11yKey),
78
+ tooltip: toolbarContext.i18n.t(args.tooltipKey),
79
+ onClick: () => toolbarContext.toggleTool(registration.toolId),
80
+ active: visibility.isActive(),
81
+ };
82
+ const componentOverrides = toolbarContext.componentOverrides ??
83
+ {};
84
+ const overlay = createToolElement(registration.toolId, context, toolbarContext, componentOverrides);
85
+ overlay.setAttribute("tool-id", visibility.fullToolId);
86
+ applyLookupParams(overlay, registration.toolId, toolbarContext, args.lookupLanguage);
87
+ return {
88
+ toolId: registration.toolId,
89
+ button,
90
+ elements: [
91
+ {
92
+ element: overlay,
93
+ mount: "after-buttons",
94
+ shell: {
95
+ title: toolbarContext.i18n.t(args.shellTitleKey),
96
+ draggable: true,
97
+ resizable: true,
98
+ closeable: true,
99
+ initialWidth: args.initialWidth,
100
+ initialHeight: args.initialHeight,
101
+ minWidth: args.minWidth,
102
+ minHeight: args.minHeight,
103
+ },
104
+ },
105
+ ],
106
+ // Re-read params on sync rather than closing over them: a host calling
107
+ // `updateToolConfig` mid-session changes the endpoint without remounting, and a
108
+ // remount would discard the learner's current lookup.
109
+ sync: () => {
110
+ syncButtonAndOverlayVisibility({
111
+ button,
112
+ overlay,
113
+ isActive: visibility.isActive,
114
+ });
115
+ applyLookupParams(overlay, registration.toolId, toolbarContext, args.lookupLanguage);
116
+ },
117
+ subscribeActive: visibility.subscribeActive,
118
+ };
119
+ }
120
+ /**
121
+ * Dictionary tool registration
122
+ *
123
+ * Word lookup from a host-supplied service, in one language. Call this again with another
124
+ * `toolId`, `pnpSupportIds` and `lookupLanguage` to offer a second language beside it.
125
+ */
126
+ export function createDictionaryToolRegistration(options = {}) {
127
+ const toolId = options.toolId ?? "dictionary";
128
+ const prefix = options.messageKeyPrefix ?? `tools.${toolId}`;
129
+ return {
130
+ toolId,
131
+ name: options.name ?? "Dictionary",
132
+ description: options.description ?? "Look up word definitions",
133
+ nameKey: `${prefix}.name`,
134
+ descriptionKey: `${prefix}.description`,
135
+ icon: "book-open",
136
+ // Text can appear at any of these; the panel itself floats at section scope.
137
+ supportedLevels: ["section", "item", "passage", "rubric"],
138
+ // PNP support IDs
139
+ // Maps to AfA PNP 3.0 / QTI 3.0 dictionary support, plus the common variants a
140
+ // host profile is likely to carry.
141
+ pnpSupportIds: options.pnpSupportIds ?? [
142
+ "dictionary", // Canonical id
143
+ "englishDictionary", // Common variant
144
+ "glossary", // Common variant
145
+ "definitions", // Common variant
146
+ ],
147
+ /** Pass 2: a dictionary is relevant wherever there is text to look words up from. */
148
+ isVisibleInContext(context) {
149
+ return hasReadableText(context);
150
+ },
151
+ renderToolbar(context, toolbarContext) {
152
+ return renderDictionaryPanel({
153
+ registration: this,
154
+ context,
155
+ toolbarContext,
156
+ buttonA11yKey: `${prefix}.buttonA11y`,
157
+ tooltipKey: `${prefix}.tooltip`,
158
+ shellTitleKey: `${prefix}.name`,
159
+ lookupLanguage: options.lookupLanguage,
160
+ initialWidth: 420,
161
+ initialHeight: 380,
162
+ minWidth: 300,
163
+ minHeight: 240,
164
+ });
165
+ },
166
+ };
167
+ }
168
+ /** The content-following dictionary every deployment gets. */
169
+ export const dictionaryToolRegistration = createDictionaryToolRegistration();
170
+ /**
171
+ * Picture Dictionary tool registration
172
+ *
173
+ * Symbol or picture lookup from a host-supplied service. Sized wider than the word
174
+ * dictionary because its results are a grid rather than a column of text.
175
+ */
176
+ export function createPictureDictionaryToolRegistration(options = {}) {
177
+ const toolId = options.toolId ?? "pictureDictionary";
178
+ const prefix = options.messageKeyPrefix ?? `tools.${toolId}`;
179
+ return {
180
+ toolId,
181
+ name: options.name ?? "Picture Dictionary",
182
+ description: options.description ?? "Look up pictures for words",
183
+ nameKey: `${prefix}.name`,
184
+ descriptionKey: `${prefix}.description`,
185
+ icon: "photo",
186
+ supportedLevels: ["section", "item", "passage", "rubric"],
187
+ // PNP support IDs
188
+ // AfA PNP 3.0 names an illustrated equivalent of a glossary; the variants cover
189
+ // what host profiles call it in practice.
190
+ pnpSupportIds: options.pnpSupportIds ?? [
191
+ "pictureDictionary", // Canonical id
192
+ "illustratedGlossary", // Common variant
193
+ "symbolDictionary", // Common variant
194
+ "pictureSupport", // Common variant
195
+ ],
196
+ isVisibleInContext(context) {
197
+ return hasReadableText(context);
198
+ },
199
+ renderToolbar(context, toolbarContext) {
200
+ return renderDictionaryPanel({
201
+ registration: this,
202
+ context,
203
+ toolbarContext,
204
+ buttonA11yKey: `${prefix}.buttonA11y`,
205
+ tooltipKey: `${prefix}.tooltip`,
206
+ shellTitleKey: `${prefix}.name`,
207
+ lookupLanguage: options.lookupLanguage,
208
+ initialWidth: 520,
209
+ initialHeight: 460,
210
+ minWidth: 320,
211
+ minHeight: 280,
212
+ });
213
+ },
214
+ };
215
+ }
216
+ /** The content-following picture dictionary every deployment gets. */
217
+ export const pictureDictionaryToolRegistration = createPictureDictionaryToolRegistration();
218
+ /**
219
+ * Spanish variants, packaged because a Spanish gloss for English content is a named
220
+ * accommodation rather than a hypothetical. Neither appears unless a PNP grants it.
221
+ */
222
+ export const spanishDictionaryToolRegistration = createDictionaryToolRegistration({
223
+ toolId: "dictionarySpanish",
224
+ name: "Spanish Dictionary",
225
+ description: "Look up word definitions in Spanish",
226
+ pnpSupportIds: ["spanishDictionary", "spanishGlossary"],
227
+ lookupLanguage: "es",
228
+ });
229
+ export const spanishPictureDictionaryToolRegistration = createPictureDictionaryToolRegistration({
230
+ toolId: "pictureDictionarySpanish",
231
+ name: "Spanish Picture Dictionary",
232
+ description: "Look up pictures for words in Spanish",
233
+ pnpSupportIds: ["spanishPictureDictionary", "spanishIllustratedGlossary"],
234
+ lookupLanguage: "es",
235
+ });
@@ -18,10 +18,3 @@ import type { ToolRegistration } from "@pie-players/pie-assessment-toolkit/tools
18
18
  * Only appears on multiple-choice style questions.
19
19
  */
20
20
  export declare const answerEliminatorToolRegistration: ToolRegistration;
21
- /**
22
- * Highlighter tool registration
23
- *
24
- * Allows students to highlight text in passages and questions.
25
- * Appears on items with readable text content.
26
- */
27
- export declare const highlighterToolRegistration: ToolRegistration;
@@ -23,6 +23,8 @@ export const answerEliminatorToolRegistration = {
23
23
  toolId: "answerEliminator",
24
24
  name: "Answer Eliminator",
25
25
  description: "Strike through answer choices",
26
+ nameKey: "tools.answerEliminator.name",
27
+ descriptionKey: "tools.answerEliminator.description",
26
28
  icon: "strikethrough",
27
29
  // Answer eliminator appears at item level only
28
30
  supportedLevels: ["item"],
@@ -52,8 +54,8 @@ export const answerEliminatorToolRegistration = {
52
54
  label: this.name,
53
55
  icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M19,3H16.3H7.7H5A2,2 0 0,0 3,5V7.7V16.4V19A2,2 0 0,0 5,21H7.7H16.4H19A2,2 0 0,0 21,19V16.3V7.7V5A2,2 0 0,0 19,3M15.6,17L12,13.4L8.4,17L7,15.6L10.6,12L7,8.4L8.4,7L12,10.6L15.6,7L17,8.4L13.4,12L17,15.6L15.6,17Z"/></svg>',
54
56
  disabled: false,
55
- ariaLabel: "Answer eliminator - Strike through choices",
56
- tooltip: "Strike Through",
57
+ ariaLabel: toolbarContext.i18n.t("tools.answerEliminator.buttonA11y"),
58
+ tooltip: toolbarContext.i18n.t("tools.answerEliminator.tooltip"),
57
59
  onClick: () => toolbarContext.toggleTool(this.toolId),
58
60
  active: visibility.isActive(),
59
61
  };
@@ -83,61 +85,3 @@ export const answerEliminatorToolRegistration = {
83
85
  };
84
86
  },
85
87
  };
86
- /**
87
- * Highlighter tool registration
88
- *
89
- * Allows students to highlight text in passages and questions.
90
- * Appears on items with readable text content.
91
- */
92
- export const highlighterToolRegistration = {
93
- toolId: "highlighter",
94
- name: "Highlighter",
95
- description: "Highlight text",
96
- icon: "highlighter",
97
- activation: "toolbar-toggle",
98
- // Highlighter appears at passage, rubric, item, and element levels
99
- supportedLevels: ["passage", "rubric", "item", "element"],
100
- // PNP support IDs
101
- pnpSupportIds: ["highlighter", "textHighlight", "annotation"],
102
- /**
103
- * Pass 2: Highlighter is relevant when readable text is available
104
- */
105
- isVisibleInContext(context) {
106
- return hasReadableText(context);
107
- },
108
- renderToolbar(context, toolbarContext) {
109
- const visibility = createScopedVisibilityBinding(this.toolId, toolbarContext);
110
- const button = {
111
- toolId: this.toolId,
112
- label: this.name,
113
- icon: typeof this.icon === "function" ? this.icon(context) : this.icon,
114
- disabled: false,
115
- ariaLabel: "Highlighter - Highlight text",
116
- tooltip: "Highlight",
117
- onClick: () => toolbarContext.toggleTool(this.toolId),
118
- active: visibility.isActive(),
119
- };
120
- const componentOverrides = toolbarContext.componentOverrides ?? {};
121
- const overlay = createToolElement(this.toolId, context, toolbarContext, componentOverrides);
122
- overlay.setAttribute("tool-id", visibility.fullToolId);
123
- return {
124
- toolId: this.toolId,
125
- button,
126
- elements: [{ element: overlay, mount: "after-buttons" }],
127
- sync: () => {
128
- syncButtonAndOverlayVisibility({
129
- button,
130
- overlay,
131
- isActive: visibility.isActive,
132
- onActiveChange: (active) => {
133
- overlay.enabled = active;
134
- },
135
- });
136
- if (toolbarContext.ttsService) {
137
- overlay.ttsService = toolbarContext.ttsService;
138
- }
139
- },
140
- subscribeActive: visibility.subscribeActive,
141
- };
142
- },
143
- };
@@ -10,6 +10,7 @@
10
10
  import { hasMathContent } from "@pie-players/pie-assessment-toolkit/tools/internal";
11
11
  import { createToolElement, } from "@pie-players/pie-assessment-toolkit/tools/internal";
12
12
  import { applyOverlaySurface, createScopedVisibilityBinding, syncButtonAndOverlayVisibility, } from "@pie-players/pie-assessment-toolkit/tools/internal";
13
+ import { renderOverlayToolbar } from "./overlay-toolbar-render.js";
13
14
  /**
14
15
  * Ruler tool registration
15
16
  *
@@ -20,6 +21,8 @@ export const rulerToolRegistration = {
20
21
  toolId: "ruler",
21
22
  name: "Ruler",
22
23
  description: "On-screen ruler for measurements",
24
+ nameKey: "tools.ruler.name",
25
+ descriptionKey: "tools.ruler.description",
23
26
  icon: "ruler",
24
27
  // Ruler typically appears at section/item/element level
25
28
  supportedLevels: ["section", "item", "element"],
@@ -36,37 +39,10 @@ export const rulerToolRegistration = {
36
39
  return hasMathContent(context);
37
40
  },
38
41
  renderToolbar(context, toolbarContext) {
39
- const visibility = createScopedVisibilityBinding(this.toolId, toolbarContext);
40
- const button = {
41
- toolId: this.toolId,
42
- label: this.name,
43
- icon: typeof this.icon === "function" ? this.icon(context) : this.icon,
44
- disabled: false,
45
- ariaLabel: "Open ruler tool",
46
- tooltip: "Ruler",
47
- onClick: () => toolbarContext.toggleTool(this.toolId),
48
- active: visibility.isActive(),
49
- };
50
- const componentOverrides = toolbarContext.componentOverrides ?? {};
51
- const overlay = createToolElement(this.toolId, context, toolbarContext, componentOverrides);
52
- overlay.setAttribute("tool-id", visibility.fullToolId);
53
- applyOverlaySurface(overlay, "frameless");
54
- return {
55
- toolId: this.toolId,
56
- button,
57
- elements: [{ element: overlay, mount: "after-buttons" }],
58
- sync: () => {
59
- syncButtonAndOverlayVisibility({
60
- button,
61
- overlay,
62
- isActive: visibility.isActive,
63
- });
64
- if (toolbarContext.toolkitCoordinator) {
65
- overlay.toolkitCoordinator = toolbarContext.toolkitCoordinator;
66
- }
67
- },
68
- subscribeActive: visibility.subscribeActive,
69
- };
42
+ return renderOverlayToolbar(this, context, toolbarContext, {
43
+ surface: "frameless",
44
+ handsOverCoordinator: true,
45
+ });
70
46
  },
71
47
  };
72
48
  /**
@@ -79,6 +55,8 @@ export const protractorToolRegistration = {
79
55
  toolId: "protractor",
80
56
  name: "Protractor",
81
57
  description: "On-screen protractor for angle measurements",
58
+ nameKey: "tools.protractor.name",
59
+ descriptionKey: "tools.protractor.description",
82
60
  icon: "protractor",
83
61
  // Protractor typically appears at section/item/element level
84
62
  supportedLevels: ["section", "item", "element"],
@@ -95,36 +73,9 @@ export const protractorToolRegistration = {
95
73
  return hasMathContent(context);
96
74
  },
97
75
  renderToolbar(context, toolbarContext) {
98
- const visibility = createScopedVisibilityBinding(this.toolId, toolbarContext);
99
- const button = {
100
- toolId: this.toolId,
101
- label: this.name,
102
- icon: typeof this.icon === "function" ? this.icon(context) : this.icon,
103
- disabled: false,
104
- ariaLabel: "Open protractor tool",
105
- tooltip: "Protractor",
106
- onClick: () => toolbarContext.toggleTool(this.toolId),
107
- active: visibility.isActive(),
108
- };
109
- const componentOverrides = toolbarContext.componentOverrides ?? {};
110
- const overlay = createToolElement(this.toolId, context, toolbarContext, componentOverrides);
111
- overlay.setAttribute("tool-id", visibility.fullToolId);
112
- applyOverlaySurface(overlay, "frameless");
113
- return {
114
- toolId: this.toolId,
115
- button,
116
- elements: [{ element: overlay, mount: "after-buttons" }],
117
- sync: () => {
118
- syncButtonAndOverlayVisibility({
119
- button,
120
- overlay,
121
- isActive: visibility.isActive,
122
- });
123
- if (toolbarContext.toolkitCoordinator) {
124
- overlay.toolkitCoordinator = toolbarContext.toolkitCoordinator;
125
- }
126
- },
127
- subscribeActive: visibility.subscribeActive,
128
- };
76
+ return renderOverlayToolbar(this, context, toolbarContext, {
77
+ surface: "frameless",
78
+ handsOverCoordinator: true,
79
+ });
129
80
  },
130
81
  };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `renderToolbar` shape shared by every packaged capability that puts one
3
+ * overlay behind one toolbar button.
4
+ *
5
+ * Seven registrations had this inline. What actually varies between them is the
6
+ * three options below and nothing else: whether the overlay paints its own chrome
7
+ * or takes a draggable window, that window's size, and whether the overlay needs
8
+ * the coordinator re-handed on each sync. Everything else — the visibility
9
+ * binding, the button, the component-override lookup, the `tool-id` attribute, the
10
+ * sync — was identical, which is why one rule landing in three spellings was
11
+ * possible: the shell title was `resolveToolRegistrationName` in two files,
12
+ * `displayName` in a third and a raw `t(key)` in a fourth.
13
+ *
14
+ * Catalog keys are derived from `toolId`, which held at all seven sites:
15
+ * `tools.<toolId>.buttonA11y` and `tools.<toolId>.tooltip`. A capability needing a
16
+ * different prefix — the dictionary variants, which compose several capabilities
17
+ * off one element — keeps its own renderer.
18
+ */
19
+ import { type ToolContext, type ToolRegistration, type ToolToolbarRenderResult, type ToolbarContext } from "@pie-players/pie-assessment-toolkit/tools/internal";
20
+ /** A window's geometry. Absent for overlays that paint their own chrome. */
21
+ export interface OverlayToolShell {
22
+ resizable: boolean;
23
+ initialWidth: number;
24
+ initialHeight: number;
25
+ minWidth: number;
26
+ minHeight: number;
27
+ }
28
+ export interface RenderOverlayToolbarOptions {
29
+ /**
30
+ * `"frameless"` for an overlay that draws its own surface — a ruler or a line
31
+ * reader sits on the content rather than in a panel.
32
+ */
33
+ surface?: "frameless";
34
+ /** Present when the capability opens in a draggable, closeable window. */
35
+ shell?: OverlayToolShell;
36
+ /**
37
+ * Re-hand the toolkit coordinator on every sync. Overlays that take part in
38
+ * stacking or visibility restore need it; the two that only render their own
39
+ * model do not, and giving them a property they ignore is not free to assume.
40
+ */
41
+ handsOverCoordinator?: boolean;
42
+ }
43
+ export declare function renderOverlayToolbar(registration: ToolRegistration, context: ToolContext, toolbarContext: ToolbarContext, options?: RenderOverlayToolbarOptions): ToolToolbarRenderResult;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The `renderToolbar` shape shared by every packaged capability that puts one
3
+ * overlay behind one toolbar button.
4
+ *
5
+ * Seven registrations had this inline. What actually varies between them is the
6
+ * three options below and nothing else: whether the overlay paints its own chrome
7
+ * or takes a draggable window, that window's size, and whether the overlay needs
8
+ * the coordinator re-handed on each sync. Everything else — the visibility
9
+ * binding, the button, the component-override lookup, the `tool-id` attribute, the
10
+ * sync — was identical, which is why one rule landing in three spellings was
11
+ * possible: the shell title was `resolveToolRegistrationName` in two files,
12
+ * `displayName` in a third and a raw `t(key)` in a fourth.
13
+ *
14
+ * Catalog keys are derived from `toolId`, which held at all seven sites:
15
+ * `tools.<toolId>.buttonA11y` and `tools.<toolId>.tooltip`. A capability needing a
16
+ * different prefix — the dictionary variants, which compose several capabilities
17
+ * off one element — keeps its own renderer.
18
+ */
19
+ import { applyOverlaySurface, createScopedVisibilityBinding, createToolElement, resolveToolRegistrationName, syncButtonAndOverlayVisibility, } from "@pie-players/pie-assessment-toolkit/tools/internal";
20
+ export function renderOverlayToolbar(registration, context, toolbarContext, options = {}) {
21
+ const { toolId } = registration;
22
+ const visibility = createScopedVisibilityBinding(toolId, toolbarContext);
23
+ const button = {
24
+ toolId,
25
+ label: registration.name,
26
+ icon: typeof registration.icon === "function"
27
+ ? registration.icon(context)
28
+ : registration.icon,
29
+ disabled: false,
30
+ ariaLabel: toolbarContext.i18n.t(`tools.${toolId}.buttonA11y`),
31
+ tooltip: toolbarContext.i18n.t(`tools.${toolId}.tooltip`),
32
+ onClick: () => toolbarContext.toggleTool(toolId),
33
+ active: visibility.isActive(),
34
+ };
35
+ const componentOverrides = toolbarContext.componentOverrides ??
36
+ {};
37
+ const overlay = createToolElement(toolId, context, toolbarContext, componentOverrides);
38
+ overlay.setAttribute("tool-id", visibility.fullToolId);
39
+ if (options.surface === "frameless") {
40
+ applyOverlaySurface(overlay, "frameless");
41
+ }
42
+ return {
43
+ toolId,
44
+ button,
45
+ elements: [
46
+ {
47
+ element: overlay,
48
+ mount: "after-buttons",
49
+ ...(options.shell
50
+ ? {
51
+ shell: {
52
+ // Through `nameKey`, so a window's title tracks the interface
53
+ // locale the way its button already does.
54
+ title: resolveToolRegistrationName(registration, toolbarContext.i18n),
55
+ draggable: true,
56
+ closeable: true,
57
+ ...options.shell,
58
+ },
59
+ }
60
+ : {}),
61
+ },
62
+ ],
63
+ sync: () => {
64
+ syncButtonAndOverlayVisibility({
65
+ button,
66
+ overlay,
67
+ isActive: visibility.isActive,
68
+ });
69
+ if (options.handsOverCoordinator && toolbarContext.toolkitCoordinator) {
70
+ overlay.toolkitCoordinator = toolbarContext.toolkitCoordinator;
71
+ }
72
+ },
73
+ subscribeActive: visibility.subscribeActive,
74
+ };
75
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Which capabilities appear as actions on the learner's current selection.
3
+ *
4
+ * The one place that pairs a selection gateway to the tools it can open. The gateway
5
+ * renders what it is handed and names nothing; the tools know nothing about
6
+ * selections. Composition is the only layer allowed to know both, which is why this
7
+ * list lives here rather than in either of them.
8
+ *
9
+ * A selection is a shortcut, never the only way in. Chromium does not extend a
10
+ * selection with Shift+Arrow in non-editable content unless caret browsing is on —
11
+ * an OS toggle that does not exist on mobile — so a sighted keyboard-only learner
12
+ * cannot originate one at all. Every tool named here also opens from the toolbar and
13
+ * carries its own term field.
14
+ */
15
+ import type { ToolSelectionAction, ToolSurfaceServices } from "@pie-players/pie-assessment-toolkit/tools/internal";
16
+ /**
17
+ * Actions for the gateway to render, given the services it was handed.
18
+ *
19
+ * Rebuilt on every sync rather than captured once: a host calling
20
+ * `updateAssessment` mid-session swaps the coordinator without remounting the
21
+ * gateway, and an action closing over the previous one would open a tool in a
22
+ * session the learner has left.
23
+ */
24
+ export declare function buildSelectionActions(services: ToolSurfaceServices): ToolSelectionAction[];
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Which capabilities appear as actions on the learner's current selection.
3
+ *
4
+ * The one place that pairs a selection gateway to the tools it can open. The gateway
5
+ * renders what it is handed and names nothing; the tools know nothing about
6
+ * selections. Composition is the only layer allowed to know both, which is why this
7
+ * list lives here rather than in either of them.
8
+ *
9
+ * A selection is a shortcut, never the only way in. Chromium does not extend a
10
+ * selection with Shift+Arrow in non-editable content unless caret browsing is on —
11
+ * an OS toggle that does not exist on mobile — so a sighted keyboard-only learner
12
+ * cannot originate one at all. Every tool named here also opens from the toolbar and
13
+ * carries its own term field.
14
+ */
15
+ import { resolveFallbackToolIcon } from "@pie-players/pie-assessment-toolkit/tools/internal";
16
+ function asRequestCapable(coordinator) {
17
+ const candidate = coordinator;
18
+ if (typeof candidate?.canRequestTool !== "function")
19
+ return null;
20
+ if (typeof candidate.requestTool !== "function")
21
+ return null;
22
+ return candidate;
23
+ }
24
+ const SELECTION_ACTION_SPECS = [
25
+ {
26
+ toolId: "dictionary",
27
+ label: "Look up selected text in dictionary",
28
+ tooltip: "Look up",
29
+ icon: "book-open",
30
+ },
31
+ {
32
+ toolId: "pictureDictionary",
33
+ label: "Show pictures for selected text",
34
+ tooltip: "Show pictures",
35
+ icon: "photo",
36
+ },
37
+ ];
38
+ /**
39
+ * Actions for the gateway to render, given the services it was handed.
40
+ *
41
+ * Rebuilt on every sync rather than captured once: a host calling
42
+ * `updateAssessment` mid-session swaps the coordinator without remounting the
43
+ * gateway, and an action closing over the previous one would open a tool in a
44
+ * session the learner has left.
45
+ */
46
+ export function buildSelectionActions(services) {
47
+ const coordinator = asRequestCapable(services.toolkitCoordinator);
48
+ if (!coordinator)
49
+ return [];
50
+ return SELECTION_ACTION_SPECS.map((spec) => {
51
+ const iconSvg = resolveFallbackToolIcon(spec.icon) ?? undefined;
52
+ return {
53
+ id: spec.toolId,
54
+ label: spec.label,
55
+ tooltip: spec.tooltip,
56
+ iconSvg,
57
+ // Asked per selection. A tool the PNP does not grant, or that no toolbar in
58
+ // this section hosts, is absent rather than present and inert.
59
+ isAvailable: () => coordinator.canRequestTool(spec.toolId),
60
+ run: (selection) => {
61
+ const term = selection.text.trim();
62
+ if (!term)
63
+ return;
64
+ // Both panels read the selected text from `term`. No level is named, so the
65
+ // request takes whichever toolbar hosts the tool, preferring section scope.
66
+ coordinator.requestTool({
67
+ toolId: spec.toolId,
68
+ params: { term },
69
+ });
70
+ },
71
+ };
72
+ });
73
+ }