Files
grimoire/src/core/logic.ts
Alejandro Gómez d59a7aee8b feat: improve spellbook UX with active tracking and update capability
- Add activeSpellbook to GrimoireState
- Display active spellbook title in header with clear button
- Add 'Update Layout' and 'Save as new' to SpellbookDropdown
- Highlight active spellbook in dropdown list
- Add 'Manage Spells' link to dropdown
- Refine dropdown styles (muted hover, no accent color)
2025-12-21 18:08:10 +01:00

504 lines
11 KiB
TypeScript

import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import {
GrimoireState,
WindowInstance,
RelayInfo,
LayoutConfig,
} from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
/**
* Finds the lowest available workspace number.
* - If workspaces have numbers [1, 2, 4], returns 3
* - If workspaces have numbers [1, 2, 3], returns 4
* - If workspaces have numbers [2, 3, 4], returns 1
*/
export const findLowestAvailableWorkspaceNumber = (
workspaces: Record<string, { number: number }>,
): number => {
// Get all workspace numbers as a Set for O(1) lookup
const numbers = new Set(Object.values(workspaces).map((ws) => ws.number));
// If no workspaces exist, start at 1
if (numbers.size === 0) return 1;
// Find first gap starting from 1
let candidate = 1;
while (numbers.has(candidate)) {
candidate++;
}
return candidate;
};
/**
* Creates a new, empty workspace.
*/
export const createWorkspace = (
state: GrimoireState,
number: number,
label?: string,
): GrimoireState => {
const newId = uuidv4();
return {
...state,
activeWorkspaceId: newId,
workspaces: {
...state.workspaces,
[newId]: {
id: newId,
number,
label,
layout: null,
windowIds: [],
},
},
};
};
/**
* Adds a window to the global store and to the active workspace.
*/
export const addWindow = (
state: GrimoireState,
payload: {
appId: string;
props: any;
commandString?: string;
customTitle?: string;
spellId?: string;
},
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
const newWindowId = uuidv4();
const newWindow: WindowInstance = {
id: newWindowId,
appId: payload.appId as any,
customTitle: payload.customTitle,
props: payload.props,
commandString: payload.commandString,
spellId: payload.spellId,
};
// Insert window using global layout configuration
const newLayout = insertWindow(ws.layout, newWindowId, state.layoutConfig);
return {
...state,
windows: {
...state.windows,
[newWindowId]: newWindow,
},
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
windowIds: [...ws.windowIds, newWindowId],
},
},
};
};
/**
* Recursively removes a window from the layout tree.
*/
const removeFromLayout = (
layout: MosaicNode<string> | null,
windowId: string,
): MosaicNode<string> | null => {
if (layout === null) {
return null;
}
if (typeof layout === "string") {
return layout === windowId ? null : layout;
}
const firstResult = removeFromLayout(layout.first, windowId);
const secondResult = removeFromLayout(layout.second, windowId);
if (firstResult === null && secondResult !== null) {
return secondResult;
}
if (secondResult === null && firstResult !== null) {
return firstResult;
}
if (firstResult === null && secondResult === null) {
return null;
}
if (firstResult === layout.first && secondResult === layout.second) {
return layout;
}
return {
...layout,
first: firstResult!,
second: secondResult!,
};
};
/**
* Removes a window from the active workspace's layout and windowIds.
* Also removes the window from the global windows object.
*/
export const removeWindow = (
state: GrimoireState,
windowId: string,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
const newLayout = removeFromLayout(ws.layout, windowId);
const newWindowIds = ws.windowIds.filter((id) => id !== windowId);
// Remove from global windows object
const { [windowId]: _removedWindow, ...remainingWindows } = state.windows;
return {
...state,
windows: remainingWindows,
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
windowIds: newWindowIds,
},
},
};
};
/**
* Moves a window from current workspace to target workspace.
*/
export const moveWindowToWorkspace = (
state: GrimoireState,
windowId: string,
targetWorkspaceId: string,
): GrimoireState => {
const currentId = state.activeWorkspaceId;
const currentWs = state.workspaces[currentId];
const targetWs = state.workspaces[targetWorkspaceId];
if (!targetWs) {
return state;
}
const newCurrentLayout = removeFromLayout(currentWs.layout, windowId);
const newCurrentWindowIds = currentWs.windowIds.filter(
(id) => id !== windowId,
);
let newTargetLayout: MosaicNode<string>;
if (targetWs.layout === null) {
newTargetLayout = windowId;
} else {
newTargetLayout = {
direction: "row",
first: targetWs.layout,
second: windowId,
splitPercentage: 50,
};
}
return {
...state,
workspaces: {
...state.workspaces,
[currentId]: {
...currentWs,
layout: newCurrentLayout,
windowIds: newCurrentWindowIds,
},
[targetWorkspaceId]: {
...targetWs,
layout: newTargetLayout,
windowIds: [...targetWs.windowIds, windowId],
},
},
};
};
export const updateLayout = (
state: GrimoireState,
layout: MosaicNode<string> | null,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
return {
...state,
workspaces: {
...state.workspaces,
[activeId]: {
...state.workspaces[activeId],
layout,
},
},
};
};
/**
* Sets the active account (pubkey).
*/
export const setActiveAccount = (
state: GrimoireState,
pubkey: string | undefined,
): GrimoireState => {
// If pubkey is already set to the same value, return state unchanged
if (state.activeAccount?.pubkey === pubkey) {
return state;
}
if (!pubkey) {
return {
...state,
activeAccount: undefined,
};
}
return {
...state,
activeAccount: {
pubkey,
relays: state.activeAccount?.relays,
},
};
};
/**
* Updates the relay list for the active account.
*/
export const setActiveAccountRelays = (
state: GrimoireState,
relays: RelayInfo[],
): GrimoireState => {
if (!state.activeAccount) {
return state;
}
// If relays reference hasn't changed, return state unchanged
if (state.activeAccount.relays === relays) {
return state;
}
return {
...state,
activeAccount: {
...state.activeAccount,
relays,
},
};
};
/**
* Deletes a workspace by ID.
* Cannot delete the last remaining workspace.
* Does NOT change activeWorkspaceId - caller is responsible for workspace navigation.
*/
export const deleteWorkspace = (
state: GrimoireState,
workspaceId: string,
): GrimoireState => {
const workspaceIds = Object.keys(state.workspaces);
// Don't delete if it's the only workspace
if (workspaceIds.length <= 1) {
return state;
}
// Don't delete if workspace doesn't exist
if (!state.workspaces[workspaceId]) {
return state;
}
// Remove the workspace (don't touch activeWorkspaceId - that's the caller's job)
const { [workspaceId]: _removed, ...remainingWorkspaces } = state.workspaces;
return {
...state,
workspaces: remainingWorkspaces,
};
};
/**
* Updates an existing window with new properties.
* Allows updating props, title, customTitle, commandString, and even appId (which changes the viewer type).
*/
export const updateWindow = (
state: GrimoireState,
windowId: string,
updates: Partial<
Pick<
WindowInstance,
"props" | "title" | "customTitle" | "commandString" | "appId"
>
>,
): GrimoireState => {
const window = state.windows[windowId];
if (!window) {
return state; // Window doesn't exist, return unchanged
}
return {
...state,
windows: {
...state.windows,
[windowId]: { ...window, ...updates },
},
};
};
/**
* Updates the global layout configuration.
* Controls how new windows are inserted across all workspaces.
*/
export const updateLayoutConfig = (
state: GrimoireState,
layoutConfig: Partial<LayoutConfig>,
): GrimoireState => {
return {
...state,
layoutConfig: {
...state.layoutConfig,
...layoutConfig,
},
};
};
/**
* Applies a preset layout to the active workspace.
* Reorganizes existing windows according to the preset template.
*/
export const applyPresetLayout = (
state: GrimoireState,
preset: LayoutPreset,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
try {
// Apply preset to current layout
const newLayout = applyPresetToLayout(ws.layout, preset);
return {
...state,
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
},
},
};
} catch (error) {
// If preset application fails (not enough windows, etc.), return unchanged
console.error("[Layout] Failed to apply preset:", error);
return state;
}
};
/**
* Updates the label of an existing workspace.
* Labels are user-friendly names that appear alongside workspace numbers.
*/
export const updateWorkspaceLabel = (
state: GrimoireState,
workspaceId: string,
label: string | undefined,
): GrimoireState => {
const workspace = state.workspaces[workspaceId];
if (!workspace) {
return state; // Workspace doesn't exist, return unchanged
}
// Normalize label: trim and treat empty strings as undefined
const normalizedLabel = label?.trim() || undefined;
// If label hasn't changed, return state unchanged (optimization)
if (workspace.label === normalizedLabel) {
return state;
}
return {
...state,
workspaces: {
...state.workspaces,
[workspaceId]: {
...workspace,
label: normalizedLabel,
},
},
};
};
/**
* Reorders workspaces based on a list of workspace IDs.
* Reassigns workspace numbers starting from 1 based on the provided order.
*/
export const reorderWorkspaces = (
state: GrimoireState,
orderedIds: string[],
): GrimoireState => {
const currentWorkspaces = Object.values(state.workspaces);
const orderedSet = new Set(orderedIds);
// Find any workspaces not included in the ordered list (should generally be empty if all are passed)
const remainingWorkspaces = currentWorkspaces
.filter((ws) => !orderedSet.has(ws.id))
.sort((a, b) => a.number - b.number);
const newWorkspaces = { ...state.workspaces };
let counter = 1;
// Assign new numbers to ordered IDs
for (const id of orderedIds) {
if (newWorkspaces[id]) {
newWorkspaces[id] = {
...newWorkspaces[id],
number: counter++,
};
}
}
// Assign new numbers to remaining IDs
for (const ws of remainingWorkspaces) {
newWorkspaces[ws.id] = {
...newWorkspaces[ws.id],
number: counter++,
};
}
return {
...state,
workspaces: newWorkspaces,
};
};
/**
* Updates the list of event kinds that should be displayed in compact mode.
*/
export const setCompactModeKinds = (
state: GrimoireState,
kinds: number[],
): GrimoireState => {
return {
...state,
compactModeKinds: kinds,
};
};
/**
* Clears the currently active spellbook tracking.
*/
export const clearActiveSpellbook = (state: GrimoireState): GrimoireState => {
return {
...state,
activeSpellbook: undefined,
};
};