@pie-players/pie-assessment-toolkit 0.2.9 → 0.2.11

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.
Files changed (112) hide show
  1. package/dist/components/ItemToolBar.custom-element.js +425 -93
  2. package/dist/components/PieAssessmentToolkit.custom-element.js +200 -46
  3. package/dist/components/SectionToolBar.custom-element.js +162 -0
  4. package/dist/components/section-toolbar-element.d.ts +3 -0
  5. package/dist/components/section-toolbar-element.d.ts.map +1 -0
  6. package/dist/components/section-toolbar-element.js +2 -0
  7. package/dist/components/section-toolbar-element.js.map +1 -0
  8. package/dist/context/assessment-toolkit-context.d.ts +1 -1
  9. package/dist/context/assessment-toolkit-context.d.ts.map +1 -1
  10. package/dist/index.d.ts +5 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/runtime/SectionRuntimeEngine.d.ts +57 -1
  15. package/dist/runtime/SectionRuntimeEngine.d.ts.map +1 -1
  16. package/dist/runtime/SectionRuntimeEngine.js +23 -0
  17. package/dist/runtime/SectionRuntimeEngine.js.map +1 -1
  18. package/dist/runtime/registration-events.d.ts +19 -0
  19. package/dist/runtime/registration-events.d.ts.map +1 -1
  20. package/dist/runtime/registration-events.js +3 -0
  21. package/dist/runtime/registration-events.js.map +1 -1
  22. package/dist/runtime/runtime-event-guards.d.ts +0 -1
  23. package/dist/runtime/runtime-event-guards.d.ts.map +1 -1
  24. package/dist/runtime/runtime-event-guards.js +0 -5
  25. package/dist/runtime/runtime-event-guards.js.map +1 -1
  26. package/dist/runtime/session-event-emitter-policy.d.ts +12 -0
  27. package/dist/runtime/session-event-emitter-policy.d.ts.map +1 -0
  28. package/dist/runtime/session-event-emitter-policy.js +57 -0
  29. package/dist/runtime/session-event-emitter-policy.js.map +1 -0
  30. package/dist/services/HighlightCoordinator.d.ts +9 -0
  31. package/dist/services/HighlightCoordinator.d.ts.map +1 -1
  32. package/dist/services/HighlightCoordinator.js +196 -55
  33. package/dist/services/HighlightCoordinator.js.map +1 -1
  34. package/dist/services/TTSService.d.ts +18 -0
  35. package/dist/services/TTSService.d.ts.map +1 -1
  36. package/dist/services/TTSService.js +343 -105
  37. package/dist/services/TTSService.js.map +1 -1
  38. package/dist/services/ToolRegistry.d.ts +40 -2
  39. package/dist/services/ToolRegistry.d.ts.map +1 -1
  40. package/dist/services/ToolRegistry.js +22 -1
  41. package/dist/services/ToolRegistry.js.map +1 -1
  42. package/dist/services/ToolkitCoordinator.d.ts +34 -1
  43. package/dist/services/ToolkitCoordinator.d.ts.map +1 -1
  44. package/dist/services/ToolkitCoordinator.js +203 -2
  45. package/dist/services/ToolkitCoordinator.js.map +1 -1
  46. package/dist/services/createDefaultToolRegistry.d.ts +5 -11
  47. package/dist/services/createDefaultToolRegistry.d.ts.map +1 -1
  48. package/dist/services/createDefaultToolRegistry.js +5 -48
  49. package/dist/services/createDefaultToolRegistry.js.map +1 -1
  50. package/dist/services/interfaces.d.ts +7 -2
  51. package/dist/services/interfaces.d.ts.map +1 -1
  52. package/dist/services/interfaces.js.map +1 -1
  53. package/dist/services/pnp-standard-features.d.ts +1 -1
  54. package/dist/services/section-controller-types.d.ts +5 -0
  55. package/dist/services/section-controller-types.d.ts.map +1 -1
  56. package/dist/services/tool-config-defaults.d.ts +14 -0
  57. package/dist/services/tool-config-defaults.d.ts.map +1 -0
  58. package/dist/services/tool-config-defaults.js +23 -0
  59. package/dist/services/tool-config-defaults.js.map +1 -0
  60. package/dist/services/tool-context.d.ts.map +1 -1
  61. package/dist/services/tool-context.js +50 -3
  62. package/dist/services/tool-context.js.map +1 -1
  63. package/dist/services/tool-instance-id.d.ts +1 -3
  64. package/dist/services/tool-instance-id.d.ts.map +1 -1
  65. package/dist/services/tool-instance-id.js +4 -10
  66. package/dist/services/tool-instance-id.js.map +1 -1
  67. package/dist/services/toolbar-items.d.ts +26 -0
  68. package/dist/services/toolbar-items.d.ts.map +1 -0
  69. package/dist/services/toolbar-items.js +10 -0
  70. package/dist/services/toolbar-items.js.map +1 -0
  71. package/dist/services/tools-config-normalizer.d.ts.map +1 -1
  72. package/dist/services/tools-config-normalizer.js +5 -6
  73. package/dist/services/tools-config-normalizer.js.map +1 -1
  74. package/dist/services/tts/browser-provider.d.ts.map +1 -1
  75. package/dist/services/tts/browser-provider.js +201 -34
  76. package/dist/services/tts/browser-provider.js.map +1 -1
  77. package/dist/services/tts/text-processing.d.ts +17 -0
  78. package/dist/services/tts/text-processing.d.ts.map +1 -0
  79. package/dist/services/tts/text-processing.js +144 -0
  80. package/dist/services/tts/text-processing.js.map +1 -0
  81. package/dist/tools/default-tool-module-loaders.d.ts.map +1 -1
  82. package/dist/tools/default-tool-module-loaders.js +1 -7
  83. package/dist/tools/default-tool-module-loaders.js.map +1 -1
  84. package/dist/tools/registrations/accessibility-tools.d.ts +7 -2
  85. package/dist/tools/registrations/accessibility-tools.d.ts.map +1 -1
  86. package/dist/tools/registrations/accessibility-tools.js +23 -15
  87. package/dist/tools/registrations/accessibility-tools.js.map +1 -1
  88. package/dist/tools/registrations/calculator.d.ts.map +1 -1
  89. package/dist/tools/registrations/calculator.js +18 -10
  90. package/dist/tools/registrations/calculator.js.map +1 -1
  91. package/dist/tools/registrations/interaction-tools.d.ts.map +1 -1
  92. package/dist/tools/registrations/interaction-tools.js +5 -4
  93. package/dist/tools/registrations/interaction-tools.js.map +1 -1
  94. package/dist/tools/registrations/measurement-tools.js +4 -4
  95. package/dist/tools/registrations/measurement-tools.js.map +1 -1
  96. package/dist/tools/registrations/subject-specific-tools.js +4 -4
  97. package/dist/tools/registrations/subject-specific-tools.js.map +1 -1
  98. package/dist/tools/registrations/tts.d.ts.map +1 -1
  99. package/dist/tools/registrations/tts.js +161 -17
  100. package/dist/tools/registrations/tts.js.map +1 -1
  101. package/dist/tools/tool-tag-map.d.ts.map +1 -1
  102. package/dist/tools/tool-tag-map.js +2 -1
  103. package/dist/tools/tool-tag-map.js.map +1 -1
  104. package/package.json +12 -8
  105. package/src/components/ItemToolBar.svelte +275 -47
  106. package/src/components/PieAssessmentToolkit.svelte +217 -53
  107. package/src/components/SectionToolBar.svelte +58 -0
  108. package/dist/services/tts/provider-interface.d.ts +0 -131
  109. package/dist/services/tts/provider-interface.d.ts.map +0 -1
  110. package/dist/services/tts/provider-interface.js +0 -10
  111. package/dist/services/tts/provider-interface.js.map +0 -1
  112. package/src/components/PNPProvenanceViewer.svelte +0 -270
@@ -3,10 +3,14 @@
3
3
  tag: 'pie-item-toolbar',
4
4
  shadow: 'open',
5
5
  props: {
6
+ level: { type: 'String', attribute: 'level' },
7
+ scopeId: { type: 'String', attribute: 'scope-id' },
6
8
  itemId: { type: 'String', attribute: 'item-id' },
9
+ sectionId: { type: 'String', attribute: 'section-id' },
7
10
  catalogId: { type: 'String', attribute: 'catalog-id' },
8
11
  tools: { type: 'String', attribute: 'tools' },
9
12
  contentKind: { type: 'String', attribute: 'content-kind' },
13
+ position: { type: 'String', attribute: 'position' },
10
14
  size: { type: 'String', attribute: 'size' },
11
15
  language: { type: 'String', attribute: 'language' },
12
16
 
@@ -16,7 +20,8 @@
16
20
  pnpResolver: { type: 'Object', reflect: false },
17
21
  assessment: { type: 'Object', reflect: false },
18
22
  itemRef: { type: 'Object', reflect: false },
19
- item: { type: 'Object', reflect: false }
23
+ item: { type: 'Object', reflect: false },
24
+ hostButtons: { type: 'Object', reflect: false }
20
25
  }
21
26
  }}
22
27
  />
@@ -38,6 +43,12 @@
38
43
  connectAssessmentToolkitShellContext,
39
44
  } from '../context/runtime-context-consumer.js';
40
45
  import type { ToolRegistry, ToolToolbarRenderResult, ToolbarContext } from '../services/ToolRegistry.js';
46
+ import {
47
+ isExternalIconUrl,
48
+ isInlineSvgIcon,
49
+ isToolbarLinkItem,
50
+ type ToolbarItem
51
+ } from '../services/toolbar-items.js';
41
52
  import type { PNPToolResolver } from '../services/PNPToolResolver.js';
42
53
  import { createDefaultToolRegistry } from '../services/createDefaultToolRegistry.js';
43
54
  import { DEFAULT_TOOL_MODULE_LOADERS } from '../tools/default-tool-module-loaders.js';
@@ -48,7 +59,7 @@
48
59
  } from '../services/tools-config-normalizer.js';
49
60
  import { createScopedToolId, parseScopedToolId } from '../services/tool-instance-id.js';
50
61
  import type { AssessmentEntity, AssessmentItemRef, ItemEntity } from '@pie-players/pie-players-shared/types';
51
- import type { ElementToolContext, ItemToolContext } from '../services/tool-context.js';
62
+ import type { ElementToolContext, ItemToolContext, ToolLevel, ToolContext } from '../services/tool-context.js';
52
63
 
53
64
  const isBrowser = typeof window !== 'undefined';
54
65
  const fallbackToolRegistry = createDefaultToolRegistry({
@@ -59,30 +70,40 @@
59
70
 
60
71
  // Props
61
72
  let {
73
+ level = 'item' as ToolLevel,
74
+ scopeId = '',
62
75
  itemId = '',
76
+ sectionId = '',
63
77
  catalogId = '',
64
78
  tools = 'calculator,textToSpeech,answerEliminator',
65
79
  contentKind = 'assessment-item',
80
+ position = 'bottom' as 'top' | 'right' | 'bottom' | 'left' | 'none',
66
81
  scopeElement = null,
67
82
  toolRegistry = null,
68
83
  pnpResolver = null,
69
84
  assessment = null,
70
85
  itemRef = null,
71
86
  item = null,
87
+ hostButtons = [] as ToolbarItem[],
72
88
  class: className = '',
73
89
  size = 'md' as 'sm' | 'md' | 'lg',
74
90
  language = 'en-US'
75
91
  }: {
92
+ level?: ToolLevel;
93
+ scopeId?: string;
76
94
  itemId?: string;
95
+ sectionId?: string;
77
96
  catalogId?: string;
78
97
  tools?: string;
79
98
  contentKind?: string;
99
+ position?: 'top' | 'right' | 'bottom' | 'left' | 'none';
80
100
  scopeElement?: HTMLElement | null;
81
101
  toolRegistry?: ToolRegistry | null;
82
102
  pnpResolver?: PNPToolResolver | null;
83
103
  assessment?: AssessmentEntity | null;
84
104
  itemRef?: AssessmentItemRef | null;
85
105
  item?: ItemEntity | null;
106
+ hostButtons?: ToolbarItem[];
86
107
  class?: string;
87
108
  size?: 'sm' | 'md' | 'lg';
88
109
  language?: string;
@@ -111,11 +132,22 @@
111
132
  const effectiveTTSService = $derived(runtimeContext?.ttsService);
112
133
  const effectiveElementToolStateStore = $derived(runtimeContext?.elementToolStateStore);
113
134
  const effectiveAssessmentId = $derived(runtimeContext?.assessmentId ?? '');
114
- const effectiveSectionId = $derived(runtimeContext?.sectionId ?? '');
115
135
  const effectiveItemId = $derived(itemId || shellContext?.itemId || '');
116
136
  const effectiveCanonicalItemId = $derived(shellContext?.canonicalItemId || effectiveItemId);
117
- const effectiveCatalogId = $derived(catalogId || effectiveItemId);
118
- const effectiveContentKind = $derived(contentKind || shellContext?.contentKind || 'assessment-item');
137
+ const effectiveSectionId = $derived(sectionId || runtimeContext?.sectionId || '');
138
+ const effectiveContentKind = $derived(contentKind || shellContext?.contentKind || (level === 'section' ? 'section' : 'assessment-item'));
139
+ const effectiveLevel = $derived.by((): ToolLevel => {
140
+ if (level && level !== 'item') return level;
141
+ if (effectiveContentKind === 'rubric-block-stimulus') return 'passage';
142
+ return 'item';
143
+ });
144
+ const effectiveScopeId = $derived.by(() => {
145
+ if (scopeId) return scopeId;
146
+ if (effectiveLevel === 'section') return effectiveSectionId || runtimeContext?.sectionId || 'default-section';
147
+ if (effectiveLevel === 'assessment') return runtimeContext?.assessmentId || 'default-assessment';
148
+ return effectiveCanonicalItemId || effectiveItemId || 'default-item';
149
+ });
150
+ const effectiveCatalogId = $derived(catalogId || effectiveScopeId);
119
151
  const effectiveItem = $derived((item as ItemEntity | null) || (shellContext?.item as ItemEntity | null) || null);
120
152
 
121
153
  // Effective registry for visibility and metadata ownership.
@@ -129,54 +161,92 @@
129
161
  const coordinatorConfig = runtimeContext?.toolkitCoordinator?.config?.tools as any;
130
162
  return normalizeToolsConfig(coordinatorConfig || {});
131
163
  });
132
- const placementLevel = $derived.by(() =>
133
- effectiveContentKind === 'rubric-block-stimulus' ? 'passage' : 'item'
134
- );
164
+ const placementLevel = $derived.by(() => {
165
+ if (effectiveLevel === 'section') return 'section';
166
+ if (effectiveLevel === 'passage' || effectiveContentKind === 'rubric-block-stimulus') return 'passage';
167
+ return 'item';
168
+ });
135
169
  const placementAllowedToolIds = $derived.by(() => {
136
170
  const resolved = resolveToolsForLevel(effectiveToolsConfig, placementLevel);
137
171
  return effectiveToolRegistry.normalizeToolIds(resolved).filter(Boolean);
138
172
  });
173
+ const providerConfig = $derived(
174
+ (effectiveToolsConfig?.providers || {}) as Record<
175
+ string,
176
+ { enabled?: boolean } | undefined
177
+ >
178
+ );
179
+ const isProviderEnabled = (toolId: string): boolean =>
180
+ providerConfig[toolId]?.enabled !== false;
139
181
 
140
182
  // Pass 1: determine allowed tools
141
183
  const allowedToolIds = $derived.by(() => {
142
184
  const configuredTools =
143
185
  normalizedExplicitTools.length > 0 ? normalizedExplicitTools : placementAllowedToolIds;
186
+ const providerEnabledConfiguredTools = configuredTools.filter((toolId) =>
187
+ isProviderEnabled(toolId)
188
+ );
144
189
  if (pnpResolver && assessment && itemRef) {
145
190
  const allowedByPnp = pnpResolver.getAllowedToolIds(assessment, itemRef);
146
- if (configuredTools.length === 0) return allowedByPnp;
147
- return allowedByPnp.filter((toolId) => configuredTools.includes(toolId));
191
+ if (providerEnabledConfiguredTools.length === 0) return allowedByPnp;
192
+ return allowedByPnp.filter((toolId) =>
193
+ providerEnabledConfiguredTools.includes(toolId)
194
+ );
148
195
  }
149
- return configuredTools;
196
+ return providerEnabledConfiguredTools;
150
197
  });
151
198
 
152
199
  const contentReady = $derived.by(() => {
200
+ if (effectiveLevel === 'section' || effectiveLevel === 'assessment') return true;
153
201
  const config = (effectiveItem as ItemEntity | null)?.config;
154
202
  return !!(effectiveItem && config && typeof config === 'object');
155
203
  });
156
204
 
157
205
  const toolContext = $derived.by((): ItemToolContext | null => {
206
+ if (effectiveLevel === 'section') {
207
+ return {
208
+ level: 'section',
209
+ assessment: (assessment || {}) as AssessmentEntity,
210
+ section: {} as any
211
+ } as ToolContext as ItemToolContext;
212
+ }
213
+ if (effectiveLevel === 'assessment') {
214
+ return {
215
+ level: 'assessment',
216
+ assessment: (assessment || {}) as AssessmentEntity
217
+ } as ToolContext as ItemToolContext;
218
+ }
158
219
  if (!contentReady || !effectiveItem) {
159
220
  return null;
160
221
  }
222
+ if (effectiveLevel === 'passage') {
223
+ return {
224
+ level: 'passage',
225
+ assessment: (assessment || {}) as AssessmentEntity,
226
+ itemRef: (itemRef || ({ id: effectiveCanonicalItemId } as AssessmentItemRef)) as AssessmentItemRef,
227
+ passage: effectiveItem as any
228
+ } as ToolContext as ItemToolContext;
229
+ }
161
230
  return {
162
231
  level: 'item',
163
232
  assessment: (assessment || {}) as AssessmentEntity,
164
233
  itemRef: (itemRef || ({ id: effectiveCanonicalItemId } as AssessmentItemRef)) as AssessmentItemRef,
165
234
  item: effectiveItem as ItemEntity
166
- };
235
+ } as ToolContext as ItemToolContext;
167
236
  });
168
237
 
169
- const renderContext = $derived.by((): ItemToolContext => {
238
+ const renderContext = $derived.by((): ToolContext => {
170
239
  if (toolContext) return toolContext;
171
240
  return {
172
241
  level: 'item',
173
242
  assessment: (assessment || {}) as AssessmentEntity,
174
243
  itemRef: (itemRef || ({ id: effectiveCanonicalItemId } as AssessmentItemRef)) as AssessmentItemRef,
175
244
  item: ((effectiveItem as ItemEntity | null) || ({ id: effectiveCanonicalItemId, config: {} } as ItemEntity)) as ItemEntity
176
- };
245
+ } as ToolContext;
177
246
  });
178
247
 
179
248
  const elementContexts = $derived.by((): ElementToolContext[] => {
249
+ if (effectiveLevel !== 'item' && effectiveLevel !== 'passage') return [];
180
250
  if (!contentReady || !effectiveItem) return [];
181
251
  const modelsRaw = (effectiveItem as any)?.config?.models;
182
252
  const models = Array.isArray(modelsRaw)
@@ -197,24 +267,40 @@
197
267
 
198
268
  // Pass 2: tool-owned context filtering (item + element aggregation)
199
269
  const visibleToolIds = $derived.by(() => {
270
+ const levelCompatibleToolIds = allowedToolIds.filter((toolId) => {
271
+ const registration = effectiveToolRegistry.get(toolId);
272
+ return registration ? registration.supportedLevels.includes(effectiveLevel) : false;
273
+ });
274
+ if (effectiveLevel === 'section') {
275
+ // Section toolbars are orchestrator-driven. Tool-level relevance is often
276
+ // item-content dependent. Keep pass-1 as the source of truth here.
277
+ return allowedToolIds;
278
+ }
200
279
  if (!contentReady) {
201
280
  // Stage 1: orchestrator-level allow-list only.
202
- return allowedToolIds;
281
+ return levelCompatibleToolIds;
203
282
  }
204
283
  if (!toolContext && elementContexts.length === 0) {
205
- return allowedToolIds;
284
+ return levelCompatibleToolIds;
206
285
  }
207
286
  const visible = new Set<string>();
208
287
  if (toolContext) {
209
- effectiveToolRegistry.filterVisibleInContext(allowedToolIds, toolContext).forEach((tool) => visible.add(tool.toolId));
288
+ effectiveToolRegistry
289
+ .filterVisibleInContext(levelCompatibleToolIds, toolContext)
290
+ .forEach((tool) => visible.add(tool.toolId));
210
291
  }
211
292
 
212
293
  for (const context of elementContexts) {
213
- effectiveToolRegistry.filterVisibleInContext(allowedToolIds, context).forEach((tool) => visible.add(tool.toolId));
294
+ effectiveToolRegistry
295
+ .filterVisibleInContext(levelCompatibleToolIds, context)
296
+ .forEach((tool) => visible.add(tool.toolId));
214
297
  }
215
298
 
216
299
  return Array.from(visible);
217
300
  });
301
+ const toolbarVisibleToolIds = $derived.by(() =>
302
+ effectiveToolRegistry.filterToolIdsByActivation(visibleToolIds, 'toolbar-toggle')
303
+ );
218
304
 
219
305
  // Dynamically load whatever tools are currently visible.
220
306
  // The registry owns module loader configuration by toolId.
@@ -222,7 +308,7 @@
222
308
  if (!isBrowser) return;
223
309
  let cancelled = false;
224
310
  void effectiveToolRegistry
225
- .ensureToolModulesLoaded(visibleToolIds)
311
+ .ensureToolModulesLoaded(toolbarVisibleToolIds)
226
312
  .then(() => {
227
313
  if (!cancelled) moduleLoadVersion += 1;
228
314
  })
@@ -257,10 +343,19 @@
257
343
  // This keeps toolbar contracts stable across mixed registration implementations.
258
344
  return parseScopedToolId(toolId)
259
345
  ? toolId
260
- : createScopedToolId(toolId, 'item', effectiveCanonicalItemId);
346
+ : createScopedToolId(toolId, effectiveLevel, effectiveScopeId);
261
347
  };
262
348
  return {
263
- itemId: effectiveCanonicalItemId,
349
+ scope: {
350
+ level: effectiveLevel,
351
+ scopeId: effectiveScopeId,
352
+ assessmentId: runtimeContext?.assessmentId,
353
+ sectionId: effectiveSectionId,
354
+ itemId: effectiveItemId,
355
+ canonicalItemId: effectiveCanonicalItemId,
356
+ contentKind: effectiveContentKind
357
+ },
358
+ itemId: effectiveScopeId,
264
359
  catalogId: effectiveCatalogId,
265
360
  language,
266
361
  ui: {
@@ -274,7 +369,13 @@
274
369
  elementToolStateStore: effectiveElementToolStateStore || null,
275
370
  toggleTool: (toolId: string) => {
276
371
  if (!effectiveToolCoordinator) return;
277
- effectiveToolCoordinator.toggleTool(toInstanceToolId(toolId));
372
+ const instanceToolId = toInstanceToolId(toolId);
373
+ // Some tools only self-register after first visibility sync.
374
+ // Seed a placeholder registration so first toggle always works.
375
+ if (!effectiveToolCoordinator.getToolState(instanceToolId)) {
376
+ effectiveToolCoordinator.registerTool(instanceToolId, toolId);
377
+ }
378
+ effectiveToolCoordinator.toggleTool(instanceToolId);
278
379
  },
279
380
  isToolVisible: (toolId: string) => {
280
381
  if (!effectiveToolCoordinator) return false;
@@ -292,7 +393,7 @@
292
393
  moduleLoadVersion;
293
394
 
294
395
  const rendered: ToolToolbarRenderResult[] = [];
295
- for (const toolId of visibleToolIds) {
396
+ for (const toolId of toolbarVisibleToolIds) {
296
397
  const result = effectiveToolRegistry.renderForToolbar(toolId, renderContext, toolbarContext);
297
398
  if (result) {
298
399
  rendered.push(result);
@@ -300,6 +401,80 @@
300
401
  }
301
402
  return rendered;
302
403
  });
404
+ const normalizedHostButtons = $derived.by((): ToolbarItem[] =>
405
+ Array.isArray(hostButtons)
406
+ ? hostButtons.filter((item): item is ToolbarItem => !!item && typeof item.id === 'string')
407
+ : []
408
+ );
409
+ const nativeToolbarItems = $derived.by((): ToolbarItem[] =>
410
+ renderedTools
411
+ .filter((renderedTool) => !!renderedTool.button)
412
+ .map((renderedTool) => {
413
+ const button = renderedTool.button!;
414
+ return {
415
+ id: renderedTool.toolId,
416
+ label: button.label,
417
+ ariaLabel: button.ariaLabel || button.label,
418
+ icon: button.icon,
419
+ tooltip: button.tooltip || button.label,
420
+ disabled: button.disabled,
421
+ active: activeToolState[renderedTool.toolId] ?? button.active ?? false,
422
+ onClick: () => {
423
+ button.onClick();
424
+ syncRenderedToolsState();
425
+ },
426
+ } satisfies ToolbarItem;
427
+ })
428
+ );
429
+ const toolbarItems = $derived.by((): ToolbarItem[] => [
430
+ ...nativeToolbarItems,
431
+ ...normalizedHostButtons
432
+ ]);
433
+ const mountedElementsBeforeButtons = $derived.by(() =>
434
+ renderedTools.flatMap((renderedTool) =>
435
+ (renderedTool.elements || [])
436
+ .filter((entry) => entry.mount === 'before-buttons')
437
+ .map((entry) => entry.element)
438
+ .filter((entry): entry is HTMLElement => Boolean(entry))
439
+ )
440
+ );
441
+ const mountedElementsAfterButtons = $derived.by(() =>
442
+ renderedTools.flatMap((renderedTool) =>
443
+ (renderedTool.elements || [])
444
+ .filter((entry) => entry.mount === 'after-buttons')
445
+ .map((entry) => entry.element)
446
+ .filter((entry): entry is HTMLElement => Boolean(entry))
447
+ )
448
+ );
449
+
450
+ function isToolbarItemActive(item: ToolbarItem): boolean {
451
+ if (item.id in activeToolState) {
452
+ return activeToolState[item.id] === true;
453
+ }
454
+ return item.active === true;
455
+ }
456
+
457
+ function getFallbackIconSvg(iconName: string): string | null {
458
+ const iconMap: Record<string, string> = {
459
+ calculator:
460
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm0 2v4h10V4H7Zm0 6v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v2h2v-2h-2Zm-8 4v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v2h2v-2h-2Zm-8 4v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v2h2v-2h-2Z" fill="currentColor"/></svg>',
461
+ 'volume-up':
462
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 3.23v2.06A7.002 7.002 0 0 1 19 12a7 7 0 0 1-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77Zm-2 17.75V3L7 8H3v8h4l5 5Zm4.5-9a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12Z" fill="currentColor"/></svg>',
463
+ swatch:
464
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9c0-.55-.45-1-1-1h-2.5a1.5 1.5 0 0 1 0-3H20a1 1 0 0 0 1-1 8.99 8.99 0 0 0-9-4Zm-5.5 9A1.5 1.5 0 1 1 8 13.5 1.5 1.5 0 0 1 6.5 12Zm3-4A1.5 1.5 0 1 1 11 9.5 1.5 1.5 0 0 1 9.5 8Zm5 0A1.5 1.5 0 1 1 16 9.5 1.5 1.5 0 0 1 14.5 8Z" fill="currentColor"/></svg>',
465
+ 'chart-bar':
466
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.75 5a.76.76 0 0 1 .75.75v11c0 .438.313.75.75.75h13a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-13C5 19 4 18 4 16.75v-11A.74.74 0 0 1 4.75 5ZM8 8.25a.74.74 0 0 1 .75-.75h6.5a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-6.5A.722.722 0 0 1 8 8.25Zm.75 2.25h4.5a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-4.5a.723.723 0 0 1-.75-.75.74.74 0 0 1 .75-.75Zm0 3h8.5a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-8.5a.723.723 0 0 1-.75-.75.74.74 0 0 1 .75-.75Z" fill="currentColor"/></svg>',
467
+ beaker:
468
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 21c-.85 0-1.454-.38-1.813-1.137-.358-.759-.27-1.463.263-2.113L9 11V5H8a.968.968 0 0 1-.713-.287A.968.968 0 0 1 7 4c0-.283.096-.52.287-.712A.968.968 0 0 1 8 3h8c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.713A.968.968 0 0 1 16 5h-1v6l5.55 6.75c.533.65.62 1.354.262 2.113C20.454 20.62 19.85 21 19 21H5Zm2-3h10l-3.4-4h-3.2L7 18Zm-2 1h14l-6-7.3V5h-2v6.7L5 19Z" fill="currentColor"/></svg>',
469
+ protractor:
470
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m6.75 21-.25-2.2 2.85-7.85a3.95 3.95 0 0 0 1.75.95l-2.75 7.55L6.75 21Zm10.5 0-1.6-1.55-2.75-7.55a3.948 3.948 0 0 0 1.75-.95l2.85 7.85-.25 2.2ZM12 11a2.893 2.893 0 0 1-2.125-.875A2.893 2.893 0 0 1 9 8c0-.65.188-1.23.563-1.737A2.935 2.935 0 0 1 11 5.2V3h2v2.2c.583.2 1.063.554 1.438 1.063C14.812 6.77 15 7.35 15 8c0 .833-.292 1.542-.875 2.125A2.893 2.893 0 0 1 12 11Zm0-2c.283 0 .52-.096.713-.287A.967.967 0 0 0 13 8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8c0 .283.096.52.287.713.192.191.43.287.713.287Z" fill="currentColor"/></svg>',
471
+ 'bars-3':
472
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6.85 15c.517 0 .98-.15 1.388-.45.408-.3.695-.692.862-1.175l.375-1.15c.267-.8.2-1.537-.2-2.213C8.875 9.337 8.3 9 7.55 9H4.025l.475 3.925c.083.583.346 1.075.787 1.475.442.4.963.6 1.563.6Zm10.3 0c.6 0 1.12-.2 1.563-.6.441-.4.704-.892.787-1.475L19.975 9h-3.5c-.75 0-1.325.342-1.725 1.025-.4.683-.467 1.425-.2 2.225l.35 1.125c.167.483.454.875.862 1.175.409.3.871.45 1.388.45Zm-10.3 2c-1.1 0-2.063-.363-2.887-1.088a4.198 4.198 0 0 1-1.438-2.737L2 9H1V7h6.55c.733 0 1.404.18 2.013.537A3.906 3.906 0 0 1 11 9h2.025c.35-.617.83-1.104 1.438-1.463A3.892 3.892 0 0 1 16.474 7H23v2h-1l-.525 4.175a4.198 4.198 0 0 1-1.438 2.737A4.238 4.238 0 0 1 17.15 17c-.95 0-1.804-.27-2.562-.813A4.234 4.234 0 0 1 13 14.026l-.375-1.125a21.35 21.35 0 0 1-.1-.363 4.926 4.926 0 0 1-.1-.537h-.85c-.033.2-.067.363-.1.488a21.35 21.35 0 0 1-.1.362L11 14a4.3 4.3 0 0 1-1.588 2.175A4.258 4.258 0 0 1 6.85 17Z" fill="currentColor"/></svg>',
473
+ ruler:
474
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m8.8 10.95 2.15-2.175-1.4-1.425-1.1 1.1-1.4-1.4 1.075-1.1L7 4.825 4.825 7 8.8 10.95Zm8.2 8.225L19.175 17l-1.125-1.125-1.1 1.075-1.4-1.4 1.075-1.1-1.425-1.4-2.15 2.15L17 19.175ZM7.25 21H3v-4.25l4.375-4.375L2 7l5-5 5.4 5.4 3.775-3.8c.2-.2.425-.35.675-.45a2.068 2.068 0 0 1 1.55 0c.25.1.475.25.675.45L20.4 4.95c.2.2.35.425.45.675.1.25.15.508.15.775a1.975 1.975 0 0 1-.6 1.425l-3.775 3.8L22 17l-5 5-5.375-5.375L7.25 21ZM5 19h1.4l9.8-9.775L14.775 7.8 5 17.6V19Z" fill="currentColor"/></svg>'
475
+ };
476
+ return iconMap[iconName] || null;
477
+ }
303
478
 
304
479
  function syncRenderedToolsState() {
305
480
  const nextActiveState: Record<string, boolean> = {};
@@ -370,41 +545,81 @@
370
545
  {#if isBrowser}
371
546
  <div
372
547
  class="item-toolbar {className} item-toolbar--{size}"
548
+ class:item-toolbar--top={position === 'top'}
549
+ class:item-toolbar--right={position === 'right'}
550
+ class:item-toolbar--bottom={position === 'bottom'}
551
+ class:item-toolbar--left={position === 'left'}
373
552
  data-content-kind={effectiveContentKind}
553
+ data-level={effectiveLevel}
374
554
  bind:this={toolbarRootElement}
375
555
  >
376
- {#each renderedTools as renderedTool (renderedTool.toolId)}
377
- {#if renderedTool.inlineElement}
378
- <span class="item-toolbar__element-host" use:mountElement={renderedTool.inlineElement}></span>
379
- {/if}
556
+ {#each mountedElementsBeforeButtons as mountedElement (`before-${mountedElement.tagName}-${mountedElement.getAttribute('tool-id') || ''}`)}
557
+ <span class="item-toolbar__element-host" use:mountElement={mountedElement}></span>
558
+ {/each}
380
559
 
381
- {#if renderedTool.button}
560
+ {#each toolbarItems as item (item.id)}
561
+ {#if isToolbarLinkItem(item)}
562
+ <a
563
+ class="item-toolbar__button"
564
+ class:item-toolbar__button--active={isToolbarItemActive(item)}
565
+ href={item.disabled ? undefined : item.href}
566
+ target={item.target}
567
+ rel={item.rel}
568
+ aria-label={item.ariaLabel || item.label}
569
+ title={item.tooltip || item.label}
570
+ aria-disabled={item.disabled ? 'true' : undefined}
571
+ onclick={(event) => {
572
+ if (item.disabled) {
573
+ event.preventDefault();
574
+ }
575
+ }}
576
+ >
577
+ {#if item.icon}
578
+ {#if isInlineSvgIcon(item.icon)}
579
+ {@html item.icon}
580
+ {:else if isExternalIconUrl(item.icon)}
581
+ <img class="item-toolbar__icon-image" src={item.icon} alt="" />
582
+ {:else}
583
+ {@const fallbackIcon = getFallbackIconSvg(item.icon)}
584
+ {#if fallbackIcon}
585
+ {@html fallbackIcon}
586
+ {:else}
587
+ <i class={`icon icon-${item.icon}`} aria-hidden="true"></i>
588
+ {/if}
589
+ {/if}
590
+ {/if}
591
+ </a>
592
+ {:else}
382
593
  <button
383
594
  type="button"
384
- class="item-toolbar__button {renderedTool.button.className || ''}"
385
- class:item-toolbar__button--active={activeToolState[renderedTool.toolId] ?? renderedTool.button.active ?? false}
386
- onclick={() => {
387
- renderedTool.button?.onClick();
388
- syncRenderedToolsState();
389
- }}
390
- aria-label={renderedTool.button.ariaLabel || renderedTool.button.label}
391
- aria-pressed={activeToolState[renderedTool.toolId] ?? renderedTool.button.active ?? false}
392
- title={renderedTool.button.tooltip || renderedTool.button.label}
393
- disabled={renderedTool.button.disabled}
595
+ class="item-toolbar__button"
596
+ class:item-toolbar__button--active={isToolbarItemActive(item)}
597
+ onclick={item.onClick}
598
+ aria-label={item.ariaLabel || item.label}
599
+ aria-pressed={isToolbarItemActive(item)}
600
+ title={item.tooltip || item.label}
601
+ disabled={item.disabled}
394
602
  >
395
- {#if renderedTool.button.icon.startsWith('<svg')}
396
- {@html renderedTool.button.icon}
397
- {:else if renderedTool.button.icon.startsWith('http')}
398
- <img class="item-toolbar__icon-image" src={renderedTool.button.icon} alt="" />
399
- {:else}
400
- <i class={`icon icon-${renderedTool.button.icon}`} aria-hidden="true"></i>
603
+ {#if item.icon}
604
+ {#if isInlineSvgIcon(item.icon)}
605
+ {@html item.icon}
606
+ {:else if isExternalIconUrl(item.icon)}
607
+ <img class="item-toolbar__icon-image" src={item.icon} alt="" />
608
+ {:else}
609
+ {@const fallbackIcon = getFallbackIconSvg(item.icon)}
610
+ {#if fallbackIcon}
611
+ {@html fallbackIcon}
612
+ {:else}
613
+ <i class={`icon icon-${item.icon}`} aria-hidden="true"></i>
614
+ {/if}
615
+ {/if}
401
616
  {/if}
402
617
  </button>
403
618
  {/if}
619
+ {/each}
404
620
 
405
- {#if renderedTool.overlayElement}
406
- <span class="item-toolbar__element-host" use:mountElement={renderedTool.overlayElement}></span>
407
- {/if}
621
+ {#each mountedElementsAfterButtons as mountedElement (`after-${mountedElement.tagName}-${mountedElement.getAttribute('tool-id') || ''}`)}
622
+ <span class="item-toolbar__element-host" use:mountElement={mountedElement}></span>
408
623
  {/each}
409
624
  </div>
410
625
  {/if}
@@ -416,6 +631,18 @@
416
631
  gap: 0.5rem;
417
632
  }
418
633
 
634
+ .item-toolbar--top,
635
+ .item-toolbar--bottom {
636
+ flex-direction: row;
637
+ flex-wrap: wrap;
638
+ }
639
+
640
+ .item-toolbar--left,
641
+ .item-toolbar--right {
642
+ flex-direction: column;
643
+ align-items: stretch;
644
+ }
645
+
419
646
  .item-toolbar__button {
420
647
  display: flex;
421
648
  align-items: center;
@@ -429,6 +656,7 @@
429
656
  color: var(--pie-text, #333);
430
657
  cursor: pointer;
431
658
  transition: all 0.15s ease;
659
+ text-decoration: none;
432
660
  }
433
661
 
434
662
  .item-toolbar__element-host {