mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-03 21:08:18 +00:00
Menu bar definition in backend (#681)
* initial menu layout working * removed api.rs functions * no action shortcut for no-op * code review * nitpicks
This commit is contained in:
parent
9bd27ec3f8
commit
5d6d2b22bc
35 changed files with 781 additions and 475 deletions
|
@ -2,7 +2,7 @@
|
|||
|
||||
<template>
|
||||
<div class="widget-layout">
|
||||
<component :is="layoutRowType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layout.layout_target" v-for="(layoutRow, index) in layout.layout" :key="index" />
|
||||
<component :is="LayoutGroupType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layout.layout_target" v-for="(layoutRow, index) in layout.layout" :key="index" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { isWidgetColumn, isWidgetRow, isWidgetSection, LayoutRow, WidgetLayout } from "@/wasm-communication/messages";
|
||||
import { isWidgetColumn, isWidgetRow, isWidgetSection, LayoutGroup, WidgetLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import WidgetSection from "@/components/widgets/groups/WidgetSection.vue";
|
||||
import WidgetRow from "@/components/widgets/WidgetRow.vue";
|
||||
|
@ -28,7 +28,7 @@ export default defineComponent({
|
|||
layout: { type: Object as PropType<WidgetLayout>, required: true },
|
||||
},
|
||||
methods: {
|
||||
layoutRowType(layoutRow: LayoutRow): unknown {
|
||||
LayoutGroupType(layoutRow: LayoutGroup): unknown {
|
||||
if (isWidgetColumn(layoutRow)) return WidgetRow;
|
||||
if (isWidgetRow(layoutRow)) return WidgetRow;
|
||||
if (isWidgetSection(layoutRow)) return WidgetSection;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<TextLabel :bold="true">{{ widgetData.name }}</TextLabel>
|
||||
</button>
|
||||
<LayoutCol class="body" v-if="expanded">
|
||||
<component :is="layoutRowType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layoutTarget" v-for="(layoutRow, index) in widgetData.layout" :key="index"></component>
|
||||
<component :is="layoutGroupType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layoutTarget" v-for="(layoutRow, index) in widgetData.layout" :key="index"></component>
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
</template>
|
||||
|
@ -72,7 +72,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { isWidgetRow, isWidgetSection, LayoutRow as LayoutSystemRow, WidgetSection as WidgetSectionFromJsMessages } from "@/wasm-communication/messages";
|
||||
import { isWidgetRow, isWidgetSection, LayoutGroup, WidgetSection as WidgetSectionFromJsMessages } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
@ -96,9 +96,9 @@ const WidgetSection = defineComponent({
|
|||
updateLayout(widgetId: bigint, value: unknown) {
|
||||
this.editor.instance.update_layout(this.layoutTarget, widgetId, value);
|
||||
},
|
||||
layoutRowType(layoutRow: LayoutSystemRow): unknown {
|
||||
if (isWidgetRow(layoutRow)) return WidgetRow;
|
||||
if (isWidgetSection(layoutRow)) return WidgetSection;
|
||||
layoutGroupType(layoutGroup: LayoutGroup): unknown {
|
||||
if (isWidgetRow(layoutGroup)) return WidgetRow;
|
||||
if (isWidgetSection(layoutGroup)) return WidgetSection;
|
||||
|
||||
throw new Error("Layout row type does not exist");
|
||||
},
|
||||
|
|
|
@ -72,151 +72,42 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
import { MenuEntry, UpdateMenuBarLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/floating-menus/MenuList.vue";
|
||||
import MenuList, { MenuListEntry } from "@/components/floating-menus/MenuList.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
function makeEntries(editor: Editor): MenuListEntries {
|
||||
return [
|
||||
{
|
||||
label: "File",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{ label: "New…", icon: "File", shortcut: ["KeyControl", "KeyN"], shortcutRequiresLock: true, action: (): void => editor.instance.request_new_document_dialog() },
|
||||
{ label: "Open…", shortcut: ["KeyControl", "KeyO"], action: (): void => editor.instance.open_document() },
|
||||
{
|
||||
label: "Open Recent",
|
||||
shortcut: ["KeyControl", "KeyShift", "KeyO"],
|
||||
action: (): void => undefined,
|
||||
children: [
|
||||
[{ label: "Reopen Last Closed", shortcut: ["KeyControl", "KeyShift", "KeyT"], shortcutRequiresLock: true }, { label: "Clear Recently Opened" }],
|
||||
[
|
||||
{ label: "Some Recent File.gdd" },
|
||||
{ label: "Another Recent File.gdd" },
|
||||
{ label: "An Older File.gdd" },
|
||||
{ label: "Some Other Older File.gdd" },
|
||||
{ label: "Yet Another Older File.gdd" },
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: "Close", shortcut: ["KeyControl", "KeyW"], shortcutRequiresLock: true, action: async (): Promise<void> => editor.instance.close_active_document_with_confirmation() },
|
||||
{ label: "Close All", shortcut: ["KeyControl", "KeyAlt", "KeyW"], action: async (): Promise<void> => editor.instance.close_all_documents_with_confirmation() },
|
||||
],
|
||||
[
|
||||
{ label: "Save", shortcut: ["KeyControl", "KeyS"], action: async (): Promise<void> => editor.instance.save_document() },
|
||||
{ label: "Save As…", shortcut: ["KeyControl", "KeyShift", "KeyS"], action: async (): Promise<void> => editor.instance.save_document() },
|
||||
{ label: "Save All", shortcut: ["KeyControl", "KeyAlt", "KeyS"] },
|
||||
{ label: "Auto-Save", icon: "CheckboxChecked" },
|
||||
],
|
||||
[
|
||||
{ label: "Import…", shortcut: ["KeyControl", "KeyI"] },
|
||||
{ label: "Export…", shortcut: ["KeyControl", "KeyE"], action: async (): Promise<void> => editor.instance.export_document() },
|
||||
],
|
||||
[{ label: "Quit", shortcut: ["KeyControl", "KeyQ"] }],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{ label: "Undo", shortcut: ["KeyControl", "KeyZ"], action: async (): Promise<void> => editor.instance.undo() },
|
||||
{ label: "Redo", shortcut: ["KeyControl", "KeyShift", "KeyZ"], action: async (): Promise<void> => editor.instance.redo() },
|
||||
],
|
||||
[
|
||||
{ label: "Cut", shortcut: ["KeyControl", "KeyX"], action: async (): Promise<void> => editor.instance.cut() },
|
||||
{ label: "Copy", icon: "Copy", shortcut: ["KeyControl", "KeyC"], action: async (): Promise<void> => editor.instance.copy() },
|
||||
// TODO: Fix this
|
||||
// { label: "Paste", icon: "Paste", shortcut: ["KeyControl", "KeyV"], action: async (): Promise<void> => editor.instance.paste() },
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Layer",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{ label: "Select All", shortcut: ["KeyControl", "KeyA"], action: async (): Promise<void> => editor.instance.select_all_layers() },
|
||||
{ label: "Deselect All", shortcut: ["KeyControl", "KeyAlt", "KeyA"], action: async (): Promise<void> => editor.instance.deselect_all_layers() },
|
||||
{
|
||||
label: "Order",
|
||||
action: (): void => undefined,
|
||||
children: [
|
||||
[
|
||||
{
|
||||
label: "Raise To Front",
|
||||
shortcut: ["KeyControl", "KeyShift", "KeyLeftBracket"],
|
||||
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.instance.i32_max()),
|
||||
},
|
||||
{ label: "Raise", shortcut: ["KeyControl", "KeyRightBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(1) },
|
||||
{ label: "Lower", shortcut: ["KeyControl", "KeyLeftBracket"], action: async (): Promise<void> => editor.instance.reorder_selected_layers(-1) },
|
||||
{
|
||||
label: "Lower to Back",
|
||||
shortcut: ["KeyControl", "KeyShift", "KeyRightBracket"],
|
||||
action: async (): Promise<void> => editor.instance.reorder_selected_layers(editor.instance.i32_min()),
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Document",
|
||||
ref: undefined,
|
||||
children: [[{ label: "Menu entries coming soon" }]],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{
|
||||
label: "Show/Hide Node Graph (In Development)",
|
||||
action: async (): Promise<void> => editor.instance.toggle_node_graph_visibility(),
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{
|
||||
label: "About Graphite",
|
||||
action: async (): Promise<void> => editor.instance.request_about_graphite_dialog(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: "Report a Bug", action: (): unknown => window.open("https://github.com/GraphiteEditor/Graphite/issues/new", "_blank") },
|
||||
{ label: "Visit on GitHub", action: (): unknown => window.open("https://github.com/GraphiteEditor/Graphite", "_blank") },
|
||||
],
|
||||
[
|
||||
{
|
||||
label: "Debug: Set Log Level",
|
||||
action: (): void => undefined,
|
||||
children: [
|
||||
[
|
||||
{ label: "Log Level Info", action: async (): Promise<void> => editor.instance.log_level_info(), shortcut: ["Key1"] },
|
||||
{ label: "Log Level Debug", action: async (): Promise<void> => editor.instance.log_level_debug(), shortcut: ["Key2"] },
|
||||
{ label: "Log Level Trace", action: async (): Promise<void> => editor.instance.log_level_trace(), shortcut: ["Key3"] },
|
||||
],
|
||||
],
|
||||
},
|
||||
{ label: "Debug: Panic (DANGER)", action: async (): Promise<void> => editor.instance.intentional_panic() },
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
const LOCK_REQUIRING_SHORTCUTS = [
|
||||
["KeyControl", "KeyN"],
|
||||
["KeyControl", "KeyShift", "KeyT"],
|
||||
["KeyControl", "KeyW"],
|
||||
];
|
||||
|
||||
type FrontendMenuColumn = {
|
||||
label: string;
|
||||
children: FrontendMenuEntry[][];
|
||||
};
|
||||
type FrontendMenuEntry = Omit<MenuEntry, "action" | "children"> & { shortcutRequiresLock: boolean | undefined; action: () => void; children: FrontendMenuEntry[][] | undefined };
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["editor"],
|
||||
mounted() {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => {
|
||||
const shortcutRequiresLock = (shortcut: string[]): boolean => LOCK_REQUIRING_SHORTCUTS.some((lockKeyCombo) => shortcut.every((shortcutKey, index) => shortcutKey === lockKeyCombo[index]));
|
||||
|
||||
const menuEntryToFrontendMenuEntry = (subLayout: MenuEntry[][]): FrontendMenuEntry[][] =>
|
||||
subLayout.map((group) =>
|
||||
group.map((entry) => ({
|
||||
...entry,
|
||||
children: entry.children ? menuEntryToFrontendMenuEntry(entry.children) : undefined,
|
||||
action: (): void => this.editor.instance.update_layout(updateMenuBarLayout.layout_target, entry.action.widget_id, undefined),
|
||||
shortcutRequiresLock: entry.shortcut ? shortcutRequiresLock(entry.shortcut) : undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
this.entries = updateMenuBarLayout.layout.map((column) => ({ ...column, children: menuEntryToFrontendMenuEntry(column.children) }));
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
onClick(menuEntry: MenuListEntry, target: EventTarget | null) {
|
||||
// Focus the target so that keyboard inputs are sent to the dropdown
|
||||
|
@ -236,7 +127,7 @@ export default defineComponent({
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
entries: makeEntries(this.editor),
|
||||
entries: [] as FrontendMenuColumn[],
|
||||
open: false,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -74,9 +74,9 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
|
|||
|
||||
// Don't redirect debugging tools
|
||||
if (key === "f12" || key === "f8") return false;
|
||||
if (e.ctrlKey && e.shiftKey && key === "c") return false;
|
||||
if (e.ctrlKey && e.shiftKey && key === "i") return false;
|
||||
if (e.ctrlKey && e.shiftKey && key === "j") return false;
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === "c") return false;
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === "i") return false;
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === "j") return false;
|
||||
|
||||
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
||||
if (!canvasFocused && !targetIsTextField(e.target) && ["tab", "enter", " ", "arrowdown", "arrowup", "arrowleft", "arrowright"].includes(key.toLowerCase())) return false;
|
||||
|
|
|
@ -370,7 +370,7 @@ export class TriggerVisitLink extends JsMessage {
|
|||
}
|
||||
|
||||
export interface WidgetLayout {
|
||||
layout: LayoutRow[];
|
||||
layout: LayoutGroup[];
|
||||
layout_target: unknown;
|
||||
}
|
||||
|
||||
|
@ -381,21 +381,20 @@ export function defaultWidgetLayout(): WidgetLayout {
|
|||
};
|
||||
}
|
||||
|
||||
// TODO: Rename LayoutRow to something more generic
|
||||
export type LayoutRow = WidgetRow | WidgetColumn | WidgetSection;
|
||||
export type LayoutGroup = WidgetRow | WidgetColumn | WidgetSection;
|
||||
|
||||
export type WidgetColumn = { columnWidgets: Widget[] };
|
||||
export function isWidgetColumn(layoutColumn: LayoutRow): layoutColumn is WidgetColumn {
|
||||
export function isWidgetColumn(layoutColumn: LayoutGroup): layoutColumn is WidgetColumn {
|
||||
return Boolean((layoutColumn as WidgetColumn).columnWidgets);
|
||||
}
|
||||
|
||||
export type WidgetRow = { rowWidgets: Widget[] };
|
||||
export function isWidgetRow(layoutRow: LayoutRow): layoutRow is WidgetRow {
|
||||
export function isWidgetRow(layoutRow: LayoutGroup): layoutRow is WidgetRow {
|
||||
return Boolean((layoutRow as WidgetRow).rowWidgets);
|
||||
}
|
||||
|
||||
export type WidgetSection = { name: string; layout: LayoutRow[] };
|
||||
export function isWidgetSection(layoutRow: LayoutRow): layoutRow is WidgetSection {
|
||||
export type WidgetSection = { name: string; layout: LayoutGroup[] };
|
||||
export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSection {
|
||||
return Boolean((layoutRow as WidgetSection).layout);
|
||||
}
|
||||
|
||||
|
@ -427,86 +426,72 @@ export class UpdateDialogDetails extends JsMessage implements WidgetLayout {
|
|||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
export class UpdateDocumentModeLayout extends JsMessage implements WidgetLayout {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
export class UpdateToolOptionsLayout extends JsMessage implements WidgetLayout {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
export class UpdateDocumentBarLayout extends JsMessage implements WidgetLayout {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
export class UpdateToolShelfLayout extends JsMessage implements WidgetLayout {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
export class UpdatePropertyPanelOptionsLayout extends JsMessage implements WidgetLayout {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
export class UpdatePropertyPanelSectionsLayout extends JsMessage implements WidgetLayout {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
export class UpdateLayerTreeOptionsLayout extends JsMessage implements WidgetLayout {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createWidgetLayout(value))
|
||||
layout!: LayoutRow[];
|
||||
layout!: LayoutGroup[];
|
||||
}
|
||||
|
||||
// Unpacking rust types to more usable type in the frontend
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function createWidgetLayout(widgetLayout: any[]): LayoutRow[] {
|
||||
return widgetLayout.map((layoutType): LayoutRow => {
|
||||
function createWidgetLayout(widgetLayout: any[]): LayoutGroup[] {
|
||||
return widgetLayout.map((layoutType): LayoutGroup => {
|
||||
if (layoutType.Column) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const columnWidgets = layoutType.Column.columnWidgets.map((widgetHolder: any) => {
|
||||
const { widget_id } = widgetHolder;
|
||||
const kind = Object.keys(widgetHolder.widget)[0];
|
||||
const props = widgetHolder.widget[kind];
|
||||
|
||||
return { widget_id, kind, props };
|
||||
});
|
||||
|
||||
const columnWidgets = hoistWidgetHolders(layoutType.Column.columnWidgets);
|
||||
const result: WidgetColumn = { columnWidgets };
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layoutType.Row) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rowWidgets = layoutType.Row.rowWidgets.map((widgetHolder: any) => {
|
||||
const { widget_id } = widgetHolder;
|
||||
const kind = Object.keys(widgetHolder.widget)[0];
|
||||
const props = widgetHolder.widget[kind];
|
||||
|
||||
return { widget_id, kind, props };
|
||||
});
|
||||
|
||||
const rowWidgets = hoistWidgetHolders(layoutType.Row.rowWidgets);
|
||||
const result: WidgetRow = { rowWidgets };
|
||||
return result;
|
||||
}
|
||||
|
@ -522,6 +507,52 @@ function createWidgetLayout(widgetLayout: any[]): LayoutRow[] {
|
|||
throw new Error("Layout row type does not exist");
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function hoistWidgetHolders(widgetHolders: any[]): Widget[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return widgetHolders.map((widgetHolder: any) => {
|
||||
const { widget_id } = widgetHolder;
|
||||
const kind = Object.keys(widgetHolder.widget)[0];
|
||||
const props = widgetHolder.widget[kind];
|
||||
|
||||
return { widget_id, kind, props } as Widget;
|
||||
});
|
||||
}
|
||||
|
||||
export class UpdateMenuBarLayout extends JsMessage {
|
||||
layout_target!: unknown;
|
||||
|
||||
@Transform(({ value }) => createMenuLayout(value))
|
||||
layout!: MenuColumn[];
|
||||
}
|
||||
|
||||
export type MenuEntry = {
|
||||
shortcut: string[] | undefined;
|
||||
action: Widget;
|
||||
label: string;
|
||||
icon: string | undefined;
|
||||
children: undefined | MenuEntry[][];
|
||||
};
|
||||
|
||||
export type MenuColumn = {
|
||||
label: string;
|
||||
children: MenuEntry[][];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function createMenuLayout(menuLayout: any[]): MenuColumn[] {
|
||||
return menuLayout.map((column) => ({ ...column, children: createMenuLayoutRecursive(column.children) }));
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function createMenuLayoutRecursive(subLayout: any[][]): MenuEntry[][] {
|
||||
return subLayout.map((groups) =>
|
||||
groups.map((entry) => ({
|
||||
...entry,
|
||||
action: hoistWidgetHolders([entry.action])[0],
|
||||
children: entry.children ? createMenuLayoutRecursive(entry.children) : undefined,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export class TriggerTextCommit extends JsMessage {}
|
||||
|
||||
|
@ -579,5 +610,6 @@ export const messageMakers: Record<string, MessageMaker> = {
|
|||
UpdateDocumentModeLayout,
|
||||
UpdateToolOptionsLayout,
|
||||
UpdateWorkingColors,
|
||||
UpdateMenuBarLayout,
|
||||
} as const;
|
||||
export type JsMessageType = keyof typeof messageMakers;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue