diff --git a/demo/index.html b/demo/index.html index d7b2af7..89a3049 100644 --- a/demo/index.html +++ b/demo/index.html @@ -7,9 +7,10 @@ href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300&family=Roboto:wght@300;400;500&display=swap" /> + @@ -21,6 +22,17 @@ await import('@webcomponents/scoped-custom-element-registry'); await import('../dist/oscd-shell.js'); await import('./index.js'); + + const _customElementsDefine = window.customElements.define; + window.customElements.define = (name, cl, conf) => { + if (!customElements.get(name)) { + try { + _customElementsDefine.call(window.customElements, name, cl, conf); + } catch (e) { + console.warn(e); + } + } + }; diff --git a/demo/index.js b/demo/index.js index e2636d9..d52770a 100644 --- a/demo/index.js +++ b/demo/index.js @@ -54,35 +54,116 @@ const plugins = { ], editor: [ { - name: 'SLD Designer', - translations: { - de: 'SLD entwerfen', - }, + name: 'SLD', icon: 'add_box', requireDoc: true, - src: 'https://omicronenergyoss.github.io/oscd-editor-sld/oscd-editor-sld.js', + plugins: [ + { + name: 'Design SLD', + icon: 'add_box', + requireDoc: true, + src: 'https://omicronenergyoss.github.io/oscd-editor-sld/oscd-editor-sld.js', + }, + { + name: 'Edit Substation', + icon: 'margin', + requireDoc: true, + src: 'https://OpenEnergyTools.github.io/scl-substation-editor/scl-substation-editor.js', + }, + ], }, - { - name: 'Source Editor', - translations: { de: 'Source Editor' }, - icon: 'code', + name: 'View GOOSE/SMV', + icon: 'hub', requireDoc: true, - tagName: 'oscd-editor-source', + plugins: [ + { + name: 'Edit Communication', + icon: 'hub', + requireDoc: true, + src: 'https://danyill.github.io/scl-communication-editor/scl-communication-editor.js', + }, + { + name: 'Explore Communication', + icon: 'lan', + requireDoc: true, + src: 'https://sprinteins.github.io/oscd-plugins/oscd-plugins.js', + }, + ], + }, + { + name: 'Subscriptions & Supervisions', + icon: 'add_box', + requireDoc: true, + plugins: [ + { + name: 'Subscribe (Later Binding)', + icon: 'link', + requireDoc: true, + src: 'https://danyill.github.io/oscd-subscriber-later-binding/oscd-subscriber-later-binding.js', + }, + { + name: 'Supervise', + icon: 'ecg', + requireDoc: true, + src: 'https://danyill.github.io/oscd-supervision/oscd-supervision.js', + }, + ], + }, + { + name: 'Publish and Address', + icon: 'network_node', + requireDoc: true, + plugins: [ + { + name: 'Publish', + icon: 'publish', + requireDoc: true, + src: 'https://com-pas.github.io/oscd-publisher/oscd-publisher.js', + }, + { + name: 'Address Multicast (TP)', + icon: 'auto_fix_normal', + requireDoc: true, + src: 'https://danyill.github.io/oscd-tp-multicast-naming/oscd-tp-multicast-naming.js', + }, + { + name: 'Communicate', + icon: 'network_node', + requireDoc: true, + src: 'https://openenergytools.github.io/scl-communication/scl-communication.js', + }, + ], }, - ], - background: [ { - name: 'EditV1 Events Listener', - icon: 'none', + name: 'Configure Network (TP)', + icon: 'news', + requireDoc: true, + src: 'https://danyill.github.io/oscd-network-config/oscd-network-config.js', + }, + { + name: 'Compare', + icon: 'compare', + requireDoc: true, + src: 'https://OMICRONEnergyOSS.github.io/oscd-editor-diff/oscd-editor-diff.js', + }, + { + name: 'Stencil', + icon: 'draw_collage', + requireDoc: true, + src: 'https://danyill.github.io/oscd-stencil/oscd-stencil.js', + }, + { + name: 'Source Editor', + icon: 'code', requireDoc: true, - tagName: 'oscd-background-editv1', + src: 'https://OMICRONEnergyOSS.github.io/oscd-editor-source/oscd-editor-source.js', }, { - name: 'EditV1 Events Listener', - icon: 'none', + name: 'Describe', + icon: 'description', requireDoc: true, - tagName: 'oscd-background-editv1', + src: 'https://danyill.github.io/oscd-description/oscd-description.js', }, ], }; diff --git a/screenshots/Chromium/baseline/app-bar-de.png b/screenshots/Chromium/baseline/app-bar-de.png index b9e26f1..0412c65 100644 Binary files a/screenshots/Chromium/baseline/app-bar-de.png and b/screenshots/Chromium/baseline/app-bar-de.png differ diff --git a/screenshots/Chromium/baseline/app-bar-en.png b/screenshots/Chromium/baseline/app-bar-en.png index b9e26f1..bdff525 100644 Binary files a/screenshots/Chromium/baseline/app-bar-en.png and b/screenshots/Chromium/baseline/app-bar-en.png differ diff --git a/screenshots/Chromium/baseline/document-name-de.png b/screenshots/Chromium/baseline/document-name-de.png index b9e26f1..0412c65 100644 Binary files a/screenshots/Chromium/baseline/document-name-de.png and b/screenshots/Chromium/baseline/document-name-de.png differ diff --git a/screenshots/Chromium/baseline/document-name-en.png b/screenshots/Chromium/baseline/document-name-en.png index b9e26f1..bdff525 100644 Binary files a/screenshots/Chromium/baseline/document-name-en.png and b/screenshots/Chromium/baseline/document-name-en.png differ diff --git a/screenshots/Chromium/baseline/editor-plugins-de.png b/screenshots/Chromium/baseline/editor-plugins-de.png index 63f24f3..1b44dd0 100644 Binary files a/screenshots/Chromium/baseline/editor-plugins-de.png and b/screenshots/Chromium/baseline/editor-plugins-de.png differ diff --git a/screenshots/Chromium/baseline/editor-plugins-en.png b/screenshots/Chromium/baseline/editor-plugins-en.png index 85fea74..915ae41 100644 Binary files a/screenshots/Chromium/baseline/editor-plugins-en.png and b/screenshots/Chromium/baseline/editor-plugins-en.png differ diff --git a/screenshots/Chromium/baseline/editor-plugins-selected-de.png b/screenshots/Chromium/baseline/editor-plugins-selected-de.png index f748869..4778a1e 100644 Binary files a/screenshots/Chromium/baseline/editor-plugins-selected-de.png and b/screenshots/Chromium/baseline/editor-plugins-selected-de.png differ diff --git a/screenshots/Chromium/baseline/editor-plugins-selected-en.png b/screenshots/Chromium/baseline/editor-plugins-selected-en.png index 63dfd03..838bc7b 100644 Binary files a/screenshots/Chromium/baseline/editor-plugins-selected-en.png and b/screenshots/Chromium/baseline/editor-plugins-selected-en.png differ diff --git a/screenshots/Chromium/baseline/menu-drawer-de.png b/screenshots/Chromium/baseline/menu-drawer-de.png index 9491a3d..7053baf 100644 Binary files a/screenshots/Chromium/baseline/menu-drawer-de.png and b/screenshots/Chromium/baseline/menu-drawer-de.png differ diff --git a/screenshots/Chromium/baseline/menu-drawer-en.png b/screenshots/Chromium/baseline/menu-drawer-en.png index 9491a3d..8c345bf 100644 Binary files a/screenshots/Chromium/baseline/menu-drawer-en.png and b/screenshots/Chromium/baseline/menu-drawer-en.png differ diff --git a/screenshots/Chromium/baseline/menu-plugins-de.png b/screenshots/Chromium/baseline/menu-plugins-de.png index 477e761..cc1ca1a 100644 Binary files a/screenshots/Chromium/baseline/menu-plugins-de.png and b/screenshots/Chromium/baseline/menu-plugins-de.png differ diff --git a/screenshots/Chromium/baseline/menu-plugins-en.png b/screenshots/Chromium/baseline/menu-plugins-en.png index 5bfc3fe..118440b 100644 Binary files a/screenshots/Chromium/baseline/menu-plugins-en.png and b/screenshots/Chromium/baseline/menu-plugins-en.png differ diff --git a/screenshots/Chromium/baseline/menu-plugins-triggered-de.png b/screenshots/Chromium/baseline/menu-plugins-triggered-de.png index 477e761..cc1ca1a 100644 Binary files a/screenshots/Chromium/baseline/menu-plugins-triggered-de.png and b/screenshots/Chromium/baseline/menu-plugins-triggered-de.png differ diff --git a/screenshots/Chromium/baseline/menu-plugins-triggered-en.png b/screenshots/Chromium/baseline/menu-plugins-triggered-en.png index 5bfc3fe..118440b 100644 Binary files a/screenshots/Chromium/baseline/menu-plugins-triggered-en.png and b/screenshots/Chromium/baseline/menu-plugins-triggered-en.png differ diff --git a/src/localization/de.xlf b/src/localization/de.xlf index fa98288..0131e90 100644 --- a/src/localization/de.xlf +++ b/src/localization/de.xlf @@ -14,6 +14,14 @@ Menu Menü + + Expand sidebar + Menü erweitern + + + Collapse sidebar + Menü einklappen + diff --git a/src/menus/plugins-menu.spec.ts b/src/menus/plugins-menu.spec.ts index b2133c9..80ccd7d 100644 --- a/src/menus/plugins-menu.spec.ts +++ b/src/menus/plugins-menu.spec.ts @@ -44,6 +44,20 @@ describe('plugins-menu', () => { await pluginsMenu.updateComplete; }); + it('renders an img when appIcon is set', async () => { + pluginsMenu.appIcon = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PC9zdmc+'; + await pluginsMenu.updateComplete; + const img = pluginsMenu.shadowRoot?.querySelector('img'); + expect(img).to.exist; + expect(img!.getAttribute('alt')).to.equal('logo'); + }); + + it('does not render an img when appIcon is not set', async () => { + const img = pluginsMenu.shadowRoot?.querySelector('img'); + expect(img).to.not.exist; + }); + it('displays a menu item for each menu plugin', async () => { const menuOpenButton = findMenuOpenButton(pluginsMenu); menuOpenButton?.click(); diff --git a/src/oscd-shell-design-tokens.ts b/src/oscd-shell-design-tokens.ts index 8137c91..5726834 100644 --- a/src/oscd-shell-design-tokens.ts +++ b/src/oscd-shell-design-tokens.ts @@ -214,11 +214,11 @@ export const oscdShellDesignTokens = css` /* Editor plugins panel */ --editor-plugins-panel-width: var( --oscd-shell-editor-plugins-panel-width, - 280px + 320px ); --editor-plugins-panel-collapsed-width: var( - --oscd-shell-editor-plugins-panel-width, - 280px + --oscd-shell-editor-plugins-panel-collapsed-width, + 72px ); --editor-plugins-panel-padding-top: var( --oscd-shell-editor-plugins-panel-padding-top, @@ -248,6 +248,10 @@ export const oscdShellDesignTokens = css` --oscd-shell-editor-plugins-panel-item-active-bg, var(--oscd-primary) ); + --editor-plugins-panel-group-active-bg: var( + --oscd-shell-editor-plugins-panel-group-active-bg, + var(--oscd-secondary) + ); --side-panel-width: var(--editor-plugins-panel-width); /* Main editor container */ diff --git a/src/oscd-shell.plugging.spec.ts b/src/oscd-shell.plugging.spec.ts index d0cc5c2..8bd89aa 100644 --- a/src/oscd-shell.plugging.spec.ts +++ b/src/oscd-shell.plugging.spec.ts @@ -5,6 +5,7 @@ import { html } from 'lit'; import '../oscd-shell.js'; import sinon from 'sinon'; import type { OscdShell } from './oscd-shell.js'; +import { flattenEditors } from './oscd-shell.js'; import { TestBackgroundPlugin, TestMenuPlugin1, @@ -208,7 +209,7 @@ describe('OscdShell Plugin Handling', () => { const { editor } = oscdShell.plugins; expect(editor).to.have.lengthOf(1); const editorPluginElement = oscdShell.shadowRoot?.querySelector( - editor[0].tagName, + flattenEditors(editor)[0].tagName, ); expect(editorPluginElement, 'Editor Plugin Element').to.exist; expect(editorPluginElement?.querySelector('h1')?.textContent).to.contain( diff --git a/src/oscd-shell.ts b/src/oscd-shell.ts index fad5ba7..fecefde 100644 --- a/src/oscd-shell.ts +++ b/src/oscd-shell.ts @@ -14,7 +14,7 @@ import { OscdFilledIconButton } from '@omicronenergy/oscd-ui/iconbutton/OscdFill import { XMLEditor } from '@omicronenergy/oscd-editor'; import { EditEventV2, OpenEvent } from '@openscd/oscd-api'; -import { loadSourcedPlugins } from './utils/plugin-utils.js'; +import { loadSourcedPlugins, loadEditorPlugins } from './utils/plugin-utils.js'; import { getLocale, LocaleTag, @@ -45,12 +45,41 @@ export type SourcedPluginEntry = { icon: string; requireDoc?: boolean; }; -export type PluginSet

= { - menu: P[]; - editor: P[]; - background: P[]; + +/** A named grouping of editor plugins. Has no `src`/`tagName` — it is not itself renderable. */ +export type PluginGroup = { + name: string; + translations?: Translations; + icon: string; + requireDoc?: boolean; + plugins: (PluginEntry | SourcedPluginEntry)[]; +}; + +/** A `PluginGroup` after sourced children have been resolved to `PluginEntry` items. */ +export type ResolvedPluginGroup = { + name: string; + translations?: Translations; + icon: string; + requireDoc?: boolean; + plugins: PluginEntry[]; }; +/** A flat editor plugin or a resolved plugin group. */ +export type EditorPluginEntry = PluginEntry | ResolvedPluginGroup; + +export type PluginSet = { + menu: PluginEntry[]; + editor: EditorPluginEntry[]; + background: PluginEntry[]; +}; + +/** Flattens groups so callers can work with a simple indexed list of leaf plugins. */ +export function flattenEditors(editors: EditorPluginEntry[]): PluginEntry[] { + return editors.flatMap(e => + 'plugins' in e ? (e as ResolvedPluginGroup).plugins : [e as PluginEntry], + ); +} + @localized() @customElement('oscd-shell') export class OscdShell extends ScopedElementsMixin(LitElement) { @@ -117,16 +146,17 @@ export class OscdShell extends ScopedElementsMixin(LitElement) { } set plugins( - plugins: Partial>>, + plugins: Partial<{ + menu: Partial[]; + editor: (Partial | PluginGroup)[]; + background: Partial[]; + }>, ) { - this._plugins = Object.entries(plugins).reduce( - (acc, [pluginType, kind]) => { - const convertedPlugins = loadSourcedPlugins(kind, this.registry!); - acc[pluginType as keyof PluginSet] = convertedPlugins; - return acc; - }, - { menu: [], editor: [], background: [] } as PluginSet, - ); + this._plugins = { + menu: loadSourcedPlugins(plugins.menu ?? [], this.registry!), + editor: loadEditorPlugins(plugins.editor ?? [], this.registry!), + background: loadSourcedPlugins(plugins.background ?? [], this.registry!), + }; } /* @@ -144,7 +174,7 @@ export class OscdShell extends ScopedElementsMixin(LitElement) { @state() get editor() { - return this.plugins.editor[this.editorIndex]?.tagName ?? ''; + return flattenEditors(this.plugins.editor)[this.editorIndex]?.tagName ?? ''; } @state() @@ -519,26 +549,14 @@ export class OscdShell extends ScopedElementsMixin(LitElement) { main { grid-area: main; display: grid; - grid-template-columns: var(--side-panel-width) 1fr; + grid-template-columns: auto 1fr; grid-template-areas: 'sidebar editor'; overflow: hidden; } - /* Side panel collapsed state */ - main.sidebar-collapsed { - grid-template-columns: 0 1fr; - } - section.editors-side-panel-section { grid-area: sidebar; - overflow-y: auto; - overflow-x: hidden; - transition: transform 0.3s ease-in-out; - } - - /* Hide side panel when collapsed */ - main.sidebar-collapsed section.editors-side-panel-section { - transform: translateX(-100%); + overflow: hidden; } section.editor-container { diff --git a/src/side-panel/editor-plugins-panel.spec.ts b/src/side-panel/editor-plugins-panel.spec.ts index 31e3e58..66acb33 100644 --- a/src/side-panel/editor-plugins-panel.spec.ts +++ b/src/side-panel/editor-plugins-panel.spec.ts @@ -2,19 +2,27 @@ import { expect, fixture, html } from '@open-wc/testing'; import type { OscdShell } from '../oscd-shell.js'; import '../oscd-shell.js'; import { EditorPluginsPanel } from './editor-plugins-panel.js'; +import { OscdIconButton } from '@omicronenergy/oscd-ui/iconbutton/OscdIconButton.js'; import { createTestDocs } from '../utils/testing/test-doc-helpers.js'; -import { OscdFilledIconButton } from '@omicronenergy/oscd-ui/iconbutton/OscdFilledIconButton.js'; import { sampleEditorPlugins } from '../utils/testing/plugin-helpers.js'; import { TestMenuPlugin1 } from '../utils/testing/test-plugins.js'; const findPanelToggleButton = (pluginsMenu: EditorPluginsPanel) => { const toggleButton = pluginsMenu.shadowRoot?.querySelector( 'oscd-icon-button.toggle-button', - ) as OscdFilledIconButton; + ) as OscdIconButton; expect(toggleButton).to.exist; return toggleButton; }; +const findToggleLabel = (pluginsMenu: EditorPluginsPanel) => { + const label = pluginsMenu.shadowRoot?.querySelector( + 'span.toggle-sidebar', + ) as HTMLSpanElement; + expect(label).to.exist; + return label; +}; + const isPanelExpanded = (pluginsMenu: EditorPluginsPanel) => { return pluginsMenu.hasAttribute('expanded') && pluginsMenu.expanded; }; @@ -97,6 +105,31 @@ describe('editor-plugins-panel', () => { oscdShell2.remove(); }); + it('collapses on toggle label click', async () => { + expect(isPanelExpanded(editorPluginsPanel)).to.be.true; + findToggleLabel(editorPluginsPanel).click(); + await editorPluginsPanel.updateComplete; + expect(isPanelExpanded(editorPluginsPanel)).to.be.false; + }); + + it('collapses on Enter key press on toggle label', async () => { + expect(isPanelExpanded(editorPluginsPanel)).to.be.true; + findToggleLabel(editorPluginsPanel).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + await editorPluginsPanel.updateComplete; + expect(isPanelExpanded(editorPluginsPanel)).to.be.false; + }); + + it('collapses on Space key press on toggle label', async () => { + expect(isPanelExpanded(editorPluginsPanel)).to.be.true; + findToggleLabel(editorPluginsPanel).dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }), + ); + await editorPluginsPanel.updateComplete; + expect(isPanelExpanded(editorPluginsPanel)).to.be.false; + }); + it('saves expanded/collapsed state (when toggled) in localStorage', async () => { const toggleButton = findPanelToggleButton(editorPluginsPanel); expect(localStorage.getItem('editorsPanel.expanded')).to.be.null; diff --git a/src/side-panel/editor-plugins-panel.ts b/src/side-panel/editor-plugins-panel.ts index 71b775f..f3ee0bf 100644 --- a/src/side-panel/editor-plugins-panel.ts +++ b/src/side-panel/editor-plugins-panel.ts @@ -1,16 +1,22 @@ -import { css, html, LitElement, nothing } from 'lit'; -import { property } from 'lit/decorators.js'; +import { css, html, LitElement, nothing, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; -import { localized } from '@lit/localize'; +import { localized, msg } from '@lit/localize'; +import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; -import { OscdIconButton } from '@omicronenergy/oscd-ui/iconbutton/OscdIconButton.js'; import { OscdIcon } from '@omicronenergy/oscd-ui/icon/OscdIcon.js'; +import { OscdIconButton } from '@omicronenergy/oscd-ui/iconbutton/OscdIconButton.js'; import { OscdList } from '@omicronenergy/oscd-ui/list/OscdList.js'; import { OscdListItem } from '@omicronenergy/oscd-ui/list/OscdListItem.js'; import { LocaleTag, Translation } from '../localization.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { PluginEntry } from '../oscd-shell.js'; +import { + EditorPluginEntry, + PluginEntry, + ResolvedPluginGroup, +} from '../oscd-shell.js'; +import { isPluginGroup } from '../utils/plugin-utils.js'; declare global { interface HTMLElementTagNameMap { @@ -18,17 +24,30 @@ declare global { } } +function loadSet(key: string): Set { + try { + const stored = localStorage.getItem(key); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch { + return new Set(); + } +} + +function saveSet(key: string, set: Set) { + localStorage.setItem(key, JSON.stringify([...set])); +} + @localized() export class EditorPluginsPanel extends ScopedElementsMixin(LitElement) { static scopedElements = { - 'oscd-icon-button': OscdIconButton, 'oscd-icon': OscdIcon, + 'oscd-icon-button': OscdIconButton, 'oscd-list': OscdList, 'oscd-list-item': OscdListItem, }; @property({ type: Array }) - editors: PluginEntry[] = []; + editors: EditorPluginEntry[] = []; @property({ type: Number }) editorIndex = 0; @@ -36,48 +55,285 @@ export class EditorPluginsPanel extends ScopedElementsMixin(LitElement) { @property({ type: String }) locale!: LocaleTag; - // eslint-disable-next-line class-methods-use-this + @state() + private collapsedGroups: Set = loadSet( + 'editorsPanel.collapsedGroups', + ); + + @state() + private pinnedGroups: Set = loadSet('editorsPanel.pinnedGroups'); + + private get groupNames(): string[] { + return this.editors + .filter(isPluginGroup) + .map(e => (e as ResolvedPluginGroup).name); + } + + private toggleGroup(name: string) { + // Collapsing a pinned group unpins it first, then collapses. + if (this.pinnedGroups.has(name) && !this.collapsedGroups.has(name)) { + const newPinned = new Set(this.pinnedGroups); + newPinned.delete(name); + this.pinnedGroups = newPinned; + saveSet('editorsPanel.pinnedGroups', newPinned); + } + const next = new Set(this.collapsedGroups); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + this.collapsedGroups = next; + saveSet('editorsPanel.collapsedGroups', next); + } + + private togglePin(name: string) { + const next = new Set(this.pinnedGroups); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + // unpin implicitly expands the group + const collapsed = new Set(this.collapsedGroups); + collapsed.delete(name); + this.collapsedGroups = collapsed; + saveSet('editorsPanel.collapsedGroups', collapsed); + } + this.pinnedGroups = next; + saveSet('editorsPanel.pinnedGroups', next); + } + + private expandAll() { + this.collapsedGroups = new Set(); + saveSet('editorsPanel.collapsedGroups', new Set()); + } + + private collapseAll() { + const next = new Set( + this.groupNames.filter(n => !this.pinnedGroups.has(n)), + ); + this.collapsedGroups = next; + saveSet('editorsPanel.collapsedGroups', next); + } + + private isExpanded: boolean = + localStorage.getItem('editorsPanel.expanded') !== 'false'; + @property({ type: Boolean, reflect: true }) get expanded() { - const expandedStr = localStorage.getItem('editorsPanel.expanded'); - return expandedStr === 'false' ? false : true; + return this.isExpanded; + } + + set expanded(value: boolean) { + const old = this.isExpanded; + this.isExpanded = value; + localStorage.setItem('editorsPanel.expanded', value.toString()); + this.requestUpdate('expanded', old); + } + + private pluginLabel(plugin: PluginEntry | ResolvedPluginGroup): string { + return plugin.translations?.[this.locale as Translation] ?? plugin.name; + } + + private selectEditor(editor: PluginEntry, index: number) { + this.dispatchEvent( + new CustomEvent('editor-select', { + detail: { editor, index }, + bubbles: true, + composed: true, + }), + ); } + // eslint-disable-next-line class-methods-use-this - set expanded(expanded: boolean) { - localStorage.setItem('editorsPanel.expanded', String(expanded)); + private renderPluginIcon(plugin: PluginEntry): TemplateResult { + if (plugin.icon) { + return html`${plugin.icon}`; + } + const letter = (plugin.name || '?')[0].toUpperCase(); + return html`${letter}`; + } + + private renderPluginItem( + plugin: PluginEntry, + flatIndex: number, + ): TemplateResult { + const isActive = this.editorIndex === flatIndex; + const tooltip = !this.expanded ? this.pluginLabel(plugin) : undefined; + return html` this.selectEditor(plugin, flatIndex)} + > + ${this.renderPluginIcon(plugin)} + ${this.expanded + ? html`${this.pluginLabel(plugin)}` + : nothing} + `; + } + + private renderGroup( + group: ResolvedPluginGroup, + _groupIdx: number, + flatStart: number, + ): TemplateResult { + const label = this.pluginLabel(group); + const isPinned = this.pinnedGroups.has(group.name); + const hasActiveChild = group.plugins.some( + (_, i) => flatStart + i === this.editorIndex, + ); + // Active groups are always expanded; pinned groups cannot be collapsed. + const isCollapsed = + !hasActiveChild && this.collapsedGroups.has(group.name) && !isPinned; + const canToggle = !hasActiveChild; + + const containerClasses = classMap({ + 'group-container': true, + 'group-container--inactive': !hasActiveChild, + }); + + const children = isCollapsed + ? nothing + : group.plugins.map((plugin, i) => + this.renderPluginItem(plugin, flatStart + i), + ); + + if (this.expanded) { + return html` +

+
this.toggleGroup(group.name) : nothing} + @keydown=${canToggle + ? (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + this.toggleGroup(group.name); + } + } + : nothing} + > + ${group.icon} + ${label} +
e.stopPropagation()} + @keydown=${(e: KeyboardEvent) => e.stopPropagation()} + > + + expand_more +
+
+ ${children} +
+ `; + } + + // Narrow sidebar: group icon above a small chevron (chevron hidden when active). + return html` +
+
this.toggleGroup(group.name) : nothing} + @keydown=${canToggle + ? (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + this.toggleGroup(group.name); + } + } + : nothing} + > + ${group.icon} + expand_more +
+ ${children} +
+ `; } render() { + let flatIndex = 0; + + const items = this.editors.map((item, groupIdx) => { + if (isPluginGroup(item)) { + const flatStart = flatIndex; + flatIndex += (item as ResolvedPluginGroup).plugins.length; + return this.renderGroup( + item as ResolvedPluginGroup, + groupIdx, + flatStart, + ); + } + const result = this.renderPluginItem(item as PluginEntry, flatIndex); + flatIndex++; + return result; + }); + + const hasGroups = this.groupNames.length > 0; + return html` - - ${this.editors.map( - (editor, index) => - html` { - this.dispatchEvent( - new CustomEvent('editor-select', { - detail: { editor, index }, - bubbles: true, - composed: true, - }), - ); - }} - > - ${editor.icon} - ${this.expanded - ? html`${editor.translations?.[this.locale as Translation] || - editor.name}` - : nothing} - `, - )} - +
+ ${this.expanded && hasGroups + ? html` + + + ` + : nothing} +
+ ${items} +
`; } static styles = css` :host { - width: var(--editor-plugins-panel-width); + width: var(--editor-plugins-panel-collapsed-width); height: calc(100% - var(--editor-plugins-panel-padding-top)); - display: grid; - grid-template-rows: 1fr auto; - min-height: 0; + display: flex; + flex-direction: column; padding-top: var(--editor-plugins-panel-padding-top); - transition: width 0.1s ease-in-out; - overflow-y: auto; + transition: width 0.2s ease-in-out; + /* overlay scrollbar floats above content — no layout shift on appear/disappear */ + /* overflow-y: overlay; */ overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 15%, + transparent + ) + transparent; } + :host([expanded]) { + width: var(--editor-plugins-panel-width); + } + + :host::-webkit-scrollbar { + width: 4px; + } + + :host::-webkit-scrollbar-track { + background: transparent; + } + + :host::-webkit-scrollbar-thumb { + background-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 15%, + transparent + ); + border-radius: 2px; + } + + :host:hover::-webkit-scrollbar-thumb { + background-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 45%, + transparent + ); + } + + /* ── Toolbar ── */ + + .toolbar { + display: flex; + gap: 6px; + padding: 6px 14px 2px; + flex-shrink: 0; + } + + .toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + border: 1.5px solid + color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 45%, + transparent + ); + background: transparent; + color: var(--editor-plugins-panel-item-icon-color); + cursor: pointer; + padding: 0; + transition: + border-color 0.15s ease, + background-color 0.15s ease; + --md-icon-size: 15px; + } + + .toolbar-btn:hover { + border-color: var(--editor-plugins-panel-item-icon-color); + background-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 12%, + transparent + ); + } + + /* ── Plugin list ── */ + .editors-list { - min-height: 0; + flex: 1 0 auto; + overflow: visible; + width: 100%; + max-width: 100%; + box-sizing: border-box; --md-list-item-leading-space: var( --editor-plugins-panel-item-leading-space ); @@ -132,21 +488,278 @@ export class EditorPluginsPanel extends ScopedElementsMixin(LitElement) { white-space: nowrap; } - .footer { - /* setting this to display:none until re-design is fixed and its safe to remove */ - display: none; - /* justify-self: center; + /* ── Plugin letter icon fallback ── */ + + .plugin-letter-icon { + display: flex; + align-items: center; justify-content: center; - padding-block: 22px; */ + width: var(--editor-plugins-panel-item-icon-size); + height: var(--editor-plugins-panel-item-icon-size); + border-radius: 5px; + background-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 30%, + transparent + ); + color: var(--editor-plugins-panel-item-icon-color); + font-size: calc(var(--editor-plugins-panel-item-icon-size) * 0.55); + font-weight: 600; + font-family: var(--oscd-text-font, Roboto); + flex-shrink: 0; } - .toggle-button { + /* ── Group container ── */ + + /* Active group: stronger secondary-blue card wraps the selected item */ + .group-container { + margin: 3px 6px; + border-radius: 10px; + overflow-x: hidden; + background: color-mix( + in srgb, + var(--editor-plugins-panel-group-active-bg) 55%, + transparent + ); + transition: + margin 0.2s ease, + background 0.2s ease; + } + + /* Inactive group: subtle tint so grouping is still obvious */ + .group-container--inactive { + margin: 1px 6px; + background: color-mix( + in srgb, + var(--editor-plugins-panel-group-active-bg) 28%, + transparent + ); + } + + /* Selected item inside an active group: subtle white overlay so the + * group card's blue clearly wraps around and outside it */ + :host([expanded]) .group-container .active { + background-color: color-mix(in srgb, white 22%, transparent); + border-radius: 7px; + margin: 0 3px; + } + + :host(:not([expanded])) .group-container .active { + background-color: color-mix(in srgb, white 22%, transparent); + border-radius: 7px; + } + + /* Center icons in collapsed group items */ + :host(:not([expanded])) .group-container { + --md-list-item-leading-space: 13px; + --md-list-item-trailing-space: 13px; + } + + /* Indent child items in expanded sidebar */ + :host([expanded]) .group-container oscd-list-item:not(.group-header) { + --md-list-item-leading-space: calc( + var(--editor-plugins-panel-item-leading-space) + 14px + ); + } + + /* Group header in expanded sidebar (plain div, not oscd-list-item) */ + .group-header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 10px var(--editor-plugins-panel-item-trailing-space) 10px + var(--editor-plugins-panel-item-leading-space); + cursor: pointer; + border-radius: 8px 8px 0 0; + color: var(--editor-plugins-panel-item-text-color); + } + + .group-header--locked { + cursor: default; + } + + .group-header .group-header-icon { + --md-icon-size: var(--editor-plugins-panel-item-icon-size); + color: var(--editor-plugins-panel-item-icon-color); + flex-shrink: 0; + } + + .group-header:focus-visible { + outline: 2px solid var(--editor-plugins-panel-item-icon-color); + outline-offset: -2px; + } + + .group-active { + background-color: var(--editor-plugins-panel-group-active-bg); + } + + .group-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--oscd-text-font, Roboto); + font-size: var(--md-list-item-label-text-size, 16px); + } + + /* Pin + chevron wrapper */ + .group-header-actions { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; + } + + /* Pin button */ + .pin-button { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 45%, + transparent + ); + --md-icon-size: 15px; + --md-icon-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 45%, + transparent + ); + transition: + color 0.15s ease, + background-color 0.15s ease; + } + + .pin-button:hover { + background-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 15%, + transparent + ); + color: var(--editor-plugins-panel-item-icon-color); --md-icon-color: var(--editor-plugins-panel-item-icon-color); - --md-icon-button-icon-size: var(--editor-plugins-panel-item-icon-size); - --md-icon-button-hover-state-layer-color: var( - --editor-plugins-panel-item-icon-color + } + + .pin-button--active { + color: var(--editor-plugins-panel-group-active-bg); + --md-icon-color: var(--editor-plugins-panel-group-active-bg); + } + + /* FILL=1 makes the push_pin icon appear filled when pinned. + * font-variation-settings is an inherited property and flows into + * oscd-icon's shadow root where the variable font text is rendered. */ + /* .pin-button--active oscd-icon { + font-variation-settings: 'FILL' 1; + } */ + + oscd-icon[filled] { + font-variation-settings: 'FILL' 1; + } + + /* Chevron in expanded sidebar — animates on collapse */ + .group-chevron { + --md-icon-size: 24px; + display: block; + transform: rotate(0deg); + transition: transform 0.25s ease; + color: var(--editor-plugins-panel-item-icon-color); + } + + .group-chevron--collapsed { + transform: rotate(-90deg); + } + + /* Tiny chevron in narrow sidebar */ + .group-chevron--narrow { + --md-icon-size: 13px; + opacity: 0.75; + } + + /* Hidden chevron: takes up space but is invisible, preventing layout jump */ + .group-chevron--hidden { + visibility: hidden; + pointer-events: none; + } + + /* + * Group header in narrow sidebar: group icon stacked above a small chevron. + */ + .group-header-narrow { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + padding: 5px 0 3px; + cursor: pointer; + color: var(--editor-plugins-panel-group-active-bg); + border-bottom: 1px solid + color-mix( + in srgb, + var(--editor-plugins-panel-group-active-bg) 50%, + transparent + ); + } + + .group-header-narrow .group-header-icon { + --md-icon-size: var(--editor-plugins-panel-item-icon-size); + } + + .group-header-narrow.group-active { + background-color: var(--editor-plugins-panel-group-active-bg); + color: var(--editor-plugins-panel-item-icon-color); + } + + /* ── Spacer between list and footer ── */ + + .list-end-spacer { + min-height: 60px; + flex-shrink: 0; + } + + /* ── Footer — always at bottom, above all content ── */ + + .footer { + position: sticky; + bottom: 0; + /* margin-top: auto pushes footer to bottom when content is short */ + margin-top: auto; + display: flex; + align-items: center; + min-height: 56px; + flex-shrink: 0; + padding-left: calc(var(--editor-plugins-panel-item-leading-space) - 6px); + background-color: var( + --oscd-shell-editor-plugins-panel-background, + var(--oscd-primary) ); - --md-icon-button-hover-state-layer-opacity: 0.08; + /* Own stacking context above all list-item hover/focus states */ + isolation: isolate; + z-index: 10; + } + + .footer:focus, + .footer:hover, + .footer:active { + background-color: color-mix( + in srgb, + var(--editor-plugins-panel-item-icon-color) 8%, + var(--oscd-shell-editor-plugins-panel-background) + ); + } + + .toggle-button { + --md-icon-button-icon-size: var(--editor-plugins-panel-item-icon-size); --md-icon-button-icon-color: var(--editor-plugins-panel-item-icon-color); --md-icon-button-hover-icon-color: var( --editor-plugins-panel-item-icon-color @@ -157,18 +770,18 @@ export class EditorPluginsPanel extends ScopedElementsMixin(LitElement) { --md-icon-button-pressed-icon-color: var( --editor-plugins-panel-item-icon-color ); - --md-icon-button-state-layer-height: 48px; - --md-icon-button-state-layer-width: 48px; } - :host([expanded]) { - width: var(--editor-plugins-panel-width); + .toggle-sidebar { + margin-left: 12px; + height: 100%; + width: 100%; + align-content: center; + cursor: pointer; + color: var(--editor-plugins-panel-item-text-color); + font-family: var(--oscd-text-font, Roboto); + font-size: var(--md-list-item-label-text-size, 16px); + white-space: nowrap; } - - /* :host([expanded]) .footer { - justify-self: flex-end; - justify-content: flex-end; - padding-inline: 22px; - } */ `; } diff --git a/src/utils/plugin-utils.ts b/src/utils/plugin-utils.ts index 46751eb..c731183 100644 --- a/src/utils/plugin-utils.ts +++ b/src/utils/plugin-utils.ts @@ -1,5 +1,11 @@ import { cyrb64 } from '../foundation/cyrb64.js'; -import { PluginEntry, SourcedPluginEntry } from '../oscd-shell.js'; +import { + EditorPluginEntry, + PluginEntry, + PluginGroup, + ResolvedPluginGroup, + SourcedPluginEntry, +} from '../oscd-shell.js'; const pluginTags = new Map(); @@ -40,6 +46,21 @@ function generateErrorWcClass(plugin: Partial) { return new Function(classString)(); } +/** + * Checks if the given object is a PluginGroup (has a `plugins` array child, no `src`/`tagName`). + * Works for both input PluginGroup and resolved ResolvedPluginGroup. + */ +export function isPluginGroup( + plugin: unknown, +): plugin is PluginGroup | ResolvedPluginGroup { + return ( + typeof plugin === 'object' && + plugin !== null && + 'plugins' in plugin && + Array.isArray((plugin as { plugins: unknown }).plugins) + ); +} + /** * Checks if the given object is a valid Plugin. * @param plugin - The object to check. @@ -117,6 +138,37 @@ export function validatePlugin(plugin: unknown): PluginEntry | undefined { * @param plugins - Array of plugins to convert. * @returns Array of plugins with tagName included. */ +/** + * Like `loadSourcedPlugins` but handles the editor array which may contain `PluginGroup` entries. + * Groups are resolved by loading their child plugins; flat entries are loaded as-is. + */ +export function loadEditorPlugins( + plugins: (Partial | PluginGroup)[], + registry: CustomElementRegistry, +): EditorPluginEntry[] { + const result: EditorPluginEntry[] = []; + for (const plugin of plugins) { + if (isPluginGroup(plugin)) { + const resolved: ResolvedPluginGroup = { + name: plugin.name, + translations: plugin.translations, + icon: plugin.icon, + requireDoc: plugin.requireDoc, + plugins: loadSourcedPlugins((plugin as PluginGroup).plugins, registry), + }; + result.push(resolved); + } else { + result.push( + ...loadSourcedPlugins( + [plugin as Partial], + registry, + ), + ); + } + } + return result; +} + export function loadSourcedPlugins( plugins: Partial[], registry: CustomElementRegistry, diff --git a/src/utils/testing/plugin-helpers.ts b/src/utils/testing/plugin-helpers.ts index 21ec5aa..adde21c 100644 --- a/src/utils/testing/plugin-helpers.ts +++ b/src/utils/testing/plugin-helpers.ts @@ -1,6 +1,6 @@ /* eslint-disable import-x/no-extraneous-dependencies */ import { waitUntil } from '@open-wc/testing'; -import { OscdShell, PluginEntry } from '../../oscd-shell.js'; +import { flattenEditors, OscdShell, PluginEntry } from '../../oscd-shell.js'; export const sampleMenuPlugins: (Omit & { tagName?: string; @@ -85,7 +85,7 @@ export const waitForPluginsToInstantiate = async ( export const waitForAllPluginsToInstantiate = async (shell: OscdShell) => { const docLoaded = !!shell.docName; - const editorPlugin = shell.plugins.editor.find( + const editorPlugin = flattenEditors(shell.plugins.editor).find( editor => editor.tagName === shell.editor, );