@omicronenergy/oscd-shell 0.0.5

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 (108) hide show
  1. package/.editorconfig +22 -0
  2. package/.github/actions/setup-node-env/action.yml +49 -0
  3. package/.github/workflows/release-please.yml +84 -0
  4. package/.github/workflows/test.yml +18 -0
  5. package/.github/workflows/update-screenshots.yml +29 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.husky/pre-commit +1 -0
  8. package/.release-please-manifest.json +1 -0
  9. package/CHANGELOG.md +73 -0
  10. package/CONTRIBUTING.md +393 -0
  11. package/LICENSE +201 -0
  12. package/README.md +74 -0
  13. package/commitlint.config.js +1 -0
  14. package/demo/AddPlugins.js +42 -0
  15. package/demo/DemoPluginSrc.js +9 -0
  16. package/demo/embedded.html +17 -0
  17. package/demo/index.html +95 -0
  18. package/foundation/cyrb64.ts +27 -0
  19. package/foundation/edit-event.ts +273 -0
  20. package/foundation/open-event.ts +21 -0
  21. package/foundation.ts +28 -0
  22. package/lit-localize.json +15 -0
  23. package/localization/de.xlf +75 -0
  24. package/oscd-shell.editing.spec.ts +813 -0
  25. package/oscd-shell.plugging.spec.ts +78 -0
  26. package/oscd-shell.spec.ts +431 -0
  27. package/oscd-shell.test.ts +382 -0
  28. package/oscd-shell.ts +906 -0
  29. package/package.json +195 -0
  30. package/release-please-config.json +21 -0
  31. package/rollup.config.js +51 -0
  32. package/screenshots/Chromium/baseline/app-bar-de.png +0 -0
  33. package/screenshots/Chromium/baseline/app-bar-en.png +0 -0
  34. package/screenshots/Chromium/baseline/document-name-de.png +0 -0
  35. package/screenshots/Chromium/baseline/document-name-en.png +0 -0
  36. package/screenshots/Chromium/baseline/editor-plugins-de.png +0 -0
  37. package/screenshots/Chromium/baseline/editor-plugins-en.png +0 -0
  38. package/screenshots/Chromium/baseline/editor-plugins-selected-de.png +0 -0
  39. package/screenshots/Chromium/baseline/editor-plugins-selected-en.png +0 -0
  40. package/screenshots/Chromium/baseline/editor-plugins-with-doc-de.png +0 -0
  41. package/screenshots/Chromium/baseline/editor-plugins-with-doc-en.png +0 -0
  42. package/screenshots/Chromium/baseline/log-entries-de.png +0 -0
  43. package/screenshots/Chromium/baseline/log-entries-en.png +0 -0
  44. package/screenshots/Chromium/baseline/log-entries-redone-de.png +0 -0
  45. package/screenshots/Chromium/baseline/log-entries-redone-en.png +0 -0
  46. package/screenshots/Chromium/baseline/log-entries-undone-de.png +0 -0
  47. package/screenshots/Chromium/baseline/log-entries-undone-en.png +0 -0
  48. package/screenshots/Chromium/baseline/log-screen-de.png +0 -0
  49. package/screenshots/Chromium/baseline/log-screen-en.png +0 -0
  50. package/screenshots/Chromium/baseline/menu-drawer-de.png +0 -0
  51. package/screenshots/Chromium/baseline/menu-drawer-en.png +0 -0
  52. package/screenshots/Chromium/baseline/menu-plugins-de.png +0 -0
  53. package/screenshots/Chromium/baseline/menu-plugins-en.png +0 -0
  54. package/screenshots/Chromium/baseline/menu-plugins-triggered-de.png +0 -0
  55. package/screenshots/Chromium/baseline/menu-plugins-triggered-en.png +0 -0
  56. package/screenshots/Firefox/baseline/app-bar-de.png +0 -0
  57. package/screenshots/Firefox/baseline/app-bar-en.png +0 -0
  58. package/screenshots/Firefox/baseline/document-name-de.png +0 -0
  59. package/screenshots/Firefox/baseline/document-name-en.png +0 -0
  60. package/screenshots/Firefox/baseline/editor-plugins-de.png +0 -0
  61. package/screenshots/Firefox/baseline/editor-plugins-en.png +0 -0
  62. package/screenshots/Firefox/baseline/editor-plugins-selected-de.png +0 -0
  63. package/screenshots/Firefox/baseline/editor-plugins-selected-en.png +0 -0
  64. package/screenshots/Firefox/baseline/editor-plugins-with-doc-de.png +0 -0
  65. package/screenshots/Firefox/baseline/editor-plugins-with-doc-en.png +0 -0
  66. package/screenshots/Firefox/baseline/log-entries-de.png +0 -0
  67. package/screenshots/Firefox/baseline/log-entries-en.png +0 -0
  68. package/screenshots/Firefox/baseline/log-entries-redone-de.png +0 -0
  69. package/screenshots/Firefox/baseline/log-entries-redone-en.png +0 -0
  70. package/screenshots/Firefox/baseline/log-entries-undone-de.png +0 -0
  71. package/screenshots/Firefox/baseline/log-entries-undone-en.png +0 -0
  72. package/screenshots/Firefox/baseline/log-screen-de.png +0 -0
  73. package/screenshots/Firefox/baseline/log-screen-en.png +0 -0
  74. package/screenshots/Firefox/baseline/menu-drawer-de.png +0 -0
  75. package/screenshots/Firefox/baseline/menu-drawer-en.png +0 -0
  76. package/screenshots/Firefox/baseline/menu-plugins-de.png +0 -0
  77. package/screenshots/Firefox/baseline/menu-plugins-en.png +0 -0
  78. package/screenshots/Firefox/baseline/menu-plugins-triggered-de.png +0 -0
  79. package/screenshots/Firefox/baseline/menu-plugins-triggered-en.png +0 -0
  80. package/screenshots/Webkit/baseline/app-bar-de.png +0 -0
  81. package/screenshots/Webkit/baseline/app-bar-en.png +0 -0
  82. package/screenshots/Webkit/baseline/document-name-de.png +0 -0
  83. package/screenshots/Webkit/baseline/document-name-en.png +0 -0
  84. package/screenshots/Webkit/baseline/editor-plugins-de.png +0 -0
  85. package/screenshots/Webkit/baseline/editor-plugins-en.png +0 -0
  86. package/screenshots/Webkit/baseline/editor-plugins-selected-de.png +0 -0
  87. package/screenshots/Webkit/baseline/editor-plugins-selected-en.png +0 -0
  88. package/screenshots/Webkit/baseline/editor-plugins-with-doc-de.png +0 -0
  89. package/screenshots/Webkit/baseline/editor-plugins-with-doc-en.png +0 -0
  90. package/screenshots/Webkit/baseline/log-entries-de.png +0 -0
  91. package/screenshots/Webkit/baseline/log-entries-en.png +0 -0
  92. package/screenshots/Webkit/baseline/log-entries-redone-de.png +0 -0
  93. package/screenshots/Webkit/baseline/log-entries-redone-en.png +0 -0
  94. package/screenshots/Webkit/baseline/log-entries-undone-de.png +0 -0
  95. package/screenshots/Webkit/baseline/log-entries-undone-en.png +0 -0
  96. package/screenshots/Webkit/baseline/log-screen-de.png +0 -0
  97. package/screenshots/Webkit/baseline/log-screen-en.png +0 -0
  98. package/screenshots/Webkit/baseline/menu-drawer-de.png +0 -0
  99. package/screenshots/Webkit/baseline/menu-drawer-en.png +0 -0
  100. package/screenshots/Webkit/baseline/menu-plugins-de.png +0 -0
  101. package/screenshots/Webkit/baseline/menu-plugins-en.png +0 -0
  102. package/screenshots/Webkit/baseline/menu-plugins-triggered-de.png +0 -0
  103. package/screenshots/Webkit/baseline/menu-plugins-triggered-en.png +0 -0
  104. package/tsconfig.json +23 -0
  105. package/utils/testing.spec.ts +210 -0
  106. package/utils/testing.ts +65 -0
  107. package/web-dev-server.config.js +24 -0
  108. package/web-test-runner.config.js +180 -0
package/oscd-shell.ts ADDED
@@ -0,0 +1,906 @@
1
+ import '@webcomponents/scoped-custom-element-registry';
2
+
3
+ import { configureLocalization, localized, msg, str } from '@lit/localize';
4
+ import { css, html, LitElement, nothing, TemplateResult } from 'lit';
5
+ import { customElement, property, query, state } from 'lit/decorators.js';
6
+ import { html as staticHtml, unsafeStatic } from 'lit/static-html.js';
7
+ import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
8
+
9
+ import { OscdAppBar } from '@omicronenergy/oscd-ui/app-bar/oscd-app-bar.js';
10
+ import { OscdDialog } from '@omicronenergy/oscd-ui/dialog/oscd-dialog.js';
11
+ import { OscdDivider } from '@omicronenergy/oscd-ui/divider/oscd-divider.js';
12
+ import { OscdFilledIconButton } from '@omicronenergy/oscd-ui/iconbutton/oscd-filled-icon-button.js';
13
+ import { OscdFilledSelect } from '@omicronenergy/oscd-ui/select/oscd-filled-select.js';
14
+ import { OscdFilledTextField } from '@omicronenergy/oscd-ui/textfield/oscd-filled-text-field.js';
15
+ import { OscdIcon } from '@omicronenergy/oscd-ui/icon/oscd-icon.js';
16
+ import { OscdList } from '@omicronenergy/oscd-ui/list/oscd-list.js';
17
+ import { OscdListItem } from '@omicronenergy/oscd-ui/list/oscd-list-item.js';
18
+ import { OscdMenu } from '@omicronenergy/oscd-ui/menu/oscd-menu.js';
19
+ import { OscdMenuItem } from '@omicronenergy/oscd-ui/menu/oscd-menu-item.js';
20
+ import { OscdNavigationDrawer } from '@omicronenergy/oscd-ui/navigation-drawer/oscd-navigation-drawer.js';
21
+ import { OscdNavigationDrawerHeader } from '@omicronenergy/oscd-ui/navigation-drawer/oscd-navigation-drawer-header.js';
22
+ import { OscdSecondaryTab } from '@omicronenergy/oscd-ui/tabs/oscd-secondary-tab.js';
23
+ import { OscdSelectOption } from '@omicronenergy/oscd-ui/select/oscd-select-option.js';
24
+ import { OscdTabs } from '@omicronenergy/oscd-ui/tabs/oscd-tabs.js';
25
+ import { OscdTextButton } from '@omicronenergy/oscd-ui/button/oscd-text-button.js';
26
+
27
+ import { allLocales, sourceLocale, targetLocales } from './locales.js';
28
+
29
+ import {
30
+ cyrb64,
31
+ Edit,
32
+ EditEvent,
33
+ handleEdit,
34
+ isComplex,
35
+ isInsert,
36
+ isRemove,
37
+ isUpdate,
38
+ OpenEvent,
39
+ } from './foundation.js';
40
+
41
+ const _customElementsDefine = window.customElements.define;
42
+ window.customElements.define = (name, cl, conf) => {
43
+ if (!customElements.get(name)) {
44
+ _customElementsDefine.call(window.customElements, name, cl, conf);
45
+ }
46
+ };
47
+
48
+ export type LogEntry = { undo: Edit; redo: Edit };
49
+
50
+ export type Plugin = {
51
+ name: string;
52
+ translations?: Record<(typeof targetLocales)[number], string>;
53
+ src: string;
54
+ icon: string;
55
+ requireDoc?: boolean;
56
+ active?: boolean;
57
+ };
58
+ export type PluginSet = { menu: Plugin[]; editor: Plugin[] };
59
+
60
+ const pluginTags = new Map<string, string>();
61
+
62
+ /** @returns a valid customElement tagName containing the URI hash. */
63
+ function pluginTag(uri: string): string {
64
+ if (!pluginTags.has(uri)) {
65
+ pluginTags.set(uri, `oscd-p${cyrb64(uri)}`);
66
+ }
67
+ return pluginTags.get(uri)!;
68
+ }
69
+
70
+ type Control = {
71
+ icon: string;
72
+ getName: () => string;
73
+ isDisabled: () => boolean;
74
+ action?: () => void | Promise<void>;
75
+ };
76
+
77
+ type RenderedPlugin = Control & { tagName: string };
78
+
79
+ type LocaleTag = (typeof allLocales)[number];
80
+
81
+ const { getLocale, setLocale } = configureLocalization({
82
+ sourceLocale,
83
+ targetLocales,
84
+ loadLocale: locale =>
85
+ import(new URL(`./locales/${locale}.js`, import.meta.url).href),
86
+ });
87
+
88
+ function describe({ undo, redo }: LogEntry) {
89
+ let result = msg('Something unexpected happened!');
90
+ if (isComplex(redo)) {
91
+ result = msg(str`≥ ${redo.length} nodes changed`);
92
+ }
93
+ if (isInsert(redo)) {
94
+ if (isInsert(undo)) {
95
+ result = msg(str`${redo.node.nodeName} moved to ${redo.parent.nodeName}`);
96
+ } else {
97
+ result = msg(
98
+ str`${redo.node.nodeName} inserted into ${redo.parent.nodeName}`,
99
+ );
100
+ }
101
+ }
102
+ if (isRemove(redo)) {
103
+ result = msg(str`${redo.node.nodeName} removed`);
104
+ }
105
+ if (isUpdate(redo)) {
106
+ result = msg(str`${redo.element.tagName} updated`);
107
+ }
108
+ return result;
109
+ }
110
+
111
+ function renderActionItem(
112
+ control: Control,
113
+ slot = 'actionItems',
114
+ ): TemplateResult {
115
+ return html`<oscd-filled-icon-button
116
+ slot="${slot}"
117
+ aria-label="${control.getName()}"
118
+ ?disabled=${control.isDisabled()}
119
+ @click=${control.action}
120
+ ><oscd-icon>${control.icon}</oscd-icon></oscd-filled-icon-button
121
+ >`;
122
+ }
123
+
124
+ function renderMenuItem(control: Control): TemplateResult {
125
+ return html`
126
+ <oscd-list-item
127
+ type="button"
128
+ .disabled=${control.isDisabled()}
129
+ @click=${control.action}
130
+ >
131
+ <oscd-icon slot="start">${control.icon}</oscd-icon>
132
+ <span>${control.getName()}</span>
133
+ </oscd-list-item>
134
+ `;
135
+ }
136
+
137
+ @customElement('oscd-shell')
138
+ @localized()
139
+ export class OpenSCD extends ScopedElementsMixin(LitElement) {
140
+ static get scopedElements() {
141
+ return {
142
+ 'oscd-app-bar': OscdAppBar,
143
+ 'oscd-dialog': OscdDialog,
144
+ 'oscd-icon': OscdIcon,
145
+ 'oscd-filled-text-field': OscdFilledTextField,
146
+ 'oscd-filled-select': OscdFilledSelect,
147
+ 'oscd-select-option': OscdSelectOption,
148
+ 'oscd-filled-icon-button': OscdFilledIconButton,
149
+ 'oscd-list': OscdList,
150
+ 'oscd-list-item': OscdListItem,
151
+ 'oscd-divider': OscdDivider,
152
+ 'oscd-menu-item': OscdMenuItem,
153
+ 'oscd-navigation-drawer': OscdNavigationDrawer,
154
+ 'oscd-navigation-drawer-header': OscdNavigationDrawerHeader,
155
+ 'oscd-secondary-tab': OscdSecondaryTab,
156
+ 'oscd-tabs': OscdTabs,
157
+ 'oscd-text-button': OscdTextButton,
158
+ 'oscd-menu': OscdMenu,
159
+ };
160
+ }
161
+
162
+ @state()
163
+ /** The `XMLDocument` currently being edited */
164
+ get doc(): XMLDocument {
165
+ return this.docs[this.docName];
166
+ }
167
+
168
+ @state()
169
+ history: LogEntry[] = [];
170
+
171
+ @state()
172
+ editCount: number = 0;
173
+
174
+ @state()
175
+ get last(): number {
176
+ return this.editCount - 1;
177
+ }
178
+
179
+ @state()
180
+ get canUndo(): boolean {
181
+ return this.last >= 0;
182
+ }
183
+
184
+ @state()
185
+ get canRedo(): boolean {
186
+ return this.editCount < this.history.length;
187
+ }
188
+
189
+ /** The set of `XMLDocument`s currently loaded */
190
+ @state()
191
+ docs: Record<string, XMLDocument> = {};
192
+
193
+ /** The name of the [[`doc`]] currently being edited */
194
+ @property({ type: String, reflect: true }) docName = '';
195
+
196
+ /** The file endings of editable files */
197
+ @property({ type: Array, reflect: true }) editable = [
198
+ 'cid',
199
+ 'icd',
200
+ 'iid',
201
+ 'scd',
202
+ 'sed',
203
+ 'ssd',
204
+ ];
205
+
206
+ isEditable(docName: string): boolean {
207
+ return !!this.editable.find(ext =>
208
+ docName.toLowerCase().endsWith(`.${ext}`),
209
+ );
210
+ }
211
+
212
+ @state()
213
+ get editableDocs(): string[] {
214
+ return Object.keys(this.docs).filter(name => this.isEditable(name));
215
+ }
216
+
217
+ #plugins: PluginSet = { menu: [], editor: [] };
218
+
219
+ @property({ type: Object })
220
+ get plugins(): PluginSet {
221
+ return this.#plugins;
222
+ }
223
+
224
+ set plugins(plugins: Partial<PluginSet>) {
225
+ Object.entries(plugins).forEach(([pluginType, kind]) =>
226
+ kind.forEach((plugin, index) => {
227
+ const tagName = pluginTag(plugin.src);
228
+ if (this.#loadedPluginTagNames.includes(tagName)) {
229
+ return;
230
+ }
231
+
232
+ if (this.registry!.get(tagName)) {
233
+ this.addLoadedPlugin(
234
+ tagName,
235
+ pluginType as keyof PluginSet,
236
+ plugin,
237
+ index,
238
+ );
239
+ this.requestUpdate('loadedPlugins');
240
+ return;
241
+ }
242
+ const url = new URL(plugin.src, window.location.href).toString();
243
+ import(url).then(mod => {
244
+ this.addLoadedPlugin(
245
+ tagName,
246
+ pluginType as keyof PluginSet,
247
+ plugin,
248
+ index,
249
+ );
250
+ // Because this is async, we need to check (again) if the element is already defined.
251
+ if (!this.registry!.get(tagName)) {
252
+ this.registry!.define(tagName, mod.default);
253
+ }
254
+ this.requestUpdate('loadedPlugins');
255
+ });
256
+ }),
257
+ );
258
+ this.#plugins = { menu: [], editor: [], ...plugins };
259
+ }
260
+
261
+ #loadedPlugins: PluginSet = {
262
+ menu: new Array(this.plugins.menu.length).fill(null),
263
+ editor: new Array(this.plugins.editor.length).fill(null),
264
+ };
265
+
266
+ #loadedPluginTagNames: string[] = [];
267
+
268
+ @state()
269
+ get loadedPlugins(): PluginSet {
270
+ return this.#loadedPlugins;
271
+ }
272
+
273
+ addLoadedPlugin(
274
+ tagName: string,
275
+ kind: keyof PluginSet,
276
+ plugin: Plugin,
277
+ index: number,
278
+ ) {
279
+ this.#loadedPlugins[kind][index] = plugin;
280
+ this.#loadedPluginTagNames.push(tagName);
281
+ }
282
+
283
+ handleOpenDoc({ detail: { docName, doc } }: OpenEvent) {
284
+ this.docs[docName] = doc;
285
+ if (this.isEditable(docName)) {
286
+ this.docName = docName;
287
+ }
288
+ this.requestUpdate();
289
+ }
290
+
291
+ handleEditEvent(event: EditEvent) {
292
+ const edit = event.detail;
293
+ this.history.splice(this.editCount);
294
+ this.history.push({ undo: handleEdit(edit), redo: edit });
295
+ this.editCount += 1;
296
+ }
297
+
298
+ /** Undo the last `n` [[Edit]]s committed */
299
+ undo(n = 1) {
300
+ if (!this.canUndo || n < 1) {
301
+ return;
302
+ }
303
+ handleEdit(this.history[this.last!].undo);
304
+ this.editCount -= 1;
305
+ if (n > 1) {
306
+ this.undo(n - 1);
307
+ }
308
+ }
309
+
310
+ /** Redo the last `n` [[Edit]]s that have been undone */
311
+ redo(n = 1) {
312
+ if (!this.canRedo || n < 1) {
313
+ return;
314
+ }
315
+ handleEdit(this.history[this.editCount].redo);
316
+ this.editCount += 1;
317
+ if (n > 1) {
318
+ this.redo(n - 1);
319
+ }
320
+ }
321
+
322
+ @query('#log')
323
+ logUI!: OscdDialog;
324
+
325
+ @query('#editFile')
326
+ editFileUI!: OscdDialog;
327
+
328
+ @query('#menu')
329
+ menuUI!: OscdNavigationDrawer;
330
+
331
+ @query('#fileName')
332
+ fileNameUI!: HTMLInputElement;
333
+
334
+ @query('#fileExtension')
335
+ fileExtensionUI!: HTMLInputElement;
336
+
337
+ @query('#fileMenu')
338
+ fileMenuUI!: OscdMenu;
339
+
340
+ @query('#fileMenuButton')
341
+ fileMenuButtonUI?: OscdFilledIconButton;
342
+
343
+ @property({ type: String, reflect: true })
344
+ get locale() {
345
+ return getLocale() as LocaleTag;
346
+ }
347
+
348
+ set locale(tag: LocaleTag) {
349
+ try {
350
+ setLocale(tag);
351
+ } catch {
352
+ // don't change locale if tag is invalid
353
+ }
354
+ }
355
+
356
+ @state()
357
+ private editorIndex = 0;
358
+
359
+ @state()
360
+ get editor() {
361
+ return this.editors[this.editorIndex]?.tagName ?? '';
362
+ }
363
+
364
+ private controls: Record<
365
+ 'undo' | 'redo' | 'log' | 'menu',
366
+ Required<Control>
367
+ > = {
368
+ undo: {
369
+ icon: 'undo',
370
+ getName: () => msg('Undo'),
371
+ action: () => {
372
+ this.undo();
373
+ this.menuUI.opened = false;
374
+ },
375
+ isDisabled: () => !this.canUndo,
376
+ },
377
+ redo: {
378
+ icon: 'redo',
379
+ getName: () => msg('Redo'),
380
+ action: () => {
381
+ this.redo();
382
+ this.menuUI.opened = false;
383
+ },
384
+ isDisabled: () => !this.canRedo,
385
+ },
386
+ log: {
387
+ icon: 'history',
388
+ getName: () => msg('Editing history'),
389
+ action: () => {
390
+ if (this.logUI.open) {
391
+ this.logUI.close();
392
+ } else {
393
+ this.logUI.show();
394
+ }
395
+ // this.menuUI.opened = false;
396
+ },
397
+ isDisabled: () => false,
398
+ },
399
+ menu: {
400
+ icon: 'menu',
401
+ getName: () => msg('Menu'),
402
+ action: async () => {
403
+ this.menuUI.opened = !this.menuUI.opened;
404
+ await this.menuUI.updateComplete;
405
+ if (this.menuUI.opened) {
406
+ this.menuUI.querySelector<OscdList>('oscd-list')!.focus();
407
+ }
408
+ },
409
+ isDisabled: () => false,
410
+ },
411
+ };
412
+
413
+ #actions = [this.controls.undo, this.controls.redo, this.controls.log];
414
+
415
+ @state()
416
+ get menu() {
417
+ return (<Required<Control>[]>this.loadedPlugins.menu
418
+ ?.map((plugin): RenderedPlugin | undefined =>
419
+ plugin.active
420
+ ? {
421
+ icon: plugin.icon,
422
+ getName: () =>
423
+ plugin.translations?.[
424
+ this.locale as (typeof targetLocales)[number]
425
+ ] || plugin.name,
426
+ isDisabled: () => (plugin.requireDoc && !this.docName) ?? false,
427
+ tagName: pluginTag(plugin.src),
428
+ action: () => {
429
+ this.shadowRoot!.querySelector<
430
+ HTMLElement & { run: () => Promise<void> }
431
+ >(pluginTag(plugin.src))!.run?.();
432
+ this.menuUI.opened = false;
433
+ },
434
+ }
435
+ : undefined,
436
+ )
437
+ .filter(p => p !== undefined)).concat(this.#actions);
438
+ }
439
+
440
+ @state()
441
+ get editors() {
442
+ return <RenderedPlugin[]>this.loadedPlugins.editor
443
+ ?.map((plugin): RenderedPlugin | undefined =>
444
+ plugin.active
445
+ ? {
446
+ icon: plugin.icon,
447
+ getName: () =>
448
+ plugin.translations?.[
449
+ this.locale as (typeof targetLocales)[number]
450
+ ] || plugin.name,
451
+ isDisabled: () => (plugin.requireDoc && !this.docName) ?? false,
452
+ tagName: pluginTag(plugin.src),
453
+ }
454
+ : undefined,
455
+ )
456
+ .filter(p => p !== undefined);
457
+ }
458
+
459
+ private hotkeys: Partial<Record<string, () => void>> = {
460
+ m: this.controls.menu.action,
461
+ z: this.controls.undo.action,
462
+ y: this.controls.redo.action,
463
+ Z: this.controls.redo.action,
464
+ l: this.controls.log.action,
465
+ };
466
+
467
+ private handleKeyPress(e: KeyboardEvent): void {
468
+ if (!e.ctrlKey) {
469
+ return;
470
+ }
471
+ if (!Object.prototype.hasOwnProperty.call(this.hotkeys, e.key)) {
472
+ return;
473
+ }
474
+ this.hotkeys[e.key]!();
475
+ e.preventDefault();
476
+ }
477
+
478
+ constructor() {
479
+ super();
480
+
481
+ document.addEventListener('keydown', event => this.handleKeyPress(event));
482
+
483
+ this.addEventListener('oscd-open', event => this.handleOpenDoc(event));
484
+ this.addEventListener('oscd-edit', event => this.handleEditEvent(event));
485
+ }
486
+
487
+ private renderLogEntry(entry: LogEntry) {
488
+ return html` <abbr title="${describe(entry)}">
489
+ <oscd-list-item ?activated=${this.history[this.last] === entry}>
490
+ <span>${describe(entry)}</span>
491
+ <oscd-icon slot="start">history</oscd-icon>
492
+ </oscd-list-item></abbr
493
+ >`;
494
+ }
495
+
496
+ private renderHistory(): TemplateResult[] | TemplateResult {
497
+ if (this.history.length > 0) {
498
+ return this.history.slice().reverse().map(this.renderLogEntry, this);
499
+ }
500
+ return html`<oscd-list-item>
501
+ <span>${msg('Your editing history will be displayed here.')}</span>
502
+ <oscd-icon slot="start">info</oscd-icon>
503
+ </oscd-list-item>`;
504
+ }
505
+
506
+ updated(changedProps: Map<string, unknown>) {
507
+ super.updated?.(changedProps);
508
+
509
+ this.updateComplete.then(() => {
510
+ // Ensure the active tab is set after tabs are rendered
511
+ const oscdTabs = this.shadowRoot?.querySelector('oscd-tabs');
512
+ if (oscdTabs && oscdTabs.activeTabIndex !== this.editorIndex) {
513
+ oscdTabs.activeTabIndex = this.editorIndex;
514
+ }
515
+ });
516
+ }
517
+
518
+ render() {
519
+ return html`<oscd-app-bar slot="appContent">
520
+ ${renderActionItem(this.controls.menu, 'actionStart')}
521
+ <div slot="title" id="title">
522
+ ${this.editableDocs.length > 1
523
+ ? html`<oscd-filled-icon-button
524
+ id="fileMenuButton"
525
+ @click=${() => this.fileMenuUI.show()}
526
+ ><oscd-icon>arrow_drop_down</oscd-icon></oscd-filled-icon-button
527
+ >`
528
+ : nothing}
529
+ ${this.docName}
530
+ ${this.docName
531
+ ? html`<oscd-filled-icon-button
532
+ @click=${() => this.editFileUI.show()}
533
+ ><oscd-icon>edit</oscd-icon></oscd-filled-icon-button
534
+ >`
535
+ : nothing}
536
+ <oscd-menu
537
+ fixed
538
+ id="fileMenu"
539
+ anchor="fileMenuButton"
540
+ corner="BOTTOM_END"
541
+ >
542
+ ${this.editableDocs.map(
543
+ (name, index) =>
544
+ html`<oscd-menu-item
545
+ @click=${() => {
546
+ this.docName = name;
547
+ }}
548
+ ?disabled=${!this.isEditable(name)}
549
+ ?selected=${this.docName === name}
550
+ id=${index}
551
+ >${name}</oscd-menu-item
552
+ >`,
553
+ )}
554
+ </oscd-menu>
555
+ </div>
556
+
557
+ <div slot="actionEnd">
558
+ ${this.#actions.map(op => renderActionItem(op))}
559
+ </div>
560
+
561
+ <oscd-tabs
562
+ .activeTabIndex=${this.editorIndex}
563
+ @change=${(event: Event) => {
564
+ const tabs = event.currentTarget as OscdTabs;
565
+ this.editorIndex = tabs.activeTabIndex;
566
+ }}
567
+ >
568
+ ${this.editors.map(editor =>
569
+ editor.isDisabled()
570
+ ? nothing
571
+ : html`<oscd-secondary-tab>
572
+ <oscd-icon slot="icon">${editor.icon}</oscd-icon>
573
+ ${editor.getName()}
574
+ </oscd-secondary-tab>`,
575
+ )}
576
+ </oscd-tabs>
577
+ </oscd-app-bar>
578
+
579
+ <oscd-navigation-drawer hasheader type="modal" id="menu">
580
+ <oscd-navigation-drawer-header>
581
+ <span slot="headline">${msg('Menu')}</span>
582
+ ${this.docName
583
+ ? html`<span slot="supporting-text">${this.docName}</span>`
584
+ : ''}
585
+ </oscd-navigation-drawer-header>
586
+ <oscd-list>
587
+ <oscd-divider></oscd-divider>
588
+ ${this.menu.map(renderMenuItem)}
589
+ </oscd-list>
590
+ </oscd-navigation-drawer>
591
+
592
+ ${this.editor
593
+ ? staticHtml`<${unsafeStatic(this.editor)} docName="${
594
+ this.docName || nothing
595
+ }" .doc=${this.doc} locale="${this.locale}" .docs=${
596
+ this.docs
597
+ } .editCount=${this.editCount}></${unsafeStatic(this.editor)}>`
598
+ : nothing}
599
+
600
+ <oscd-dialog
601
+ id="editFile"
602
+ @closed=${(event: CustomEvent) => {
603
+ const dialog = event.target as OscdDialog;
604
+ if (dialog.returnValue === 'remove') {
605
+ delete this.docs[this.docName];
606
+ this.docName = this.editableDocs[0] || '';
607
+ }
608
+ }}
609
+ >
610
+ <div slot="headline">
611
+ <oscd-icon>description</oscd-icon>${this.docName}
612
+ </div>
613
+ <form slot="content" id="edit-file-form" method="dialog">
614
+ <oscd-filled-text-field
615
+ id="fileName"
616
+ label="${msg('Filename')}"
617
+ value="${this.docName.replace(/\.[^.]+$/, '')}"
618
+ @input=${(event: Event) => {
619
+ const input = event.target as HTMLInputElement;
620
+ const { value } = input;
621
+ const name = `${value}.${this.fileExtensionUI.value}`;
622
+ if (name in this.docs && name !== this.docName) {
623
+ input.setCustomValidity('File already exists!');
624
+ } else {
625
+ input.setCustomValidity('');
626
+ }
627
+ input.reportValidity();
628
+ }}
629
+ ></oscd-filled-text-field>
630
+ <oscd-filled-select
631
+ label="${msg('Extension')}"
632
+ fixedMenuPosition
633
+ id="fileExtension"
634
+ @selected=${() => this.fileNameUI.reportValidity()}
635
+ >
636
+ ${this.editable.map(
637
+ ext =>
638
+ html`<oscd-select-option
639
+ ?selected=${this.docName.endsWith(`.${ext}`)}
640
+ value="${ext}"
641
+ >${ext}</oscd-select-option
642
+ >`,
643
+ )}
644
+ </oscd-filled-select>
645
+ </form>
646
+ <div slot="actions">
647
+ <oscd-text-button
648
+ form="edit-file-form"
649
+ class="edit-dialog-remove-button"
650
+ value="remove"
651
+ >
652
+ <oscd-icon slot="icon">delete</oscd-icon>
653
+ ${msg('Close file')}
654
+ </oscd-text-button>
655
+ <oscd-text-button form="edit-file-form" value="close">
656
+ ${msg('Cancel')}
657
+ </oscd-text-button>
658
+ <oscd-text-button
659
+ @click=${() => {
660
+ const valid = this.fileNameUI.checkValidity();
661
+ if (!valid) {
662
+ this.fileNameUI.reportValidity();
663
+ return;
664
+ }
665
+ const newDocName = `${this.fileNameUI.value}.${this.fileExtensionUI.value}`;
666
+ this.docs[newDocName] = this.doc;
667
+ delete this.docs[this.docName];
668
+ this.docName = newDocName;
669
+ this.editFileUI.close();
670
+ }}
671
+ trailing-icon
672
+ >
673
+ <oscd-icon slot="icon">edit</oscd-icon>
674
+ ${msg('Rename')}
675
+ </oscd-text-button>
676
+ </div>
677
+ </oscd-dialog>
678
+ <oscd-dialog id="log" heading="">
679
+ <div slot="headline">
680
+ <oscd-icon>history</oscd-icon>${this.controls.log.getName()}
681
+ </div>
682
+ <form slot="content" id="log-dialog-form" method="dialog">
683
+ <oscd-list>${this.renderHistory()}</oscd-list>
684
+ </form>
685
+ <div slot="actions">
686
+ <oscd-text-button
687
+ ?disabled=${!this.canUndo}
688
+ @click=${this.undo}
689
+ value="undo"
690
+ >${msg('Undo')}<oscd-icon slot="icon"
691
+ >undo</oscd-icon
692
+ ></oscd-text-button
693
+ >
694
+ <oscd-text-button
695
+ ?disabled=${!this.canRedo}
696
+ @click=${this.redo}
697
+ value="redo"
698
+ >${msg('Redo')}<oscd-icon slot="icon"
699
+ >redo</oscd-icon
700
+ ></oscd-text-button
701
+ >
702
+ <oscd-text-button form="log-dialog-form" value="close"
703
+ >${msg('Close')}</oscd-text-button
704
+ >
705
+ </div>
706
+ </oscd-dialog>
707
+ <aside>
708
+ ${this.loadedPlugins.menu.map(
709
+ plugin =>
710
+ staticHtml`<${unsafeStatic(pluginTag(plugin.src))} docName="${
711
+ this.docName
712
+ }" .doc=${this.doc} locale="${this.locale}" .docs=${
713
+ this.docs
714
+ } .editCount=${this.editCount}></${unsafeStatic(
715
+ pluginTag(plugin.src),
716
+ )}>`,
717
+ )}
718
+ </aside>`;
719
+ }
720
+
721
+ firstUpdated() {
722
+ const background = getComputedStyle(this.menuUI).getPropertyValue(
723
+ '--oscd-base2',
724
+ );
725
+ document.body.style.background = background;
726
+ }
727
+
728
+ static styles = css`
729
+ .fileext {
730
+ opacity: 0.81;
731
+ }
732
+
733
+ .filename {
734
+ caret-color: var(--oscd-secondary);
735
+ }
736
+
737
+ .filename:focus {
738
+ outline: none;
739
+ }
740
+
741
+ aside {
742
+ position: absolute;
743
+ top: 0;
744
+ left: 0;
745
+ width: 0;
746
+ height: 0;
747
+ overflow: hidden;
748
+ margin: 0;
749
+ padding: 0;
750
+ }
751
+
752
+ abbr {
753
+ text-decoration: none;
754
+ }
755
+
756
+ @media (prefers-color-scheme: light) {
757
+ * {
758
+ --oscd-primary: var(--oscd-theme-primary, #2aa198);
759
+ --oscd-secondary: var(--oscd-theme-secondary, #6c71c4);
760
+ --oscd-base03: var(--oscd-theme-base03, #002b36);
761
+ --oscd-base02: var(--oscd-theme-base02, #073642);
762
+ --oscd-base01: var(--oscd-theme-base01, #586e75);
763
+ --oscd-base00: var(--oscd-theme-base00, #657b83);
764
+ --oscd-base0: var(--oscd-theme-base0, #839496);
765
+ --oscd-base1: var(--oscd-theme-base1, #93a1a1);
766
+ --oscd-base2: var(--oscd-theme-base2, #eee8d5);
767
+ --oscd-base3: var(--oscd-theme-base3, #fdf6e3);
768
+
769
+ --mdc-theme-text-disabled-on-light: rgba(255, 255, 255, 0.38);
770
+ }
771
+ }
772
+
773
+ @media (prefers-color-scheme: dark) {
774
+ * {
775
+ --oscd-primary: var(--oscd-theme-secondary, #6c71c4);
776
+ --oscd-secondary: var(--oscd-theme-primary, #2aa198);
777
+ --oscd-base03: var(--oscd-theme-base3, #fdf6e3);
778
+ --oscd-base02: var(--oscd-theme-base2, #eee8d5);
779
+ --oscd-base01: var(--oscd-theme-base1, #93a1a1);
780
+ --oscd-base00: var(--oscd-theme-base0, #839496);
781
+ --oscd-base0: var(--oscd-theme-base00, #657b83);
782
+ --oscd-base1: var(--oscd-theme-base01, #586e75);
783
+ --oscd-base2: var(--oscd-theme-base02, #073642);
784
+ --oscd-base3: var(--oscd-theme-base03, #002b36);
785
+ }
786
+ }
787
+
788
+ * {
789
+ --oscd-error: var(--oscd-theme-error, #dc322f);
790
+
791
+ --oscd-text-font: var(--oscd-theme-text-font, 'Roboto');
792
+ --oscd-icon-font: var(--oscd-theme-icon-font, 'Material Icons');
793
+ --oscd-text-font-mono: var(--oscd-theme-text-font-mono, 'Roboto Mono');
794
+
795
+ --oscd-warning: var(--oscd-theme-warning, #b58900);
796
+ --md-sys-color-primary: var(--oscd-primary);
797
+ --md-sys-color-on-primary: var(--oscd-base3);
798
+
799
+ --md-sys-color-secondary: var(--oscd-secondary);
800
+ --md-sys-color-on-secondary: var(--oscd-base3);
801
+ --md-sys-color-secondary-container: var(--oscd-base2);
802
+
803
+ --md-sys-color-surface: var(--oscd-base3);
804
+ --md-sys-color-on-surface: var(--oscd-base00);
805
+ --md-sys-color-surface-variant: var(--oscd-base3);
806
+ --md-sys-color-on-surface-variant: var(--oscd-base00);
807
+ --md-sys-color-surface-bright: var(--oscd-base2);
808
+ --md-sys-color-surface-container: var(--oscd-base3);
809
+ --md-sys-color-surface-container-high: var(--oscd-base3);
810
+ --md-sys-color-surface-container-highest: var(--oscd-base3);
811
+
812
+ --md-sys-color-outline-variant: var(--oscd-primary);
813
+ --md-sys-color-scrim: #000000;
814
+ --md-sys-color-error: var(--oscd-error);
815
+ --md-sys-color-on-error: var(--oscd-base3);
816
+
817
+ /* --md-menu-item-selected-label-text-color: var(--oscd-base01); */
818
+ --md-icon-button-disabled-icon-color: var(--oscd-base3);
819
+
820
+ /* MDC Theme Colors
821
+ * Needed for supporting any pluggins still using the depricated MWC Components
822
+ */
823
+ --mdc-theme-primary: var(--oscd-primary);
824
+ --mdc-theme-secondary: var(--oscd-secondary);
825
+ --mdc-theme-background: var(--oscd-base3);
826
+ --mdc-theme-surface: var(--oscd-base3);
827
+ --mdc-theme-on-primary: var(--oscd-base2);
828
+ --mdc-theme-on-secondary: var(--oscd-base02);
829
+ --mdc-theme-on-background: var(--oscd-base00);
830
+ --mdc-theme-on-surface: var(--oscd-base00);
831
+ --mdc-theme-text-primary-on-background: var(--oscd-base01);
832
+ --mdc-theme-text-secondary-on-background: var(--oscd-base00);
833
+ --mdc-theme-text-icon-on-background: var(--oscd-base00);
834
+ --mdc-theme-error: var(--oscd-error);
835
+
836
+ --mdc-button-disabled-ink-color: var(--oscd-base1);
837
+
838
+ --mdc-drawer-heading-ink-color: var(--oscd-base00);
839
+
840
+ --mdc-dialog-heading-ink-color: var(--oscd-base00);
841
+
842
+ --mdc-text-field-fill-color: var(--oscd-base2);
843
+ --mdc-text-field-ink-color: var(--oscd-base02);
844
+ --mdc-text-field-label-ink-color: var(--oscd-base01);
845
+ --mdc-text-field-idle-line-color: var(--oscd-base00);
846
+ --mdc-text-field-hover-line-color: var(--oscd-base02);
847
+
848
+ --mdc-select-fill-color: var(--oscd-base2);
849
+ --mdc-select-ink-color: var(--oscd-base02);
850
+ --mdc-select-label-ink-color: var(--oscd-base01);
851
+ --mdc-select-idle-line-color: var(--oscd-base00);
852
+ --mdc-select-hover-line-color: var(--oscd-base02);
853
+ --mdc-select-dropdown-icon-color: var(--oscd-base01);
854
+
855
+ --mdc-typography-font-family: var(--oscd-text-font);
856
+ --mdc-icon-font: var(--oscd-icon-font);
857
+ }
858
+
859
+ oscd-navigation-drawer-header {
860
+ --md-list-item-supporting-text-color: var(--md-sys-color-on-surface);
861
+ }
862
+ oscd-app-bar * {
863
+ --md-filled-icon-button-disabled-container-opacity: 0;
864
+ --md-filled-icon-button-disabled-icon-color: var(
865
+ --md-sys-color-on-primary
866
+ );
867
+ }
868
+
869
+ oscd-tabs {
870
+ display: flex;
871
+ flex-grow: 1;
872
+ --md-secondary-tab-container-color: var(--oscd-primary);
873
+ }
874
+ oscd-secondary-tab {
875
+ --md-sys-color-on-surface: var(--md-sys-color-on-primary);
876
+ --md-sys-color-on-surface-variant: var(--md-sys-color-on-primary);
877
+ --md-secondary-tab-active-indicator-color: var(--oscd-base2);
878
+ }
879
+
880
+ .edit-dialog-remove-button {
881
+ --md-text-button-icon-color: var(--oscd-error);
882
+ --md-text-button-label-text-color: var(--oscd-error);
883
+
884
+ --md-text-button-focus-label-text-color: var(--oscd-error);
885
+ --md-text-button-focus-icon-color: var(--oscd-error);
886
+
887
+ --md-text-button-hover-label-text-color: var(--oscd-error);
888
+ --md-text-button-hover-state-layer-color: var(--oscd-error);
889
+ --md-text-button-hover-icon-color: var(--oscd-error);
890
+
891
+ --md-text-button-pressed-label-text-color: var(--oscd-error);
892
+ --md-text-button-pressed-state-layer-color: var(--oscd-error);
893
+ --md-text-button-pressed-icon-color: var(--oscd-error);
894
+ }
895
+
896
+ #title {
897
+ position: relative;
898
+ }
899
+ `;
900
+ }
901
+
902
+ declare global {
903
+ interface HTMLElementTagNameMap {
904
+ 'oscd-shell': OpenSCD;
905
+ }
906
+ }