import { css, html, LitElement, nothing, TemplateResult } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html as staticHtml, unsafeStatic } from 'lit/static-html.js';
import { configureLocalization, localized, msg, str } from '@lit/localize';
import '@material/mwc-button';
import '@material/mwc-dialog';
import '@material/mwc-drawer';
import '@material/mwc-icon';
import '@material/mwc-icon-button';
import '@material/mwc-list';
import '@material/mwc-tab-bar';
import '@material/mwc-top-app-bar-fixed';
import type { ActionDetail } from '@material/mwc-list';
import type { Dialog } from '@material/mwc-dialog';
import type { Drawer } from '@material/mwc-drawer';
import { allLocales, sourceLocale, targetLocales } from './locales.js';
import {
cyrb64,
Edit,
EditEvent,
handleEdit,
isComplex,
isInsert,
isRemove,
isUpdate,
OpenEvent,
} from './foundation.js';
export type LogEntry = { undo: Edit; redo: Edit };
export type Plugin = {
name: string;
translations?: Record;
src: string;
icon: string;
requireDoc?: boolean;
active?: boolean;
};
export type PluginSet = { menu: Plugin[]; editor: Plugin[] };
const pluginTags = new Map();
/** @returns a valid customElement tagName containing the URI hash. */
function pluginTag(uri: string): string {
if (!pluginTags.has(uri)) pluginTags.set(uri, `oscd-p${cyrb64(uri)}`);
return pluginTags.get(uri)!;
}
type Control = {
icon: string;
getName: () => string;
isDisabled: () => boolean;
action?: () => unknown;
};
type RenderedPlugin = Control & { tagName: string };
type LocaleTag = typeof allLocales[number];
const { getLocale, setLocale } = configureLocalization({
sourceLocale,
targetLocales,
loadLocale: locale =>
import(new URL(`locales/${locale}.js`, import.meta.url).href),
});
function describe({ undo, redo }: LogEntry) {
let result = msg('Something unexpected happened!');
if (isComplex(redo)) result = msg(str`≥ ${redo.length} nodes changed`);
if (isInsert(redo))
if (isInsert(undo))
result = msg(str`${redo.node.nodeName} moved to ${redo.parent.nodeName}`);
else
result = msg(
str`${redo.node.nodeName} inserted into ${redo.parent.nodeName}`
);
if (isRemove(redo)) result = msg(str`${redo.node.nodeName} removed`);
if (isUpdate(redo)) result = msg(str`${redo.element.tagName} updated`);
return result;
}
function renderActionItem(
control: Control,
slot = 'actionItems'
): TemplateResult {
return html``;
}
function renderMenuItem(control: Control): TemplateResult {
return html`
${control.icon}
${control.getName()}
`;
}
@customElement('open-scd')
@localized()
export class OpenSCD extends LitElement {
@state()
/** The `XMLDocument` currently being edited */
get doc(): XMLDocument {
return this.docs[this.docName];
}
@state()
history: LogEntry[] = [];
@state()
editCount: number = 0;
@state()
get last(): number {
return this.editCount - 1;
}
@state()
get canUndo(): boolean {
return this.last >= 0;
}
@state()
get canRedo(): boolean {
return this.editCount < this.history.length;
}
/** The set of `XMLDocument`s currently loaded */
@state()
docs: Record = {};
/** The name of the [[`doc`]] currently being edited */
@property({ type: String, reflect: true }) docName = '';
#loadedPlugins = new Map();
@state()
get loadedPlugins(): Map {
return this.#loadedPlugins;
}
#plugins: PluginSet = { menu: [], editor: [] };
@property({ type: Object })
get plugins(): PluginSet {
return this.#plugins;
}
set plugins(plugins: Partial) {
Object.values(plugins).forEach(kind =>
kind.forEach(plugin => {
const tagName = pluginTag(plugin.src);
if (this.loadedPlugins.has(tagName)) return;
this.#loadedPlugins.set(tagName, plugin);
if (customElements.get(tagName)) return;
const url = new URL(plugin.src, window.location.href).toString();
import(url).then(mod => customElements.define(tagName, mod.default));
})
);
this.#plugins = { menu: [], editor: [], ...plugins };
this.requestUpdate();
}
handleOpenDoc({ detail: { docName, doc } }: OpenEvent) {
this.docName = docName;
this.docs[this.docName] = doc;
}
handleEditEvent(event: EditEvent) {
const edit = event.detail;
this.history.splice(this.editCount);
this.history.push({ undo: handleEdit(edit), redo: edit });
this.editCount += 1;
}
/** Undo the last `n` [[Edit]]s committed */
undo(n = 1) {
if (!this.canUndo || n < 1) return;
handleEdit(this.history[this.last!].undo);
this.editCount -= 1;
if (n > 1) this.undo(n - 1);
}
/** Redo the last `n` [[Edit]]s that have been undone */
redo(n = 1) {
if (!this.canRedo || n < 1) return;
handleEdit(this.history[this.editCount].redo);
this.editCount += 1;
if (n > 1) this.redo(n - 1);
}
@query('#log')
logUI!: Dialog;
@query('#menu')
menuUI!: Drawer;
@property({ type: String, reflect: true })
get locale() {
return getLocale() as LocaleTag;
}
set locale(tag: LocaleTag) {
try {
setLocale(tag);
} catch {
// don't change locale if tag is invalid
}
}
@state()
private editorIndex = 0;
@state()
get editor() {
return this.editors[this.editorIndex]?.tagName ?? '';
}
private controls: Record<
'undo' | 'redo' | 'log' | 'menu',
Required
> = {
undo: {
icon: 'undo',
getName: () => msg('Undo'),
action: () => this.undo(),
isDisabled: () => !this.canUndo,
},
redo: {
icon: 'redo',
getName: () => msg('Redo'),
action: () => this.redo(),
isDisabled: () => !this.canRedo,
},
log: {
icon: 'history',
getName: () => msg('Editing history'),
action: () => (this.logUI.open ? this.logUI.close() : this.logUI.show()),
isDisabled: () => false,
},
menu: {
icon: 'menu',
getName: () => msg('Menu'),
action: async () => {
this.menuUI.open = !this.menuUI.open;
await this.menuUI.updateComplete;
if (this.menuUI.open) this.menuUI.querySelector('mwc-list')!.focus();
},
isDisabled: () => false,
},
};
#actions = [this.controls.undo, this.controls.redo, this.controls.log];
@state()
get menu() {
return ([]>this.plugins.menu
?.map((plugin): RenderedPlugin | undefined =>
plugin.active
? {
icon: plugin.icon,
getName: () =>
plugin.translations?.[
this.locale as typeof targetLocales[number]
] || plugin.name,
isDisabled: () => (plugin.requireDoc && !this.docName) ?? false,
tagName: pluginTag(plugin.src),
action: () =>
this.shadowRoot!.querySelector<
HTMLElement & { run: () => Promise }
>(pluginTag(plugin.src))!.run?.(),
}
: undefined
)
.filter(p => p !== undefined)).concat(this.#actions);
}
@state()
get editors() {
return this.plugins.editor
?.map((plugin): RenderedPlugin | undefined =>
plugin.active
? {
icon: plugin.icon,
getName: () =>
plugin.translations?.[
this.locale as typeof targetLocales[number]
] || plugin.name,
isDisabled: () => (plugin.requireDoc && !this.docName) ?? false,
tagName: pluginTag(plugin.src),
}
: undefined
)
.filter(p => p !== undefined);
}
private hotkeys: Partial void>> = {
m: this.controls.menu.action,
z: this.controls.undo.action,
y: this.controls.redo.action,
Z: this.controls.redo.action,
l: this.controls.log.action,
};
private handleKeyPress(e: KeyboardEvent): void {
if (!e.ctrlKey) return;
if (!Object.prototype.hasOwnProperty.call(this.hotkeys, e.key)) return;
this.hotkeys[e.key]!();
e.preventDefault();
}
constructor() {
super();
document.addEventListener('keydown', event => this.handleKeyPress(event));
this.addEventListener('oscd-open', event => this.handleOpenDoc(event));
this.addEventListener('oscd-edit', event => this.handleEditEvent(event));
}
private renderLogEntry(entry: LogEntry) {
return html`
${describe(entry)}
history
`;
}
private renderHistory(): TemplateResult[] | TemplateResult {
if (this.history.length > 0)
return this.history.slice().reverse().map(this.renderLogEntry, this);
return html`
${msg('Your editing history will be displayed here.')}
info
`;
}
render() {
return html`
${this.renderHistory()}
${msg('Close')}
`;
}
static styles = css`
aside {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
margin: 0;
padding: 0;
}
abbr {
text-decoration: none;
}
mwc-top-app-bar-fixed {
--mdc-theme-text-disabled-on-light: rgba(255, 255, 255, 0.38);
} /* hack to fix disabled icon buttons rendering black */
`;
}
declare global {
interface HTMLElementTagNameMap {
'open-scd': OpenSCD;
}
}