Worldshaper/src/worldshaperStudio/changelogSplashWindowController.ts

472 lines
16 KiB
TypeScript
Raw Normal View History

2026-06-26 18:18:14 -04:00
import { clampFloatingWindowRect } from "./floatingWindowUtils";
const CHANGELOG_SPLASH_WINDOW_KEY = "changelogSplash";
const CHANGELOG_SPLASH_VERSION = "2026-06-22-world-editor-release-v6";
2026-06-26 20:30:30 -04:00
const CHANGELOG_SPLASH_STORAGE_KEY = `worldshaper:studio:changelog-seen:${CHANGELOG_SPLASH_VERSION}`;
2026-06-26 18:18:14 -04:00
const DEFAULT_WIDTH = 700;
const DEFAULT_HEIGHT = 560;
const MIN_WIDTH = 520;
const MIN_HEIGHT = 360;
type LayerRect = {
left: number;
top: number;
width: number;
height: number;
};
type PersistedWindowState = {
visible?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
};
type ControllerScope = {
uiScope?: {
toolWindowLayerEl?: HTMLElement | null;
editorBodyEl?: HTMLElement | null;
};
sessionScope?: {
getPersistedToolWindowState?: (key: string) => PersistedWindowState | null;
setPersistedToolWindowState?: (key: string, value: Record<string, unknown>) => void;
persistPopupSessionLayout?: () => void;
};
persistPopupSessionLayout?: () => void;
};
type SplashState = {
visible: boolean;
x: number;
y: number;
width: number;
height: number;
shellEl: HTMLDivElement | null;
titleEl: HTMLDivElement | null;
metaEl: HTMLDivElement | null;
sectionListEl: HTMLDivElement | null;
actionBtnEl: HTMLButtonElement | null;
resizeEl: HTMLDivElement | null;
nextZIndex: number;
};
type OpenOptions = {
center?: boolean;
markSeen?: boolean;
};
type ChangelogItem = string | {
text: string;
note?: string;
};
const CHANGELOG_SECTIONS = [
{
title: "World Rendering",
items: [
"Added live image opacity support in the renderer for both tiles and entity sprites.",
{
text: "Fixed world painting placement drift on very wide displays.",
note: "For the many of you out there using this on superultrawide monitors.",
},
],
},
{
title: "Graphics & Animation",
items: [
"Added animation frame timelines to the Graphic Painter.",
"Added frame duplication, enable/disable, delete, drag reorder, and default-frame selection.",
"Added animation speed and playback settings to graphics data.",
"Added animation preview support from the Graphic Painter.",
],
},
{
title: "World Overview",
items: [
"Added chunk move, duplicate, rotate, flip, and delete workflows from the world overview.",
"Added safer chunk management feedback and confirmation flows.",
],
},
{
title: "Editor Stability",
items: [
"Fixed unified graphics conversion so saved image properties flow correctly into runtime tiles and sprites.",
"Improved live refresh behavior for renderer-facing graphic updates.",
],
},
{
title: "TBI (To Be Implemented [Or thought about really hard])",
items: [
"Image animations on renderer with hotkey toggle and engine override toggle.",
"Add premade frame duplication effects, like duplicate and shift by direction.",
"Add animation effects that change opacity over time.",
"Add animation effects that shift saturation over time.",
"Work on a system to use an image with animation frames as a tile strip, placing the instance once but specifying which subimage to use.",
"Add an elevation system where some tiles can appear at different z-indexes and show a different subframe.",
"Allow unsnapped tile placement, possibly via hotkey, writing into a chunk patch instead of chunk rows.",
"Explore whether unsnapped placement should be true free placement or an additional sub-layer drawn over an existing layer.",
"Add custom prompts for text input and confirmation dialogs.",
"Prototype terrain painters for meta chunk painting and tile replacement, like rivers, mountains, and woods.",
"Talk to Justin more about zone and subzone music regions via chunk painting.",
"Autotilers.",
"Prefab stamps.",
],
},
] as const;
function clampWindowRect(layerRect: LayerRect, left: number, top: number, width: number, height: number) {
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
export function createChangelogSplashWindowController(scope: ControllerScope) {
let initialized = false;
const uiScope = (scope.uiScope || scope) as NonNullable<ControllerScope["uiScope"]>;
const sessionScope = (scope.sessionScope || scope) as NonNullable<ControllerScope["sessionScope"]>;
const persistedState = typeof sessionScope.getPersistedToolWindowState === "function"
? sessionScope.getPersistedToolWindowState(CHANGELOG_SPLASH_WINDOW_KEY)
: null;
const state: SplashState = {
visible: false,
x: Number(persistedState?.x) || 88,
y: Number(persistedState?.y) || 56,
width: Number(persistedState?.width) || DEFAULT_WIDTH,
height: Number(persistedState?.height) || DEFAULT_HEIGHT,
shellEl: null,
titleEl: null,
metaEl: null,
sectionListEl: null,
actionBtnEl: null,
resizeEl: null,
nextZIndex: 132,
};
function getLayerRect(): LayerRect {
return uiScope.toolWindowLayerEl?.getBoundingClientRect() || uiScope.editorBodyEl?.getBoundingClientRect() || {
left: 0,
top: 0,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
}
function persistState() {
if (typeof sessionScope.setPersistedToolWindowState === "function") {
sessionScope.setPersistedToolWindowState(CHANGELOG_SPLASH_WINDOW_KEY, {
visible: state.visible === true,
mode: "floating",
x: state.x,
y: state.y,
width: state.width,
height: state.height,
order: 994,
});
}
scope.persistPopupSessionLayout?.();
sessionScope.persistPopupSessionLayout?.();
}
function markSeen() {
try {
window.localStorage.setItem(CHANGELOG_SPLASH_STORAGE_KEY, "1");
} catch {
// Ignore localStorage write errors and keep the splash functional.
}
}
function hasSeenCurrentVersion() {
try {
return window.localStorage.getItem(CHANGELOG_SPLASH_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function focusWindow() {
if (!state.shellEl || state.visible !== true) {
return;
}
state.nextZIndex += 1;
state.shellEl.style.zIndex = String(state.nextZIndex);
state.shellEl.classList.add("is-focused");
}
function clearFocus() {
state.shellEl?.classList.remove("is-focused");
}
function applyWindowRect() {
if (!state.shellEl) {
return;
}
state.shellEl.style.left = `${Math.round(state.x)}px`;
state.shellEl.style.top = `${Math.round(state.y)}px`;
state.shellEl.style.width = `${Math.round(state.width)}px`;
state.shellEl.style.height = `${Math.round(state.height)}px`;
}
function centerWindow() {
const layerRect = getLayerRect();
const centeredLeft = Math.max(0, ((Number(layerRect.width) || DEFAULT_WIDTH) - state.width) / 2);
const centeredTop = Math.max(0, ((Number(layerRect.height) || DEFAULT_HEIGHT) - state.height) / 2);
const nextRect = clampWindowRect(layerRect, centeredLeft, centeredTop, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
}
function refresh() {
if (state.titleEl) {
state.titleEl.textContent = "What's New";
}
if (state.metaEl) {
state.metaEl.textContent = `Release ${CHANGELOG_SPLASH_VERSION}`;
}
if (!state.sectionListEl) {
return;
}
state.sectionListEl.innerHTML = "";
CHANGELOG_SECTIONS.forEach((section) => {
const sectionEl = document.createElement("section");
sectionEl.className = "changelog-splash-section";
const headingEl = document.createElement("h3");
headingEl.className = "changelog-splash-section-title";
headingEl.textContent = section.title;
const listEl = document.createElement("ul");
listEl.className = "changelog-splash-bullets";
section.items.forEach((item) => {
const itemEl = document.createElement("li");
const normalizedItem: ChangelogItem = item;
if (typeof normalizedItem === "string") {
itemEl.textContent = normalizedItem;
} else {
const textEl = document.createElement("div");
textEl.textContent = normalizedItem.text;
itemEl.appendChild(textEl);
if (normalizedItem.note) {
const noteEl = document.createElement("div");
noteEl.className = "changelog-splash-bullet-note";
noteEl.textContent = normalizedItem.note;
itemEl.appendChild(noteEl);
}
}
listEl.appendChild(itemEl);
});
sectionEl.appendChild(headingEl);
sectionEl.appendChild(listEl);
state.sectionListEl?.appendChild(sectionEl);
});
}
function ensureShell() {
if (state.shellEl && state.shellEl.isConnected) {
return state.shellEl;
}
const shellEl = document.createElement("div");
shellEl.className = "tool-popout-window changelog-splash-window hidden";
const titlebarEl = document.createElement("div");
titlebarEl.className = "tool-popout-titlebar";
titlebarEl.innerHTML =
`<div class="tool-popout-title">What's New</div>` +
`<div class="tool-popout-hint">Shown once per release</div>` +
`<button class="tool-popout-close-btn" type="button" aria-label="Close changelog">X</button>`;
const bodyEl = document.createElement("div");
bodyEl.className = "tool-popout-body";
const cardEl = document.createElement("div");
cardEl.className = "changelog-splash-card";
const heroEl = document.createElement("div");
heroEl.className = "changelog-splash-hero";
const kickerEl = document.createElement("div");
kickerEl.className = "changelog-splash-kicker";
kickerEl.textContent = "Mid-release update";
const titleEl = document.createElement("div");
titleEl.className = "changelog-splash-title";
const metaEl = document.createElement("div");
metaEl.className = "changelog-splash-meta";
heroEl.appendChild(kickerEl);
heroEl.appendChild(titleEl);
heroEl.appendChild(metaEl);
const sectionListEl = document.createElement("div");
sectionListEl.className = "changelog-splash-list";
const footerEl = document.createElement("div");
footerEl.className = "changelog-splash-footer";
const footnoteEl = document.createElement("div");
footnoteEl.className = "changelog-splash-footnote";
footnoteEl.textContent = "Major features and fixes only. Removed experiments are intentionally omitted.";
const actionBtnEl = document.createElement("button");
actionBtnEl.type = "button";
actionBtnEl.className = "mini-btn";
actionBtnEl.textContent = "Let's go";
actionBtnEl.addEventListener("click", () => {
close();
});
footerEl.appendChild(footnoteEl);
footerEl.appendChild(actionBtnEl);
cardEl.appendChild(heroEl);
cardEl.appendChild(sectionListEl);
cardEl.appendChild(footerEl);
bodyEl.appendChild(cardEl);
const resizeEl = document.createElement("div");
resizeEl.className = "tool-popout-resize";
const closeBtnEl = titlebarEl.querySelector<HTMLButtonElement>(".tool-popout-close-btn");
shellEl.appendChild(titlebarEl);
shellEl.appendChild(bodyEl);
shellEl.appendChild(resizeEl);
shellEl.addEventListener("pointerdown", () => {
focusWindow();
});
titlebarEl.addEventListener("pointerdown", (event: PointerEvent) => {
if (closeBtnEl && event.target instanceof Node && closeBtnEl.contains(event.target)) {
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const originLeft = Number(state.x) || 0;
const originTop = Number(state.y) || 0;
const startX = event.clientX;
const startY = event.clientY;
const move = (moveEvent: PointerEvent) => {
const nextRect = clampWindowRect(
layerRect,
originLeft + (moveEvent.clientX - startX),
originTop + (moveEvent.clientY - startY),
state.width,
state.height,
);
state.x = nextRect.left;
state.y = nextRect.top;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
closeBtnEl?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
close();
});
resizeEl.addEventListener("pointerdown", (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = Number(state.width) || DEFAULT_WIDTH;
const originHeight = Number(state.height) || DEFAULT_HEIGHT;
const move = (moveEvent: PointerEvent) => {
const nextRect = clampWindowRect(
layerRect,
state.x,
state.y,
Math.max(MIN_WIDTH, originWidth + (moveEvent.clientX - startX)),
Math.max(MIN_HEIGHT, originHeight + (moveEvent.clientY - startY)),
);
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
state.shellEl = shellEl;
state.titleEl = titleEl;
state.metaEl = metaEl;
state.sectionListEl = sectionListEl;
state.actionBtnEl = actionBtnEl;
state.resizeEl = resizeEl;
uiScope.toolWindowLayerEl?.appendChild(shellEl);
applyWindowRect();
shellEl.classList.toggle("hidden", state.visible !== true);
refresh();
return shellEl;
}
function open(options: OpenOptions = {}) {
ensureShell();
if (options.center === true) {
centerWindow();
} else {
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
}
state.visible = true;
if (options.markSeen !== false) {
markSeen();
}
refresh();
state.shellEl?.classList.remove("hidden");
applyWindowRect();
focusWindow();
persistState();
return true;
}
function close() {
state.visible = false;
clearFocus();
state.shellEl?.classList.add("hidden");
persistState();
return true;
}
function maybeOpenForCurrentVersion() {
if (hasSeenCurrentVersion()) {
return false;
}
return open({ markSeen: true, center: true });
}
function initialize() {
if (initialized) {
return;
}
initialized = true;
ensureShell();
window.addEventListener("resize", () => {
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
persistState();
});
state.visible = false;
state.shellEl?.classList.add("hidden");
}
return {
initialize,
open,
close,
refresh,
maybeOpenForCurrentVersion,
isOpen: () => state.visible === true,
version: CHANGELOG_SPLASH_VERSION,
};
}
2026-06-26 20:30:30 -04:00