diff --git a/src/AppShell/src/components/LibraryElementBanner.svelte b/src/AppShell/src/components/LibraryElementBanner.svelte new file mode 100644 index 000000000..4a72039f3 --- /dev/null +++ b/src/AppShell/src/components/LibraryElementBanner.svelte @@ -0,0 +1,26 @@ + + +{#if $selectedData?.isLibraryElement} +
+ + This element comes from a library{$selectedData.filename ? ` (${$selectedData.filename})` : ""} and can't be edited directly. + + +
+{/if} diff --git a/src/AppShell/src/components/PropertyEditor.svelte b/src/AppShell/src/components/PropertyEditor.svelte index 1eda1f4b7..94081653b 100644 --- a/src/AppShell/src/components/PropertyEditor.svelte +++ b/src/AppShell/src/components/PropertyEditor.svelte @@ -15,6 +15,7 @@ import ExitsEditor from "./ExitsEditor.svelte"; import VerbsEditor from "./VerbsEditor.svelte"; import AddElementModal from "./AddElementModal.svelte"; + import LibraryElementBanner from "./LibraryElementBanner.svelte"; let { onback }: { onback?: () => void } = $props(); @@ -164,6 +165,7 @@ Properties {/if} + {#if $selectedKey === null}

Select an object to view its properties.

@@ -196,9 +198,10 @@ {@const viewControls = getControlsForView()} {@const hasAttributesPanel = viewControls.some(c => c.controlType === "attributes")} + {@const readOnly = $selectedData.isLibraryElement} {#if hasAttributesPanel} {@const { main, advanced } = partitionControls(viewControls.filter(c => c.controlType !== "attributes"))} -
+
{#snippet extraControls()} {#each main as ctrl, i (i)} @@ -210,7 +213,7 @@
{:else} {@const { main, advanced } = partitionControls(viewControls)} -
+
{#each main as ctrl, i (i)} {@render controlRow(ctrl)} {/each} diff --git a/src/AppShell/src/components/Toolbar.svelte b/src/AppShell/src/components/Toolbar.svelte index d2aee3348..261d1b35f 100644 --- a/src/AppShell/src/components/Toolbar.svelte +++ b/src/AppShell/src/components/Toolbar.svelte @@ -119,7 +119,7 @@ let nt = $derived(selectedNode?.nodeType ?? ""); let canDelete = $derived( - nt !== "" && nt !== "header" && nt !== "game" && nt !== "other" + nt !== "" && nt !== "header" && nt !== "game" && nt !== "other" && !selectedNode?.isLibrary ); // Context-sensitive add options. Function/Timer/Walkthrough/Library/Template/ @@ -134,15 +134,16 @@ ] : [ // Always available { label: "Add Room", action: () => openAddModal("room", null) }, - // Context-sensitive: when a room or object is selected - ...(nt === "room" || nt === "object" ? [ + // Context-sensitive: when a room or object is selected (but not a read-only + // library-origin one — same restriction as the tree's own "…" menu). + ...(!selectedNode?.isLibrary && (nt === "room" || nt === "object") ? [ { label: `Add Object in "${selectedNode!.text}"`, action: () => openAddModal("object", selectedNode!.key) }, ] : []), - ...(nt === "room" ? [ + ...(!selectedNode?.isLibrary && nt === "room" ? [ { label: `Add Room in "${selectedNode!.text}"`, action: () => openAddModal("room", selectedNode!.key) }, { label: `Add Exit from "${selectedNode!.text}"`, action: () => createExit(selectedNode!.key) }, ] : []), - ...(nt === "room" || nt === "object" ? [ + ...(!selectedNode?.isLibrary && (nt === "room" || nt === "object") ? [ { label: `Add Command to "${selectedNode!.text}"`, action: () => createCommand(selectedNode!.key) }, { label: `Add Verb to "${selectedNode!.text}"`, action: () => createVerb(selectedNode!.key) }, { label: `Add Turn Script to "${selectedNode!.text}"`, action: () => createTurnScript(selectedNode!.key) }, diff --git a/src/AppShell/src/components/TreePanel.svelte b/src/AppShell/src/components/TreePanel.svelte index 7aa95ced8..b147e19e0 100644 --- a/src/AppShell/src/components/TreePanel.svelte +++ b/src/AppShell/src/components/TreePanel.svelte @@ -3,6 +3,10 @@ import { TreeView, createTreeViewCollection } from "@skeletonlabs/skeleton-svelte"; import Search from "@lucide/svelte/icons/search"; import X from "@lucide/svelte/icons/x"; + import SlidersHorizontal from "@lucide/svelte/icons/sliders-horizontal"; + import Check from "@lucide/svelte/icons/check"; + import DropdownMenu from "./DropdownMenu.svelte"; + import type { DropdownMenuItem } from "./DropdownMenu.svelte"; import { treeNodes, selectedKey, selectNode, isGamebook, openAddModal, createExit, createTurnScript, createCommand, createVerb, @@ -10,6 +14,7 @@ deleteElement, canMoveElement, openMoveModal, copyElements, cutElements, canPasteElements, pasteElements, clipboardVersion, cutElementKeys, + showLibraryElements, toggleShowLibraryElements, } from "$lib/editor-store"; import type { TreeNode } from "$lib/types"; @@ -19,6 +24,7 @@ id: string text: string nodeType: string + isLibrary: boolean children?: HierNode[] } @@ -43,6 +49,7 @@ id: node.key, text: node.text, nodeType: node.nodeType, + isLibrary: node.isLibrary, ...(children ? { children: children.map(build) } : {}), }; }; @@ -178,7 +185,7 @@ createTreeViewCollection({ nodeToValue: (n) => n.id, nodeToString: (n) => n.text, - rootNode: { id: "__root__", text: "", nodeType: "header", children: filteredHierTree }, + rootNode: { id: "__root__", text: "", nodeType: "header", isLibrary: false, children: filteredHierTree }, }) ); @@ -259,6 +266,11 @@ } function nodeMenuOptions(node: HierNode): Array<{ label: string; action: () => void }> { + // Library-origin nodes (from Core.aslx or another included library) are read-only — + // the only way to act on one is the "Copy into your game" banner in the properties + // panel, so they get no context menu at all. + if (node.isLibrary) return []; + // Read (not used) so this function's callers re-run whenever the clipboard // changes — canPasteElements() below reads server-side state that Svelte // has no other reactive handle on. @@ -326,6 +338,14 @@ return opts; } + + let libraryMenuItems = $derived([ + { + label: "Show Library Elements", + icon: $showLibraryElements ? Check : undefined, + action: () => toggleShowLibraryElements(), + }, + ]);
{$isGamebook ? "Game Pages" : "Game Objects"}
-
- - { if (e.key === "Escape" && filterText) { e.stopPropagation(); clearFilter(); } }} - /> - {#if filterText} - - {/if} +
+
+ + { if (e.key === "Escape" && filterText) { e.stopPropagation(); clearFilter(); } }} + /> + {#if filterText} + + {/if} +
+ + {#snippet trigger(toggle)} + + {/snippet} +
{#if isFiltering && (collection.rootNode.children ?? []).length === 0}
No matches
@@ -415,7 +448,7 @@ > - {node.text} + {node.text} {@render nodeActions(node)} @@ -429,7 +462,7 @@ {:else} activateIfAlreadySelected(node.id)}> - {node.text} + {node.text} {@render nodeActions(node)} diff --git a/src/AppShell/src/lib/editor-store.ts b/src/AppShell/src/lib/editor-store.ts index 7086a4286..e66458b65 100644 --- a/src/AppShell/src/lib/editor-store.ts +++ b/src/AppShell/src/lib/editor-store.ts @@ -36,6 +36,7 @@ export const canBackup = writable(false); export const showBackupBanner = writable(false); export const canPublishToServer = writable(false); export const treeNodes = writable([]); +export const showLibraryElements = writable(false); export const selectedKey = writable(null); export const selectedData = writable(null); export const fullAttributeData = writable(null); @@ -111,6 +112,7 @@ export async function openGame(bytes: Uint8Array, filename: string, adapter: Fil isGamebook.set(_bridge.IsGamebook()); const nodes: TreeNode[] = JSON.parse(_bridge.GetTreeNodes()); treeNodes.set(nodes); + showLibraryElements.set(false); isLoaded.set(true); gameFilename.set(filename); refreshUndoRedo(); @@ -148,6 +150,7 @@ export async function setGameXml(xml: string): Promise { isGamebook.set(_bridge.IsGamebook()); const nodes: TreeNode[] = JSON.parse(_bridge.GetTreeNodes()); treeNodes.set(nodes); + showLibraryElements.set(false); scriptClipboardHasContent.set(false); cutElementKeys.set(new Set()); await refreshAssets(); @@ -172,6 +175,24 @@ export async function selectNode(key: string) { fullAttributeData.set(attrJson ? JSON.parse(attrJson) : null); } +export function toggleShowLibraryElements(): void { + if (!_bridge) return; + const next = !get(showLibraryElements); + _bridge.SetShowLibraryElements(next); + showLibraryElements.set(next); + refreshTree(); +} + +// "Copy into your game" — turns a read-only library element (from Core.aslx or another +// included library) into a normal, editable element of the user's own game. +export async function makeElementLocal(key: string): Promise { + if (!_bridge) return; + _bridge.MakeElementLocal(key); + refreshTree(); + await refreshSelectedDataAsync(); + refreshUndoRedo(); +} + export function setAttribute(elementKey: string, attribute: string, controlType: string, value: string): string { if (!_bridge) return "error"; const result = _bridge.SetAttribute(elementKey, attribute, controlType, value); diff --git a/src/AppShell/src/lib/types.ts b/src/AppShell/src/lib/types.ts index 16801bb80..1335b1720 100644 --- a/src/AppShell/src/lib/types.ts +++ b/src/AppShell/src/lib/types.ts @@ -4,6 +4,7 @@ export interface TreeNode { parent: string | null nodeIcon: string | null nodeType: string + isLibrary: boolean } export interface ControlOption { @@ -47,6 +48,8 @@ export interface EditorDataResponse { attributes: Record tabs: TabInfo[] controls: ControlInfo[] + isLibraryElement: boolean + filename: string | null } export interface CompassDirectionInfo { diff --git a/src/AppShell/src/lib/wasm.ts b/src/AppShell/src/lib/wasm.ts index 90300fd9e..aafd74c34 100644 --- a/src/AppShell/src/lib/wasm.ts +++ b/src/AppShell/src/lib/wasm.ts @@ -1,6 +1,8 @@ export interface WasmBridge { Initialise(bytes: Uint8Array, filename: string): Promise GetTreeNodes(): string + SetShowLibraryElements(show: boolean): void + MakeElementLocal(elementKey: string): string GetEditorData(key: string): Promise SetAttribute(elementKey: string, attribute: string, controlType: string, value: string): string SetMultiType(elementKey: string, attribute: string, newType: string): string diff --git a/src/EditorCore/EditorController.cs b/src/EditorCore/EditorController.cs index 28eb52316..469e3666a 100644 --- a/src/EditorCore/EditorController.cs +++ b/src/EditorCore/EditorController.cs @@ -917,6 +917,24 @@ public IEditorData GetEditorData(string elementKey) return new EditorData(WorldModel.Elements.Get(elementKey), this); } + // Turns a library-origin element (loaded from Core.aslx or another included library) into a + // normal, editable game element. On the next save this element then gets written into the + // user's own game file (GameSaver skips anything still flagged "library"), which is how + // library overriding works — the local copy shadows the library original from then on. + public void MakeElementLocal(string key) + { + if (GetEditorData(key) is not IEditorDataExtendedAttributeInfo data || !data.IsLibraryElement) + { + return; + } + + WorldModel.UndoLogger.StartTransaction($"Copy '{GetDisplayName(WorldModel.Elements.Get(key))}' into game"); + data.MakeElementLocal(); + WorldModel.UndoLogger.EndTransaction(); + + AddElementToTree(WorldModel.Elements.Get(key)); + } + public IEditorData GetScriptEditorData(IEditableScript script) { switch (script.Type) diff --git a/src/WasmEditor/WasmEditorBridge.cs b/src/WasmEditor/WasmEditorBridge.cs index 248ea167a..053fa7eb1 100644 --- a/src/WasmEditor/WasmEditorBridge.cs +++ b/src/WasmEditor/WasmEditorBridge.cs @@ -16,7 +16,7 @@ namespace QuestViva.WasmEditor; -internal record TreeNodeData(string Key, string Text, string? Parent, string? NodeIcon, string NodeType); +internal record TreeNodeData(string Key, string Text, string? Parent, string? NodeIcon, string NodeType, bool IsLibrary); internal record ControlOption(string Value, string Label); @@ -46,7 +46,9 @@ internal record TabInfo(string? Caption, List Controls); internal record EditorDataResponse( Dictionary Attributes, List Tabs, - List Controls); + List Controls, + bool IsLibraryElement, + string? Filename); internal record ScriptControlData( string ControlType, @@ -290,6 +292,26 @@ public static string GetTreeNodes() return JsonSerializer.Serialize(TreeNodes, WasmEditorJsonContext.Default.ListTreeNodeData); } + [JSExport] + public static void SetShowLibraryElements(bool show) + { + if (_controller == null) return; + + var options = new FilterOptions(); + options.Set("libraries", show); + _controller.UpdateFilterOptions(options); + _controller.UpdateTree(); + } + + [JSExport] + public static string MakeElementLocal(string elementKey) + { + if (_controller == null) return "error:No game loaded."; + + _controller.MakeElementLocal(elementKey); + return "ok"; + } + [JSExport] public static async Task GetEditorData(string key) { @@ -355,7 +377,7 @@ public static string GetTreeNodes() } return JsonSerializer.Serialize( - new EditorDataResponse(attrs, tabs, topControls), + new EditorDataResponse(attrs, tabs, topControls, extended?.IsLibraryElement ?? false, extended?.Filename), WasmEditorJsonContext.Default.EditorDataResponse); } @@ -3138,7 +3160,7 @@ private static void OnAddedNode(object? sender, EditorController.AddedNodeEventA return; } - var node = new TreeNodeData(e.Key, e.Text, e.Parent, e.NodeIcon, GetNodeType(e.Key, e.NodeIcon)); + var node = new TreeNodeData(e.Key, e.Text, e.Parent, e.NodeIcon, GetNodeType(e.Key, e.NodeIcon), e.IsLibraryNode); if (_isRebuilding) { // ClearTree already emptied the list; all keys are unique in a fresh rebuild.