Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/AppShell/src/components/LibraryElementBanner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script lang="ts">
import { selectedKey, selectedData, makeElementLocal } from "$lib/editor-store";

let copying = $state(false);

async function handleCopy() {
const key = $selectedKey;
if (!key) return;
copying = true;
try { await makeElementLocal(key); } finally { copying = false; }
}
</script>

{#if $selectedData?.isLibraryElement}
<div class="flex items-center gap-3 px-4 py-2 bg-warning-100-900 border-b border-warning-300-700 text-sm">
<span class="flex-1">
This element comes from a library{$selectedData.filename ? ` (${$selectedData.filename})` : ""} and can't be edited directly.
</span>
<button
type="button"
class="btn btn-sm preset-filled-warning-500"
onclick={handleCopy}
disabled={copying}
>{copying ? "Copying…" : "Copy into your game"}</button>
</div>
{/if}
7 changes: 5 additions & 2 deletions src/AppShell/src/components/PropertyEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -164,6 +165,7 @@
<span class="text-xs font-semibold uppercase text-surface-500-400">Properties</span>
{/if}
</div>
<LibraryElementBanner />

{#if $selectedKey === null}
<p class="px-3 py-4 text-sm text-surface-400-500">Select an object to view its properties.</p>
Expand Down Expand Up @@ -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"))}
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div class="flex-1 overflow-hidden flex flex-col min-h-0 {readOnly ? "pointer-events-none opacity-60" : ""}">
<AttributesEditor>
{#snippet extraControls()}
{#each main as ctrl, i (i)}
Expand All @@ -210,7 +213,7 @@
</div>
{:else}
{@const { main, advanced } = partitionControls(viewControls)}
<div class="flex-1 overflow-y-auto">
<div class="flex-1 overflow-y-auto {readOnly ? "pointer-events-none opacity-60" : ""}">
{#each main as ctrl, i (i)}
{@render controlRow(ctrl)}
{/each}
Expand Down
11 changes: 6 additions & 5 deletions src/AppShell/src/components/Toolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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) },
Expand Down
79 changes: 56 additions & 23 deletions src/AppShell/src/components/TreePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
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,
createIncludedLibrary, createJavascript,
deleteElement,
canMoveElement, openMoveModal, copyElements, cutElements, canPasteElements, pasteElements,
clipboardVersion, cutElementKeys,
showLibraryElements, toggleShowLibraryElements,
} from "$lib/editor-store";
import type { TreeNode } from "$lib/types";

Expand All @@ -19,6 +24,7 @@
id: string
text: string
nodeType: string
isLibrary: boolean
children?: HierNode[]
}

Expand All @@ -43,6 +49,7 @@
id: node.key,
text: node.text,
nodeType: node.nodeType,
isLibrary: node.isLibrary,
...(children ? { children: children.map(build) } : {}),
};
};
Expand Down Expand Up @@ -178,7 +185,7 @@
createTreeViewCollection<HierNode>({
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 },
})
);

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -326,6 +338,14 @@

return opts;
}

let libraryMenuItems = $derived<DropdownMenuItem[]>([
{
label: "Show Library Elements",
icon: $showLibraryElements ? Check : undefined,
action: () => toggleShowLibraryElements(),
},
]);
</script>

<div
Expand All @@ -335,26 +355,39 @@
<div class="px-3 py-2 text-xs font-semibold uppercase text-surface-500-400 border-b border-surface-200-800">
{$isGamebook ? "Game Pages" : "Game Objects"}
</div>
<div class="p-1.5 border-b border-surface-200-800 relative">
<Search class="absolute left-3.5 top-1/2 -translate-y-1/2 size-3.5 text-surface-400 pointer-events-none" />
<input
bind:this={filterInputEl}
type="text"
autocapitalize="off"
bind:value={filterText}
placeholder="Filter..."
aria-label="Filter game objects"
class="input text-xs py-1 pl-7 pr-6 w-full"
onkeydown={(e) => { if (e.key === "Escape" && filterText) { e.stopPropagation(); clearFilter(); } }}
/>
{#if filterText}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 size-4 flex items-center justify-center text-surface-400 hover:text-surface-900-50"
onclick={clearFilter}
aria-label="Clear filter"
><X class="size-3.5" /></button>
{/if}
<div class="p-1.5 border-b border-surface-200-800 flex items-center gap-1">
<div class="relative flex-1">
<Search class="absolute left-3.5 top-1/2 -translate-y-1/2 size-3.5 text-surface-400 pointer-events-none" />
<input
bind:this={filterInputEl}
type="text"
autocapitalize="off"
bind:value={filterText}
placeholder="Filter..."
aria-label="Filter game objects"
class="input text-xs py-1 pl-7 pr-6 w-full"
onkeydown={(e) => { if (e.key === "Escape" && filterText) { e.stopPropagation(); clearFilter(); } }}
/>
{#if filterText}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 size-4 flex items-center justify-center text-surface-400 hover:text-surface-900-50"
onclick={clearFilter}
aria-label="Clear filter"
><X class="size-3.5" /></button>
{/if}
</div>
<DropdownMenu items={libraryMenuItems}>
{#snippet trigger(toggle)}
<button
type="button"
class="size-6 flex-shrink-0 flex items-center justify-center rounded {$showLibraryElements ? "text-primary-500" : "text-surface-400"} hover:text-primary-500 hover:bg-surface-200-800"
onclick={toggle}
title="Tree view options"
aria-label="Tree view options"
><SlidersHorizontal class="size-3.5" /></button>
{/snippet}
</DropdownMenu>
</div>
{#if isFiltering && (collection.rootNode.children ?? []).length === 0}
<div class="px-3 py-2 text-xs text-surface-400">No matches</div>
Expand Down Expand Up @@ -415,7 +448,7 @@
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-4"><polyline points="9 18 15 12 9 6" /></svg>
</button>
<TreeView.BranchText class="flex-1 min-w-0 truncate {$cutElementKeys.has(node.id) ? "opacity-50 italic" : ""}">{node.text}</TreeView.BranchText>
<TreeView.BranchText class="flex-1 min-w-0 truncate {node.isLibrary ? "text-surface-600-400" : ""} {$cutElementKeys.has(node.id) ? "opacity-50 italic" : ""}">{node.text}</TreeView.BranchText>
<span class="opacity-0 group-hover:opacity-100 pointer-coarse:opacity-100">
{@render nodeActions(node)}
</span>
Expand All @@ -429,7 +462,7 @@
</TreeView.Branch>
{:else}
<TreeView.Item class="group flex items-center" onclick={() => activateIfAlreadySelected(node.id)}>
<span class="flex-1 min-w-0 truncate {$cutElementKeys.has(node.id) ? "opacity-50 italic" : ""}">{node.text}</span>
<span class="flex-1 min-w-0 truncate {node.isLibrary ? "text-surface-600-400" : ""} {$cutElementKeys.has(node.id) ? "opacity-50 italic" : ""}">{node.text}</span>
<span class="opacity-0 group-hover:opacity-100 pointer-coarse:opacity-100">
{@render nodeActions(node)}
</span>
Expand Down
21 changes: 21 additions & 0 deletions src/AppShell/src/lib/editor-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const canBackup = writable(false);
export const showBackupBanner = writable(false);
export const canPublishToServer = writable(false);
export const treeNodes = writable<TreeNode[]>([]);
export const showLibraryElements = writable(false);
export const selectedKey = writable<string | null>(null);
export const selectedData = writable<EditorDataResponse | null>(null);
export const fullAttributeData = writable<FullAttributeData | null>(null);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -148,6 +150,7 @@ export async function setGameXml(xml: string): Promise<string> {
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();
Expand All @@ -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<void> {
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);
Expand Down
3 changes: 3 additions & 0 deletions src/AppShell/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface TreeNode {
parent: string | null
nodeIcon: string | null
nodeType: string
isLibrary: boolean
}

export interface ControlOption {
Expand Down Expand Up @@ -47,6 +48,8 @@ export interface EditorDataResponse {
attributes: Record<string, string | null>
tabs: TabInfo[]
controls: ControlInfo[]
isLibraryElement: boolean
filename: string | null
}

export interface CompassDirectionInfo {
Expand Down
2 changes: 2 additions & 0 deletions src/AppShell/src/lib/wasm.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export interface WasmBridge {
Initialise(bytes: Uint8Array, filename: string): Promise<boolean>
GetTreeNodes(): string
SetShowLibraryElements(show: boolean): void
MakeElementLocal(elementKey: string): string
GetEditorData(key: string): Promise<string | null>
SetAttribute(elementKey: string, attribute: string, controlType: string, value: string): string
SetMultiType(elementKey: string, attribute: string, newType: string): string
Expand Down
18 changes: 18 additions & 0 deletions src/EditorCore/EditorController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading