diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 56f08328..c9aeb0b6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -35,6 +35,9 @@ jobs: - name: Build pikcell app for Pages run: npm run build:app --workspace=@moxijs/pikcell + - name: Build tilemap-matic app for Pages + run: npm run build:app --workspace=@moxijs/tilemap-matic + - name: Generate documentation run: npm run typedoc @@ -54,6 +57,13 @@ jobs: cp packages/pikcell/dist/bundle.js _site/pikcell/ cp -r packages/pikcell/assets _site/pikcell/assets + # TileMapMatic at /tilemap-matic/ + mkdir -p _site/tilemap-matic + cp packages/tilemap-matic/index.html _site/tilemap-matic/ + cp packages/tilemap-matic/index.css _site/tilemap-matic/ + cp packages/tilemap-matic/dist/bundle.js _site/tilemap-matic/ + cp -r packages/tilemap-matic/assets _site/tilemap-matic/assets + # Package builds at /packages/*/lib/ mkdir -p _site/packages/core/lib mkdir -p _site/packages/ui/lib diff --git a/LLMS.txt b/LLMS.txt index 8a1f44fe..b0ba8bc1 100644 --- a/LLMS.txt +++ b/LLMS.txt @@ -1,5 +1,19 @@ # MoxiJS + +## API Quick Reference + + +### @moxijs/core +**Classes:** ActionManager(add, getCount, hasAction, remove, removeAll) | AssetLoader(off, on) | ClientEvents(isKeyDown, isKeyUp) | CollisionManager(offCollision, onCollision) | CollisionRegistry(bitsToTags, getBit, getTag, register, tagsToBits) | Engine(addPhysicsWorld, loadStage, start, stop) | LoadingScene(destroy, hide, init, show) | ParallaxBackgroundLogic(update) | PhysicsWorld(createBody, destroy, destroyBody, disableDebugRenderer, enableDebugRenderer...) | PixelGrid(px, units) | RenderManager(render) | StateLogic(entity, init, onEnter, onExit, update) | StateMachine(addState, currentState, setState, update) | TextureFrameSequences(addSequence, getFrame, getFrameFromSequence, getFrameSequence, getSequence) | TilingParallaxLayer(resize, updateParallax) +**Interfaces:** AsTextureFramesOptions, AssetLoaderEvents, BitmapTextOptions, BorderConfig, CellPosition, ClientEventsArgs, CollisionEvent, FallingSquaresOptions, GraphicsDrawOptions, GridOptions, LoadingAnimation, LoadingAnimationContext, LoadingSceneOptions, MSDFTextOptions, MoxiEntityClass, OnAction, OnEvent, ParallaxBackgroundOptions, ParallaxLayerOptions, ParsedShape, PhysicsBodyOptions, PhysicsDebugOptions, PhysicsShapeMetadata, PhysicsWorldOptions, PixelGridConfig, PixelPerfectOptions, PixiProps, PixiPropsWithEntity, ResizeHandlerOptions, SVGToTextureOptions, SequenceInfo, SetupMoxiArgs, SetupMoxiResult, ShapeConfig, SpriteOptions, TextDPROptions, TextOptions, TilingParallaxLayerOptions + +### @moxijs/ui +**Classes:** CardPanel(destroy, getBodyContainer, getBodySize, getFooterContainer, getTitleBarContainer...) | EdgeInsets(clone, equals, horizontal, isZero, toString...) | FlatCardStyle(drawBackground, drawDragStrip, drawFooter, drawTitleBar, getBorderInsets...) | FlexContainer(addChild, layout, measure, removeAllChildren, removeChild) | FlexContainerPanel(addChild, destroy, getContentContainer, layout, measure...) | FlexLayoutEngine(compute) | LayoutDebugOverlay(dispose, hide, setOptions, show, toggle...) | LayoutEngine(layout, measure, position) | LayoutTree(addChild, computeNow, createNode, dispose, find...) | ThemeApplier(applyBackground, applyBorderColor, applyCheckmarkColor, applyPlaceholderColor, applyTextColor...) | ThemeResolver(getCheckmarkColor, getColor, getControlColor, getControlProperty, getPlaceholderColor...) | UIButton(destroy, getState, layout, measure, setEnabled...) | UICheckbox(destroy, getChecked, layout, measure, setChecked...) | UICheckboxWithLabel(destroy, getCheckbox, getChecked, getLabel, layout...) | UIFocusManager(blur, clear, destroy, focus, focusFirst...) | UILabel(getFontType, getText, isBitmapText, layout, measure...) | UIPanel(layout, measure, setBackgroundColor, setBorder, setTexture) | UIRadioButton(destroy, getSelected, layout, measure, setDisabled...) | UIRadioGroup(destroy, getValue, layout, measure, setDisabled...) | UIScrollContainer(addChild, destroy, getMaxScrollY, getScrollPaddingBottom, getScrollY...) | UISelect(destroy, getValue, layout, measure, setOptions...) | UITabs(destroy, getActiveKey, layout, measure, render...) | UITextArea(destroy, getHeight, getValue, getWidth, layout...) | UITextInput(destroy, getHeight, getValue, getWidth, layout...) +**Interfaces:** BaseTheme, BoxModel, ButtonBackgroundStrategy, CardPanelFooter, CardPanelProps, CardPanelTitle, CardSectionDimensions, CardStyle, CardThemeColors, ComponentState, ComputedLayout, ComputedLayout, DefaultUITheme, DirtyInfo, FlatCardStyleConfig, FlexContainerPanelProps, FlexContainerProps, FlexItem, FlexLine, FlexProps, Focusable, FontProps, FormStateConfig, IFlexLayoutParticipant, IconConfig, LayoutDebugOverlayOptions, LayoutNode, LayoutStyle, LayoutTreeOptions, LayoutWrapperOptions, LayoutWrapperStyle, MeasuredLayout, MeasuredSize, NineSliceConfig, ParsedSize, RadioOption, ResolvedStyle, SelectOption, SizeConstraints, SpriteBackgroundConfig, TabItem, ThemeInfo, UIButtonProps, UICheckboxProps, UICheckboxWithLabelProps, UIFontConfig, UILabelProps, UILayerOptions, UILayoutConfig, UIPanelProps, UIRadioButtonProps, UIRadioGroupProps, UIScrollContainerProps, UISelectProps, UITabsProps, UITextAreaProps, UITextInputProps + + + > A game framework built on PIXI.js with Entity-Component-System architecture, physics integration, and UI components. ## Package: @moxijs/core diff --git a/package-lock.json b/package-lock.json index 90747fd9..1c5b8423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "packages/ui", "packages/mini-gui", "packages/moxijs-examples", - "packages/pikcell" + "packages/pikcell", + "packages/tilemap-matic" ], "devDependencies": { "@playwright/test": "^1.48.0", @@ -2280,6 +2281,10 @@ "resolved": "packages/pikcell", "link": true }, + "node_modules/@moxijs/tilemap-matic": { + "resolved": "packages/tilemap-matic", + "link": true + }, "node_modules/@moxijs/ui": { "resolved": "packages/ui", "link": true @@ -14348,7 +14353,7 @@ }, "packages/core": { "name": "@moxijs/core", - "version": "0.3.1", + "version": "0.3.3", "license": "MIT", "dependencies": { "fontfaceobserver": "^2.3.0", @@ -14532,6 +14537,7 @@ "@codemirror/theme-one-dark": "^6.1.3", "@moxijs/core": "file:../core", "@moxijs/mini-gui": "file:../mini-gui", + "@moxijs/tilemap-matic": "file:../tilemap-matic", "@moxijs/ui": "file:../ui", "codemirror": "^6.0.2", "pikcell": "file:../pikcell", @@ -14586,9 +14592,29 @@ "webpack-dev-server": "^5.2.0" } }, + "packages/tilemap-matic": { + "name": "@moxijs/tilemap-matic", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@moxijs/core": "file:../core", + "@moxijs/ui": "file:../ui" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "html-webpack-plugin": "^5.6.3", + "jest": "^30.0.0", + "ts-jest": "^29.4.0", + "ts-loader": "^9.5.1", + "typescript": "^5.7.2", + "webpack": "^5.102.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.0" + } + }, "packages/ui": { "name": "@moxijs/ui", - "version": "0.3.1", + "version": "0.3.3", "license": "MIT", "dependencies": { "@moxijs/core": "^0.2.4" diff --git a/package.json b/package.json index 71e503d0..bbdfbc1d 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,12 @@ "packages/ui", "packages/mini-gui", "packages/moxijs-examples", - "packages/pikcell" + "packages/pikcell", + "packages/tilemap-matic" ], "scripts": { "build": "npm run build --workspaces --if-present", + "build:moxijs": "npm run build --workspace=@moxijs/core && npm run build --workspace=@moxijs/ui && npm run build --workspace=moxijs-examples", "build:clean": "npm run build:clean --workspaces --if-present", "clean": "npm run clean --workspaces --if-present", "build:dev": "npm run build:dev --workspaces --if-present", @@ -22,6 +24,7 @@ "test:watch": "npm run test:watch --workspace=@moxijs/core", "test:ci": "npm run test:ci --workspace=@moxijs/core && npm run test:ci --workspace=@moxijs/ui", "pikcell": "npm run dev --workspace=@moxijs/pikcell", + "tilemap-matic": "npm run dev --workspace=@moxijs/tilemap-matic", "publish:dry-run": "npx ts-node scripts/dry-run-publish.ts", "typedoc": "npm run typedoc --workspaces --if-present" }, diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 583e7be7..d0845fc1 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.5] - 2025-12-11 + +### LLM Documentation Enhancement + +### Added + +#### Documentation (`llms.txt`) +- **API Quick Reference TOC**: Auto-generated table of contents at top of llms.txt + - Lists all exported classes with their key methods + - Lists all exported interfaces + - Generated via `node scripts/generate-llm-toc.js` + - Wrapped in `` / `` markers for easy regeneration + ## [0.3.4] - 2025-12-09 ### MSDF Font System Improvements diff --git a/packages/core/llms.txt b/packages/core/llms.txt index 1e90ac9d..741ee6f2 100644 --- a/packages/core/llms.txt +++ b/packages/core/llms.txt @@ -1,5 +1,12 @@ # @moxijs/core + +## API Quick Reference + +**Classes:** ActionManager(add, getCount, hasAction, remove, removeAll) | AssetLoader(off, on) | ClientEvents(isKeyDown, isKeyUp) | CollisionManager(offCollision, onCollision) | CollisionRegistry(bitsToTags, getBit, getTag, register, tagsToBits) | Engine(addPhysicsWorld, loadStage, start, stop) | LoadingScene(destroy, hide, init, show) | ParallaxBackgroundLogic(update) | PhysicsWorld(createBody, destroy, destroyBody, disableDebugRenderer, enableDebugRenderer...) | PixelGrid(px, units) | RenderManager(render) | StateLogic(entity, init, onEnter, onExit, update) | StateMachine(addState, currentState, setState, update) | TextureFrameSequences(addSequence, getFrame, getFrameFromSequence, getFrameSequence, getSequence) | TilingParallaxLayer(resize, updateParallax) +**Interfaces:** AsTextureFramesOptions, AssetLoaderEvents, BitmapTextOptions, BorderConfig, CellPosition, ClientEventsArgs, CollisionEvent, FallingSquaresOptions, GraphicsDrawOptions, GridOptions, LoadingAnimation, LoadingAnimationContext, LoadingSceneOptions, MSDFTextOptions, MoxiEntityClass, OnAction, OnEvent, ParallaxBackgroundOptions, ParallaxLayerOptions, ParsedShape, PhysicsBodyOptions, PhysicsDebugOptions, PhysicsShapeMetadata, PhysicsWorldOptions, PixelGridConfig, PixelPerfectOptions, PixiProps, PixiPropsWithEntity, ResizeHandlerOptions, SVGToTextureOptions, SequenceInfo, SetupMoxiArgs, SetupMoxiResult, ShapeConfig, SpriteOptions, TextDPROptions, TextOptions, TilingParallaxLayerOptions + + > Game framework on PIXI.js with ECS architecture and physics. ## Install diff --git a/packages/moxijs-examples/index.html b/packages/moxijs-examples/index.html index a35ba550..efc4aabe 100644 --- a/packages/moxijs-examples/index.html +++ b/packages/moxijs-examples/index.html @@ -65,6 +65,38 @@ - + + + diff --git a/packages/moxijs-examples/package.json b/packages/moxijs-examples/package.json index ba7c05c3..e09e392f 100644 --- a/packages/moxijs-examples/package.json +++ b/packages/moxijs-examples/package.json @@ -54,6 +54,7 @@ "@moxijs/ui": "file:../ui", "codemirror": "^6.0.2", "pikcell": "file:../pikcell", + "@moxijs/tilemap-matic": "file:../tilemap-matic", "pixi.js": "^8.14.0" } } diff --git a/packages/moxijs-examples/src/examples/05-tools/tilemap-matic.ts b/packages/moxijs-examples/src/examples/05-tools/tilemap-matic.ts new file mode 100644 index 00000000..e92eb44f --- /dev/null +++ b/packages/moxijs-examples/src/examples/05-tools/tilemap-matic.ts @@ -0,0 +1,29 @@ +/** + * TileMapMatic Example Stub + * The full implementation is in the @moxijs/tilemap-matic package. + * This stub provides integration with the moxijs-examples gallery. + */ +import { initTileMapMatic } from '@moxijs/tilemap-matic'; + +type CleanupFunction = () => void; + +/** + * Initialize TileMapMatic in the examples context + */ +export async function initTileMapMaticExample(): Promise { + const root = document.getElementById('canvas-container'); + if (!root) throw new Error('Canvas container not found'); + + // Initialize using the package + const cleanup = await initTileMapMatic({ + hostElement: root, + width: 1280, + height: 720, + backgroundColor: 0x1a1a2e + }); + + return cleanup; +} + +// Re-export the init function with the expected name pattern +export { initTileMapMaticExample as initTileMapMatic }; diff --git a/packages/moxijs-examples/src/index.ts b/packages/moxijs-examples/src/index.ts index b30c3efa..80015d24 100644 --- a/packages/moxijs-examples/src/index.ts +++ b/packages/moxijs-examples/src/index.ts @@ -35,6 +35,7 @@ import { initNewtonsCradle } from './examples/06-physics/newtons-cradle'; import { initSpriteLibrary } from './examples/05-tools/sprite-library'; import { initSpriteEditor } from './examples/05-tools/sprite-editor'; import { initParticleEmitterSandbox } from './examples/05-tools/particle-sandbox'; +import { initTileMapMatic } from './examples/05-tools/tilemap-matic'; // CodeMirror imports import { EditorView, basicSetup } from 'codemirror'; @@ -74,6 +75,7 @@ import newtonsCradleSource from './examples/06-physics/newtons-cradle.ts?raw'; import spriteLibrarySource from './examples/05-tools/sprite-library.ts?raw'; import spriteEditorSource from './examples/05-tools/sprite-editor.ts?raw'; import particleSandboxSource from './examples/05-tools/particle-sandbox.ts?raw'; +import tileMapMaticSource from './examples/05-tools/tilemap-matic.ts?raw'; // Cleanup function type - examples can return this to clean up resources type CleanupFunction = () => void; @@ -247,6 +249,12 @@ const categories: Record = { init: initSpriteEditor, source: spriteEditorSource }, + 'tilemap-matic': { + name: 'TileMapMatic', + description: 'Multi-sheet editor with project persistence and export', + init: initTileMapMatic, + source: tileMapMaticSource + }, 'particle-sandbox': { name: 'Particle Sandbox', description: 'Particle system editor with presets', diff --git a/packages/moxijs-examples/tsconfig.json b/packages/moxijs-examples/tsconfig.json index beeea616..da85cbfb 100644 --- a/packages/moxijs-examples/tsconfig.json +++ b/packages/moxijs-examples/tsconfig.json @@ -15,6 +15,7 @@ "baseUrl": ".", "paths": { "pikcell": ["../pikcell/src/index.ts"], + "@moxijs/tilemap-matic": ["../tilemap-matic/src/index.ts"], "@moxijs/mini-gui": ["../mini-gui/src/index.ts"] } }, diff --git a/packages/moxijs-examples/webpack.config.js b/packages/moxijs-examples/webpack.config.js index ac8533b1..825e8c33 100644 --- a/packages/moxijs-examples/webpack.config.js +++ b/packages/moxijs-examples/webpack.config.js @@ -15,8 +15,12 @@ module.exports = (env, argv) => ({ // Use local moxi source for development so webpack can properly handle externals 'moxi': path.resolve(__dirname, '../core/src/index.ts'), '@moxijs/core': path.resolve(__dirname, '../core/src/index.ts'), + '@moxijs/ui': path.resolve(__dirname, '../ui/src/index.ts'), // Use local pikcell source for development 'pikcell': path.resolve(__dirname, '../pikcell/src/index.ts'), + // Use local tilemap-matic source for development + 'tilemap-matic': path.resolve(__dirname, '../tilemap-matic/src/index.ts'), + '@moxijs/tilemap-matic': path.resolve(__dirname, '../tilemap-matic/src/index.ts'), // Use local mini-gui for development '@moxijs/mini-gui': path.resolve(__dirname, '../mini-gui/src/index.ts') } diff --git a/packages/tilemap-matic/CHANGELOG.md b/packages/tilemap-matic/CHANGELOG.md new file mode 100644 index 00000000..51fd760d --- /dev/null +++ b/packages/tilemap-matic/CHANGELOG.md @@ -0,0 +1,109 @@ +# Changelog + +All notable changes to `@moxijs/tilemap-matic` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2025-12-11 + +### Initial Release + +TileMapMatic is a sprite sheet and tilemap management tool built on MoxiJS and PIXI.js. This initial release provides a complete workflow for importing sprite sheets, defining tile regions and animations, and exporting PIXI.js-compatible JSON. + +### Added + +#### Core Application +- Full-featured sprite sheet editor with pan/zoom canvas +- Drag-and-drop sprite sheet import (PNG, JPG, GIF, WebP) +- Slate/gray theme with muted lime green accents +- Persistent session state (panel positions, zoom levels, selections) +- GitHub Pages deployment at `/tilemap-matic/` + +#### Sprite Sheet Grid +- Configurable grid overlay (tile width, height, offset, spacing) +- Cell selection with click and drag +- Multi-cell region creation +- Visual highlighting for selected cells and regions + +#### Animation System +- **Ctrl+A** animation creation mode +- Click cells in order to define frame sequence +- Numbered overlay shows frame order (1, 2, 3...) +- **Enter** to finalize animation with name prompt +- **Escape** to cancel animation creation +- Frame duration configuration (default: 100ms) +- Loop toggle for animations +- Input binding support (left, right, up, down, action1, action2) + +#### Library Panel +- Vertical carousel of loaded sprite sheets +- Thumbnail previews with selection indicator +- Add (+) button for importing new sheets +- Delete button per sheet with confirmation +- Drag-and-drop reordering support + +#### Animation/Region Library Panel +- Collapsible sections for Animations and Regions +- Animation previews using PIXI.AnimatedSprite +- Region previews showing actual sprite texture +- Click to select and highlight on grid +- Pan/zoom navigation (0.5x to 4x) +- CardPanel wrapper with drag/resize support + +#### Configuration Panel +- Dynamic form based on selection context +- Grid settings (tile size, offset, spacing) +- Region naming with real-time grid label updates +- Animation editing (name, duration, loop, input binding) +- Delete buttons for regions and animations + +#### Export System +- PIXI.js Spritesheet JSON format export +- MoxiJS extensions in `meta.moxiExtensions`: + - `namedCells` - Named tile regions with coordinates + - `animations` - Animation sequences with frame references +- Copy to clipboard functionality +- JSON viewer panel with syntax highlighting + +#### Canvas Controls +- Pan with middle mouse or Ctrl+drag +- Zoom with mouse wheel (0.25x to 8x range) +- Grid snapping for region creation +- Keyboard shortcuts: + - **Ctrl+A** - Enter animation creation mode + - **Enter** - Confirm animation + - **Escape** - Cancel animation mode + - **Delete** - Remove selected region/animation + +#### UI Components +- CardPanel wrappers with SE drop shadows +- Draggable, resizable panels +- Collapsible sections +- Pixel font support (PixelOperator8) +- Drop zone indicator when library is empty + +### Technical Details + +#### Files +- `tilemap-matic.ts` - Main application orchestrator +- `sprite-carousel.ts` - Sprite sheet library carousel +- `sprite-sheet-grid.ts` - Grid overlay and cell selection +- `sprite-sheet-config-panel.ts` - Dynamic configuration form +- `animation-region-library-panel.ts` - Animation/region browser +- `sprite-sheet-project.ts` - Project state management +- `sprite-sheet-data.ts` - Data types and export functions +- `canvas-pan-zoom.ts` - Pan/zoom controller +- `json-viewer.ts` - JSON export viewer + +#### Dependencies +- `@moxijs/core` - Entity system, asset management +- `@moxijs/ui` - CardPanel, FlexContainer, UI components +- PIXI.js v8.14 (external) + +#### Build +- Webpack with TypeScript +- Development server on port 9002 +- Production build outputs to `dist/bundle.js` diff --git a/packages/tilemap-matic/assets/fonts/license.txt b/packages/tilemap-matic/assets/fonts/license.txt new file mode 100644 index 00000000..ed0d3ec5 --- /dev/null +++ b/packages/tilemap-matic/assets/fonts/license.txt @@ -0,0 +1,8 @@ +The FontStruction “VHS Gothic” +(https://fontstruct.com/fontstructions/show/2330337) by Spottie Leonard is +licensed under a Creative Commons Attribution Share Alike license +(http://creativecommons.org/licenses/by-sa/3.0/). +“VHS Gothic” was originally cloned (copied) from the FontStruction +“CHARGEN '92” (https://fontstruct.com/fontstructions/show/1599418) by +“ParadigmTheGreat”, which is licensed under a Creative Commons Attribution +Share Alike license (http://creativecommons.org/licenses/by-sa/3.0/). \ No newline at end of file diff --git a/packages/tilemap-matic/assets/fonts/pixel_operator/PixelOperator8-Bold.ttf b/packages/tilemap-matic/assets/fonts/pixel_operator/PixelOperator8-Bold.ttf new file mode 100644 index 00000000..10225eb8 Binary files /dev/null and b/packages/tilemap-matic/assets/fonts/pixel_operator/PixelOperator8-Bold.ttf differ diff --git a/packages/tilemap-matic/assets/fonts/pixel_operator/PixelOperator8.ttf b/packages/tilemap-matic/assets/fonts/pixel_operator/PixelOperator8.ttf new file mode 100644 index 00000000..f9146acf Binary files /dev/null and b/packages/tilemap-matic/assets/fonts/pixel_operator/PixelOperator8.ttf differ diff --git a/packages/tilemap-matic/index.css b/packages/tilemap-matic/index.css new file mode 100644 index 00000000..24167d40 --- /dev/null +++ b/packages/tilemap-matic/index.css @@ -0,0 +1,60 @@ +/* TileMapMatic - Shared styles */ + +/* Font faces */ +@font-face { + font-family: 'PixelOperator8'; + src: url('./assets/fonts/pixel_operator/PixelOperator8.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'PixelOperator8-Bold'; + src: url('./assets/fonts/pixel_operator/PixelOperator8-Bold.ttf') format('truetype'); + font-weight: bold; + font-style: normal; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: #1a1a2e; +} + +#app { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +canvas { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +/* Loading indicator */ +#loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #00d4ff; + font-family: monospace; + font-size: 14px; + text-align: center; +} + +#loading.hidden { + display: none; +} diff --git a/packages/tilemap-matic/index.html b/packages/tilemap-matic/index.html new file mode 100644 index 00000000..1120f875 --- /dev/null +++ b/packages/tilemap-matic/index.html @@ -0,0 +1,29 @@ + + + + + + + [T]ile[Map]Matic - Sprite Sheet Editor + + + + + +
+
Loading TileMapMatic...
+
+ + + + diff --git a/packages/tilemap-matic/package.json b/packages/tilemap-matic/package.json new file mode 100644 index 00000000..2df0a0f8 --- /dev/null +++ b/packages/tilemap-matic/package.json @@ -0,0 +1,43 @@ +{ + "name": "@moxijs/tilemap-matic", + "version": "0.1.0", + "description": "Sprite sheet and tile map management tool built on MoxiJS and PIXI.js", + "main": "dist/index.js", + "types": "dist/types/index.d.ts", + "scripts": { + "dev": "webpack serve --mode development", + "build": "webpack --mode production", + "build:app": "webpack --mode development", + "watch": "webpack --mode development --watch", + "clean": "rimraf dist" + }, + "keywords": [ + "tilemap-matic", + "sprite-sheet", + "tile-map", + "moxijs", + "pixijs", + "game-dev" + ], + "private": true, + "author": "Chad Neff (https://github.com/ineffably)", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ineffably/moxijs.git", + "directory": "packages/tilemap-matic" + }, + "type": "commonjs", + "devDependencies": { + "html-webpack-plugin": "^5.6.3", + "ts-loader": "^9.5.1", + "typescript": "^5.7.2", + "webpack": "^5.102.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.0" + }, + "dependencies": { + "@moxijs/core": "file:../core", + "@moxijs/ui": "file:../ui" + } +} diff --git a/packages/tilemap-matic/src/animation-region-library-panel.ts b/packages/tilemap-matic/src/animation-region-library-panel.ts new file mode 100644 index 00000000..03af2dc8 --- /dev/null +++ b/packages/tilemap-matic/src/animation-region-library-panel.ts @@ -0,0 +1,1078 @@ +/** + * Animation & Region Library Panel + * Draggable, resizable CardPanel displaying animations and regions + * with pan/zoom navigation for previews + */ +import { + CardPanel, + FlatCardStyle +} from '@moxijs/ui'; +import { + Container, + Graphics, + Sprite, + Texture, + AnimatedSprite, + Rectangle, + Text +} from 'pixi.js'; +import { AnimationSequence, TileRegion, GridSettings } from './sprite-sheet-data'; + +// Storage keys for panel state +const LIBRARY_PANEL_POSITION_KEY = 'tilemap-matic-library-panel-position'; +const LIBRARY_PANEL_SIZE_KEY = 'tilemap-matic-library-panel-size'; + +/** + * Callbacks for library panel interactions + */ +export interface LibraryPanelCallbacks { + /** Called when an animation is selected */ + onSelectAnimation?: (animation: AnimationSequence) => void; + /** Called when a region is selected */ + onSelectRegion?: (region: TileRegion) => void; + /** Called when selection is cleared */ + onClearSelection?: () => void; + /** Called when an animation is renamed */ + onRenameAnimation?: (animationId: string, newName: string) => void; + /** Called when a region is renamed */ + onRenameRegion?: (regionId: string, newName: string) => void; + /** Called when an animation is deleted */ + onDeleteAnimation?: (animationId: string) => void; + /** Called when a region is deleted */ + onDeleteRegion?: (regionId: string) => void; +} + +/** + * Props for the library panel + */ +export interface AnimationRegionLibraryPanelProps { + x?: number; + y?: number; + width?: number; + height?: number; + callbacks?: LibraryPanelCallbacks; +} + +/** + * Internal item representing an animation or region in the library + */ +interface LibraryItem { + type: 'animation' | 'region'; + id: string; + name: string; + container: Container; + preview: Container; + nameLabel?: Text; +} + +/** + * Animation & Region Library Panel + */ +export class AnimationRegionLibraryPanel { + private panel: CardPanel; + private contentContainer: Container; + private contentMask: Graphics; + private itemsContainer: Container; + private scrollbar: Graphics; + private scrollbarThumb: Graphics; + + // Pan/zoom state + private scale: number = 1; + private panY: number = 0; + private minScale: number = 0.5; + private maxScale: number = 4; + + // Library items + private items: LibraryItem[] = []; + private selectedItemId: string | null = null; + + // Editing state + private editingItemId: string | null = null; + private editInput: HTMLInputElement | null = null; + + // Current sheet data + private sheetTexture: Texture | null = null; + private gridSettings: GridSettings | null = null; + + // Callbacks + private callbacks: LibraryPanelCallbacks; + + // Layout constants + private readonly PANEL_WIDTH: number; + private readonly PANEL_HEIGHT: number; + private readonly ITEM_HEIGHT = 60; + private readonly ITEM_PADDING = 8; + private readonly SECTION_HEADER_HEIGHT = 24; + + constructor(props: AnimationRegionLibraryPanelProps = {}) { + this.callbacks = props.callbacks ?? {}; + + // Load saved size or use defaults + const savedSize = this.loadSize(); + this.PANEL_WIDTH = savedSize?.width ?? props.width ?? 200; + this.PANEL_HEIGHT = savedSize?.height ?? props.height ?? 400; + + // Create the CardPanel with SE drop shadow + this.panel = new CardPanel({ + title: { text: 'Animations / Regions', fontSize: 12 }, + bodyWidth: this.PANEL_WIDTH, + bodyHeight: this.PANEL_HEIGHT, + draggable: true, + resizable: ['e', 'w', 's', 'se', 'sw'], + minWidth: 150, + minHeight: 200, + style: new FlatCardStyle({ + showShadow: true, + shadowOffset: 6, + shadowAlpha: 0.4 + }), + colors: { + background: 0x222226, + border: 0x404045, + titleBar: 0x2a2a2e, + titleText: 0x9acd32 // Muted lime green to match app title + }, + onResize: (w, h) => this.handleResize(w, h), + onMove: (x, y) => this.savePosition() + }); + + // Load saved position or use defaults + const savedPosition = this.loadPosition(); + this.panel.x = savedPosition?.x ?? props.x ?? 10; + this.panel.y = savedPosition?.y ?? props.y ?? 60; + + // Create content container with mask for clipping + this.contentContainer = new Container(); + this.contentMask = new Graphics(); + this.updateMask(); + this.contentContainer.mask = this.contentMask; + + // Items container (scrollable content) + this.itemsContainer = new Container(); + this.contentContainer.addChild(this.itemsContainer); + + // Scrollbar track (starts with padding from top) + this.scrollbar = new Graphics(); + this.scrollbar.x = this.PANEL_WIDTH - 10; + this.scrollbar.y = 4; + + // Scrollbar thumb + this.scrollbarThumb = new Graphics(); + this.scrollbarThumb.x = this.PANEL_WIDTH - 10; + this.scrollbarThumb.y = 4; + + // Add to panel body + const body = this.panel.getBodyContainer(); + body.addChild(this.contentMask); + body.addChild(this.contentContainer); + body.addChild(this.scrollbar); + body.addChild(this.scrollbarThumb); + + // Setup event listeners + this.setupEventListeners(); + this.setupScrollbarInteraction(); + + // Initial render + this.renderEmptyState(); + } + + /** + * Update the clipping mask + */ + private updateMask(): void { + this.contentMask.clear(); + this.contentMask.rect(0, 0, this.PANEL_WIDTH, this.PANEL_HEIGHT); + this.contentMask.fill({ color: 0xffffff }); + } + + /** + * Handle panel resize + */ + private handleResize(width: number, height: number): void { + // Update mask to new size + this.contentMask.clear(); + this.contentMask.rect(0, 0, width, height); + this.contentMask.fill({ color: 0xffffff }); + + // Update scrollbar position + this.scrollbar.x = width - 10; + this.scrollbarThumb.x = width - 10; + + // Clamp pan position + this.clampPan(); + + // Re-render items to fit new width + this.renderItems(); + this.updateScrollbar(); + + // Save size to localStorage + this.saveSize(width, height); + } + + /** + * Setup mouse/touch event listeners for pan and zoom + */ + private setupEventListeners(): void { + const body = this.panel.getBodyContainer(); + body.eventMode = 'static'; + + // Mouse wheel for zoom + body.on('wheel', (e: WheelEvent) => { + e.preventDefault(); + + const zoomSpeed = 0.1; + const delta = e.deltaY > 0 ? -zoomSpeed : zoomSpeed; + const newScale = Math.max(this.minScale, Math.min(this.maxScale, this.scale + delta)); + + if (newScale !== this.scale) { + this.scale = newScale; + this.applyTransform(); + } + }); + + // Drag for vertical pan + let isDragging = false; + let lastY = 0; + + body.on('pointerdown', (e: PointerEvent) => { + // Only pan with middle mouse or when holding shift + if (e.button === 1 || e.shiftKey) { + isDragging = true; + lastY = e.clientY; + e.stopPropagation(); + } + }); + + body.on('globalpointermove', (e: PointerEvent) => { + if (!isDragging) return; + + const deltaY = e.clientY - lastY; + lastY = e.clientY; + + this.panY += deltaY; + this.clampPan(); + this.applyTransform(); + }); + + body.on('pointerup', () => { + isDragging = false; + }); + + body.on('pointerupoutside', () => { + isDragging = false; + }); + } + + /** + * Clamp pan position to content bounds + */ + private clampPan(): void { + const contentHeight = this.getContentHeight(); + const viewportHeight = this.PANEL_HEIGHT; + const scaledHeight = contentHeight * this.scale; + + if (scaledHeight <= viewportHeight) { + // Content fits, no panning needed + this.panY = 0; + } else { + // Clamp pan to valid range + const maxPan = 0; + const minPan = viewportHeight - scaledHeight; + this.panY = Math.max(minPan, Math.min(maxPan, this.panY)); + } + } + + /** + * Get total content height + */ + private getContentHeight(): number { + let height = this.ITEM_PADDING; + + // Animations section + const animations = this.items.filter(i => i.type === 'animation'); + if (animations.length > 0) { + height += this.SECTION_HEADER_HEIGHT; + height += animations.length * (this.ITEM_HEIGHT + this.ITEM_PADDING); + } + + // Regions section + const regions = this.items.filter(i => i.type === 'region'); + if (regions.length > 0) { + height += this.SECTION_HEADER_HEIGHT; + height += regions.length * (this.ITEM_HEIGHT + this.ITEM_PADDING); + } + + return Math.max(height, 100); + } + + /** + * Apply pan/zoom transform to items container + */ + private applyTransform(): void { + this.itemsContainer.scale.set(this.scale); + this.itemsContainer.y = this.panY; + this.updateScrollbar(); + } + + /** + * Update scrollbar appearance based on content and scroll position + */ + private updateScrollbar(): void { + const contentHeight = this.getContentHeight() * this.scale; + const viewportHeight = this.PANEL_HEIGHT - 8; // Account for top/bottom padding + const scrollbarWidth = 6; + const trackHeight = viewportHeight; + + // Clear and redraw track + this.scrollbar.clear(); + this.scrollbar.roundRect(0, 0, scrollbarWidth, trackHeight, 3); + this.scrollbar.fill({ color: 0x2a2a2e, alpha: 0.5 }); + + // Calculate thumb size and position + if (contentHeight <= viewportHeight) { + // Content fits, hide thumb + this.scrollbarThumb.visible = false; + return; + } + + this.scrollbarThumb.visible = true; + + const thumbHeight = Math.max(20, (viewportHeight / contentHeight) * trackHeight); + const scrollRange = contentHeight - viewportHeight; + const scrollProgress = -this.panY / scrollRange; + const thumbY = scrollProgress * (trackHeight - thumbHeight); + + // Draw thumb + this.scrollbarThumb.clear(); + this.scrollbarThumb.roundRect(0, thumbY, scrollbarWidth, thumbHeight, 3); + this.scrollbarThumb.fill({ color: 0x505058 }); + } + + /** + * Setup scrollbar drag interaction + */ + private setupScrollbarInteraction(): void { + this.scrollbarThumb.eventMode = 'static'; + this.scrollbarThumb.cursor = 'pointer'; + + let isDragging = false; + let dragStartY = 0; + let panStartY = 0; + + this.scrollbarThumb.on('pointerdown', (e: PointerEvent) => { + isDragging = true; + dragStartY = e.clientY; + panStartY = this.panY; + e.stopPropagation(); + }); + + this.scrollbarThumb.on('globalpointermove', (e: PointerEvent) => { + if (!isDragging) return; + + const contentHeight = this.getContentHeight() * this.scale; + const viewportHeight = this.PANEL_HEIGHT - 4; + + if (contentHeight <= viewportHeight) return; + + const trackHeight = viewportHeight; + const thumbHeight = Math.max(20, (viewportHeight / contentHeight) * trackHeight); + const scrollRange = contentHeight - viewportHeight; + const thumbRange = trackHeight - thumbHeight; + + const deltaY = e.clientY - dragStartY; + const scrollDelta = (deltaY / thumbRange) * scrollRange; + + this.panY = panStartY - scrollDelta; + this.clampPan(); + this.applyTransform(); + }); + + this.scrollbarThumb.on('pointerup', () => { isDragging = false; }); + this.scrollbarThumb.on('pointerupoutside', () => { isDragging = false; }); + + // Click on track to jump + this.scrollbar.eventMode = 'static'; + this.scrollbar.cursor = 'pointer'; + this.scrollbar.on('pointerdown', (e: PointerEvent) => { + const contentHeight = this.getContentHeight() * this.scale; + const viewportHeight = this.PANEL_HEIGHT - 4; + + if (contentHeight <= viewportHeight) return; + + const localY = e.clientY - this.scrollbar.getGlobalPosition().y; + const scrollProgress = localY / viewportHeight; + const scrollRange = contentHeight - viewportHeight; + + this.panY = -scrollProgress * scrollRange; + this.clampPan(); + this.applyTransform(); + }); + } + + /** + * Update the library with new data + */ + update( + animations: AnimationSequence[], + regions: TileRegion[], + sheetTexture: Texture | null, + gridSettings: GridSettings | null + ): void { + this.sheetTexture = sheetTexture; + this.gridSettings = gridSettings; + + // Clear existing items + this.clearItems(); + + // Build new items + for (const anim of animations) { + this.items.push({ + type: 'animation', + id: anim.id, + name: anim.name, + container: new Container(), + preview: new Container() + }); + } + + for (const region of regions) { + this.items.push({ + type: 'region', + id: region.id, + name: region.name || `Region ${region.id.slice(-4)}`, + container: new Container(), + preview: new Container() + }); + } + + // Render + if (this.items.length === 0) { + this.renderEmptyState(); + } else { + this.renderItems(); + } + } + + /** + * Clear all library items + */ + private clearItems(): void { + for (const item of this.items) { + item.container.destroy({ children: true }); + } + this.items = []; + this.itemsContainer.removeChildren(); + } + + /** + * Render empty state message + */ + private renderEmptyState(): void { + this.itemsContainer.removeChildren(); + + const emptyLabel = new Text({ + text: 'No animations or regions', + style: { + fontSize: 12, + fill: 0x707078, + fontFamily: 'Arial' + } + }); + emptyLabel.x = this.PANEL_WIDTH / 2 - 60; + emptyLabel.y = this.PANEL_HEIGHT / 2 - 10; + this.itemsContainer.addChild(emptyLabel); + } + + /** + * Render all library items + */ + private renderItems(): void { + this.itemsContainer.removeChildren(); + + let y = this.ITEM_PADDING; + + // Animations section + const animations = this.items.filter(i => i.type === 'animation'); + if (animations.length > 0) { + // Section header + const animHeader = this.createSectionHeader('Animations', animations.length); + animHeader.y = y; + this.itemsContainer.addChild(animHeader); + y += this.SECTION_HEADER_HEIGHT; + + // Animation items + for (const item of animations) { + this.renderItem(item, y); + this.itemsContainer.addChild(item.container); + y += this.ITEM_HEIGHT + this.ITEM_PADDING; + } + } + + // Regions section + const regions = this.items.filter(i => i.type === 'region'); + if (regions.length > 0) { + // Section header + const regionHeader = this.createSectionHeader('Regions', regions.length); + regionHeader.y = y; + this.itemsContainer.addChild(regionHeader); + y += this.SECTION_HEADER_HEIGHT; + + // Region items + for (const item of regions) { + this.renderItem(item, y); + this.itemsContainer.addChild(item.container); + y += this.ITEM_HEIGHT + this.ITEM_PADDING; + } + } + + // Update pan bounds + this.clampPan(); + this.applyTransform(); + } + + /** + * Create a section header + */ + private createSectionHeader(text: string, count: number): Container { + const container = new Container(); + + const bg = new Graphics(); + bg.rect(0, 0, this.PANEL_WIDTH - this.ITEM_PADDING * 2, this.SECTION_HEADER_HEIGHT); + bg.fill({ color: 0x2a2a2e, alpha: 0.8 }); + container.addChild(bg); + + const label = new Text({ + text: `${text} (${count})`, + style: { + fontSize: 11, + fill: 0x909095, + fontFamily: 'Arial' + } + }); + label.x = this.ITEM_PADDING; + label.y = 5; + container.addChild(label); + + container.x = this.ITEM_PADDING; + + return container; + } + + /** + * Render a single library item + */ + private renderItem(item: LibraryItem, y: number): void { + const container = item.container; + container.removeChildren(); + container.y = y; + container.x = this.ITEM_PADDING; + + const itemWidth = this.PANEL_WIDTH - this.ITEM_PADDING * 2; + const isSelected = item.id === this.selectedItemId; + + // Background + const bg = new Graphics(); + bg.roundRect(0, 0, itemWidth, this.ITEM_HEIGHT, 4); + bg.fill({ color: isSelected ? 0x3a3a40 : 0x2a2a2e }); + bg.stroke({ color: isSelected ? 0x9acd32 : 0x38383c, width: 1 }); + container.addChild(bg); + + // Preview area (left side) + const previewSize = this.ITEM_HEIGHT - 8; + const previewBg = new Graphics(); + previewBg.rect(4, 4, previewSize, previewSize); + previewBg.fill({ color: 0x1a1a1e }); + container.addChild(previewBg); + + // Render preview content + this.renderPreview(item, previewSize); + item.preview.x = 4; + item.preview.y = 4; + container.addChild(item.preview); + + // Name label (right side) - use raw PIXI.Text (UILabel requires layout() call) + const nameLabel = new Text({ + text: item.name, + style: { + fontSize: 11, + fill: 0xc0c0c8, + fontFamily: 'Arial' + } + }); + nameLabel.x = previewSize + 12; + nameLabel.y = 8; + container.addChild(nameLabel); + item.nameLabel = nameLabel; + + // Type badge + const typeLabel = new Text({ + text: item.type === 'animation' ? 'ANIM' : 'REGION', + style: { + fontSize: 9, + fill: item.type === 'animation' ? 0x7a9a7a : 0x9a7a7a, + fontFamily: 'Arial' + } + }); + typeLabel.x = previewSize + 12; + typeLabel.y = 28; + container.addChild(typeLabel); + + // Frame count for animations + if (item.type === 'animation') { + const animData = this.getAnimationData(item.id); + if (animData) { + const frameLabel = new Text({ + text: `${animData.frames.length} frames`, + style: { + fontSize: 9, + fill: 0x707078, + fontFamily: 'Arial' + } + }); + frameLabel.x = previewSize + 12; + frameLabel.y = 42; + container.addChild(frameLabel); + } + } + + // Delete button (top right corner) + const deleteBtn = new Graphics(); + const deleteBtnSize = 16; + deleteBtn.roundRect(0, 0, deleteBtnSize, deleteBtnSize, 3); + deleteBtn.fill({ color: 0x443338 }); + deleteBtn.x = itemWidth - deleteBtnSize - 4; + deleteBtn.y = 4; + container.addChild(deleteBtn); + + // X icon on delete button + const xIcon = new Text({ + text: '×', + style: { + fontSize: 12, + fill: 0xaa6666, + fontFamily: 'Arial' + } + }); + xIcon.x = itemWidth - deleteBtnSize + 1; + xIcon.y = 2; + container.addChild(xIcon); + + // Delete button interaction + deleteBtn.eventMode = 'static'; + deleteBtn.cursor = 'pointer'; + deleteBtn.on('pointerdown', (e: PointerEvent) => { + e.stopPropagation(); + this.deleteItem(item); + }); + + // Make interactive + container.eventMode = 'static'; + container.cursor = 'pointer'; + container.on('pointerdown', () => this.selectItem(item)); + + // Double-click to edit name + let lastClickTime = 0; + container.on('click', () => { + const now = Date.now(); + if (now - lastClickTime < 300 && this.selectedItemId === item.id) { + this.startEditing(item); + } + lastClickTime = now; + }); + } + + /** + * Render preview for an item + */ + private renderPreview(item: LibraryItem, size: number): void { + item.preview.removeChildren(); + + if (!this.sheetTexture || !this.gridSettings) { + // Placeholder + const placeholder = new Graphics(); + placeholder.rect(0, 0, size, size); + placeholder.fill({ color: 0x2a2a30 }); + item.preview.addChild(placeholder); + return; + } + + if (item.type === 'animation') { + this.renderAnimationPreview(item, size); + } else { + this.renderRegionPreview(item, size); + } + } + + /** + * Render animated preview for an animation item + */ + private renderAnimationPreview(item: LibraryItem, size: number): void { + const animData = this.getAnimationData(item.id); + if (!animData || animData.frames.length === 0 || !this.sheetTexture || !this.gridSettings) { + return; + } + + const { cellWidth, cellHeight } = this.gridSettings; + + // Create textures for each frame + const textures: Texture[] = []; + for (const frame of animData.frames) { + const frameRect = new Rectangle( + frame.col * cellWidth, + frame.row * cellHeight, + cellWidth, + cellHeight + ); + const frameTexture = new Texture({ + source: this.sheetTexture.source, + frame: frameRect + }); + textures.push(frameTexture); + } + + if (textures.length === 0) return; + + // Create animated sprite + const animSprite = new AnimatedSprite(textures); + animSprite.animationSpeed = 1000 / animData.frameDuration / 60; // Convert ms to animation speed + animSprite.loop = animData.loop; + animSprite.play(); + + // Scale to fit preview + const scale = Math.min(size / cellWidth, size / cellHeight); + animSprite.scale.set(scale); + + // Center in preview area + animSprite.x = (size - cellWidth * scale) / 2; + animSprite.y = (size - cellHeight * scale) / 2; + + item.preview.addChild(animSprite); + } + + /** + * Render static preview for a region item + */ + private renderRegionPreview(item: LibraryItem, size: number): void { + const regionData = this.getRegionData(item.id); + if (!regionData || !this.sheetTexture || !this.gridSettings) { + return; + } + + const { cellWidth, cellHeight } = this.gridSettings; + + // Create texture for region + const regionRect = new Rectangle( + regionData.col * cellWidth, + regionData.row * cellHeight, + regionData.colSpan * cellWidth, + regionData.rowSpan * cellHeight + ); + + const regionTexture = new Texture({ + source: this.sheetTexture.source, + frame: regionRect + }); + + const sprite = new Sprite(regionTexture); + + // Scale to fit preview + const scale = Math.min(size / regionRect.width, size / regionRect.height); + sprite.scale.set(scale); + + // Center in preview area + sprite.x = (size - regionRect.width * scale) / 2; + sprite.y = (size - regionRect.height * scale) / 2; + + item.preview.addChild(sprite); + } + + /** + * Get animation data by ID (from stored reference) + */ + private getAnimationData(id: string): AnimationSequence | null { + // This would need to be passed in or stored + // For now, return null - will be updated when we integrate + return this._animationsCache?.find(a => a.id === id) ?? null; + } + + /** + * Get region data by ID (from stored reference) + */ + private getRegionData(id: string): TileRegion | null { + return this._regionsCache?.find(r => r.id === id) ?? null; + } + + // Cache for lookups + private _animationsCache: AnimationSequence[] = []; + private _regionsCache: TileRegion[] = []; + + /** + * Update with full data including caches for lookup + */ + updateWithData( + animations: AnimationSequence[], + regions: TileRegion[], + sheetTexture: Texture | null, + gridSettings: GridSettings | null + ): void { + this._animationsCache = animations; + this._regionsCache = regions; + this.update(animations, regions, sheetTexture, gridSettings); + } + + /** + * Select an item + */ + private selectItem(item: LibraryItem): void { + this.selectedItemId = item.id; + + // Re-render to show selection + this.renderItems(); + + // Notify callbacks + if (item.type === 'animation') { + const animData = this.getAnimationData(item.id); + if (animData && this.callbacks.onSelectAnimation) { + this.callbacks.onSelectAnimation(animData); + } + } else { + const regionData = this.getRegionData(item.id); + if (regionData && this.callbacks.onSelectRegion) { + this.callbacks.onSelectRegion(regionData); + } + } + } + + /** + * Clear selection + */ + clearSelection(): void { + this.selectedItemId = null; + this.cancelEditing(); + this.renderItems(); + this.callbacks.onClearSelection?.(); + } + + /** + * Delete an item + */ + private deleteItem(item: LibraryItem): void { + // Clear selection if deleting selected item + if (this.selectedItemId === item.id) { + this.selectedItemId = null; + } + + // Notify via callbacks + if (item.type === 'animation' && this.callbacks.onDeleteAnimation) { + this.callbacks.onDeleteAnimation(item.id); + } else if (item.type === 'region' && this.callbacks.onDeleteRegion) { + this.callbacks.onDeleteRegion(item.id); + } + } + + /** + * Start editing an item's name + */ + private startEditing(item: LibraryItem): void { + if (this.editingItemId === item.id) return; + + // Cancel any existing edit + this.cancelEditing(); + + this.editingItemId = item.id; + + // Hide the text label + if (item.nameLabel) { + item.nameLabel.visible = false; + } + + // Create HTML input for editing + const input = document.createElement('input'); + input.type = 'text'; + input.value = item.name; + input.style.cssText = ` + position: absolute; + font-family: Arial, sans-serif; + font-size: 11px; + padding: 2px 4px; + border: 1px solid #6060a0; + border-radius: 3px; + background: #1a1a2a; + color: #d0d0e0; + outline: none; + width: 80px; + `; + + // Position the input over the label + const canvas = document.querySelector('canvas'); + if (canvas && item.nameLabel) { + const canvasRect = canvas.getBoundingClientRect(); + const globalPos = item.nameLabel.getGlobalPosition(); + + // Account for panel position and content scroll + input.style.left = `${canvasRect.left + globalPos.x}px`; + input.style.top = `${canvasRect.top + globalPos.y - 2}px`; + } + + document.body.appendChild(input); + this.editInput = input; + + // Focus and select + input.focus(); + input.select(); + + // Track if edit has been handled to prevent double-processing + let editHandled = false; + + // Handle submit + const submitEdit = () => { + if (editHandled) return; + editHandled = true; + + const newName = input.value.trim(); + if (newName && newName !== item.name) { + item.name = newName; + // Notify via callbacks + if (item.type === 'animation' && this.callbacks.onRenameAnimation) { + this.callbacks.onRenameAnimation(item.id, newName); + } else if (item.type === 'region' && this.callbacks.onRenameRegion) { + this.callbacks.onRenameRegion(item.id, newName); + } + } + this.finishEditing(); + }; + + // Handle cancel + const cancelEdit = () => { + if (editHandled) return; + editHandled = true; + this.cancelEditing(); + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitEdit(); + } else if (e.key === 'Escape') { + cancelEdit(); + } + }); + + input.addEventListener('blur', submitEdit); + } + + /** + * Cancel editing without saving + */ + private cancelEditing(): void { + if (!this.editingItemId) return; + + const item = this.items.find(i => i.id === this.editingItemId); + if (item?.nameLabel) { + item.nameLabel.visible = true; + } + + if (this.editInput) { + this.editInput.remove(); + this.editInput = null; + } + + this.editingItemId = null; + } + + /** + * Finish editing and clean up + */ + private finishEditing(): void { + if (!this.editingItemId) return; + + const item = this.items.find(i => i.id === this.editingItemId); + + if (this.editInput) { + this.editInput.remove(); + this.editInput = null; + } + + this.editingItemId = null; + + // Re-render to update the label + this.renderItems(); + } + + /** + * Get the panel for adding to scene + */ + getPanel(): CardPanel { + return this.panel; + } + + /** + * Set panel position + */ + setPosition(x: number, y: number): void { + this.panel.x = x; + this.panel.y = y; + } + + /** + * Save panel position to localStorage + */ + private savePosition(): void { + try { + const position = { x: this.panel.x, y: this.panel.y }; + localStorage.setItem(LIBRARY_PANEL_POSITION_KEY, JSON.stringify(position)); + } catch (e) { + // Silently fail if localStorage is unavailable + } + } + + /** + * Load panel position from localStorage + */ + private loadPosition(): { x: number; y: number } | null { + try { + const saved = localStorage.getItem(LIBRARY_PANEL_POSITION_KEY); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + // Silently fail if localStorage is unavailable + } + return null; + } + + /** + * Save panel size to localStorage + */ + private saveSize(width: number, height: number): void { + try { + const size = { width, height }; + localStorage.setItem(LIBRARY_PANEL_SIZE_KEY, JSON.stringify(size)); + } catch (e) { + // Silently fail if localStorage is unavailable + } + } + + /** + * Load panel size from localStorage + */ + private loadSize(): { width: number; height: number } | null { + try { + const saved = localStorage.getItem(LIBRARY_PANEL_SIZE_KEY); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + // Silently fail if localStorage is unavailable + } + return null; + } + + /** + * Destroy and cleanup + */ + destroy(): void { + this.clearItems(); + this.panel.destroy(); + } +} diff --git a/packages/tilemap-matic/src/canvas-pan-zoom.ts b/packages/tilemap-matic/src/canvas-pan-zoom.ts new file mode 100644 index 00000000..85831bdc --- /dev/null +++ b/packages/tilemap-matic/src/canvas-pan-zoom.ts @@ -0,0 +1,228 @@ +/** + * Canvas Pan/Zoom Handler + * Provides mouse wheel zoom and middle-click pan for a PIXI Container + */ +import { Container, Renderer } from 'pixi.js'; + +export interface PanZoomOptions { + /** Minimum scale (default: 0.25) */ + minScale?: number; + /** Maximum scale (default: 8) */ + maxScale?: number; + /** Zoom speed multiplier (default: 0.1) */ + zoomSpeed?: number; + /** Initial scale (default: 1) */ + initialScale?: number; + /** Callback when zoom changes */ + onZoomChange?: (scale: number) => void; + /** Callback when pan changes */ + onPanChange?: (x: number, y: number) => void; +} + +/** + * Manages pan and zoom for a PIXI Container + * - Mouse wheel: zoom toward cursor + * - Middle mouse drag: pan + */ +export class CanvasPanZoom { + private container: Container; + private renderer: Renderer; + private options: Required; + + // State + private currentScale: number; + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private containerStartX = 0; + private containerStartY = 0; + + // Bound handlers for cleanup + private boundWheel: (e: WheelEvent) => void; + private boundMouseDown: (e: MouseEvent) => void; + private boundMouseMove: (e: MouseEvent) => void; + private boundMouseUp: (e: MouseEvent) => void; + + constructor(container: Container, renderer: Renderer, options: PanZoomOptions = {}) { + this.container = container; + this.renderer = renderer; + + this.options = { + minScale: options.minScale ?? 0.25, + maxScale: options.maxScale ?? 8, + zoomSpeed: options.zoomSpeed ?? 0.1, + initialScale: options.initialScale ?? 1, + onZoomChange: options.onZoomChange ?? (() => {}), + onPanChange: options.onPanChange ?? (() => {}) + }; + + this.currentScale = this.options.initialScale; + this.container.scale.set(this.currentScale); + + // Bind handlers + this.boundWheel = this.handleWheel.bind(this); + this.boundMouseDown = this.handleMouseDown.bind(this); + this.boundMouseMove = this.handleMouseMove.bind(this); + this.boundMouseUp = this.handleMouseUp.bind(this); + + this.setupEventListeners(); + } + + private setupEventListeners(): void { + const canvas = this.renderer.canvas as HTMLCanvasElement; + + // Wheel for zoom (on canvas only) + canvas.addEventListener('wheel', this.boundWheel, { passive: false }); + + // Middle mouse for pan + canvas.addEventListener('mousedown', this.boundMouseDown); + window.addEventListener('mousemove', this.boundMouseMove); + window.addEventListener('mouseup', this.boundMouseUp); + } + + /** + * Handle mouse wheel for zoom toward cursor + */ + private handleWheel(e: WheelEvent): void { + const canvas = this.renderer.canvas as HTMLCanvasElement; + const rect = canvas.getBoundingClientRect(); + + // Get mouse position relative to canvas + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Scale factor for CSS vs canvas size + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + // Mouse position in canvas coordinates + const canvasMouseX = mouseX * scaleX; + const canvasMouseY = mouseY * scaleY; + + // Check if mouse is roughly over our container area + const bounds = this.container.getBounds(); + if (canvasMouseX < bounds.x - 50 || canvasMouseX > bounds.x + bounds.width + 50 || + canvasMouseY < bounds.y - 50 || canvasMouseY > bounds.y + bounds.height + 50) { + return; // Mouse not over container + } + + e.preventDefault(); + + // Calculate zoom + const zoomDelta = e.deltaY > 0 ? -this.options.zoomSpeed : this.options.zoomSpeed; + const newScale = Math.max( + this.options.minScale, + Math.min(this.options.maxScale, this.currentScale + zoomDelta * this.currentScale) + ); + + if (newScale === this.currentScale) return; + + // Get mouse position relative to container's parent (for proper positioning) + const localMouseX = canvasMouseX - this.container.parent.x; + const localMouseY = canvasMouseY - this.container.parent.y; + + // Calculate position in container's local space before zoom + const beforeZoomX = (localMouseX - this.container.x) / this.currentScale; + const beforeZoomY = (localMouseY - this.container.y) / this.currentScale; + + // Apply new scale + this.currentScale = newScale; + this.container.scale.set(this.currentScale); + + // Adjust position so point under mouse stays in same place + this.container.x = localMouseX - beforeZoomX * this.currentScale; + this.container.y = localMouseY - beforeZoomY * this.currentScale; + + this.options.onZoomChange(this.currentScale); + } + + /** + * Handle middle mouse button down for pan start + */ + private handleMouseDown(e: MouseEvent): void { + // Middle mouse button + if (e.button !== 1) return; + + e.preventDefault(); + this.isPanning = true; + this.panStartX = e.clientX; + this.panStartY = e.clientY; + this.containerStartX = this.container.x; + this.containerStartY = this.container.y; + + // Change cursor + document.body.style.cursor = 'grabbing'; + } + + /** + * Handle mouse move for panning + */ + private handleMouseMove(e: MouseEvent): void { + if (!this.isPanning) return; + + const canvas = this.renderer.canvas as HTMLCanvasElement; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + // Calculate delta in canvas coordinates + const deltaX = (e.clientX - this.panStartX) * scaleX; + const deltaY = (e.clientY - this.panStartY) * scaleY; + + this.container.x = this.containerStartX + deltaX; + this.container.y = this.containerStartY + deltaY; + + this.options.onPanChange(this.container.x, this.container.y); + } + + /** + * Handle mouse up to end panning + */ + private handleMouseUp(e: MouseEvent): void { + if (e.button === 1 && this.isPanning) { + this.isPanning = false; + document.body.style.cursor = ''; + } + } + + /** + * Get current scale + */ + getScale(): number { + return this.currentScale; + } + + /** + * Set scale programmatically + */ + setScale(scale: number): void { + this.currentScale = Math.max( + this.options.minScale, + Math.min(this.options.maxScale, scale) + ); + this.container.scale.set(this.currentScale); + this.options.onZoomChange(this.currentScale); + } + + /** + * Reset to initial scale and position + */ + reset(): void { + this.currentScale = this.options.initialScale; + this.container.scale.set(this.currentScale); + this.container.position.set(0, 0); + this.options.onZoomChange(this.currentScale); + this.options.onPanChange(0, 0); + } + + /** + * Cleanup event listeners + */ + destroy(): void { + const canvas = this.renderer.canvas as HTMLCanvasElement; + canvas.removeEventListener('wheel', this.boundWheel); + canvas.removeEventListener('mousedown', this.boundMouseDown); + window.removeEventListener('mousemove', this.boundMouseMove); + window.removeEventListener('mouseup', this.boundMouseUp); + } +} diff --git a/packages/tilemap-matic/src/container-wrapper.ts b/packages/tilemap-matic/src/container-wrapper.ts new file mode 100644 index 00000000..e2fdf3f5 --- /dev/null +++ b/packages/tilemap-matic/src/container-wrapper.ts @@ -0,0 +1,78 @@ +/** + * Container Wrapper + * Wraps a raw PIXI Container as a UIComponent for use in UITabs and other UI components + */ +import { Container } from 'pixi.js'; +import { UIComponent, MeasuredSize, BoxModel } from '@moxijs/ui'; + +export interface ContainerWrapperProps { + /** The PIXI container to wrap */ + content: Container; + /** Fixed width */ + width?: number; + /** Fixed height */ + height?: number; + /** Background color (optional) */ + backgroundColor?: number; +} + +/** + * Wraps a PIXI Container as a UIComponent + * Useful for embedding raw PIXI content in UI layouts like tabs + */ +export class ContainerWrapper extends UIComponent { + private content: Container; + private width: number; + private height: number; + + constructor(props: ContainerWrapperProps, boxModel?: Partial) { + super(boxModel); + + this.content = props.content; + this.width = props.width ?? 100; + this.height = props.height ?? 100; + + this.container.addChild(this.content); + } + + /** + * Update the wrapped content + */ + setContent(content: Container): void { + if (this.content && this.content.parent === this.container) { + this.container.removeChild(this.content); + } + this.content = content; + this.container.addChild(this.content); + } + + /** + * Get the wrapped content + */ + getContent(): Container { + return this.content; + } + + measure(): MeasuredSize { + return this.layoutEngine.measure(this.boxModel, { + width: this.width, + height: this.height + }); + } + + layout(availableWidth: number, availableHeight: number): void { + this.width = availableWidth; + this.height = availableHeight; + + const measured = this.measure(); + this.computedLayout = this.layoutEngine.layout( + this.boxModel, + measured, + { width: availableWidth, height: availableHeight } + ); + } + + protected render(): void { + // Content renders itself + } +} diff --git a/packages/tilemap-matic/src/index.ts b/packages/tilemap-matic/src/index.ts new file mode 100644 index 00000000..37ff9524 --- /dev/null +++ b/packages/tilemap-matic/src/index.ts @@ -0,0 +1,84 @@ +/** + * TileMapMatic - Sprite Sheet & Tilemap Management Tool + * Built on MoxiJS and PIXI.js + */ + +// Main initialization function +export { initTileMapMatic, TileMapMaticOptions } from './tilemap-matic'; + +// Core data types and utilities +export { + GridSettings, + TileRegion, + AnimationSequence, + FrameData, + SpriteSheetJSON, + LoadedSpriteSheet, + NamedCell, + calculateGrid, + guessCellSize, + generateFrames, + generateFramesWithRegions, + framesToJSON, + downloadJSON, + generateRegionId, + generateAnimationId, + getCellRegion, + areCellsAdjacent, + createRegionFromCells +} from './sprite-sheet-data'; + +// Project management +export { + SpriteSheetProjectManager, + SpriteSheetEntry, + SpriteSheetProject, + ProjectBundle, + AddSheetInput, + OperationResult +} from './sprite-sheet-project'; + +// Grid visualization +export { + GridVisualizationOptions, + createGridOverlay, + createCellNumbers, + createCellHighlight, + pixelToCell, + cellToIndex +} from './sprite-sheet-grid'; + +// UI Components +export { + SpriteSheetConfigPanel, + SpriteSheetConfigPanelProps, + ConfigPanelCallbacks +} from './sprite-sheet-config-panel'; + +export { + SpriteCarousel, + CarouselItem, + CarouselConfig, + CarouselOrientation +} from './sprite-carousel'; + +export { + CanvasPanZoom, + PanZoomOptions +} from './canvas-pan-zoom'; + +export { + ContainerWrapper, + ContainerWrapperProps +} from './container-wrapper'; + +export { + JSONViewer, + JSONViewerProps +} from './json-viewer'; + +export { + AnimationRegionLibraryPanel, + AnimationRegionLibraryPanelProps, + LibraryPanelCallbacks +} from './animation-region-library-panel'; diff --git a/packages/tilemap-matic/src/json-viewer.ts b/packages/tilemap-matic/src/json-viewer.ts new file mode 100644 index 00000000..fb6ec8c9 --- /dev/null +++ b/packages/tilemap-matic/src/json-viewer.ts @@ -0,0 +1,156 @@ +/** + * JSON Viewer Component + * Read-only scrollable display for JSON data with monospace font + */ +import * as PIXI from 'pixi.js'; +import { + UIComponent, + BoxModel, + MeasuredSize +} from '@moxijs/ui'; + +export interface JSONViewerProps { + /** Width of the viewer */ + width: number; + /** Height of the viewer */ + height: number; + /** Initial JSON data to display */ + data?: object | null; + /** Background color */ + backgroundColor?: number; + /** Text color */ + textColor?: number; + /** Font size */ + fontSize?: number; +} + +/** + * A read-only scrollable JSON viewer using native PIXI.Text + */ +export class JSONViewer extends UIComponent { + private textObject: PIXI.Text; + private background: PIXI.Graphics; + private props: Required; + private currentData: object | null = null; + + constructor(props: JSONViewerProps, boxModel?: Partial) { + super(boxModel); + + this.props = { + width: props.width, + height: props.height, + data: props.data ?? null, + backgroundColor: props.backgroundColor ?? 0x1a1a2e, + textColor: props.textColor ?? 0x88ff88, + fontSize: props.fontSize ?? 12 + }; + + this.currentData = this.props.data; + + // Create background + this.background = new PIXI.Graphics(); + this.drawBackground(); + this.container.addChild(this.background); + + // Create PIXI.Text directly + this.textObject = new PIXI.Text({ + text: this.formatJSON(this.currentData), + style: { + fontFamily: 'Arial', + fontSize: this.props.fontSize, + fill: 0x88ff88, + wordWrap: true, + wordWrapWidth: this.props.width - 24 + } + }); + this.textObject.position.set(12, 12); + this.container.addChild(this.textObject); + + console.log('JSONViewer text created:', this.textObject.text.substring(0, 50)); + } + + private drawBackground(): void { + this.background.clear(); + this.background.rect(0, 0, this.props.width, this.props.height); + this.background.fill({ color: this.props.backgroundColor }); + } + + /** + * Format JSON for display + */ + private formatJSON(data: object | null): string { + if (!data) { + return '// No data to display\n// Select a sprite sheet to see its JSON'; + } + + try { + return JSON.stringify(data, null, 2); + } catch { + return '// Error formatting JSON'; + } + } + + /** + * Update the displayed JSON data + */ + setData(data: object | null): void { + this.currentData = data; + const jsonText = this.formatJSON(data); + this.textObject.text = jsonText; + console.log('JSONViewer setData:', jsonText.substring(0, 100)); + } + + /** + * Get the current data + */ + getData(): object | null { + return this.currentData; + } + + /** + * Update viewer size + */ + setSize(width: number, height: number): void { + this.props.width = width; + this.props.height = height; + this.layout(width, height); + } + + measure(): MeasuredSize { + return { + width: this.props.width, + height: this.props.height + }; + } + + layout(availableWidth: number, availableHeight: number): void { + this.props.width = availableWidth; + this.props.height = availableHeight; + + // Update background size + this.drawBackground(); + + // Update text wrap width + this.textObject.style.wordWrapWidth = availableWidth - 24; + + const measured = this.measure(); + this.computedLayout = { + x: 0, + y: 0, + width: measured.width ?? availableWidth, + height: measured.height ?? availableHeight, + contentX: 0, + contentY: 0, + contentWidth: measured.width ?? availableWidth, + contentHeight: measured.height ?? availableHeight + }; + } + + protected render(): void { + // Rendering handled by PIXI + } + + destroy(): void { + super.destroy(); + } +} diff --git a/packages/tilemap-matic/src/main.ts b/packages/tilemap-matic/src/main.ts new file mode 100644 index 00000000..7202558e --- /dev/null +++ b/packages/tilemap-matic/src/main.ts @@ -0,0 +1,30 @@ +/** + * TileMapMatic Standalone Entry Point + * This file is the entry point for running tilemap-matic as a standalone app. + * For library usage, import from 'tilemap-matic' directly. + */ +import { initTileMapMatic } from './index'; + +// Mark bundle as loaded for the lazy-load trick +declare global { + interface Window { + _bundleLoaded?: boolean; + tileMapMatic?: Awaited>; + } +} + +window._bundleLoaded = true; + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + const app = document.getElementById('app'); + if (app) { + initTileMapMatic({ hostElement: app }) + .then((cleanup) => { + // Expose cleanup function for testing/debugging + window.tileMapMatic = cleanup as any; + console.log('TileMapMatic ready'); + }) + .catch(console.error); + } +}); diff --git a/packages/tilemap-matic/src/sprite-carousel.ts b/packages/tilemap-matic/src/sprite-carousel.ts new file mode 100644 index 00000000..cb862cbb --- /dev/null +++ b/packages/tilemap-matic/src/sprite-carousel.ts @@ -0,0 +1,609 @@ +/** + * Sprite Carousel Component + * A reusable carousel/dock-style selector for displaying sprites + * Supports both horizontal (arc) and vertical (dock) orientations + */ +import * as PIXI from 'pixi.js'; +import { Container, Sprite, Graphics, Texture, Text } from 'pixi.js'; + +export interface CarouselItem { + id: string; + texture: Texture; + label?: string; +} + +export type CarouselOrientation = 'horizontal' | 'vertical'; + +export interface CarouselConfig { + /** Width of the carousel bar */ + width: number; + /** Height of the carousel bar */ + height: number; + /** Orientation: 'horizontal' (arc) or 'vertical' (dock) */ + orientation?: CarouselOrientation; + /** Maximum scale for center/focused item (default: 1.0) */ + centerScale?: number; + /** Minimum scale for side items (default: 0.5) */ + sideScale?: number; + /** Spacing between items (default: 20) */ + itemSpacing?: number; + /** Arc curvature amount for horizontal mode (default: 30) */ + arcCurve?: number; + /** Background color (default: 0x2a2a3e) */ + backgroundColor?: number; + /** Reticle color for selected item (default: 0x00ff00) */ + reticleColor?: number; + /** Show labels (default: true) */ + showLabels?: boolean; + /** Show add button (default: false) */ + showAddButton?: boolean; + /** Thumbnail size for vertical mode (default: 64) */ + thumbnailSize?: number; +} + +export class SpriteCarousel extends Container { + private config: Required; + private items: CarouselItem[] = []; + private selectedIndex: number = 0; + private itemSprites: Sprite[] = []; + private itemLabels: Text[] = []; + private background: Graphics; + private reticle: Graphics; + private addButton: Graphics | null = null; + private onSelectCallback?: (item: CarouselItem, index: number) => void; + private onAddClickCallback?: () => void; + + constructor(config: CarouselConfig) { + super(); + + // Set defaults + this.config = { + width: config.width, + height: config.height, + orientation: config.orientation ?? 'horizontal', + centerScale: config.centerScale ?? 1.0, + sideScale: config.sideScale ?? 0.5, + itemSpacing: config.itemSpacing ?? 20, + arcCurve: config.arcCurve ?? 30, + backgroundColor: config.backgroundColor ?? 0x2a2a3e, + reticleColor: config.reticleColor ?? 0x00ff00, + showLabels: config.showLabels ?? true, + showAddButton: config.showAddButton ?? false, + thumbnailSize: config.thumbnailSize ?? 64 + }; + + // Create background + this.background = new Graphics(); + this.drawBackground(); + this.addChild(this.background); + + // Create reticle + this.reticle = new Graphics(); + this.addChild(this.reticle); + + // Create add button if enabled + if (this.config.showAddButton) { + this.createAddButton(); + } + + // Enable clipping mask + const mask = new Graphics(); + mask.rect(0, 0, this.config.width, this.config.height); + mask.fill({ color: 0xffffff }); + this.addChild(mask); + this.mask = mask; + + // Enable mouse wheel + this.eventMode = 'static'; + this.hitArea = new PIXI.Rectangle(0, 0, this.config.width, this.config.height); + + this.on('wheel', (e: any) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 1 : -1; + this.cycleSelection(delta); + }); + } + + /** + * Draw background (no shadow - parent CardPanel provides it) + */ + private drawBackground(): void { + this.background.clear(); + this.background.rect(0, 0, this.config.width, this.config.height); + this.background.fill({ color: this.config.backgroundColor, alpha: 1 }); + } + + /** + * Create the add button at the bottom (for vertical) or right (for horizontal) + */ + private createAddButton(): void { + this.addButton = new Graphics(); + this.addButton.eventMode = 'static'; + this.addButton.cursor = 'pointer'; + + this.drawAddButton(); + + this.addButton.on('pointerdown', () => { + if (this.onAddClickCallback) { + this.onAddClickCallback(); + } + }); + + this.addButton.on('pointerover', () => { + this.drawAddButton(true); + }); + + this.addButton.on('pointerout', () => { + this.drawAddButton(false); + }); + + this.addChild(this.addButton); + } + + /** + * Draw the add button + */ + private drawAddButton(hovered: boolean = false): void { + if (!this.addButton) return; + + this.addButton.clear(); + + const size = this.config.thumbnailSize - 16; + const color = hovered ? 0x8ab832 : 0x7a9a32; // Muted lime green + + if (this.config.orientation === 'vertical') { + // Position at bottom center + const x = (this.config.width - size) / 2; + const y = this.config.height - size - 12; + + this.addButton.roundRect(x, y, size, size, 8); + this.addButton.fill({ color: color, alpha: 0.9 }); + this.addButton.stroke({ color: 0x9acd32, width: 2 }); + + // Draw plus icon + const centerX = x + size / 2; + const centerY = y + size / 2; + const iconSize = size * 0.4; + + this.addButton.moveTo(centerX - iconSize / 2, centerY); + this.addButton.lineTo(centerX + iconSize / 2, centerY); + this.addButton.moveTo(centerX, centerY - iconSize / 2); + this.addButton.lineTo(centerX, centerY + iconSize / 2); + this.addButton.stroke({ color: 0xffffff, width: 3 }); + } else { + // Position at right center for horizontal + const x = this.config.width - size - 12; + const y = (this.config.height - size) / 2; + + this.addButton.roundRect(x, y, size, size, 8); + this.addButton.fill({ color: color, alpha: 0.9 }); + this.addButton.stroke({ color: 0x9acd32, width: 2 }); + + // Draw plus icon + const centerX = x + size / 2; + const centerY = y + size / 2; + const iconSize = size * 0.4; + + this.addButton.moveTo(centerX - iconSize / 2, centerY); + this.addButton.lineTo(centerX + iconSize / 2, centerY); + this.addButton.moveTo(centerX, centerY - iconSize / 2); + this.addButton.lineTo(centerX, centerY + iconSize / 2); + this.addButton.stroke({ color: 0xffffff, width: 3 }); + } + } + + /** + * Set items to display in carousel + */ + setItems(items: CarouselItem[]): void { + this.items = items; + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, items.length - 1)); + this.renderItems(); + } + + /** + * Add a single item to the carousel + */ + addItem(item: CarouselItem): void { + this.items.push(item); + this.renderItems(); + } + + /** + * Remove an item by ID + */ + removeItem(id: string): void { + const index = this.items.findIndex(item => item.id === id); + if (index === -1) return; + + this.items.splice(index, 1); + + // Adjust selected index if needed + if (this.selectedIndex >= this.items.length) { + this.selectedIndex = Math.max(0, this.items.length - 1); + } + + this.renderItems(); + + // Notify if selection changed + if (this.items.length > 0) { + this.notifySelection(); + } + } + + /** + * Select item by ID + */ + selectById(id: string): void { + const index = this.items.findIndex(item => item.id === id); + if (index !== -1) { + this.setSelectedIndex(index); + } + } + + /** + * Get item count + */ + getItemCount(): number { + return this.items.length; + } + + /** + * Get currently selected item + */ + getSelectedItem(): CarouselItem | null { + if (this.items.length === 0) return null; + return this.items[this.selectedIndex]; + } + + /** + * Get selected index + */ + getSelectedIndex(): number { + return this.selectedIndex; + } + + /** + * Set selected index + */ + setSelectedIndex(index: number): void { + if (index < 0 || index >= this.items.length) return; + this.selectedIndex = index; + this.renderItems(); + this.notifySelection(); + } + + /** + * Cycle selection by delta + */ + cycleSelection(delta: number): void { + if (this.items.length === 0) return; + + this.selectedIndex = (this.selectedIndex + delta + this.items.length) % this.items.length; + this.renderItems(); + this.notifySelection(); + } + + /** + * Register callback for selection changes + */ + onSelect(callback: (item: CarouselItem, index: number) => void): void { + this.onSelectCallback = callback; + } + + /** + * Register callback for add button click + */ + onAddClick(callback: () => void): void { + this.onAddClickCallback = callback; + } + + /** + * Notify selection change + */ + private notifySelection(): void { + const item = this.getSelectedItem(); + if (item && this.onSelectCallback) { + this.onSelectCallback(item, this.selectedIndex); + } + } + + /** + * Render all items + */ + private renderItems(): void { + if (this.config.orientation === 'vertical') { + this.renderVertical(); + } else { + this.renderHorizontal(); + } + } + + /** + * Render horizontal (arc) layout + */ + private renderHorizontal(): void { + // Clear existing sprites + this.clearSprites(); + + if (this.items.length === 0) return; + + const centerX = this.config.width / 2; + const centerY = this.config.height / 2; + + // Calculate how many items can fit based on width + const maxSize = this.config.height * 0.6; + const estimatedItemWidth = maxSize * this.config.centerScale + this.config.itemSpacing; + const maxVisibleItems = Math.floor(this.config.width / estimatedItemWidth); + const visibleCount = Math.min(Math.max(3, maxVisibleItems), this.items.length); + + // Calculate visible range + const halfVisible = Math.floor(visibleCount / 2); + const startIndex = this.selectedIndex - halfVisible; + + // Render items + for (let i = 0; i < visibleCount; i++) { + const itemIndex = (startIndex + i + this.items.length) % this.items.length; + const item = this.items[itemIndex]; + + if (!item || !item.texture) continue; + + // Create sprite + const sprite = new Sprite(item.texture); + sprite.anchor.set(0.5); + sprite.texture.source.scaleMode = 'nearest'; + + // Calculate position relative to center + const offsetFromCenter = i - halfVisible; + + // Position along arc + const xOffset = offsetFromCenter * (this.config.width / (visibleCount + 1)); + const yOffset = Math.abs(offsetFromCenter) * this.config.arcCurve; + + sprite.x = centerX + xOffset; + sprite.y = centerY + yOffset; + + // Scale based on distance from center + const distanceRatio = Math.abs(offsetFromCenter) / halfVisible; + const scale = this.config.centerScale - + (this.config.centerScale - this.config.sideScale) * distanceRatio; + + // Fit sprite to a reasonable size + const spriteScale = Math.min( + maxSize / sprite.width, + maxSize / sprite.height + ) * scale; + + sprite.scale.set(spriteScale); + + // Alpha based on distance + sprite.alpha = 1.0 - (distanceRatio * 0.5); + + // Make clickable + sprite.eventMode = 'static'; + sprite.cursor = 'pointer'; + sprite.on('pointerdown', () => { + this.setSelectedIndex(itemIndex); + }); + + this.addChild(sprite); + this.itemSprites.push(sprite); + } + + // Draw reticle around center item + this.drawReticleHorizontal(); + } + + /** + * Render vertical (dock) layout + */ + private renderVertical(): void { + // Clear existing sprites + this.clearSprites(); + + if (this.items.length === 0) return; + + const thumbSize = this.config.thumbnailSize; + const spacing = this.config.itemSpacing; + const startX = (this.config.width - thumbSize) / 2; + const startY = 12; + + // Calculate how many items can fit + const availableHeight = this.config.height - (this.config.showAddButton ? thumbSize + 24 : 0) - startY; + const maxVisibleItems = Math.floor(availableHeight / (thumbSize + spacing)); + + // Calculate scroll offset to keep selected item visible + const visibleStart = Math.max(0, Math.min( + this.selectedIndex - Math.floor(maxVisibleItems / 2), + this.items.length - maxVisibleItems + )); + + // Render visible items + for (let i = 0; i < Math.min(maxVisibleItems, this.items.length); i++) { + const itemIndex = visibleStart + i; + if (itemIndex >= this.items.length) break; + + const item = this.items[itemIndex]; + if (!item || !item.texture) continue; + + // Create sprite + const sprite = new Sprite(item.texture); + sprite.anchor.set(0.5); + sprite.texture.source.scaleMode = 'nearest'; + + // Position + const y = startY + i * (thumbSize + spacing) + thumbSize / 2; + sprite.x = startX + thumbSize / 2; + sprite.y = y; + + // Scale to fit thumbnail size + const scaleX = thumbSize / sprite.width; + const scaleY = thumbSize / sprite.height; + const scale = Math.min(scaleX, scaleY) * 0.85; + sprite.scale.set(scale); + + // Highlight if selected + const isSelected = itemIndex === this.selectedIndex; + sprite.alpha = isSelected ? 1.0 : 0.7; + + // Make clickable + sprite.eventMode = 'static'; + sprite.cursor = 'pointer'; + sprite.on('pointerdown', () => { + this.setSelectedIndex(itemIndex); + }); + + this.addChild(sprite); + this.itemSprites.push(sprite); + + // Add label if enabled + if (this.config.showLabels && item.label) { + const label = new Text({ + text: this.truncateLabel(item.label, 12), + style: { + fontFamily: 'Arial', + fontSize: 10, + fill: isSelected ? 0xffffff : 0xaaaaaa, + align: 'center' + } + }); + label.anchor.set(0.5, 0); + label.x = sprite.x; + label.y = y + thumbSize / 2 + 4; + this.addChild(label); + this.itemLabels.push(label); + } + } + + // Draw reticle around selected item + this.drawReticleVertical(); + } + + /** + * Truncate label to max length + */ + private truncateLabel(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 2) + '..'; + } + + /** + * Clear all sprites and labels + */ + private clearSprites(): void { + this.itemSprites.forEach(sprite => sprite.destroy()); + this.itemSprites = []; + this.itemLabels.forEach(label => label.destroy()); + this.itemLabels = []; + } + + /** + * Draw reticle for horizontal mode + */ + private drawReticleHorizontal(): void { + this.reticle.clear(); + + if (this.items.length === 0) return; + + const centerSprite = this.itemSprites[Math.floor(this.itemSprites.length / 2)]; + if (!centerSprite) return; + + const padding = 10; + const width = centerSprite.width + padding * 2; + const height = centerSprite.height + padding * 2; + + this.reticle.rect( + centerSprite.x - width / 2, + centerSprite.y - height / 2, + width, + height + ); + this.reticle.stroke({ + color: this.config.reticleColor, + width: 2, + alpha: 0.8 + }); + + // Corner marks + const cornerSize = 10; + const corners = [ + { x: -width / 2, y: -height / 2 }, + { x: width / 2, y: -height / 2 }, + { x: -width / 2, y: height / 2 }, + { x: width / 2, y: height / 2 } + ]; + + corners.forEach(corner => { + const baseX = centerSprite.x + corner.x; + const baseY = centerSprite.y + corner.y; + + this.reticle.moveTo(baseX, baseY); + this.reticle.lineTo(baseX + (corner.x > 0 ? -cornerSize : cornerSize), baseY); + this.reticle.moveTo(baseX, baseY); + this.reticle.lineTo(baseX, baseY + (corner.y > 0 ? -cornerSize : cornerSize)); + }); + + this.reticle.stroke({ + color: this.config.reticleColor, + width: 2, + alpha: 0.8 + }); + } + + /** + * Draw reticle for vertical mode + */ + private drawReticleVertical(): void { + this.reticle.clear(); + + if (this.items.length === 0 || this.itemSprites.length === 0) return; + + // Find the sprite for the selected item + const thumbSize = this.config.thumbnailSize; + const spacing = this.config.itemSpacing; + const availableHeight = this.config.height - (this.config.showAddButton ? thumbSize + 24 : 0) - 12; + const maxVisibleItems = Math.floor(availableHeight / (thumbSize + spacing)); + const visibleStart = Math.max(0, Math.min( + this.selectedIndex - Math.floor(maxVisibleItems / 2), + this.items.length - maxVisibleItems + )); + + const spriteIndex = this.selectedIndex - visibleStart; + if (spriteIndex < 0 || spriteIndex >= this.itemSprites.length) return; + + const selectedSprite = this.itemSprites[spriteIndex]; + if (!selectedSprite) return; + + const padding = 6; + const size = thumbSize + padding * 2; + + // Draw selection border + this.reticle.roundRect( + selectedSprite.x - size / 2, + selectedSprite.y - size / 2, + size, + size, + 4 + ); + this.reticle.stroke({ + color: this.config.reticleColor, + width: 2, + alpha: 0.9 + }); + } + + /** + * Update carousel (call in animation loop if needed) + */ + update(): void { + // Can add animations here if needed + } + + /** + * Destroy and cleanup + */ + destroy(): void { + this.clearSprites(); + super.destroy({ children: true }); + } +} diff --git a/packages/tilemap-matic/src/sprite-sheet-config-panel.ts b/packages/tilemap-matic/src/sprite-sheet-config-panel.ts new file mode 100644 index 00000000..f1050132 --- /dev/null +++ b/packages/tilemap-matic/src/sprite-sheet-config-panel.ts @@ -0,0 +1,581 @@ +/** + * Sprite Sheet Config Panel + * Right-side CardPanel with grid settings, region preview, and menu + */ +import { + CardPanel, + FlexContainer, + FlexDirection, + FlexAlign, + EdgeInsets, + UILabel, + UIButton, + UITextInput, + FlatCardStyle +} from '@moxijs/ui'; +import { Graphics, Container } from 'pixi.js'; +import { SpriteSheetEntry } from './sprite-sheet-project'; +import { GridSettings, TileRegion } from './sprite-sheet-data'; + +// Screen dimensions for centering modal +const SCREEN_WIDTH = 1280; +const SCREEN_HEIGHT = 720; + +/** + * Callbacks for config panel actions + */ +export interface ConfigPanelCallbacks { + onGridChange: (cellWidth: number, cellHeight: number) => void; + onExportSheet: () => void; + onExportProject: () => void; + onClearProject: () => void; + onRemoveSheet?: () => void; + onCopyJSON?: () => void; +} + +/** + * Config panel props + */ +export interface SpriteSheetConfigPanelProps { + callbacks: ConfigPanelCallbacks; + x?: number; + y?: number; +} + +/** + * Sprite Sheet Config Panel Component + * Provides UI for editing grid settings and exporting sheets + */ +export class SpriteSheetConfigPanel { + private panel: CardPanel; + private layout: FlexContainer; + private menuButton: UIButton; + private menuButtonDots: Graphics; + private menuDropdown: Container; + private menuVisible: boolean = false; + + // UI Elements + private widthInput: UITextInput; + private heightInput: UITextInput; + private gridInfoLabel: UILabel; + private regionPreview: Graphics; + private regionInfoLabel: UILabel; + private viewJSONBtn: UIButton; // In panel body, not menu + + // Animation mode indicator + private animationModeContainer: Container; + private animationModeLabel: UILabel; + + // Menu buttons (in dropdown) + private exportSheetBtn: UIButton; + private exportProjectBtn: UIButton; + private removeSheetBtn: UIButton; + private clearProjectBtn: UIButton; + + private callbacks: ConfigPanelCallbacks; + private currentSheet: SpriteSheetEntry | null = null; + + constructor(props: SpriteSheetConfigPanelProps) { + this.callbacks = props.callbacks; + + const PANEL_WIDTH = 180; + + // Create the CardPanel first with SE drop shadow + this.panel = new CardPanel({ + title: { text: 'No Sheet', fontSize: 14 }, + bodyWidth: PANEL_WIDTH, + bodyHeight: 100, // Initial height, will auto-size to fit content + draggable: true, + style: new FlatCardStyle({ + showShadow: true, + shadowOffset: 6, + shadowAlpha: 0.4 + }), + colors: { + background: 0x28282c, + border: 0x404045, + titleBar: 0x333338, + titleText: 0x9acd32 // Muted lime green to match app title + } + }); + + // Create [...] menu button in title bar with larger hit area + const titleBarHeight = this.panel.getTitleBarHeight(); + const MENU_BTN_SIZE = 28; + + this.menuButton = new UIButton({ + width: MENU_BTN_SIZE, + height: MENU_BTN_SIZE, + backgroundColor: 0x38383c, // Slightly darker background + borderRadius: 4, + onClick: () => this.toggleMenu(), + onHover: () => this.drawMenuButtonDots(true) + }); + + // Add border around the button (non-interactive so clicks pass through) + const menuButtonBorder = new Graphics(); + menuButtonBorder.roundRect(0, 0, MENU_BTN_SIZE, MENU_BTN_SIZE, 4); + menuButtonBorder.stroke({ color: 0x505055, width: 1 }); + menuButtonBorder.eventMode = 'none'; + this.menuButton.container.addChild(menuButtonBorder); + + // Draw the three dots on top of the button (non-interactive) + this.menuButtonDots = new Graphics(); + this.menuButtonDots.eventMode = 'none'; + this.drawMenuButtonDots(false); + this.menuButton.container.addChild(this.menuButtonDots); + + // Handle hover out to restore dot color + this.menuButton.container.on('pointerout', () => { + if (!this.menuVisible) this.drawMenuButtonDots(false); + }); + + // Position menu button on right side of title bar + this.menuButton.container.position.set( + PANEL_WIDTH - MENU_BTN_SIZE - 4, + (titleBarHeight - MENU_BTN_SIZE) / 2 + ); + this.panel.getTitleBarContainer().addChild(this.menuButton.container); + + // Create modal menu (centered dialog) + this.menuDropdown = new Container(); + this.menuDropdown.visible = false; + + // Backdrop (dims the screen and catches clicks to close) + const backdrop = new Graphics(); + backdrop.rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); + backdrop.fill({ color: 0x000000, alpha: 0.5 }); + backdrop.eventMode = 'static'; + backdrop.cursor = 'default'; + backdrop.on('pointerdown', () => this.hideMenu()); + this.menuDropdown.addChild(backdrop); + + // Menu dialog (4 buttons now - removed View JSON) + const MENU_WIDTH = 200; + const MENU_HEIGHT = 220; + const menuDialog = new Container(); + menuDialog.position.set( + (SCREEN_WIDTH - MENU_WIDTH) / 2, + (SCREEN_HEIGHT - MENU_HEIGHT) / 2 + ); + + // Dialog background + const menuBg = new Graphics(); + menuBg.roundRect(0, 0, MENU_WIDTH, MENU_HEIGHT, 8); + menuBg.fill({ color: 0x28282c }); + menuBg.stroke({ color: 0x48484d, width: 2 }); + menuDialog.addChild(menuBg); + + // Dialog title + const menuTitle = new UILabel({ + text: 'Actions', + fontSize: 14, + color: 0xffffff, + fontWeight: 'bold' + }); + menuTitle.container.position.set(MENU_WIDTH / 2 - 25, 12); + menuDialog.addChild(menuTitle.container); + + // Menu buttons (auto-layout when width/height provided) + this.exportSheetBtn = new UIButton({ + label: 'Export Sheet JSON', + width: 170, + height: 36, + backgroundColor: 0x4a90e2, + textColor: 0xffffff, + fontSize: 12, + onClick: () => { + this.callbacks.onExportSheet(); + this.hideMenu(); + } + }); + this.exportSheetBtn.container.position.set(15, 45); + menuDialog.addChild(this.exportSheetBtn.container); + + this.exportProjectBtn = new UIButton({ + label: 'Export Project Bundle', + width: 170, + height: 36, + backgroundColor: 0x5a9a5a, + textColor: 0xffffff, + fontSize: 12, + onClick: () => { + this.callbacks.onExportProject(); + this.hideMenu(); + } + }); + this.exportProjectBtn.container.position.set(15, 88); + menuDialog.addChild(this.exportProjectBtn.container); + + this.removeSheetBtn = new UIButton({ + label: 'Remove Current Sheet', + width: 170, + height: 36, + backgroundColor: 0x996633, + textColor: 0xffffff, + fontSize: 12, + onClick: () => { + this.callbacks.onRemoveSheet?.(); + this.hideMenu(); + } + }); + this.removeSheetBtn.container.position.set(15, 131); + menuDialog.addChild(this.removeSheetBtn.container); + + this.clearProjectBtn = new UIButton({ + label: 'Clear Entire Project', + width: 170, + height: 36, + backgroundColor: 0xaa4444, + textColor: 0xffffff, + fontSize: 12, + onClick: () => { + this.callbacks.onClearProject(); + this.hideMenu(); + } + }); + this.clearProjectBtn.container.position.set(15, 174); + menuDialog.addChild(this.clearProjectBtn.container); + + this.menuDropdown.addChild(menuDialog); + + // Create the layout container for body content + this.layout = new FlexContainer({ + direction: FlexDirection.Column, + align: FlexAlign.Stretch, + gap: 6, + padding: EdgeInsets.all(10) + }); + + // Cell Size section (combined W/H on one line) + const cellSizeLabel = new UILabel({ + text: 'Cell Size', + fontSize: 11, + color: 0x888890 + }); + this.layout.addChild(cellSizeLabel); + + // Width/Height inputs in a row + const inputRow = new FlexContainer({ + direction: FlexDirection.Row, + gap: 6, + align: FlexAlign.Center + }); + + this.widthInput = new UITextInput({ + width: 55, + height: 26, + fontSize: 12, + type: 'number', + placeholder: '32', + onChange: () => this.handleGridChange() + }); + + const xLabel = new UILabel({ text: '×', fontSize: 14, color: 0x666670 }); + + this.heightInput = new UITextInput({ + width: 55, + height: 26, + fontSize: 12, + type: 'number', + placeholder: '32', + onChange: () => this.handleGridChange() + }); + + const pxLabel = new UILabel({ text: 'px', fontSize: 11, color: 0x666670 }); + + inputRow.addChild(this.widthInput); + inputRow.addChild(xLabel); + inputRow.addChild(this.heightInput); + inputRow.addChild(pxLabel); + inputRow.layout(PANEL_WIDTH - 20, 26); + this.layout.addChild(inputRow); + + // Grid info label (shows cols x rows) + this.gridInfoLabel = new UILabel({ + text: '', + fontSize: 10, + color: 0x707075 + }); + this.layout.addChild(this.gridInfoLabel); + + // Spacer before selection section + const spacer = new FlexContainer({ height: 4 }); + this.layout.addChild(spacer); + + // Selection section (only visible when there's a selection) + this.regionInfoLabel = new UILabel({ + text: '', + fontSize: 10, + color: 0x707075 + }); + this.layout.addChild(this.regionInfoLabel); + + // Region preview (graphics drawn directly, shown/hidden based on selection) + this.regionPreview = new Graphics(); + this.layout.container.addChild(this.regionPreview); + + // Animation mode indicator (hidden by default) + this.animationModeContainer = new Container(); + this.animationModeContainer.visible = false; + + const animModeBg = new Graphics(); + animModeBg.roundRect(0, 0, PANEL_WIDTH - 20, 50, 6); + animModeBg.fill({ color: 0xffaa00, alpha: 0.2 }); + animModeBg.stroke({ color: 0xffaa00, width: 2 }); + this.animationModeContainer.addChild(animModeBg); + + const animModeTitle = new UILabel({ + text: '🎬 Animation Mode', + fontSize: 12, + color: 0xffaa00, + fontWeight: 'bold' + }); + animModeTitle.container.position.set(8, 6); + this.animationModeContainer.addChild(animModeTitle.container); + + this.animationModeLabel = new UILabel({ + text: 'Click cells in order', + fontSize: 10, + color: 0xddaa44 + }); + this.animationModeLabel.container.position.set(8, 26); + this.animationModeContainer.addChild(this.animationModeLabel.container); + + this.layout.container.addChild(this.animationModeContainer); + + // Spacer to push button to bottom + const bottomSpacer = new FlexContainer({ height: 8 }); + this.layout.addChild(bottomSpacer); + + // Copy JSON button at bottom of panel + this.viewJSONBtn = new UIButton({ + label: 'Copy JSON', + width: PANEL_WIDTH - 20, + height: 32, + backgroundColor: 0x4a90e2, + textColor: 0xffffff, + fontSize: 12, + onClick: () => { + this.callbacks.onCopyJSON?.(); + } + }); + this.layout.addChild(this.viewJSONBtn); + + // Layout the flex container (use a large height, panel will size to fit) + this.layout.layout(PANEL_WIDTH, 500); + + // Add layout to panel body + this.panel.getBodyContainer().addChild(this.layout.container); + + // Auto-size panel to fit content + this.panel.sizeToFit(); + + // Position the panel + if (props.x !== undefined && props.y !== undefined) { + this.panel.container.position.set(props.x, props.y); + } + + // Initial state + this.setEnabled(false); + } + + /** + * Draw the [...] dots on the menu button + */ + private drawMenuButtonDots(hover: boolean): void { + this.menuButtonDots.clear(); + const color = hover ? 0xffffff : 0xaaaaaa; + // Draw three dots centered in the 28x28 button + const centerY = 14; + const dotSpacing = 6; + const startX = 14 - dotSpacing; // Center the 3 dots + for (let i = 0; i < 3; i++) { + this.menuButtonDots.circle(startX + i * dotSpacing, centerY, 2); + } + this.menuButtonDots.fill({ color }); + } + + /** + * Toggle menu visibility + */ + private toggleMenu(): void { + if (this.menuVisible) { + this.hideMenu(); + } else { + this.showMenu(); + } + } + + /** + * Show the modal menu + */ + private showMenu(): void { + this.menuVisible = true; + this.menuDropdown.visible = true; + this.drawMenuButtonDots(true); + } + + /** + * Hide the dropdown menu + */ + private hideMenu(): void { + this.menuVisible = false; + this.menuDropdown.visible = false; + this.drawMenuButtonDots(false); + } + + /** + * Get the CardPanel container for adding to scene + */ + getPanel(): CardPanel { + return this.panel; + } + + /** + * Get the menu dropdown container (add to scene after panel for z-order) + */ + getMenuDropdown(): Container { + return this.menuDropdown; + } + + /** + * Set the currently active sheet + */ + setActiveSheet(sheet: SpriteSheetEntry | null): void { + this.currentSheet = sheet; + + if (sheet) { + this.panel.setTitle(sheet.name); + this.widthInput.setValue(String(sheet.gridSettings.cellWidth)); + this.heightInput.setValue(String(sheet.gridSettings.cellHeight)); + this.updateGridInfo(sheet.gridSettings); + this.setEnabled(true); + } else { + this.panel.setTitle('No Sheet'); + this.widthInput.setValue(''); + this.heightInput.setValue(''); + this.gridInfoLabel.setText(''); + this.clearRegionPreview(); + this.setEnabled(false); + } + } + + /** + * Update the region preview with selected cells/region + */ + updateRegionPreview( + cells: Array<{ col: number; row: number }>, + gridSettings: GridSettings, + region?: TileRegion + ): void { + this.regionPreview.clear(); + + if (cells.length === 0) { + this.regionInfoLabel.setText(''); + return; + } + + // Calculate bounds + const minCol = Math.min(...cells.map(c => c.col)); + const maxCol = Math.max(...cells.map(c => c.col)); + const minRow = Math.min(...cells.map(c => c.row)); + const maxRow = Math.max(...cells.map(c => c.row)); + + const colSpan = maxCol - minCol + 1; + const rowSpan = maxRow - minRow + 1; + const cellW = gridSettings.cellWidth; + const cellH = gridSettings.cellHeight; + const pixelW = colSpan * cellW; + const pixelH = rowSpan * cellH; + + // Update info label with selection details + if (region) { + this.regionInfoLabel.setText(`Selected: ${colSpan}×${rowSpan} (${pixelW}×${pixelH}px)`); + } else { + this.regionInfoLabel.setText(`Selected: ${cells.length} cell${cells.length > 1 ? 's' : ''}`); + } + } + + /** + * Clear the region preview + */ + clearRegionPreview(): void { + this.regionPreview.clear(); + this.regionInfoLabel.setText(''); + } + + /** + * Update grid info display + */ + updateGridInfo(settings: GridSettings): void { + const total = settings.columns * settings.rows; + this.gridInfoLabel.setText(`Grid: ${settings.columns} × ${settings.rows} = ${total} cells`); + } + + /** + * Enable/disable input controls + */ + private setEnabled(enabled: boolean): void { + this.viewJSONBtn.setEnabled(enabled); + this.exportSheetBtn.setEnabled(enabled); + this.removeSheetBtn.setEnabled(enabled); + } + + /** + * Handle grid settings change from inputs + */ + private handleGridChange(): void { + if (!this.currentSheet) return; + + const width = parseInt(this.widthInput.getValue(), 10); + const height = parseInt(this.heightInput.getValue(), 10); + + if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { + this.callbacks.onGridChange(width, height); + } + } + + /** + * Update the sheet count display (for project button state) + */ + setHasSheets(hasSheets: boolean): void { + this.exportProjectBtn.setEnabled(hasSheets); + this.clearProjectBtn.setEnabled(hasSheets); + } + + /** + * Get focusable inputs for registration with UIFocusManager + */ + getFocusableInputs(): UITextInput[] { + return [this.widthInput, this.heightInput]; + } + + /** + * Set animation mode indicator visibility and frame count + * @param active - Whether animation mode is active + * @param frameCount - Number of frames selected (shown when active) + */ + setAnimationMode(active: boolean, frameCount: number = 0): void { + this.animationModeContainer.visible = active; + + if (active) { + if (frameCount === 0) { + this.animationModeLabel.setText('Click cells in order'); + } else { + this.animationModeLabel.setText(`${frameCount} frame${frameCount > 1 ? 's' : ''} selected`); + } + } + + // Re-layout to adjust panel size + this.panel.sizeToFit(); + } + + /** + * Show brief "Copied!" feedback on the Copy JSON button + */ + showCopyFeedback(): void { + this.viewJSONBtn.setLabel('Copied!'); + setTimeout(() => this.viewJSONBtn.setLabel('Copy JSON'), 1500); + } +} diff --git a/packages/tilemap-matic/src/sprite-sheet-data.ts b/packages/tilemap-matic/src/sprite-sheet-data.ts new file mode 100644 index 00000000..199a24be --- /dev/null +++ b/packages/tilemap-matic/src/sprite-sheet-data.ts @@ -0,0 +1,490 @@ +/** + * Sprite Sheet Data Library + * Manages sprite sheet data structures and JSON generation + */ +import { Texture } from 'pixi.js'; + +export interface LoadedSpriteSheet { + id: string; + name: string; + texture: Texture; + width: number; + height: number; +} + +export interface GridSettings { + cellWidth: number; + cellHeight: number; + columns: number; + rows: number; +} + +/** + * A tile region represents a merged group of cells + * Stored as grid coordinates (not pixels) + */ +export interface TileRegion { + id: string; + /** Starting column (0-indexed) */ + col: number; + /** Starting row (0-indexed) */ + row: number; + /** Width in cells */ + colSpan: number; + /** Height in cells */ + rowSpan: number; + /** Optional custom name for this tile */ + name?: string; +} + +/** + * An animation sequence - ordered collection of frames + * Frames reference cells by grid coordinates + */ +export interface AnimationSequence { + id: string; + /** Display name for the animation */ + name: string; + /** Ordered frame references (cells in playback order) */ + frames: Array<{ col: number; row: number }>; + /** Frame duration in milliseconds (default: 100) */ + frameDuration: number; + /** Whether animation loops (default: true) */ + loop: boolean; + /** Optional input binding (e.g., 'left', 'right', 'up', 'down') */ + inputBinding?: string; +} + +export interface FrameData { + name: string; + x: number; + y: number; + w: number; + h: number; +} + +/** + * PIXI.js compatible SpriteSheet JSON format + * See: https://pixijs.download/dev/docs/assets.Spritesheet.html + */ +export interface SpriteSheetJSON { + frames: Record; + /** Animation sequences - PIXI.js format: array of frame names */ + animations?: Record; + meta: { + image: string; + format: string; + size: { w: number; h: number }; + scale: string; + /** MoxiJS extension: grid settings used to slice the sprite sheet */ + grid?: { + cellWidth: number; + cellHeight: number; + columns: number; + rows: number; + }; + /** MoxiJS extension: additional animation metadata (duration, loop, bindings) */ + animationData?: Record; + }; +} + +/** + * Calculate grid dimensions based on sprite sheet size and cell dimensions + */ +export function calculateGrid( + sheetWidth: number, + sheetHeight: number, + cellWidth: number, + cellHeight: number +): GridSettings { + return { + cellWidth, + cellHeight, + columns: Math.floor(sheetWidth / cellWidth), + rows: Math.floor(sheetHeight / cellHeight) + }; +} + +/** + * Common sprite cell sizes (power of 2 preferred in game dev) + */ +const COMMON_CELL_SIZES = [8, 16, 24, 32, 48, 64, 96, 128, 256]; + +/** + * Calculate GCD of two numbers (Euclidean algorithm) + */ +function gcd(a: number, b: number): number { + while (b !== 0) { + const temp = b; + b = a % b; + a = temp; + } + return a; +} + +/** + * Guess the optimal cell size for a sprite sheet based on its dimensions + * + * Algorithm: + * 1. Try GCD of width and height - often reveals cell size + * 2. If GCD is too small or too large, find best common size that divides evenly + * 3. Prefer square cells when possible + * 4. Bias toward power-of-2 sizes commonly used in game dev + */ +export function guessCellSize(width: number, height: number): { cellWidth: number; cellHeight: number } { + const sheetGcd = gcd(width, height); + + // If GCD is a reasonable cell size (8-256), use it + if (sheetGcd >= 8 && sheetGcd <= 256) { + // Check if it's close to a common size + const nearestCommon = COMMON_CELL_SIZES.find(s => Math.abs(s - sheetGcd) <= 4); + if (nearestCommon && width % nearestCommon === 0 && height % nearestCommon === 0) { + return { cellWidth: nearestCommon, cellHeight: nearestCommon }; + } + return { cellWidth: sheetGcd, cellHeight: sheetGcd }; + } + + // GCD didn't work well, try common sizes that divide both dimensions evenly + for (const size of [...COMMON_CELL_SIZES].reverse()) { // Start from largest + if (width % size === 0 && height % size === 0) { + const cols = width / size; + const rows = height / size; + // Reasonable grid (at least 2 cells, not too many) + if (cols >= 2 && rows >= 1 && cols <= 32 && rows <= 32) { + return { cellWidth: size, cellHeight: size }; + } + } + } + + // Try sizes that divide width evenly (allow non-square cells) + for (const size of [...COMMON_CELL_SIZES].reverse()) { + if (width % size === 0) { + const cols = width / size; + if (cols >= 2 && cols <= 32) { + // Find best height divisor + const heightSize = COMMON_CELL_SIZES.find(h => height % h === 0 && height / h <= 32) || size; + return { cellWidth: size, cellHeight: heightSize }; + } + } + } + + // Fallback: use 32x32 (very common default) + return { cellWidth: 32, cellHeight: 32 }; +} + +/** + * Generate frame data from grid settings + */ +export function generateFrames( + sheetName: string, + gridSettings: GridSettings +): FrameData[] { + const frames: FrameData[] = []; + let frameIndex = 0; + + for (let row = 0; row < gridSettings.rows; row++) { + for (let col = 0; col < gridSettings.columns; col++) { + frames.push({ + name: `${sheetName}_${frameIndex}.png`, + x: col * gridSettings.cellWidth, + y: row * gridSettings.cellHeight, + w: gridSettings.cellWidth, + h: gridSettings.cellHeight + }); + frameIndex++; + } + } + + return frames; +} + +/** + * Named cell for export - allows custom names for individual cells + */ +export interface NamedCell { + col: number; + row: number; + name: string; + description?: string; +} + +/** + * Convert frame data to PIXI.js-compatible JSON format + * Follows the standard PIXI.js Spritesheet format with MoxiJS extensions in meta + * @see https://pixijs.download/dev/docs/assets.Spritesheet.html + */ +export function framesToJSON( + frames: FrameData[], + sheetName: string, + sheetWidth: number, + sheetHeight: number, + gridSettings?: GridSettings, + animations?: AnimationSequence[], + namedCells?: NamedCell[] +): SpriteSheetJSON { + // Build a lookup for named cells by position + const namedCellMap = new Map(); + if (namedCells) { + for (const cell of namedCells) { + namedCellMap.set(`${cell.col},${cell.row}`, cell); + } + } + + const framesObject: Record = {}; + + frames.forEach((frame, idx) => { + // Check if this frame has a custom name via namedCells + // For standard grid frames, calculate col/row from index + let frameName = frame.name; + + if (gridSettings && namedCellMap.size > 0) { + // Calculate col/row for this frame (for non-region frames) + const col = idx % gridSettings.columns; + const row = Math.floor(idx / gridSettings.columns); + const namedCell = namedCellMap.get(`${col},${row}`); + if (namedCell) { + frameName = namedCell.name; + } + } + + framesObject[frameName] = { + frame: { + x: frame.x, + y: frame.y, + w: frame.w, + h: frame.h + }, + sourceSize: { + w: frame.w, + h: frame.h + } + }; + }); + + // Build PIXI.js animations object (simple array of frame names) + let animationsObject: Record | undefined; + let animationDataObject: Record | undefined; + + if (animations && animations.length > 0 && gridSettings) { + animationsObject = {}; + animationDataObject = {}; + + for (const anim of animations) { + // Convert cell coordinates to frame names + const frameNames = anim.frames.map(f => { + // Check if this cell has a custom name + const namedCell = namedCellMap.get(`${f.col},${f.row}`); + if (namedCell) { + return namedCell.name; + } + // Default frame name format: sheetName_XXX.png + const index = f.row * gridSettings.columns + f.col; + return `${sheetName}_${String(index).padStart(3, '0')}.png`; + }); + + // PIXI.js format: just array of frame names + animationsObject[anim.name] = frameNames; + + // MoxiJS extension: additional metadata + animationDataObject[anim.name] = { + frameDuration: anim.frameDuration, + loop: anim.loop, + ...(anim.inputBinding && { inputBinding: anim.inputBinding }) + }; + } + } + + return { + frames: framesObject, + ...(animationsObject && { animations: animationsObject }), + meta: { + image: `${sheetName}.png`, + format: 'RGBA8888', + size: { + w: sheetWidth, + h: sheetHeight + }, + scale: '1', + // MoxiJS extensions stored in meta + ...(gridSettings && { + grid: { + cellWidth: gridSettings.cellWidth, + cellHeight: gridSettings.cellHeight, + columns: gridSettings.columns, + rows: gridSettings.rows + } + }), + ...(animationDataObject && { animationData: animationDataObject }) + } + }; +} + +/** + * Download JSON data as a file + */ +export function downloadJSON(data: SpriteSheetJSON, filename: string): void { + const jsonString = JSON.stringify(data, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** + * Generate a unique ID for a tile region + */ +export function generateRegionId(): string { + return `region_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Generate a unique ID for an animation sequence + */ +export function generateAnimationId(): string { + return `anim_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Check if a cell is covered by any tile region + */ +export function getCellRegion( + col: number, + row: number, + regions: TileRegion[] +): TileRegion | null { + for (const region of regions) { + if ( + col >= region.col && + col < region.col + region.colSpan && + row >= region.row && + row < region.row + region.rowSpan + ) { + return region; + } + } + return null; +} + +/** + * Check if two cells are adjacent (horizontally or vertically) + */ +export function areCellsAdjacent( + col1: number, + row1: number, + col2: number, + row2: number +): boolean { + const colDiff = Math.abs(col1 - col2); + const rowDiff = Math.abs(row1 - row2); + return (colDiff === 1 && rowDiff === 0) || (colDiff === 0 && rowDiff === 1); +} + +/** + * Create a tile region from a selection of cells + * Returns null if cells don't form a valid rectangle + */ +export function createRegionFromCells( + cells: Array<{ col: number; row: number }>, + existingRegions: TileRegion[] +): TileRegion | null { + if (cells.length < 2) return null; + + // Find bounding box + const minCol = Math.min(...cells.map(c => c.col)); + const maxCol = Math.max(...cells.map(c => c.col)); + const minRow = Math.min(...cells.map(c => c.row)); + const maxRow = Math.max(...cells.map(c => c.row)); + + const colSpan = maxCol - minCol + 1; + const rowSpan = maxRow - minRow + 1; + + // Check if selection forms a complete rectangle + if (cells.length !== colSpan * rowSpan) { + return null; // Not a complete rectangle + } + + // Check all cells in bounding box are selected + for (let r = minRow; r <= maxRow; r++) { + for (let c = minCol; c <= maxCol; c++) { + const found = cells.some(cell => cell.col === c && cell.row === r); + if (!found) return null; + } + } + + // Check no cell is already part of another region + for (const cell of cells) { + if (getCellRegion(cell.col, cell.row, existingRegions)) { + return null; // Cell already in a region + } + } + + return { + id: generateRegionId(), + col: minCol, + row: minRow, + colSpan, + rowSpan + }; +} + +/** + * Generate frames from grid settings with tile regions + * Regions are output as larger frames, remaining cells as individual frames + */ +export function generateFramesWithRegions( + sheetName: string, + gridSettings: GridSettings, + regions: TileRegion[] +): FrameData[] { + const frames: FrameData[] = []; + const coveredCells = new Set(); + + // First, add frames for all tile regions + regions.forEach((region, idx) => { + const frameName = region.name || `${sheetName}_region_${idx}`; + frames.push({ + name: `${frameName}.png`, + x: region.col * gridSettings.cellWidth, + y: region.row * gridSettings.cellHeight, + w: region.colSpan * gridSettings.cellWidth, + h: region.rowSpan * gridSettings.cellHeight + }); + + // Mark cells as covered + for (let r = region.row; r < region.row + region.rowSpan; r++) { + for (let c = region.col; c < region.col + region.colSpan; c++) { + coveredCells.add(`${c},${r}`); + } + } + }); + + // Then add frames for uncovered cells + let frameIndex = 0; + for (let row = 0; row < gridSettings.rows; row++) { + for (let col = 0; col < gridSettings.columns; col++) { + if (!coveredCells.has(`${col},${row}`)) { + frames.push({ + name: `${sheetName}_${frameIndex}.png`, + x: col * gridSettings.cellWidth, + y: row * gridSettings.cellHeight, + w: gridSettings.cellWidth, + h: gridSettings.cellHeight + }); + } + frameIndex++; + } + } + + return frames; +} diff --git a/packages/tilemap-matic/src/sprite-sheet-grid.ts b/packages/tilemap-matic/src/sprite-sheet-grid.ts new file mode 100644 index 00000000..879d5750 --- /dev/null +++ b/packages/tilemap-matic/src/sprite-sheet-grid.ts @@ -0,0 +1,168 @@ +/** + * Sprite Sheet Grid Visualization Library + * Handles grid overlay rendering and cell highlighting + */ +import { Graphics, Container, Text } from 'pixi.js'; +import { GridSettings } from './sprite-sheet-data'; + +export interface GridVisualizationOptions { + gridColor?: number; + gridAlpha?: number; + gridWidth?: number; + dashed?: boolean; + dashLength?: number; + gapLength?: number; + showCellNumbers?: boolean; + cellNumberColor?: number; + cellNumberSize?: number; +} + +/** + * Create a grid overlay graphics object + */ +export function createGridOverlay( + gridSettings: GridSettings, + options: GridVisualizationOptions = {} +): Graphics { + const { + gridColor = 0x00ff00, + gridAlpha = 0.7, + gridWidth = 1, + dashed = false, + dashLength = 4, + gapLength = 4 + } = options; + + const grid = new Graphics(); + + if (dashed) { + // Draw dashed vertical lines + for (let col = 0; col <= gridSettings.columns; col++) { + const x = col * gridSettings.cellWidth; + const totalHeight = gridSettings.rows * gridSettings.cellHeight; + let y = 0; + while (y < totalHeight) { + const endY = Math.min(y + dashLength, totalHeight); + grid.moveTo(x, y); + grid.lineTo(x, endY); + grid.stroke({ color: gridColor, width: gridWidth, alpha: gridAlpha }); + y += dashLength + gapLength; + } + } + + // Draw dashed horizontal lines + for (let row = 0; row <= gridSettings.rows; row++) { + const y = row * gridSettings.cellHeight; + const totalWidth = gridSettings.columns * gridSettings.cellWidth; + let x = 0; + while (x < totalWidth) { + const endX = Math.min(x + dashLength, totalWidth); + grid.moveTo(x, y); + grid.lineTo(endX, y); + grid.stroke({ color: gridColor, width: gridWidth, alpha: gridAlpha }); + x += dashLength + gapLength; + } + } + } else { + // Draw solid vertical lines + for (let col = 0; col <= gridSettings.columns; col++) { + const x = col * gridSettings.cellWidth; + grid.moveTo(x, 0); + grid.lineTo(x, gridSettings.rows * gridSettings.cellHeight); + grid.stroke({ color: gridColor, width: gridWidth, alpha: gridAlpha }); + } + + // Draw solid horizontal lines + for (let row = 0; row <= gridSettings.rows; row++) { + const y = row * gridSettings.cellHeight; + grid.moveTo(0, y); + grid.lineTo(gridSettings.columns * gridSettings.cellWidth, y); + grid.stroke({ color: gridColor, width: gridWidth, alpha: gridAlpha }); + } + } + + return grid; +} + +/** + * Create cell number labels for the grid + */ +export function createCellNumbers( + gridSettings: GridSettings, + options: GridVisualizationOptions = {} +): Container { + const { + showCellNumbers = true, + cellNumberColor = 0xffff00, + cellNumberSize = 10 + } = options; + + const container = new Container(); + + if (!showCellNumbers) return container; + + let cellIndex = 0; + + for (let row = 0; row < gridSettings.rows; row++) { + for (let col = 0; col < gridSettings.columns; col++) { + const label = new Text({ + text: cellIndex.toString(), + style: { + fontFamily: 'Arial, sans-serif', + fontSize: cellNumberSize, + fill: cellNumberColor + } + }); + label.x = col * gridSettings.cellWidth + 2; + label.y = row * gridSettings.cellHeight + 2; + container.addChild(label); + cellIndex++; + } + } + + return container; +} + +/** + * Create a cell highlight overlay + */ +export function createCellHighlight( + col: number, + row: number, + cellWidth: number, + cellHeight: number, + color: number = 0xffff00, + alpha: number = 0.3 +): Graphics { + const highlight = new Graphics(); + highlight.rect(col * cellWidth, row * cellHeight, cellWidth, cellHeight); + highlight.fill({ color, alpha }); + highlight.stroke({ color, width: 2, alpha: 1 }); + return highlight; +} + +/** + * Get cell coordinates from pixel position + */ +export function pixelToCell( + x: number, + y: number, + cellWidth: number, + cellHeight: number +): { col: number; row: number } { + return { + col: Math.floor(x / cellWidth), + row: Math.floor(y / cellHeight) + }; +} + +/** + * Get cell index from grid position + */ +export function cellToIndex( + col: number, + row: number, + columns: number +): number { + return row * columns + col; +} diff --git a/packages/tilemap-matic/src/sprite-sheet-project.ts b/packages/tilemap-matic/src/sprite-sheet-project.ts new file mode 100644 index 00000000..41934608 --- /dev/null +++ b/packages/tilemap-matic/src/sprite-sheet-project.ts @@ -0,0 +1,667 @@ +/** + * Sprite Sheet Project Manager + * Manages sprite sheet project data with localStorage persistence + * Stores image data as base64 for persistence across sessions + */ +import { + GridSettings, + TileRegion, + AnimationSequence, + SpriteSheetJSON, + calculateGrid, + guessCellSize, + generateFrames, + generateFramesWithRegions, + framesToJSON, + downloadJSON, + getCellRegion, + createRegionFromCells, + generateAnimationId +} from './sprite-sheet-data'; + +const PROJECT_STORAGE_KEY = 'tilemap-matic-project'; + +/** + * Result type for operations that can fail + */ +export interface OperationResult { + success: boolean; + data?: T; + error?: string; +} + +/** + * Named cell - a single cell with a custom name and optional description + */ +export interface NamedCell { + /** Grid column (0-indexed) */ + col: number; + /** Grid row (0-indexed) */ + row: number; + /** Custom name for this cell (e.g., "milk_jug") */ + name: string; + /** Optional description */ + description?: string; +} + +/** + * A single sprite sheet entry in the project + */ +export interface SpriteSheetEntry { + id: string; + name: string; + imageDataUrl: string; // Base64 data URL for persistence + width: number; + height: number; + gridSettings: GridSettings; + /** Merged tile regions (cells combined into larger tiles) */ + tileRegions: TileRegion[]; + /** Animation sequences (ordered frame collections) */ + animations: AnimationSequence[]; + /** Named individual cells */ + namedCells: NamedCell[]; + createdAt: number; + modifiedAt: number; +} + +/** + * Complete project state (persisted to localStorage) + */ +export interface SpriteSheetProject { + version: string; + createdAt: number; + modifiedAt: number; + sheets: SpriteSheetEntry[]; + activeSheetId: string | null; +} + +/** + * Project bundle for export (includes generated JSON for each sheet) + */ +export interface ProjectBundle { + project: SpriteSheetProject; + sheetsJSON: Record; +} + +/** + * Input for adding a new sheet (without auto-generated fields) + */ +export interface AddSheetInput { + name: string; + imageDataUrl: string; + width: number; + height: number; + gridSettings?: Partial; +} + +type ProjectListener = (project: SpriteSheetProject) => void; + +/** + * Sprite Sheet Project Manager + * Handles project state with auto-save to localStorage and observer pattern + */ +export class SpriteSheetProjectManager { + private static readonly CURRENT_VERSION = '1.0.0'; + + private project: SpriteSheetProject; + private listeners: Set = new Set(); + + constructor() { + this.project = this.loadFromStorage() ?? this.createEmpty(); + } + + /** + * Create a new empty project + */ + private createEmpty(): SpriteSheetProject { + return { + version: SpriteSheetProjectManager.CURRENT_VERSION, + createdAt: Date.now(), + modifiedAt: Date.now(), + sheets: [], + activeSheetId: null + }; + } + + /** + * Generate a unique ID for a new sheet + */ + private generateId(): string { + return `sheet_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + /** + * Load project from localStorage + */ + private loadFromStorage(): SpriteSheetProject | null { + try { + const serialized = localStorage.getItem(PROJECT_STORAGE_KEY); + if (!serialized) return null; + + const project = JSON.parse(serialized) as SpriteSheetProject; + console.log(`Loaded project with ${project.sheets.length} sheets`); + return project; + } catch (error) { + console.error('Failed to load project from storage:', error); + return null; + } + } + + /** + * Save project to localStorage + */ + private saveToStorage(): OperationResult { + try { + this.project.modifiedAt = Date.now(); + const serialized = JSON.stringify(this.project); + localStorage.setItem(PROJECT_STORAGE_KEY, serialized); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Failed to save project:', error); + return { success: false, error: errorMessage }; + } + } + + /** + * Notify all listeners of project changes + */ + private notify(): void { + this.listeners.forEach(listener => listener(this.project)); + } + + /** + * Subscribe to project changes + * @returns Unsubscribe function + */ + subscribe(listener: ProjectListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Get current project state + */ + getProject(): SpriteSheetProject { + return this.project; + } + + /** + * Get all sheets + */ + getAllSheets(): SpriteSheetEntry[] { + return this.project.sheets; + } + + /** + * Get a sheet by ID + */ + getSheet(id: string): SpriteSheetEntry | null { + return this.project.sheets.find(s => s.id === id) ?? null; + } + + /** + * Get the currently active sheet + */ + getActiveSheet(): SpriteSheetEntry | null { + if (!this.project.activeSheetId) return null; + return this.getSheet(this.project.activeSheetId); + } + + /** + * Add a new sprite sheet to the project + */ + addSheet(input: AddSheetInput): SpriteSheetEntry { + const now = Date.now(); + + // Calculate default grid settings if not provided + // Use smart cell size detection based on image dimensions + const guessed = guessCellSize(input.width, input.height); + const defaultCellWidth = input.gridSettings?.cellWidth ?? guessed.cellWidth; + const defaultCellHeight = input.gridSettings?.cellHeight ?? guessed.cellHeight; + const gridSettings = calculateGrid( + input.width, + input.height, + defaultCellWidth, + defaultCellHeight + ); + + const entry: SpriteSheetEntry = { + id: this.generateId(), + name: input.name, + imageDataUrl: input.imageDataUrl, + width: input.width, + height: input.height, + gridSettings, + tileRegions: [], + animations: [], + namedCells: [], + createdAt: now, + modifiedAt: now + }; + + this.project.sheets.push(entry); + this.project.activeSheetId = entry.id; + this.saveToStorage(); + this.notify(); + + console.log(`Added sheet: ${entry.name}`); + return entry; + } + + /** + * Remove a sheet from the project + */ + removeSheet(id: string): void { + const index = this.project.sheets.findIndex(s => s.id === id); + if (index === -1) return; + + const removed = this.project.sheets.splice(index, 1)[0]; + + // Update active sheet if we removed the active one + if (this.project.activeSheetId === id) { + this.project.activeSheetId = this.project.sheets.length > 0 + ? this.project.sheets[Math.max(0, index - 1)].id + : null; + } + + this.saveToStorage(); + this.notify(); + + console.log(`Removed sheet: ${removed.name}`); + } + + /** + * Set the active sheet + */ + setActiveSheet(id: string | null): void { + if (id !== null && !this.getSheet(id)) { + console.warn(`Sheet not found: ${id}`); + return; + } + + this.project.activeSheetId = id; + this.saveToStorage(); + this.notify(); + } + + /** + * Update grid settings for a sheet + */ + updateGridSettings(id: string, cellWidth: number, cellHeight: number): void { + const sheet = this.getSheet(id); + if (!sheet) return; + + sheet.gridSettings = calculateGrid(sheet.width, sheet.height, cellWidth, cellHeight); + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + } + + /** + * Update sheet name + */ + updateSheetName(id: string, name: string): void { + const sheet = this.getSheet(id); + if (!sheet) return; + + sheet.name = name; + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + } + + /** + * Export a single sheet as PIXI.js JSON + * Includes grid settings, tile regions, animations, and named cells + */ + exportSheetJSON(id: string): SpriteSheetJSON | null { + const sheet = this.getSheet(id); + if (!sheet) return null; + + // Use region-aware frame generation if there are tile regions + // Handle legacy sheets that don't have tileRegions array + const tileRegions = sheet.tileRegions ?? []; + const animations = sheet.animations ?? []; + const namedCells = sheet.namedCells ?? []; + const frames = tileRegions.length > 0 + ? generateFramesWithRegions(sheet.name, sheet.gridSettings, tileRegions) + : generateFrames(sheet.name, sheet.gridSettings); + return framesToJSON( + frames, + sheet.name, + sheet.width, + sheet.height, + sheet.gridSettings, + animations, + namedCells + ); + } + + /** + * Join selected cells into a tile region + * Returns the new region or null if invalid selection + */ + joinCells(sheetId: string, cells: Array<{ col: number; row: number }>): TileRegion | null { + const sheet = this.getSheet(sheetId); + if (!sheet) return null; + + // Initialize tileRegions if missing (legacy sheets) + if (!sheet.tileRegions) { + sheet.tileRegions = []; + } + + const region = createRegionFromCells(cells, sheet.tileRegions); + if (!region) return null; + + sheet.tileRegions.push(region); + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + return region; + } + + /** + * Split a tile region back into individual cells + * Returns true if region was found and removed + */ + splitRegion(sheetId: string, regionId: string): boolean { + const sheet = this.getSheet(sheetId); + if (!sheet || !sheet.tileRegions) return false; + + const index = sheet.tileRegions.findIndex(r => r.id === regionId); + if (index === -1) return false; + + sheet.tileRegions.splice(index, 1); + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + return true; + } + + /** + * Get the tile region at a specific cell, if any + */ + getRegionAtCell(sheetId: string, col: number, row: number): TileRegion | null { + const sheet = this.getSheet(sheetId); + if (!sheet) return null; + + return getCellRegion(col, row, sheet.tileRegions ?? []); + } + + /** + * Get all tile regions for a sheet + */ + getTileRegions(sheetId: string): TileRegion[] { + const sheet = this.getSheet(sheetId); + return sheet?.tileRegions ?? []; + } + + // ========================================== + // Animation Management + // ========================================== + + /** + * Add a new animation sequence from ordered cell selection + * @param sheetId - The sheet to add the animation to + * @param frames - Ordered array of cell coordinates + * @param name - Optional name (auto-generated if not provided) + * @returns The created animation or null if invalid + */ + addAnimation( + sheetId: string, + frames: Array<{ col: number; row: number }>, + name?: string + ): AnimationSequence | null { + const sheet = this.getSheet(sheetId); + if (!sheet || frames.length < 1) return null; + + // Initialize animations array if missing (legacy sheets) + if (!sheet.animations) { + sheet.animations = []; + } + + const animation: AnimationSequence = { + id: generateAnimationId(), + name: name || `animation_${sheet.animations.length + 1}`, + frames: [...frames], // Copy to avoid mutation + frameDuration: 100, // Default 100ms per frame + loop: true + }; + + sheet.animations.push(animation); + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + console.log(`Added animation: ${animation.name} with ${frames.length} frames`); + return animation; + } + + /** + * Update an existing animation + */ + updateAnimation( + sheetId: string, + animationId: string, + updates: Partial> + ): boolean { + const sheet = this.getSheet(sheetId); + if (!sheet || !sheet.animations) return false; + + const animation = sheet.animations.find(a => a.id === animationId); + if (!animation) return false; + + // Apply updates + if (updates.name !== undefined) animation.name = updates.name; + if (updates.frames !== undefined) animation.frames = [...updates.frames]; + if (updates.frameDuration !== undefined) animation.frameDuration = updates.frameDuration; + if (updates.loop !== undefined) animation.loop = updates.loop; + if (updates.inputBinding !== undefined) animation.inputBinding = updates.inputBinding; + + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + return true; + } + + /** + * Remove an animation from a sheet + */ + removeAnimation(sheetId: string, animationId: string): boolean { + const sheet = this.getSheet(sheetId); + if (!sheet || !sheet.animations) return false; + + const index = sheet.animations.findIndex(a => a.id === animationId); + if (index === -1) return false; + + const removed = sheet.animations.splice(index, 1)[0]; + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + console.log(`Removed animation: ${removed.name}`); + return true; + } + + /** + * Get all animations for a sheet + */ + getAnimations(sheetId: string): AnimationSequence[] { + const sheet = this.getSheet(sheetId); + return sheet?.animations ?? []; + } + + /** + * Get a specific animation by ID + */ + getAnimation(sheetId: string, animationId: string): AnimationSequence | null { + const sheet = this.getSheet(sheetId); + if (!sheet || !sheet.animations) return null; + return sheet.animations.find(a => a.id === animationId) ?? null; + } + + /** + * Update region name + */ + updateRegionName(sheetId: string, regionId: string, name: string): boolean { + const sheet = this.getSheet(sheetId); + if (!sheet || !sheet.tileRegions) return false; + + const region = sheet.tileRegions.find(r => r.id === regionId); + if (!region) return false; + + region.name = name; + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + return true; + } + + // ========================================== + // Named Cell Management + // ========================================== + + /** + * Set or update a named cell + * @param sheetId - The sheet ID + * @param col - Grid column + * @param row - Grid row + * @param name - Name for this cell + * @param description - Optional description + */ + setNamedCell(sheetId: string, col: number, row: number, name: string, description?: string): boolean { + const sheet = this.getSheet(sheetId); + if (!sheet) return false; + + // Initialize namedCells if missing (legacy sheets) + if (!sheet.namedCells) { + sheet.namedCells = []; + } + + // Check if cell already has a name + const existing = sheet.namedCells.find(c => c.col === col && c.row === row); + if (existing) { + existing.name = name; + existing.description = description; + } else { + sheet.namedCells.push({ col, row, name, description }); + } + + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + return true; + } + + /** + * Remove a named cell + */ + removeNamedCell(sheetId: string, col: number, row: number): boolean { + const sheet = this.getSheet(sheetId); + if (!sheet || !sheet.namedCells) return false; + + const index = sheet.namedCells.findIndex(c => c.col === col && c.row === row); + if (index === -1) return false; + + sheet.namedCells.splice(index, 1); + sheet.modifiedAt = Date.now(); + this.saveToStorage(); + this.notify(); + + return true; + } + + /** + * Get named cell at position + */ + getNamedCell(sheetId: string, col: number, row: number): NamedCell | null { + const sheet = this.getSheet(sheetId); + if (!sheet || !sheet.namedCells) return null; + return sheet.namedCells.find(c => c.col === col && c.row === row) ?? null; + } + + /** + * Get all named cells for a sheet + */ + getNamedCells(sheetId: string): NamedCell[] { + const sheet = this.getSheet(sheetId); + return sheet?.namedCells ?? []; + } + + /** + * Download a single sheet's JSON + */ + downloadSheetJSON(id: string): void { + const sheet = this.getSheet(id); + if (!sheet) return; + + const json = this.exportSheetJSON(id); + if (json) { + downloadJSON(json, `${sheet.name}.json`); + } + } + + /** + * Export entire project as a bundle + */ + exportProjectBundle(): ProjectBundle { + const sheetsJSON: Record = {}; + + for (const sheet of this.project.sheets) { + const json = this.exportSheetJSON(sheet.id); + if (json) { + sheetsJSON[sheet.id] = json; + } + } + + return { + project: this.project, + sheetsJSON + }; + } + + /** + * Download project bundle as JSON file + */ + downloadProjectBundle(): void { + const bundle = this.exportProjectBundle(); + const jsonString = JSON.stringify(bundle, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'tilemap-matic-project.json'; + a.click(); + URL.revokeObjectURL(url); + } + + /** + * Clear the entire project + */ + clearProject(): void { + this.project = this.createEmpty(); + this.saveToStorage(); + this.notify(); + console.log('Project cleared'); + } + + /** + * Check if project has any sheets + */ + hasSheets(): boolean { + return this.project.sheets.length > 0; + } + + /** + * Get sheet count + */ + getSheetCount(): number { + return this.project.sheets.length; + } +} diff --git a/packages/tilemap-matic/src/tilemap-matic.ts b/packages/tilemap-matic/src/tilemap-matic.ts new file mode 100644 index 00000000..eef36b88 --- /dev/null +++ b/packages/tilemap-matic/src/tilemap-matic.ts @@ -0,0 +1,1314 @@ +/** + * [T]ile[Map]Matic + * Full-featured sprite sheet / tile map management with project persistence + * + * Features: + * - Drag and drop sprite sheet loading + * - Project persistence via localStorage + * - Multi-sheet library with carousel navigation + * - Per-sheet grid configuration + * - Tile region merging (Ctrl+J) and splitting (Ctrl+K) + * - Individual and project export + * - Tab view: Sheet (visual) and JSON (export preview) + */ +import { setupMoxi } from '@moxijs/core'; +import { + UILabel, + UILayer, + UIFocusManager, + UIButton, + FlexContainer, + FlexDirection, + FlexJustify, + FlexAlign, + EdgeInsets, + CardPanel, + FlatCardStyle +} from '@moxijs/ui'; +import { Sprite, Container, Graphics, Texture, Assets, BitmapFont, AnimatedSprite, Rectangle, Text } from 'pixi.js'; +import { + SpriteSheetProjectManager, + SpriteSheetEntry +} from './sprite-sheet-project'; +import { + SpriteSheetConfigPanel +} from './sprite-sheet-config-panel'; +import { + createGridOverlay, + createCellHighlight, + pixelToCell, + cellToIndex +} from './sprite-sheet-grid'; +import { GridSettings, AnimationSequence, TileRegion } from './sprite-sheet-data'; +import { SpriteCarousel, CarouselItem } from './sprite-carousel'; +import { CanvasPanZoom } from './canvas-pan-zoom'; +import { AnimationRegionLibraryPanel } from './animation-region-library-panel'; + +type CleanupFunction = () => void; + +/** + * TileMapMatic configuration options + */ +export interface TileMapMaticOptions { + /** Host element to render into (default: looks for #app) */ + hostElement?: HTMLElement; + /** Canvas width (default: 1280) */ + width?: number; + /** Canvas height (default: 720) */ + height?: number; + /** Background color (default: 0x1a1a2e) */ + backgroundColor?: number; + /** Font asset path (optional - will use default bundled font if not provided) */ + fontPath?: string; + /** Skip font loading (useful when fonts are already loaded by parent app) */ + skipFontLoading?: boolean; +} + +/** + * Initialize TileMapMatic + */ +export async function initTileMapMatic(options: TileMapMaticOptions = {}): Promise { + const root = options.hostElement || document.getElementById('app'); + if (!root) throw new Error('Host element not found'); + + const width = options.width ?? 1280; + const height = options.height ?? 720; + const backgroundColor = options.backgroundColor ?? 0x18181b; // Neutral dark gray + + const { scene, engine, renderer } = await setupMoxi({ + hostElement: root, + showLoadingScene: false, + renderOptions: { + width, + height, + backgroundColor, + } + }); + + // Load fonts (unless skipped - e.g., when parent app already loaded them) + if (!options.skipFontLoading) { + const fontPath = options.fontPath || './assets/fonts/pixel_operator/PixelOperator8.ttf'; + await Assets.load({ + alias: 'PixelOperator8', + src: fontPath, + data: { family: 'PixelOperator8' } + }); + + // Install as BitmapFont for pixel-perfect text rendering + BitmapFont.install({ + name: 'PixelOperator8Bitmap', + style: { + fontFamily: 'PixelOperator8', + fontSize: 16, + fill: 0xffffff, + } + }); + + console.log('🔤 TileMapMatic: PixelOperator8 font loaded'); + } + + // Initialize project manager (loads from localStorage) + const projectManager = new SpriteSheetProjectManager(); + + // Create focus manager for keyboard navigation + const focusManager = new UIFocusManager(); + + // Create UI Layer + const uiLayer = new UILayer({ + targetWidth: renderer.width, + targetHeight: renderer.height + }); + scene.addChild(uiLayer); + + // Layout constants + const HEADER_HEIGHT = 50; + const CAROUSEL_WIDTH = 140; + const CONFIG_PANEL_WIDTH = 200; + const CONTENT_PADDING = 10; + + // Calculate content area dimensions + const contentX = CAROUSEL_WIDTH; + const contentY = HEADER_HEIGHT; + const contentWidth = renderer.width - CAROUSEL_WIDTH; // Fill to right edge, panels overlay + const contentHeight = renderer.height - HEADER_HEIGHT; + + // === HEADER === + const headerContainer = new FlexContainer({ + direction: FlexDirection.Row, + justify: FlexJustify.SpaceBetween, + align: FlexAlign.Center, + padding: EdgeInsets.symmetric(0, 20), + width: renderer.width, + height: HEADER_HEIGHT, + backgroundColor: 0x2a2a2f + }); + + // Brackets highlight key letters: [T]ile[Map]Matic - intentional styling + const titleLabel = new UILabel({ + text: '[T]ile[Map]Matic (ALPHA!)', + fontSize: 22, + color: 0x9acd32, // Muted lime green (yellowgreen) + fontWeight: 'bold' + }); + headerContainer.addChild(titleLabel); + + const infoLabel = new UILabel({ + text: 'Drop sprite sheets to add to project', + fontSize: 13, + color: 0xaaaaaa + }); + headerContainer.addChild(infoLabel); + + headerContainer.layout(renderer.width, HEADER_HEIGHT); + headerContainer.container.position.set(0, 0); + uiLayer.addChild(headerContainer.container); + + // === LIBRARY CARD PANEL (Left Side - contains carousel) === + const LIBRARY_TITLE_HEIGHT = 28; // Approximate title bar height + const libraryCardPanel = new CardPanel({ + title: { text: 'Library', fontSize: 12 }, + bodyWidth: CAROUSEL_WIDTH, + bodyHeight: renderer.height - HEADER_HEIGHT - LIBRARY_TITLE_HEIGHT, + draggable: false, + style: new FlatCardStyle({ + showShadow: true, + shadowOffset: 6, + shadowAlpha: 0.4 + }), + colors: { + background: 0x222226, + border: 0x404045, + titleBar: 0x2a2a2e, + titleText: 0x9acd32 // Muted lime green to match app title + } + }); + libraryCardPanel.container.position.set(0, HEADER_HEIGHT); + scene.addChild(libraryCardPanel.container); + + // Carousel inside the library card panel body + const carousel = new SpriteCarousel({ + width: CAROUSEL_WIDTH, + height: renderer.height - HEADER_HEIGHT - LIBRARY_TITLE_HEIGHT, + orientation: 'vertical', + showAddButton: true, + showLabels: true, + thumbnailSize: 80, + itemSpacing: 12, + backgroundColor: 0x222226, + reticleColor: 0x9acd32 + }); + // Add carousel to the card panel body (no shadow since panel has it) + libraryCardPanel.getBodyContainer().addChild(carousel); + + // Hidden file input for add button + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + + carousel.onAddClick(() => { + fileInput.click(); + }); + + fileInput.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + await loadImageFile(file); + fileInput.value = ''; // Reset for next selection + } + }); + + // === SHEET VIEW (Canvas Area with mask for clipping) === + const canvasContainer = new Container(); + canvasContainer.position.set(contentX, contentY); + uiLayer.addChild(canvasContainer); + + // Background + const canvasBg = new Graphics(); + canvasBg.rect(0, 0, contentWidth, contentHeight); + canvasBg.fill({ color: 0x18181b }); // Neutral dark gray + canvasContainer.addChild(canvasBg); + + // Canvas area (holds sprite, grid, etc) + const canvasArea = new Container(); + canvasContainer.addChild(canvasArea); + + // Mask to clip content to bounds + const canvasMask = new Graphics(); + canvasMask.rect(0, 0, contentWidth, contentHeight); + canvasMask.fill({ color: 0xffffff }); + canvasContainer.addChild(canvasMask); + canvasArea.mask = canvasMask; + + // Sprite display state + let spriteDisplay: Sprite | null = null; + let gridOverlay: Graphics | null = null; + let cellHighlight: Graphics | null = null; + let selectedCells: Array<{ col: number; row: number }> = []; // Multi-select support + let displayedSheetId: string | null = null; // Track which sheet is currently displayed + let regionOverlays: Graphics[] = []; // Visual overlays for tile regions + + // Animation creation mode state + let animationCreationMode = false; + let animationFrames: Array<{ col: number; row: number }> = []; // Ordered frames for animation + let animationNumberOverlays: Container[] = []; // Number badges showing frame order + let animationPreviewSprite: AnimatedSprite | null = null; // Live preview of animation being created + let animationPreviewContainer: Container | null = null; // Container for preview + + // === DROP ZONE HINT === + const dropHintContainer = new Container(); + const dropHint = new Graphics(); + const dropHintText = new Text({ + text: 'Drag your spritesheets here', + style: { + fontSize: 18, + fill: 0x4a5a28, // Very muted lime green + fontFamily: 'Arial' + } + }); + dropHintText.anchor.set(0.5, 0.5); + dropHintContainer.addChild(dropHint); + dropHintContainer.addChild(dropHintText); + + function drawDropHint(active: boolean = false) { + dropHint.clear(); + dropHintContainer.visible = !projectManager.hasSheets(); + + if (!projectManager.hasSheets()) { + const margin = 50; + const w = 400; + const h = 150; + const x = margin; + const y = margin; + const dashLen = 12; + const gapLen = 8; + const color = active ? 0x9acd32 : 0x4a5a28; // Muted lime green when inactive + + // Draw dashed rectangle + // Top edge + for (let i = 0; i < w; i += dashLen + gapLen) { + dropHint.moveTo(x + i, y); + dropHint.lineTo(x + Math.min(i + dashLen, w), y); + } + // Bottom edge + for (let i = 0; i < w; i += dashLen + gapLen) { + dropHint.moveTo(x + i, y + h); + dropHint.lineTo(x + Math.min(i + dashLen, w), y + h); + } + // Left edge + for (let i = 0; i < h; i += dashLen + gapLen) { + dropHint.moveTo(x, y + i); + dropHint.lineTo(x, y + Math.min(i + dashLen, h)); + } + // Right edge + for (let i = 0; i < h; i += dashLen + gapLen) { + dropHint.moveTo(x + w, y + i); + dropHint.lineTo(x + w, y + Math.min(i + dashLen, h)); + } + dropHint.stroke({ color, width: 2 }); + + // Center the text + dropHintText.x = x + w / 2; + dropHintText.y = y + h / 2; + dropHintText.style.fill = color; + } + } + canvasArea.addChild(dropHintContainer); + drawDropHint(); + + // === PAN/ZOOM STATE PERSISTENCE === + const ZOOM_STATE_KEY = 'tilemap-matic-zoom-state'; + let zoomSaveTimeout: ReturnType | null = null; + + function saveZoomState(scale: number, x: number, y: number): void { + // Debounce saves to avoid excessive writes + if (zoomSaveTimeout) { + clearTimeout(zoomSaveTimeout); + } + zoomSaveTimeout = setTimeout(() => { + try { + localStorage.setItem(ZOOM_STATE_KEY, JSON.stringify({ scale, x, y })); + } catch (e) { + // Silently fail + } + }, 300); + } + + function loadZoomState(): { scale: number; x: number; y: number } | null { + try { + const saved = localStorage.getItem(ZOOM_STATE_KEY); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + // Silently fail + } + return null; + } + + // === PAN/ZOOM === + let panZoom: CanvasPanZoom | null = null; + + function initPanZoom(): void { + if (panZoom) { + panZoom.destroy(); + } + + const savedState = loadZoomState(); + + panZoom = new CanvasPanZoom(canvasArea, renderer, { + minScale: 0.25, + maxScale: 10, + zoomSpeed: 0.15, + initialScale: savedState?.scale ?? 1, + onZoomChange: (scale) => { + const pct = Math.round(scale * 100); + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + const gs = activeSheet.gridSettings; + infoLabel.setText( + `${activeSheet.name} | ${activeSheet.width}x${activeSheet.height}px | Grid: ${gs.columns}x${gs.rows} | Zoom: ${pct}%` + ); + } + // Save zoom state lazily + saveZoomState(scale, canvasArea.x, canvasArea.y); + }, + onPanChange: (x, y) => { + // Save pan state lazily + if (panZoom) { + saveZoomState(panZoom.getScale(), x, y); + } + } + }); + + // Restore pan position if we have saved state + if (savedState) { + canvasArea.position.set(savedState.x, savedState.y); + } + } + + // Init pan/zoom + initPanZoom(); + + // === CONFIG PANEL (Right Side) === + const configPanel = new SpriteSheetConfigPanel({ + callbacks: { + onGridChange: (cellWidth, cellHeight) => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + projectManager.updateGridSettings(activeSheet.id, cellWidth, cellHeight); + } + }, + onExportSheet: () => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + projectManager.downloadSheetJSON(activeSheet.id); + } + }, + onExportProject: () => { + projectManager.downloadProjectBundle(); + }, + onRemoveSheet: () => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + carousel.removeItem(activeSheet.id); + projectManager.removeSheet(activeSheet.id); + } + }, + onClearProject: () => { + if (confirm('Clear entire project? This cannot be undone.')) { + carousel.setItems([]); + projectManager.clearProject(); + clearCanvasDisplay(); + } + }, + onCopyJSON: async () => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + const json = projectManager.exportSheetJSON(activeSheet.id); + const jsonString = JSON.stringify(json, null, 2); + try { + await navigator.clipboard.writeText(jsonString); + // Brief visual feedback on the button + configPanel.showCopyFeedback(); + } catch (err) { + console.error('Failed to copy JSON:', err); + } + } + } + }, + x: renderer.width - CONFIG_PANEL_WIDTH - 10, + y: HEADER_HEIGHT + 10 + }); + scene.addChild(configPanel.getPanel().container); + scene.addChild(configPanel.getMenuDropdown()); // Add menu dropdown on top for z-order + + // Register focusable inputs with focus manager + for (const input of configPanel.getFocusableInputs()) { + focusManager.register(input); + } + + // === LIBRARY PANEL (Animations & Regions) - Docked to left of config panel === + const LIBRARY_PANEL_WIDTH = 180; + const libraryPanel = new AnimationRegionLibraryPanel({ + x: renderer.width - CONFIG_PANEL_WIDTH - LIBRARY_PANEL_WIDTH - 30, + y: HEADER_HEIGHT + 10, + width: LIBRARY_PANEL_WIDTH, + height: 350, + callbacks: { + onSelectAnimation: (anim: AnimationSequence) => { + // Highlight animation frames on the grid + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + selectedCells = [...anim.frames]; + updateSelectionHighlight(activeSheet.gridSettings); + } + }, + onSelectRegion: (region: TileRegion) => { + // Highlight region cells on the grid + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + selectedCells = []; + for (let r = region.row; r < region.row + region.rowSpan; r++) { + for (let c = region.col; c < region.col + region.colSpan; c++) { + selectedCells.push({ col: c, row: r }); + } + } + updateSelectionHighlight(activeSheet.gridSettings); + } + }, + onClearSelection: () => { + clearSelection(); + }, + onRenameAnimation: (animationId: string, newName: string) => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + projectManager.updateAnimation(activeSheet.id, animationId, { name: newName }); + } + }, + onRenameRegion: (regionId: string, newName: string) => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + projectManager.updateRegionName(activeSheet.id, regionId, newName); + } + }, + onDeleteAnimation: (animationId: string) => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + projectManager.removeAnimation(activeSheet.id, animationId); + updateLibraryPanel(); + clearSelection(); + } + }, + onDeleteRegion: (regionId: string) => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + projectManager.splitRegion(activeSheet.id, regionId); + updateLibraryPanel(); + drawTileRegions(activeSheet.gridSettings); + clearSelection(); + } + } + } + }); + scene.addChild(libraryPanel.getPanel().container); + + // Update library panel when sheet changes + function updateLibraryPanel(): void { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet && spriteDisplay) { + const animations = projectManager.getAnimations(activeSheet.id); + const regions = projectManager.getTileRegions(activeSheet.id); + libraryPanel.updateWithData( + animations, + regions, + spriteDisplay.texture, + activeSheet.gridSettings + ); + } else { + libraryPanel.updateWithData([], [], null, null); + } + } + + // === LOAD IMAGE FILE === + async function loadImageFile(file: File): Promise { + if (!file.type.startsWith('image/')) { + console.warn('Please drop an image file'); + return; + } + + try { + // Read file as data URL for persistence + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + + // Create image to get dimensions + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = dataUrl; + }); + + // Add to project + const name = file.name.replace(/\.[^/.]+$/, ''); + const entry = projectManager.addSheet({ + name, + imageDataUrl: dataUrl, + width: img.width, + height: img.height + }); + + // Add to carousel + const texture = Texture.from(img); + carousel.addItem({ + id: entry.id, + texture, + label: entry.name + }); + + // Select the new sheet + carousel.selectById(entry.id); + + console.log(`Added sheet: ${entry.name} (${entry.width}x${entry.height})`); + } catch (error) { + console.error('Error loading image:', error); + } + } + + // === DISPLAY SPRITE SHEET === + async function displaySpriteSheet(entry: SpriteSheetEntry, restoreZoom: boolean = false): Promise { + clearCanvasDisplay(); + drawDropHint(); + + // Reset or restore pan/zoom based on context + if (restoreZoom) { + // Restore saved zoom state (on initial load) + const savedState = loadZoomState(); + if (savedState && panZoom) { + panZoom.setScale(savedState.scale); + canvasArea.position.set(savedState.x, savedState.y); + } + } else { + // Reset pan/zoom when switching sheets + if (panZoom) panZoom.reset(); + } + + // Create texture from data URL + const img = new Image(); + await new Promise((resolve) => { + img.onload = () => resolve(); + img.src = entry.imageDataUrl; + }); + + const texture = Texture.from(img); + + // Create sprite + spriteDisplay = new Sprite(texture); + spriteDisplay.texture.source.scaleMode = 'nearest'; + spriteDisplay.eventMode = 'static'; + + // Add interaction handlers + spriteDisplay.on('pointermove', (event) => { + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet || !spriteDisplay) return; + + const localPos = spriteDisplay.toLocal(event.global); + const gridSettings = activeSheet.gridSettings; + const cell = pixelToCell( + localPos.x, + localPos.y, + gridSettings.cellWidth, + gridSettings.cellHeight + ); + + if ( + cell.col >= 0 && cell.col < gridSettings.columns && + cell.row >= 0 && cell.row < gridSettings.rows + ) { + if (selectedCells.length === 0) { + updateCellHighlight(cell.col, cell.row, true, gridSettings); + } + + const cellIdx = cellToIndex(cell.col, cell.row, gridSettings.columns); + const totalCells = gridSettings.columns * gridSettings.rows; + infoLabel.setText( + `${activeSheet.name} | ${activeSheet.width}x${activeSheet.height}px | Grid: ${gridSettings.columns}x${gridSettings.rows} (${totalCells} cells) | Cell ${cellIdx}` + ); + } + }); + + spriteDisplay.on('pointerout', () => { + if (selectedCells.length === 0) { + clearCellHighlight(); + } + updateInfoLabel(); + }); + + spriteDisplay.on('pointerdown', (event) => { + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet || !spriteDisplay) return; + + const localPos = spriteDisplay.toLocal(event.global); + const gridSettings = activeSheet.gridSettings; + const cell = pixelToCell( + localPos.x, + localPos.y, + gridSettings.cellWidth, + gridSettings.cellHeight + ); + + if ( + cell.col >= 0 && cell.col < gridSettings.columns && + cell.row >= 0 && cell.row < gridSettings.rows + ) { + // Animation creation mode - add frame to sequence + if (animationCreationMode) { + addAnimationFrame(cell.col, cell.row); + return; + } + + const nativeEvent = event.nativeEvent as PointerEvent; + + if (nativeEvent.shiftKey) { + // Shift+click: toggle cell in selection + const existingIdx = selectedCells.findIndex( + c => c.col === cell.col && c.row === cell.row + ); + if (existingIdx >= 0) { + selectedCells.splice(existingIdx, 1); + } else { + selectedCells.push(cell); + } + } else { + // Normal click: replace selection + selectedCells = [cell]; + } + updateSelectionHighlight(gridSettings); + } + }); + + canvasArea.addChild(spriteDisplay); + + // Draw grid overlay + drawGridOverlay(entry.gridSettings); + + // Draw tile region overlays + drawTileRegions(entry.gridSettings); + + // Track displayed sheet + displayedSheetId = entry.id; + + // Update config panel + configPanel.setActiveSheet(entry); + configPanel.setHasSheets(true); + updateInfoLabel(); + + // Update library panel with animations and regions + updateLibraryPanel(); + } + + // === GRID OVERLAY === + function drawGridOverlay(gridSettings: { cellWidth: number; cellHeight: number; columns: number; rows: number }): void { + // Clear existing grid + if (gridOverlay) { + canvasArea.removeChild(gridOverlay); + gridOverlay.destroy(); + gridOverlay = null; + } + + // Don't draw grid if cell dimensions are too small + if (gridSettings.cellWidth < 5 || gridSettings.cellHeight < 5) { + return; + } + + gridOverlay = createGridOverlay(gridSettings, { + gridColor: 0xcccccc, + gridAlpha: 0.5, + gridWidth: 1, + dashed: true + }); + canvasArea.addChild(gridOverlay); + } + + // === CELL HIGHLIGHT === + function updateCellHighlight( + col: number, + row: number, + isHover: boolean, + gridSettings: { cellWidth: number; cellHeight: number } + ): void { + clearCellHighlight(); + + const color = isHover ? 0xffffff : 0xffff00; + const alpha = isHover ? 0.2 : 0.3; + + cellHighlight = createCellHighlight( + col, + row, + gridSettings.cellWidth, + gridSettings.cellHeight, + color, + alpha + ); + canvasArea.addChild(cellHighlight); + + if (spriteDisplay) { + const spriteIndex = canvasArea.children.indexOf(spriteDisplay); + if (spriteIndex !== -1) { + canvasArea.setChildIndex(cellHighlight, spriteIndex + 1); + } + } + } + + function clearCellHighlight(): void { + if (cellHighlight) { + canvasArea.removeChild(cellHighlight); + cellHighlight.destroy(); + cellHighlight = null; + } + } + + // === SELECTION HIGHLIGHT (Multi-cell) === + function updateSelectionHighlight(gridSettings: GridSettings): void { + // Clear existing highlight + clearCellHighlight(); + + if (selectedCells.length === 0) { + configPanel.clearRegionPreview(); + return; + } + + // Create a combined graphics for all selected cells + cellHighlight = new Graphics(); + + for (const cell of selectedCells) { + cellHighlight.rect( + cell.col * gridSettings.cellWidth, + cell.row * gridSettings.cellHeight, + gridSettings.cellWidth, + gridSettings.cellHeight + ); + } + cellHighlight.fill({ color: 0x9acd32, alpha: 0.3 }); + cellHighlight.stroke({ color: 0x9acd32, width: 2, alpha: 1 }); + + canvasArea.addChild(cellHighlight); + + // Position after sprite but before grid + if (spriteDisplay) { + const spriteIndex = canvasArea.children.indexOf(spriteDisplay); + if (spriteIndex !== -1) { + canvasArea.setChildIndex(cellHighlight, spriteIndex + 1); + } + } + + // Update config panel preview + configPanel.updateRegionPreview(selectedCells, gridSettings); + } + + // === CLEAR SELECTION === + function clearSelection(): void { + selectedCells = []; + clearCellHighlight(); + configPanel.clearRegionPreview(); + } + + // === CLEAR CANVAS === + function clearCanvasDisplay(): void { + // Remove all children except dropHintContainer + const children = [...canvasArea.children]; + for (const child of children) { + if (child !== dropHintContainer) { + canvasArea.removeChild(child); + child.destroy(); + } + } + + // Reset references + spriteDisplay = null; + gridOverlay = null; + cellHighlight = null; + selectedCells = []; + regionOverlays = []; + displayedSheetId = null; + configPanel.setActiveSheet(null); + updateInfoLabel(); + } + + // === UPDATE INFO LABEL === + function updateInfoLabel(): void { + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet) { + const count = projectManager.getSheetCount(); + infoLabel.setText( + count > 0 + ? `${count} sheet(s) in project - Select from carousel` + : 'Drop sprite sheets to add to project' + ); + return; + } + + const gs = activeSheet.gridSettings; + const totalCells = gs.columns * gs.rows; + infoLabel.setText( + `${activeSheet.name} | ${activeSheet.width}x${activeSheet.height}px | Grid: ${gs.columns}x${gs.rows} (${totalCells} cells)` + ); + } + + // === CAROUSEL SELECTION === + carousel.onSelect((item) => { + projectManager.setActiveSheet(item.id); + }); + + // === PROJECT MANAGER SUBSCRIPTION === + projectManager.subscribe((project) => { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + // Only re-display if switching to a different sheet + // Otherwise just update the grid overlay (much faster) + if (displayedSheetId !== activeSheet.id) { + displaySpriteSheet(activeSheet); + } else { + // Same sheet - just update grid, regions, and config panel + drawGridOverlay(activeSheet.gridSettings); + drawTileRegions(activeSheet.gridSettings); + configPanel.setActiveSheet(activeSheet); + updateInfoLabel(); + updateLibraryPanel(); + } + } else { + clearCanvasDisplay(); + drawDropHint(); + updateLibraryPanel(); + } + configPanel.setHasSheets(project.sheets.length > 0); + }); + + // === DRAG AND DROP === + function setupDragAndDrop(): void { + root.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + drawDropHint(true); + }); + + root.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + drawDropHint(false); + }); + + root.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + drawDropHint(false); + + const files = e.dataTransfer?.files; + if (!files) return; + + // Load all dropped files + for (let i = 0; i < files.length; i++) { + await loadImageFile(files[i]); + } + }); + } + + setupDragAndDrop(); + + // === TILE REGION VISUALIZATION === + function drawTileRegions(gridSettings: { cellWidth: number; cellHeight: number }): void { + // Clear existing region overlays + for (const overlay of regionOverlays) { + canvasArea.removeChild(overlay); + overlay.destroy(); + } + regionOverlays = []; + + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet) return; + + const regions = projectManager.getTileRegions(activeSheet.id); + for (const region of regions) { + const overlay = new Graphics(); + const x = region.col * gridSettings.cellWidth; + const y = region.row * gridSettings.cellHeight; + const w = region.colSpan * gridSettings.cellWidth; + const h = region.rowSpan * gridSettings.cellHeight; + + // Draw filled rectangle with border + overlay.rect(x, y, w, h); + overlay.fill({ color: 0xff9900, alpha: 0.25 }); + overlay.stroke({ color: 0xff9900, width: 2, alpha: 1 }); + + canvasArea.addChild(overlay); + regionOverlays.push(overlay); + } + } + + // === JOIN SELECTED CELLS === + function joinSelectedCells(): void { + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet || selectedCells.length < 2) { + console.log('Select at least 2 cells to join (Shift+click to multi-select)'); + return; + } + + const region = projectManager.joinCells(activeSheet.id, selectedCells); + if (region) { + console.log(`Created tile region: ${region.colSpan}x${region.rowSpan} cells`); + clearSelection(); + drawTileRegions(activeSheet.gridSettings); + } else { + console.log('Cannot join cells: selection must form a complete rectangle with no overlapping regions'); + } + } + + // === SPLIT REGION AT SELECTION === + function splitRegionAtSelection(): void { + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet || selectedCells.length === 0) { + console.log('Select a cell within a region to split it'); + return; + } + + // Find region at first selected cell + const cell = selectedCells[0]; + const region = projectManager.getRegionAtCell(activeSheet.id, cell.col, cell.row); + if (region) { + projectManager.splitRegion(activeSheet.id, region.id); + console.log('Split region back to individual cells'); + clearSelection(); + drawTileRegions(activeSheet.gridSettings); + } else { + console.log('No tile region at selected cell'); + } + } + + // === ANIMATION CREATION MODE === + function enterAnimationMode(): void { + if (animationCreationMode) return; + + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet) return; + + animationCreationMode = true; + animationFrames = []; + clearAnimationOverlays(); + clearSelection(); + + // Update config panel indicator + configPanel.setAnimationMode(true, 0); + + infoLabel.setText('🎬 Animation Mode: Click cells in order | Enter to save | Escape to cancel'); + } + + function exitAnimationMode(save: boolean = false): void { + if (!animationCreationMode) return; + + if (save && animationFrames.length > 0) { + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + // Prompt for animation name + const name = prompt('Animation name:', `animation_${projectManager.getAnimations(activeSheet.id).length + 1}`); + if (name) { + const animation = projectManager.addAnimation(activeSheet.id, animationFrames, name); + if (animation) { + console.log(`Created animation: ${animation.name} with ${animationFrames.length} frames`); + updateLibraryPanel(); + } + } + } + } + + animationCreationMode = false; + animationFrames = []; + clearAnimationOverlays(); + hideAnimationPreview(); + + // Hide config panel indicator + configPanel.setAnimationMode(false); + + updateInfoLabel(); + } + + function clearAnimationOverlays(): void { + for (const overlay of animationNumberOverlays) { + canvasArea.removeChild(overlay); + overlay.destroy({ children: true }); + } + animationNumberOverlays = []; + } + + function addAnimationFrame(col: number, row: number): void { + if (!animationCreationMode) return; + + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet) return; + + const { cellWidth, cellHeight } = activeSheet.gridSettings; + + // Check if already in sequence + const existingIdx = animationFrames.findIndex(f => f.col === col && f.row === row); + if (existingIdx >= 0) { + // Remove this frame and all after it + animationFrames = animationFrames.slice(0, existingIdx); + // Rebuild overlays + clearAnimationOverlays(); + animationFrames.forEach((f, i) => { + createFrameNumberOverlay(f.col, f.row, i + 1, cellWidth, cellHeight); + }); + // Update config panel indicator with new frame count + configPanel.setAnimationMode(true, animationFrames.length); + infoLabel.setText(`🎬 Animation Mode: ${animationFrames.length} frame(s) | Enter to save | Escape to cancel`); + updateAnimationPreview(); + return; + } + + // Add new frame + animationFrames.push({ col, row }); + createFrameNumberOverlay(col, row, animationFrames.length, cellWidth, cellHeight); + + // Update config panel indicator with frame count + configPanel.setAnimationMode(true, animationFrames.length); + + infoLabel.setText(`🎬 Animation Mode: ${animationFrames.length} frame(s) | Enter to save | Escape to cancel`); + updateAnimationPreview(); + } + + function createFrameNumberOverlay(col: number, row: number, frameNum: number, cellWidth: number, cellHeight: number): void { + const container = new Container(); + + // Simple semi-transparent overlay + const overlay = new Graphics(); + overlay.rect(col * cellWidth, row * cellHeight, cellWidth, cellHeight); + overlay.fill({ color: 0x000000, alpha: 0.4 }); + container.addChild(overlay); + + // Number text centered in cell + const numText = new Text({ + text: String(frameNum), + style: { + fontSize: Math.min(cellWidth, cellHeight) * 0.6, + fill: 0xffffff, + fontFamily: 'Arial', + fontWeight: 'bold', + stroke: { color: 0x000000, width: 3 } + } + }); + numText.anchor.set(0.5, 0.5); + numText.x = col * cellWidth + cellWidth / 2; + numText.y = row * cellHeight + cellHeight / 2; + container.addChild(numText); + + canvasArea.addChild(container); + animationNumberOverlays.push(container); + } + + /** + * Update the animation preview with current frames + */ + function updateAnimationPreview(): void { + // Clean up existing preview + if (animationPreviewSprite) { + animationPreviewSprite.stop(); + animationPreviewSprite.destroy(); + animationPreviewSprite = null; + } + + if (!animationPreviewContainer) { + // Create preview container (positioned in bottom-left of canvas area) + animationPreviewContainer = new Container(); + + // Background + const bg = new Graphics(); + bg.roundRect(0, 0, 100, 120, 6); + bg.fill({ color: 0x222226, alpha: 0.95 }); + bg.stroke({ color: 0x9acd32, width: 2 }); + animationPreviewContainer.addChild(bg); + + // Label + const label = new Text({ + text: 'Preview', + style: { fontSize: 10, fill: 0x9acd32, fontFamily: 'Arial' } + }); + label.x = 6; + label.y = 4; + animationPreviewContainer.addChild(label); + + // Position at bottom-left of content area + animationPreviewContainer.x = 10; + animationPreviewContainer.y = contentHeight - 130; + scene.addChild(animationPreviewContainer); + } + + // Need at least 1 frame to show preview + if (animationFrames.length === 0 || !spriteDisplay) { + animationPreviewContainer.visible = false; + return; + } + + animationPreviewContainer.visible = true; + + const activeSheet = projectManager.getActiveSheet(); + if (!activeSheet) return; + + const { cellWidth, cellHeight } = activeSheet.gridSettings; + const sheetTexture = spriteDisplay.texture; + + // Create textures for each frame + const textures: Texture[] = []; + for (const frame of animationFrames) { + const frameRect = new Rectangle( + frame.col * cellWidth, + frame.row * cellHeight, + cellWidth, + cellHeight + ); + const frameTexture = new Texture({ + source: sheetTexture.source, + frame: frameRect + }); + textures.push(frameTexture); + } + + // Create animated sprite + animationPreviewSprite = new AnimatedSprite(textures); + animationPreviewSprite.animationSpeed = 0.1; + animationPreviewSprite.play(); + + // Scale to fit preview area (80x80 max) + const maxSize = 80; + const scale = Math.min(maxSize / cellWidth, maxSize / cellHeight, 2); + animationPreviewSprite.scale.set(scale); + + // Center in preview box + animationPreviewSprite.x = 50 - (cellWidth * scale) / 2; + animationPreviewSprite.y = 65 - (cellHeight * scale) / 2; + + animationPreviewContainer.addChild(animationPreviewSprite); + } + + /** + * Hide and clean up animation preview + */ + function hideAnimationPreview(): void { + if (animationPreviewSprite) { + animationPreviewSprite.stop(); + animationPreviewSprite.destroy(); + animationPreviewSprite = null; + } + if (animationPreviewContainer) { + animationPreviewContainer.visible = false; + } + } + + // === KEYBOARD SHORTCUTS === + function handleKeyDown(e: KeyboardEvent): void { + // Ctrl+A (or Cmd+A on Mac): Enter animation creation mode + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + enterAnimationMode(); + return; + } + + // Enter: Save animation (in animation mode) + if (e.key === 'Enter' && animationCreationMode) { + e.preventDefault(); + exitAnimationMode(true); + return; + } + + // Ctrl+J: Join selected cells + if (e.ctrlKey && e.key === 'j') { + e.preventDefault(); + joinSelectedCells(); + return; + } + + // Ctrl+K: Split region + if (e.ctrlKey && e.key === 'k') { + e.preventDefault(); + splitRegionAtSelection(); + return; + } + + // Escape: Cancel animation mode or clear selection + if (e.key === 'Escape') { + if (animationCreationMode) { + exitAnimationMode(false); + } else { + clearSelection(); + } + return; + } + } + + document.addEventListener('keydown', handleKeyDown); + + // === RESTORE PROJECT FROM STORAGE === + async function restoreProject(): Promise { + const sheets = projectManager.getAllSheets(); + if (sheets.length === 0) return; + + // Restore carousel items + const carouselItems: CarouselItem[] = []; + for (const sheet of sheets) { + try { + const img = new Image(); + await new Promise((resolve) => { + img.onload = () => resolve(); + img.onerror = () => resolve(); // Skip failed images + img.src = sheet.imageDataUrl; + }); + + if (img.width > 0) { + const texture = Texture.from(img); + carouselItems.push({ + id: sheet.id, + texture, + label: sheet.name + }); + } + } catch (err) { + console.warn(`Failed to restore sheet: ${sheet.name}`); + } + } + + carousel.setItems(carouselItems); + + // Select active sheet + const activeSheet = projectManager.getActiveSheet(); + if (activeSheet) { + carousel.selectById(activeSheet.id); + await displaySpriteSheet(activeSheet, true); // Restore zoom on initial load + } + + configPanel.setHasSheets(sheets.length > 0); + console.log(`Restored project with ${sheets.length} sheet(s)`); + } + + // Initialize scene + scene.init(); + engine.start(); + + // Restore project + await restoreProject(); + + console.log('TileMapMatic loaded'); + + // Return cleanup function + return () => { + document.removeEventListener('keydown', handleKeyDown); + focusManager.destroy(); + if (panZoom) panZoom.destroy(); + fileInput.remove(); + engine.stop(); + scene.destroy({ children: true }); + }; +} diff --git a/packages/tilemap-matic/tsconfig.json b/packages/tilemap-matic/tsconfig.json new file mode 100644 index 00000000..3b3ee31b --- /dev/null +++ b/packages/tilemap-matic/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "declaration": true, + "declarationDir": "./dist/types", + "outDir": "./dist", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": false, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "paths": { + "@moxijs/core": ["../core/src/index.ts"], + "@moxijs/ui": ["../ui/src/index.ts"], + "moxi": ["../core/src/index.ts"] + }, + "baseUrl": "." + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/tests/**", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/tilemap-matic/webpack.config.js b/packages/tilemap-matic/webpack.config.js new file mode 100644 index 00000000..8b42bbcd --- /dev/null +++ b/packages/tilemap-matic/webpack.config.js @@ -0,0 +1,123 @@ +const path = require('path'); + +const isProduction = process.env.NODE_ENV === 'production'; + +// Library build config (for use as npm package) +const libraryConfig = { + mode: 'production', + entry: './src/index.ts', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'index.js', + library: { + name: 'TileMapMatic', + type: 'umd', + umdNamedDefine: true + }, + globalObject: 'this', + clean: true + }, + resolve: { + extensions: ['.ts', '.tsx', '.js'], + alias: { + '@moxijs/core': path.resolve(__dirname, '../core/src/index.ts'), + '@moxijs/ui': path.resolve(__dirname, '../ui/src/index.ts'), + 'moxi': path.resolve(__dirname, '../core/src/index.ts'), + 'pixi.js': path.resolve(__dirname, '../../node_modules/pixi.js') + } + }, + externals: { + 'pixi.js': { + commonjs: 'pixi.js', + commonjs2: 'pixi.js', + amd: 'pixi.js', + root: 'PIXI' + }, + '@moxijs/core': { + commonjs: '@moxijs/core', + commonjs2: '@moxijs/core', + amd: '@moxijs/core', + root: 'moxi' + }, + '@moxijs/ui': { + commonjs: '@moxijs/ui', + commonjs2: '@moxijs/ui', + amd: '@moxijs/ui', + root: 'moxiUI' + } + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: [ + { + loader: 'ts-loader', + options: { + compilerOptions: { + declaration: true, + declarationDir: './dist/types' + } + } + } + ], + exclude: /node_modules/ + } + ] + }, + devtool: 'source-map' +}; + +// Dev server / app build config (for standalone development and GitHub Pages) +const devConfig = { + mode: 'development', + entry: './src/main.ts', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'bundle.js', + clean: true + }, + resolve: { + extensions: ['.ts', '.tsx', '.js'], + alias: { + '@moxijs/core': path.resolve(__dirname, '../core/src/index.ts'), + '@moxijs/ui': path.resolve(__dirname, '../ui/src/index.ts'), + 'moxi': path.resolve(__dirname, '../core/src/index.ts'), + 'pixi.js': path.resolve(__dirname, '../../node_modules/pixi.js') + } + }, + externalsType: 'window', + externals: { + 'pixi.js': 'PIXI' + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + devServer: { + static: { + directory: path.join(__dirname), + watch: true + }, + compress: true, + port: 9002, + hot: true + }, + devtool: 'source-map' +}; + +module.exports = (env, argv) => { + // If mode is explicitly set via CLI (e.g., --mode production), use that + // Otherwise fall back to NODE_ENV + const mode = argv?.mode || (isProduction ? 'production' : 'development'); + + if (mode === 'production') { + return libraryConfig; + } + return devConfig; +}; diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index bf3039b2..ee5e14ad 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.5] - 2025-12-11 + +### CardPanel Improvements & FlexContainerPanel + +This release adds FlexContainerPanel for wrapping FlexContainer layouts in CardPanel chrome, and fixes CardPanel resize handle positioning. + +### Added + +#### FlexContainerPanel +- New `FlexContainerPanel` component combining CardPanel chrome with FlexContainer body +- Supports all CardPanel options (title, draggable, resizable, collapsible, shadows) +- FlexContainer body auto-flows content with flexbox layout +- Body styling via `bodyGap`, `bodyPadding`, `bodyDirection`, `bodyWrap`, `bodyJustify`, `bodyAlign` +- Access inner FlexContainer via `body` property + +#### UIComponent +- `isVisible` getter to check visibility state + +#### FlexContainer +- Debug visualization mode with `setDebugMode(enabled)` +- Shows layout direction, alignment arrows, gap indicators, and dimension labels +- `setBorder(color, width, radius)` method for visual borders + +### Changed + +#### CardPanel +- Resize handles now respect title bar height, preventing overlap with title area +- North handle disabled when title bar present +- Corner handles (NE, NW) start below title bar +- Edge handles (E, W) offset by title bar height + +### Fixed +- CardPanel resize handles no longer overlap with title bar drag area +- Proper resize handle hit areas when title bar is present + ## [0.3.4] - 2025-12-09 ### Font Standardization & MSDF Fixes diff --git a/packages/ui/llms.txt b/packages/ui/llms.txt index 5611a6d8..67fe22dc 100644 --- a/packages/ui/llms.txt +++ b/packages/ui/llms.txt @@ -1,5 +1,12 @@ # @moxijs/ui + +## API Quick Reference + +**Classes:** CardPanel(destroy, getBodyContainer, getBodySize, getFooterContainer, getTitleBarContainer...) | EdgeInsets(clone, equals, horizontal, isZero, toString...) | FlatCardStyle(drawBackground, drawDragStrip, drawFooter, drawTitleBar, getBorderInsets...) | FlexContainer(addChild, layout, measure, removeAllChildren, removeChild) | FlexContainerPanel(addChild, destroy, getContentContainer, layout, measure...) | FlexLayoutEngine(compute) | LayoutDebugOverlay(dispose, hide, setOptions, show, toggle...) | LayoutEngine(layout, measure, position) | LayoutTree(addChild, computeNow, createNode, dispose, find...) | ThemeApplier(applyBackground, applyBorderColor, applyCheckmarkColor, applyPlaceholderColor, applyTextColor...) | ThemeResolver(getCheckmarkColor, getColor, getControlColor, getControlProperty, getPlaceholderColor...) | UIButton(destroy, getState, layout, measure, setEnabled...) | UICheckbox(destroy, getChecked, layout, measure, setChecked...) | UICheckboxWithLabel(destroy, getCheckbox, getChecked, getLabel, layout...) | UIFocusManager(blur, clear, destroy, focus, focusFirst...) | UILabel(getFontType, getText, isBitmapText, layout, measure...) | UIPanel(layout, measure, setBackgroundColor, setBorder, setTexture) | UIRadioButton(destroy, getSelected, layout, measure, setDisabled...) | UIRadioGroup(destroy, getValue, layout, measure, setDisabled...) | UIScrollContainer(addChild, destroy, getMaxScrollY, getScrollPaddingBottom, getScrollY...) | UISelect(destroy, getValue, layout, measure, setOptions...) | UITabs(destroy, getActiveKey, layout, measure, render...) | UITextArea(destroy, getHeight, getValue, getWidth, layout...) | UITextInput(destroy, getHeight, getValue, getWidth, layout...) +**Interfaces:** BaseTheme, BoxModel, ButtonBackgroundStrategy, CardPanelFooter, CardPanelProps, CardPanelTitle, CardSectionDimensions, CardStyle, CardThemeColors, ComponentState, ComputedLayout, ComputedLayout, DefaultUITheme, DirtyInfo, FlatCardStyleConfig, FlexContainerPanelProps, FlexContainerProps, FlexItem, FlexLine, FlexProps, Focusable, FontProps, FormStateConfig, IFlexLayoutParticipant, IconConfig, LayoutDebugOverlayOptions, LayoutNode, LayoutStyle, LayoutTreeOptions, LayoutWrapperOptions, LayoutWrapperStyle, MeasuredLayout, MeasuredSize, NineSliceConfig, ParsedSize, RadioOption, ResolvedStyle, SelectOption, SizeConstraints, SpriteBackgroundConfig, TabItem, ThemeInfo, UIButtonProps, UICheckboxProps, UICheckboxWithLabelProps, UIFontConfig, UILabelProps, UILayerOptions, UILayoutConfig, UIPanelProps, UIRadioButtonProps, UIRadioGroupProps, UIScrollContainerProps, UISelectProps, UITabsProps, UITextAreaProps, UITextInputProps + + > UI component library for PIXI.js games with theming and flexbox layout. ## Install diff --git a/packages/ui/src/base/ui-component.ts b/packages/ui/src/base/ui-component.ts index 915332aa..e37ec8d5 100644 --- a/packages/ui/src/base/ui-component.ts +++ b/packages/ui/src/base/ui-component.ts @@ -46,6 +46,19 @@ export interface UILayoutConfig { * Implements IFlexLayoutParticipant for integration with the new flex layout system. * Components can be added to a LayoutTree for automatic layout management. * + * IMPORTANT: UI components use a two-phase lifecycle: + * 1. Construction - creates the component structure + * 2. layout() - measures and positions internal elements + * + * When using FlexContainer, layout() is called automatically on children. + * When adding directly to a PIXI.Container, you MUST call layout() manually: + * + * ```typescript + * const btn = new UIButton({ label: 'Click', width: 100, height: 32 }); + * btn.layout(100, 32); // Required for label to render! + * pixiContainer.addChild(btn.container); + * ``` + * * @category UI */ export abstract class UIComponent implements IFlexLayoutParticipant { diff --git a/packages/ui/src/base/ui-focus-manager.ts b/packages/ui/src/base/ui-focus-manager.ts index 6fdc6354..4a3903b2 100644 --- a/packages/ui/src/base/ui-focus-manager.ts +++ b/packages/ui/src/base/ui-focus-manager.ts @@ -149,10 +149,23 @@ export class UIFocusManager { } /** - * Sorts components by tab index + * Sorts components by tab index, then by visual position (top-to-bottom, left-to-right) + * This matches HTML's default tab behavior when tabIndex values are equal */ private sortByTabIndex(): void { - this.focusableComponents.sort((a, b) => a.tabIndex - b.tabIndex); + this.focusableComponents.sort((a, b) => { + // Primary sort: tabIndex (lower values first) + if (a.tabIndex !== b.tabIndex) { + return a.tabIndex - b.tabIndex; + } + // Secondary sort: visual position (top-to-bottom, then left-to-right) + const posA = a.container.getGlobalPosition(); + const posB = b.container.getGlobalPosition(); + if (posA.y !== posB.y) { + return posA.y - posB.y; + } + return posA.x - posB.x; + }); } /** diff --git a/packages/ui/src/components/card-panel/card-panel.ts b/packages/ui/src/components/card-panel/card-panel.ts index 36255175..155866cc 100644 --- a/packages/ui/src/components/card-panel/card-panel.ts +++ b/packages/ui/src/components/card-panel/card-panel.ts @@ -154,6 +154,7 @@ export class CardPanel extends UIComponent { // Containers private bodyContainer: PIXI.Container; private footerContainer: PIXI.Container; + private titleBarContainer: PIXI.Container; private bodyMask: PIXI.Graphics | null = null; // Title elements @@ -203,10 +204,12 @@ export class CardPanel extends UIComponent { // Create containers this.bodyContainer = new PIXI.Container(); this.footerContainer = new PIXI.Container(); + this.titleBarContainer = new PIXI.Container(); // Add to main container in correct order this.container.addChild(this.backgroundGraphics); this.container.addChild(this.headerGraphics); + this.container.addChild(this.titleBarContainer); this.container.addChild(this.bodyContainer); this.container.addChild(this.footerGraphics); this.container.addChild(this.footerContainer); @@ -237,6 +240,28 @@ export class CardPanel extends UIComponent { return this.footerContainer; } + /** + * Get the title bar container for adding custom elements (like menu buttons) + * Elements added here will appear on top of the title bar + */ + public getTitleBarContainer(): PIXI.Container { + return this.titleBarContainer; + } + + /** + * Get the title bar height (useful for positioning custom elements) + */ + public getTitleBarHeight(): number { + const hasTitle = !!this.props.title?.text; + const isDraggable = this.props.draggable ?? false; + if (hasTitle) { + return this.cardStyle.getTitleBarHeight(true); + } else if (isDraggable) { + return this.cardStyle.getDragStripHeight(); + } + return 0; + } + /** * Get current body dimensions */ @@ -257,6 +282,26 @@ export class CardPanel extends UIComponent { this.props.onResize?.(width, height); } + /** + * Auto-size body to fit content. + * Measures the bounds of all children in the body container and resizes the panel. + * @param padding Optional extra padding to add around content bounds + */ + public sizeToFit(padding: number = 0): void { + // Get bounds of all children in body container + const bounds = this.bodyContainer.getLocalBounds(); + + if (bounds.width > 0 && bounds.height > 0) { + // Size body to fit content bounds plus any padding + const newWidth = Math.max(this.props.bodyWidth, bounds.x + bounds.width + padding); + const newHeight = Math.max(this.props.bodyHeight, bounds.y + bounds.height + padding); + + this.props.bodyWidth = newWidth; + this.props.bodyHeight = newHeight; + this.redraw(); + } + } + /** * Update title text */ @@ -355,6 +400,9 @@ export class CardPanel extends UIComponent { this.clearTitle(); } + // Position title bar container (for custom elements like menu buttons) + this.titleBarContainer.position.set(borderInsets.left, borderInsets.top); + // Position body container const bodyX = borderInsets.left + contentPadding.left; const bodyY = borderInsets.top + headerHeight + contentPadding.top; @@ -578,30 +626,42 @@ export class CardPanel extends UIComponent { const edgeThickness = 8; const width = this.computedLayout.width; const height = this.computedLayout.height; + const titleBarHeight = this.getTitleBarHeight(); for (const [direction, handle] of this.resizeHandles) { handle.clear(); - // Draw hit area - use a visible color for debugging, can set alpha to 0 later - // For now using slight visibility so we can see the handles + // Draw hit area - handles should not overlap title bar + // Use titleBarHeight as top offset for vertical handles switch (direction) { case 'n': - handle.rect(handleSize, 0, width - handleSize * 2, edgeThickness); + // North handle at top edge (only if no title bar, otherwise skip) + if (titleBarHeight === 0) { + handle.rect(handleSize, 0, width - handleSize * 2, edgeThickness); + } break; case 's': handle.rect(handleSize, height - edgeThickness, width - handleSize * 2, edgeThickness); break; case 'e': - handle.rect(width - edgeThickness, handleSize, edgeThickness, height - handleSize * 2); + // East handle starts below title bar + handle.rect(width - edgeThickness, titleBarHeight + handleSize, edgeThickness, height - titleBarHeight - handleSize * 2); break; case 'w': - handle.rect(0, handleSize, edgeThickness, height - handleSize * 2); + // West handle starts below title bar + handle.rect(0, titleBarHeight + handleSize, edgeThickness, height - titleBarHeight - handleSize * 2); break; case 'ne': - handle.rect(width - handleSize, 0, handleSize, handleSize); + // Northeast corner - only if no title bar + if (titleBarHeight === 0) { + handle.rect(width - handleSize, 0, handleSize, handleSize); + } break; case 'nw': - handle.rect(0, 0, handleSize, handleSize); + // Northwest corner - only if no title bar + if (titleBarHeight === 0) { + handle.rect(0, 0, handleSize, handleSize); + } break; case 'se': handle.rect(width - handleSize, height - handleSize, handleSize, handleSize); @@ -611,8 +671,8 @@ export class CardPanel extends UIComponent { break; } - // Fill AFTER drawing the shape - using slight alpha for visibility during debug - handle.fill({ color: 0x4a90e2, alpha: 0.3 }); + // Fill AFTER drawing the shape - invisible hit areas + handle.fill({ color: 0x4a90e2, alpha: 0 }); } } diff --git a/packages/ui/src/components/ui-button.ts b/packages/ui/src/components/ui-button.ts index 95cfc8bd..c503c4ea 100644 --- a/packages/ui/src/components/ui-button.ts +++ b/packages/ui/src/components/ui-button.ts @@ -102,6 +102,10 @@ export interface UIButtonProps { * pressed: pressedTexture * } * }); + * + * // When width/height are provided, layout() is called automatically: + * const btn = new UIButton({ label: 'Click Me', width: 100, height: 32 }); + * myContainer.addChild(btn.container); // Ready to use immediately * ``` */ export class UIButton extends UIComponent { @@ -204,6 +208,12 @@ export class UIButton extends UIComponent { if (this.enabled === false) { this.setState(ButtonState.Disabled); } + + // Auto-layout if dimensions provided + // This ensures the button is ready to use immediately without manual layout() call + if (initialWidth > 0 && initialHeight > 0) { + this.layout(initialWidth, initialHeight); + } } /** @@ -253,7 +263,7 @@ export class UIButton extends UIComponent { } private handleKeyDown(e: KeyboardEvent): void { - if (this.isFocused() && this.props.enabled) { + if (this.isFocused() && this.enabled) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); @@ -265,7 +275,7 @@ export class UIButton extends UIComponent { // Return to hover state after a short delay (simulating button release) setTimeout(() => { - if (this.props.enabled) { + if (this.enabled) { this.setState(ButtonState.Normal); } }, 100); @@ -274,7 +284,7 @@ export class UIButton extends UIComponent { } private handlePointerOver(): void { - if (this.props.enabled && this.state === ButtonState.Normal) { + if (this.enabled && this.state === ButtonState.Normal) { this.hovered = true; this.setState(ButtonState.Hover); this.safeInvokeCallback(this.onHover, 'onHover'); @@ -282,14 +292,14 @@ export class UIButton extends UIComponent { } private handlePointerOut(): void { - if (this.props.enabled && this.state !== ButtonState.Pressed) { + if (this.enabled && this.state !== ButtonState.Pressed) { this.hovered = false; this.setState(ButtonState.Normal); } } private handlePointerDown(): void { - if (this.props.enabled) { + if (this.enabled) { this.pressed = true; this.setState(ButtonState.Pressed); @@ -307,7 +317,7 @@ export class UIButton extends UIComponent { } private handlePointerUp(): void { - if (this.props.enabled) { + if (this.enabled) { this.pressed = false; this.hovered = true; this.setState(ButtonState.Hover); @@ -316,7 +326,7 @@ export class UIButton extends UIComponent { } private handlePointerUpOutside(): void { - if (this.props.enabled) { + if (this.enabled) { this.pressed = false; this.hovered = false; this.setState(ButtonState.Normal); diff --git a/packages/ui/src/components/ui-tabs.ts b/packages/ui/src/components/ui-tabs.ts index 9386f49a..df779c4e 100644 --- a/packages/ui/src/components/ui-tabs.ts +++ b/packages/ui/src/components/ui-tabs.ts @@ -306,6 +306,9 @@ export class UITabs extends UIComponent { const activeItem = this.items.find(item => item.key === this.activeKey); if (!activeItem) return; + // Set parent for font inheritance + activeItem.content.parent = this; + // Add content const contentHeight = this.height - this.tabBarHeight; activeItem.content.layout(this.width, contentHeight); diff --git a/packages/ui/src/components/ui-text-input.ts b/packages/ui/src/components/ui-text-input.ts index f34fd5d4..c9d9ab53 100644 --- a/packages/ui/src/components/ui-text-input.ts +++ b/packages/ui/src/components/ui-text-input.ts @@ -2,14 +2,10 @@ import * as PIXI from 'pixi.js'; import { UIComponent, FontType } from '../base/ui-component'; import { BoxModel, MeasuredSize } from '../base/box-model'; import { UIPanel } from './ui-panel'; -import { EdgeInsets } from '../base/edge-insets'; import { UIFocusManager } from '../base/ui-focus-manager'; import { asTextDPR, ActionManager } from '@moxijs/core'; import { ThemeResolver } from '../theming/theme-resolver'; -import { - FormStateManager, - TextInputHandler -} from '../services'; +import { FormStateManager } from '../services'; import { UI_DEFAULTS } from '../theming/theme-data'; /** @@ -55,7 +51,7 @@ export interface UITextInputProps { * - 'canvas' (default): Standard PIXI.Text with DPR scaling * - 'msdf': Multi-channel Signed Distance Field for crisp text at any scale * - 'bitmap': Pre-rendered bitmap font atlas - * + * * Note: Text inputs currently only support 'canvas' mode for cursor positioning. */ fontType?: FontType; @@ -87,17 +83,18 @@ export class UITextInput extends UIComponent { private localFontFamily?: string; /** Local fontType prop (can be overridden by parent inheritance) */ private localFontType?: FontType; - - // Services (composition) + + // State management private stateManager: FormStateManager; - private inputHandler: TextInputHandler; - // ThemeApplier removed - using base class helpers - + + // Cursor state (inlined from TextInputHandler) + private cursorPosition: number = 0; + // Visual elements private background: UIPanel; private textDisplay: PIXI.Text; private cursor: PIXI.Graphics; - + // Cursor blink state private cursorBlinkInterval?: number; private cursorVisible: boolean = true; @@ -105,8 +102,6 @@ export class UITextInput extends UIComponent { // Event listener management private actions = new ActionManager(); - // State is now in base class (enabled, focused, hovered, pressed) - // Theme resolver is now in base class private colorOverrides: { backgroundColor?: number; @@ -144,19 +139,15 @@ export class UITextInput extends UIComponent { // Initialize theme resolver this.themeResolver = props.themeResolver; - + this.stateManager = new FormStateManager({ value: props.value, defaultValue: props.defaultValue, onChange: props.onChange }); - // Initialize input handler - it now uses stateManager directly - this.inputHandler = new TextInputHandler({ - stateManager: this.stateManager, - maxLength: this.props.maxLength, - type: this.props.type, - multiline: false - }); + + // Initialize cursor position + this.cursorPosition = this.stateManager.getValue().length; // Update component state this.enabled = !this.props.disabled; @@ -173,6 +164,12 @@ export class UITextInput extends UIComponent { // Setup interactivity this.setupInteractivity(); + + // Auto-layout if dimensions provided + // This ensures the input is ready to use immediately without manual layout() call + if (this.props.width > 0 && this.props.height > 0) { + this.layout(this.props.width, this.props.height); + } } /** @@ -181,7 +178,7 @@ export class UITextInput extends UIComponent { private createVisuals(): void { // Create background const bgColor = this.resolveColor('background', this.colorOverrides.backgroundColor); - + this.background = new UIPanel({ backgroundColor: bgColor, width: this.props.width, @@ -265,10 +262,9 @@ export class UITextInput extends UIComponent { if (this.focused) return; super.onFocus(); - // focused is already set by super.onFocus() - - // Set cursor position - this.inputHandler.setCursorPosition(this.stateManager.getValue().length); + + // Set cursor position to end + this.cursorPosition = this.stateManager.getValue().length; this.cursor.visible = true; this.updateCursor(); this.startCursorBlink(); @@ -284,7 +280,6 @@ export class UITextInput extends UIComponent { if (!this.focused) return; super.onBlur(); - // focused is already set by super.onBlur() this.cursor.visible = false; this.stopCursorBlink(); @@ -292,42 +287,124 @@ export class UITextInput extends UIComponent { this.updateBackground(); } + /** + * Handle keyboard input (inlined from TextInputHandler) + */ private handleKeyDown(e: KeyboardEvent): void { if (!this.focused || this.props.disabled) return; - // Handle Enter/Escape for blur (before delegating to handler) - if (e.key === 'Enter' || e.key === 'Escape') { + const key = e.key; + + // Handle Enter/Escape for blur + if (key === 'Enter' || key === 'Escape') { this.onBlur(); return; } - // Delegate keyboard handling to TextInputHandler - const handled = this.inputHandler.handleKeyDown(e); + let handled = false; + + if (key === 'Backspace') { + this.handleBackspace(); + handled = true; + } else if (key === 'Delete') { + this.handleDelete(); + handled = true; + } else if (key === 'ArrowLeft') { + this.moveCursor(-1); + handled = true; + } else if (key === 'ArrowRight') { + this.moveCursor(1); + handled = true; + } else if (key === 'Home') { + this.cursorPosition = 0; + handled = true; + } else if (key === 'End') { + this.cursorPosition = this.stateManager.getValue().length; + handled = true; + } else if (key.length === 1 && !e.ctrlKey && !e.metaKey) { + // Character input with type validation + if (this.props.type === 'number' && !/[0-9.-]/.test(key)) { + handled = true; // Consume invalid character + } else { + this.insertCharacter(key); + handled = true; + } + } + if (handled) { e.preventDefault(); e.stopPropagation(); - // Handler updates stateManager directly, so we just need to update visuals this.updateText(); this.updateCursor(); } } + /** + * Insert a character at cursor position + */ + private insertCharacter(char: string): void { + const value = this.stateManager.getValue(); + if (value.length >= this.props.maxLength) return; + + const newValue = + value.substring(0, this.cursorPosition) + + char + + value.substring(this.cursorPosition); + this.stateManager.setValue(newValue); + this.cursorPosition++; + } + + /** + * Handle backspace key + */ + private handleBackspace(): void { + if (this.cursorPosition > 0) { + const value = this.stateManager.getValue(); + const newValue = + value.substring(0, this.cursorPosition - 1) + + value.substring(this.cursorPosition); + this.stateManager.setValue(newValue); + this.cursorPosition--; + } + } + + /** + * Handle delete key + */ + private handleDelete(): void { + const value = this.stateManager.getValue(); + if (this.cursorPosition < value.length) { + const newValue = + value.substring(0, this.cursorPosition) + + value.substring(this.cursorPosition + 1); + this.stateManager.setValue(newValue); + } + } + + /** + * Move cursor horizontally + */ + private moveCursor(direction: number): void { + const value = this.stateManager.getValue(); + this.cursorPosition = Math.max(0, Math.min(this.cursorPosition + direction, value.length)); + } + /** * Update text display */ private updateText(): void { const value = this.stateManager.getValue(); const displayText = this.getDisplayText(); - + this.textDisplay.text = displayText; - + // Update text color based on whether showing placeholder const textColor = value ? this.resolveTextColor(this.colorOverrides.textColor) : this.resolvePlaceholderColor(this.colorOverrides.placeholderColor); - + this.textDisplay.style.fill = textColor; - this.updateCursor(); + // Note: cursor update is handled separately by caller to avoid double updates } /** @@ -347,42 +424,47 @@ export class UITextInput extends UIComponent { */ private updateCursor(): void { const value = this.stateManager.getValue(); - const cursorPos = this.inputHandler.getCursorPosition(); - const textUpToCursor = value.substring(0, cursorPos); - + const textUpToCursor = value.substring(0, this.cursorPosition); + // Ensure cursor position is valid - if (cursorPos > value.length) { - this.inputHandler.setCursorPosition(value.length); + if (this.cursorPosition > value.length) { + this.cursorPosition = value.length; } - const dprScale = UI_DEFAULTS.DPR_SCALE; - // Use same font family as display text + + // Measure text width using canvas context (much faster than creating PIXI.Text) + // Must use scaled font size to match asTextDPR rendering (fontSize * dprScale, then scaled down) const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; - const tempText = new PIXI.Text({ - text: textUpToCursor, - style: { - fontFamily: effectiveFontFamily, // Match the display font - fontSize: this.props.fontSize * dprScale, // Account for DPR scaling - fill: this.textDisplay.style.fill, - align: 'left' - } - }); - // Ensure effects is initialized to prevent null reference errors - if (tempText.effects === null) { - tempText.effects = []; - } + const scaledFontSize = this.props.fontSize * UI_DEFAULTS.DPR_SCALE; + const scaledWidth = this.measureTextWidth(textUpToCursor, effectiveFontFamily, scaledFontSize); + // Scale back down to match the visual text width + const textWidth = scaledWidth / UI_DEFAULTS.DPR_SCALE; - // Account for DPR scaling when calculating cursor position - const cursorX = 12 + (tempText.width / dprScale); + const cursorX = 12 + textWidth; const cursorY = (this.props.height - this.props.fontSize) / 2; this.cursor.clear(); this.cursor.rect(cursorX, cursorY, 1, this.props.fontSize); const cursorColor = this.resolveTextColor(this.colorOverrides.textColor); this.cursor.fill({ color: cursorColor }); + } - tempText.destroy(); + /** + * Measure text width using canvas 2D context (much faster than PIXI.Text) + */ + private measureTextWidth(text: string, fontFamily: string, fontSize: number): number { + // Use a shared canvas context for text measurement + if (!UITextInput.measureCanvas) { + UITextInput.measureCanvas = document.createElement('canvas'); + UITextInput.measureContext = UITextInput.measureCanvas.getContext('2d')!; + } + UITextInput.measureContext.font = `${fontSize}px ${fontFamily}`; + return UITextInput.measureContext.measureText(text).width; } + // Shared canvas for text measurement (static to avoid creating per instance) + private static measureCanvas: HTMLCanvasElement; + private static measureContext: CanvasRenderingContext2D; + /** * Starts cursor blinking animation */ @@ -414,7 +496,7 @@ export class UITextInput extends UIComponent { width: this.props.width, height: this.props.height }; - + return this.layoutEngine.measure(this.boxModel, contentSize); } @@ -437,12 +519,27 @@ export class UITextInput extends UIComponent { } /** - * Sets the value programmatically + * Sets the value programmatically without triggering onChange. + * Use this when setting value from external state (e.g., parent component). */ setValue(value: string): void { - this.stateManager.setValue(value); - this.inputHandler.setValue(value); - this.inputHandler.setCursorPosition(value.length); + const currentValue = this.stateManager.getValue(); + + // If value hasn't changed, don't do anything (preserves cursor position) + if (currentValue === value) { + return; + } + + this.stateManager.setValueSilent(value); + + // Only reset cursor to end if not focused (external update) + // If focused, user is actively typing - preserve cursor position within bounds + if (this.focused) { + this.cursorPosition = Math.min(this.cursorPosition, value.length); + } else { + this.cursorPosition = value.length; + } + this.updateText(); this.updateCursor(); } @@ -458,9 +555,22 @@ export class UITextInput extends UIComponent { * Update value (for controlled mode) */ updateValue(value: string): void { + const currentValue = this.stateManager.getValue(); + + // If value hasn't changed, don't do anything + if (currentValue === value) { + return; + } + this.stateManager.updateValue(value); - // Handler uses stateManager directly, just update cursor position - this.inputHandler.setCursorPosition(value.length); + + // Preserve cursor position if focused, otherwise reset to end + if (this.focused) { + this.cursorPosition = Math.min(this.cursorPosition, value.length); + } else { + this.cursorPosition = value.length; + } + this.updateText(); this.updateCursor(); } diff --git a/packages/ui/src/components/ui-textarea.ts b/packages/ui/src/components/ui-textarea.ts index a59042e5..d5f2b95d 100644 --- a/packages/ui/src/components/ui-textarea.ts +++ b/packages/ui/src/components/ui-textarea.ts @@ -5,10 +5,7 @@ import { UIPanel } from './ui-panel'; import { UIFocusManager } from '../base/ui-focus-manager'; import { asTextDPR, ActionManager } from '@moxijs/core'; import { ThemeResolver } from '../theming/theme-resolver'; -import { - FormStateManager, - TextInputHandler -} from '../services'; +import { FormStateManager } from '../services'; import { UI_DEFAULTS } from '../theming/theme-data'; /** @@ -33,6 +30,8 @@ export interface UITextAreaProps { height?: number; /** Whether the textarea is disabled */ disabled?: boolean; + /** Whether the textarea is read-only (displays text but not editable) */ + readOnly?: boolean; /** Background color (overrides theme) */ backgroundColor?: number; /** Text color (overrides theme) */ @@ -54,7 +53,7 @@ export interface UITextAreaProps { * - 'canvas' (default): Standard PIXI.Text with DPR scaling * - 'msdf': Multi-channel Signed Distance Field for crisp text at any scale * - 'bitmap': Pre-rendered bitmap font atlas - * + * * Note: Text areas currently only support 'canvas' mode for cursor positioning. */ fontType?: FontType; @@ -85,28 +84,30 @@ export interface UITextAreaProps { */ export class UITextArea extends UIComponent { // Props - private props: Required>; + private props: Required> & { readOnly: boolean }; /** Local fontFamily prop (can be overridden by parent inheritance) */ private localFontFamily?: string; /** Local fontType prop (can be overridden by parent inheritance) */ private localFontType?: FontType; - - // Services (composition) + + // State management private stateManager: FormStateManager; - private inputHandler: TextInputHandler; - + + // Cursor state (inlined from TextInputHandler) + private cursorPosition: number = 0; + // Visual elements private background: UIPanel; private textDisplay: PIXI.Text; private cursor: PIXI.Graphics; - + // Cursor blink state private cursorBlinkInterval?: number; private cursorVisible: boolean = true; // Event listener management private actions = new ActionManager(); - + // Theme data private colorOverrides: { backgroundColor?: number; @@ -117,7 +118,7 @@ export class UITextArea extends UIComponent { constructor(props: UITextAreaProps, boxModel?: Partial) { super(boxModel); - const defaultLineHeight = props.lineHeight ?? 1.2; // Tighter spacing for pixel fonts + const defaultLineHeight = props.lineHeight ?? 1.2; const defaultHeight = (props.rows ?? 4) * (props.fontSize ?? 14) * defaultLineHeight + 24; this.props = { @@ -126,6 +127,7 @@ export class UITextArea extends UIComponent { width: props.width ?? 400, height: props.height ?? defaultHeight, disabled: props.disabled ?? false, + readOnly: props.readOnly ?? false, backgroundColor: props.backgroundColor ?? 0xffffff, textColor: props.textColor ?? 0x000000, placeholderColor: props.placeholderColor ?? 0x999999, @@ -148,19 +150,15 @@ export class UITextArea extends UIComponent { // Initialize theme resolver this.themeResolver = props.themeResolver; - + this.stateManager = new FormStateManager({ value: props.value, defaultValue: props.defaultValue, onChange: props.onChange }); - // Initialize input handler - it now uses stateManager directly - this.inputHandler = new TextInputHandler({ - stateManager: this.stateManager, - maxLength: this.props.maxLength, - type: 'text', // Textarea doesn't support number type - multiline: true - }); + + // Initialize cursor position + this.cursorPosition = this.stateManager.getValue().length; // Update component state this.enabled = !this.props.disabled; @@ -179,6 +177,12 @@ export class UITextArea extends UIComponent { // Setup interactivity this.setupInteractivity(); + + // Auto-layout if dimensions provided + // This ensures the textarea is ready to use immediately without manual layout() call + if (this.props.width > 0 && this.props.height > 0) { + this.layout(this.props.width, this.props.height); + } } /** @@ -187,7 +191,7 @@ export class UITextArea extends UIComponent { private createVisuals(): void { // Create background const bgColor = this.resolveColor('background', this.colorOverrides.backgroundColor); - + this.background = new UIPanel({ backgroundColor: bgColor, width: this.props.width, @@ -269,10 +273,9 @@ export class UITextArea extends UIComponent { if (this.focused) return; super.onFocus(); - // focused is already set by super.onFocus() - - // Set cursor position - this.inputHandler.setCursorPosition(this.stateManager.getValue().length); + + // Set cursor position to end + this.cursorPosition = this.stateManager.getValue().length; this.cursor.visible = true; this.updateCursor(); this.startCursorBlink(); @@ -288,7 +291,6 @@ export class UITextArea extends UIComponent { if (!this.focused) return; super.onBlur(); - // focused is already set by super.onBlur() this.cursor.visible = false; this.stopCursorBlink(); @@ -296,47 +298,188 @@ export class UITextArea extends UIComponent { this.updateBackground(); } + /** + * Handle keyboard input (inlined from TextInputHandler with multiline support) + */ private handleKeyDown(e: KeyboardEvent): void { if (!this.focused || this.props.disabled) return; + const key = e.key; + // Don't prevent default for Tab (allow tab navigation) - if (e.key === 'Tab') { - return; // Let tab navigation work + if (key === 'Tab') { + return; } - // Handle Enter/Escape for blur - if (e.key === 'Escape') { + // Handle Escape for blur + if (key === 'Escape') { this.onBlur(); return; } - // Delegate keyboard handling to TextInputHandler - const handled = this.inputHandler.handleKeyDown(e); + let handled = false; + + if (key === 'Enter') { + // Multiline: insert newline + this.insertCharacter('\n'); + handled = true; + } else if (key === 'Backspace') { + this.handleBackspace(); + handled = true; + } else if (key === 'Delete') { + this.handleDelete(); + handled = true; + } else if (key === 'ArrowLeft') { + this.moveCursor(-1); + handled = true; + } else if (key === 'ArrowRight') { + this.moveCursor(1); + handled = true; + } else if (key === 'ArrowUp') { + this.moveCursorVertically(-1); + handled = true; + } else if (key === 'ArrowDown') { + this.moveCursorVertically(1); + handled = true; + } else if (key === 'Home') { + this.moveToLineStart(); + handled = true; + } else if (key === 'End') { + this.moveToLineEnd(); + handled = true; + } else if (key.length === 1 && !e.ctrlKey && !e.metaKey) { + // Character input + this.insertCharacter(key); + handled = true; + } + if (handled) { e.preventDefault(); e.stopPropagation(); - // Handler updates stateManager directly, so we need to update visuals this.updateText(); this.updateCursor(); } } + /** + * Insert a character at cursor position + */ + private insertCharacter(char: string): void { + const value = this.stateManager.getValue(); + if (value.length >= this.props.maxLength) return; + + const newValue = + value.substring(0, this.cursorPosition) + + char + + value.substring(this.cursorPosition); + this.stateManager.setValue(newValue); + this.cursorPosition++; + } + + /** + * Handle backspace key + */ + private handleBackspace(): void { + if (this.cursorPosition > 0) { + const value = this.stateManager.getValue(); + const newValue = + value.substring(0, this.cursorPosition - 1) + + value.substring(this.cursorPosition); + this.stateManager.setValue(newValue); + this.cursorPosition--; + } + } + + /** + * Handle delete key + */ + private handleDelete(): void { + const value = this.stateManager.getValue(); + if (this.cursorPosition < value.length) { + const newValue = + value.substring(0, this.cursorPosition) + + value.substring(this.cursorPosition + 1); + this.stateManager.setValue(newValue); + } + } + + /** + * Move cursor horizontally + */ + private moveCursor(direction: number): void { + const value = this.stateManager.getValue(); + this.cursorPosition = Math.max(0, Math.min(this.cursorPosition + direction, value.length)); + } + + /** + * Move cursor vertically (for multiline) + */ + private moveCursorVertically(direction: number): void { + const value = this.stateManager.getValue(); + const lines = value.split('\n'); + let currentLine = 0; + let positionInLine = 0; + let charCount = 0; + + // Find current line and position + for (let i = 0; i < lines.length; i++) { + if (charCount + lines[i].length >= this.cursorPosition) { + currentLine = i; + positionInLine = this.cursorPosition - charCount; + break; + } + charCount += lines[i].length + 1; // +1 for newline + } + + // Move to target line + const targetLine = Math.max(0, Math.min(lines.length - 1, currentLine + direction)); + if (targetLine === currentLine) return; + + // Calculate new cursor position + let newPosition = 0; + for (let i = 0; i < targetLine; i++) { + newPosition += lines[i].length + 1; + } + newPosition += Math.min(positionInLine, lines[targetLine].length); + + this.cursorPosition = newPosition; + } + + /** + * Move cursor to start of current line + */ + private moveToLineStart(): void { + const value = this.stateManager.getValue(); + const lineStart = value.lastIndexOf('\n', this.cursorPosition - 1) + 1; + this.cursorPosition = lineStart; + } + + /** + * Move cursor to end of current line + */ + private moveToLineEnd(): void { + const value = this.stateManager.getValue(); + let lineEnd = value.indexOf('\n', this.cursorPosition); + if (lineEnd === -1) lineEnd = value.length; + this.cursorPosition = lineEnd; + } + /** * Update text display */ private updateText(): void { const value = this.stateManager.getValue(); const displayText = this.getDisplayText(); - + this.textDisplay.text = displayText; - + // Update text color based on whether showing placeholder const textColor = value ? this.resolveTextColor(this.colorOverrides.textColor) : this.resolvePlaceholderColor(this.colorOverrides.placeholderColor); - + this.textDisplay.style.fill = textColor; - this.updateCursor(); + // Note: cursor update is handled separately by caller to avoid double updates } /** @@ -356,68 +499,53 @@ export class UITextArea extends UIComponent { */ private updateCursor(): void { const value = this.stateManager.getValue(); - const cursorPos = this.inputHandler.getCursorPosition(); - const textUpToCursor = value.substring(0, cursorPos); - - // Resolve font family through inheritance - const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; + const textUpToCursor = value.substring(0, this.cursorPosition); - // Create temporary text to measure - // Note: wordWrapWidth needs to account for DPR scaling (multiply by dprScale) - const dprScale = UI_DEFAULTS.DPR_SCALE; - const tempText = new PIXI.Text({ - text: textUpToCursor, - style: { - fontFamily: effectiveFontFamily, - fontSize: this.props.fontSize * dprScale, // Account for DPR scaling - fill: this.textDisplay.style.fill, - align: 'left', - wordWrap: true, - wordWrapWidth: (this.props.width - 24) * dprScale, - lineHeight: this.props.fontSize * this.props.lineHeight * dprScale - } - }); - // Ensure effects is initialized to prevent null reference errors - if (tempText.effects === null) { - tempText.effects = []; + // Ensure cursor position is valid + if (this.cursorPosition > value.length) { + this.cursorPosition = value.length; } - // Get the bounds to find cursor position - const metrics = tempText.getLocalBounds(); + // Resolve font family through inheritance + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; // For multi-line, we need to find the last line position const lines = textUpToCursor.split('\n'); const lastLine = lines[lines.length - 1]; - const lastLineText = new PIXI.Text({ - text: lastLine, - style: { - fontFamily: effectiveFontFamily, - fontSize: this.props.fontSize * dprScale, // Account for DPR scaling - fill: this.textDisplay.style.fill, - align: 'left', - lineHeight: this.props.fontSize * this.props.lineHeight * dprScale - } - }); - // Ensure effects is initialized to prevent null reference errors - if (lastLineText.effects === null) { - lastLineText.effects = []; - } + // Measure text width using canvas context (much faster than creating PIXI.Text) + // Must use scaled font size to match asTextDPR rendering (fontSize * dprScale, then scaled down) + const scaledFontSize = this.props.fontSize * UI_DEFAULTS.DPR_SCALE; + const scaledWidth = this.measureTextWidth(lastLine, effectiveFontFamily, scaledFontSize); + // Scale back down to match the visual text width + const lastLineWidth = scaledWidth / UI_DEFAULTS.DPR_SCALE; - // Account for DPR scaling when calculating cursor position - const cursorX = 12 + (lastLineText.width / dprScale); + const cursorX = 12 + lastLineWidth; const cursorY = 12 + (lines.length - 1) * (this.props.fontSize * this.props.lineHeight); this.cursor.clear(); this.cursor.rect(cursorX, cursorY, 1, this.props.fontSize); const cursorColor = this.resolveTextColor(this.colorOverrides.textColor); this.cursor.fill({ color: cursorColor }); + } - // Clean up temp objects - tempText.destroy(); - lastLineText.destroy(); + /** + * Measure text width using canvas 2D context (much faster than PIXI.Text) + */ + private measureTextWidth(text: string, fontFamily: string, fontSize: number): number { + // Use a shared canvas context for text measurement + if (!UITextArea.measureCanvas) { + UITextArea.measureCanvas = document.createElement('canvas'); + UITextArea.measureContext = UITextArea.measureCanvas.getContext('2d')!; + } + UITextArea.measureContext.font = `${fontSize}px ${fontFamily}`; + return UITextArea.measureContext.measureText(text).width; } + // Shared canvas for text measurement (static to avoid creating per instance) + private static measureCanvas: HTMLCanvasElement; + private static measureContext: CanvasRenderingContext2D; + /** * Starts cursor blinking animation */ @@ -449,7 +577,7 @@ export class UITextArea extends UIComponent { width: this.props.width, height: this.props.height }; - + return this.layoutEngine.measure(this.boxModel, contentSize); } @@ -472,13 +600,29 @@ export class UITextArea extends UIComponent { } /** - * Sets the value programmatically + * Sets the value programmatically without triggering onChange. + * Use this when setting value from external state (e.g., parent component). */ setValue(value: string): void { - this.stateManager.setValue(value); - // Handler uses stateManager directly, just update cursor position - this.inputHandler.setCursorPosition(value.length); + const currentValue = this.stateManager.getValue(); + + // If value hasn't changed, don't do anything (preserves cursor position) + if (currentValue === value) { + return; + } + + this.stateManager.setValueSilent(value); + + // Only reset cursor to end if not focused (external update) + // If focused, user is actively typing - preserve cursor position within bounds + if (this.focused) { + this.cursorPosition = Math.min(this.cursorPosition, value.length); + } else { + this.cursorPosition = value.length; + } + this.updateText(); + this.updateCursor(); } /** @@ -493,8 +637,7 @@ export class UITextArea extends UIComponent { */ updateValue(value: string): void { this.stateManager.updateValue(value); - // Handler uses stateManager directly, just update cursor position - this.inputHandler.setCursorPosition(value.length); + this.cursorPosition = value.length; this.updateText(); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3df4396d..c8b887dc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -21,14 +21,13 @@ export { SizeConstraints, FormStateManager, FormStateConfig, - TextInputHandler, - TextInputHandlerConfig, ThemeApplier, ComponentState } from './services'; // Layout (existing FlexContainer) export { FlexContainer, FlexDirection, FlexJustify, FlexAlign, FlexContainerProps } from './layout/flex-container'; +export { FlexContainerPanel, FlexContainerPanelProps } from './layout/flex-container-panel'; // New Flex Layout System (flat structure) export { diff --git a/packages/ui/src/layout/flex-container-panel.ts b/packages/ui/src/layout/flex-container-panel.ts new file mode 100644 index 00000000..c3cdad33 --- /dev/null +++ b/packages/ui/src/layout/flex-container-panel.ts @@ -0,0 +1,390 @@ +import * as PIXI from 'pixi.js'; +import { UIComponent, UIFontConfig } from '../base/ui-component'; +import { BoxModel, MeasuredSize } from '../base/box-model'; +import { EdgeInsets } from '../base/edge-insets'; +import { UI_LAYOUT_DEFAULTS } from '../theming/theme-data'; +import { FlexDirection, FlexJustify, FlexAlign } from './flex-container'; +import { isFlexLayoutParticipant } from './layout-participant'; + +/** + * FlexContainerPanel configuration. + * Combines FlexContainer layout with panel features (background, border, clipping). + */ +export interface FlexContainerPanelProps { + /** Flex direction */ + direction?: FlexDirection; + /** Main axis alignment */ + justify?: FlexJustify; + /** Cross axis alignment */ + align?: FlexAlign; + /** Gap between children */ + gap?: number; + /** Inner padding */ + padding?: EdgeInsets; + /** Panel width */ + width?: number; + /** Panel height */ + height?: number; + /** Background color */ + backgroundColor?: number; + /** Background alpha (0-1) */ + backgroundAlpha?: number; + /** Border color */ + borderColor?: number; + /** Border width */ + borderWidth?: number; + /** Border radius */ + borderRadius?: number; + /** Clip children to panel bounds */ + clipContent?: boolean; + /** Font configuration for children to inherit */ + fontConfig?: UIFontConfig; +} + +/** + * A panel with flex layout capabilities. + * Combines the layout power of FlexContainer with panel styling (background, border, clipping). + * + * @example + * ```ts + * const panel = new FlexContainerPanel({ + * width: 400, + * height: 300, + * backgroundColor: 0x2a2a3a, + * borderColor: 0x404050, + * borderWidth: 1, + * borderRadius: 8, + * clipContent: true, + * direction: FlexDirection.Column, + * padding: EdgeInsets.all(10), + * gap: 8 + * }); + * + * panel.addChild(label); + * panel.addChild(button); + * ``` + */ +export class FlexContainerPanel extends UIComponent { + private props: Required>; + public children: UIComponent[] = []; + + private background: PIXI.Graphics; + private contentContainer: PIXI.Container; + private contentMask: PIXI.Graphics | null = null; + + constructor(props: FlexContainerPanelProps = {}) { + const boxModel: Partial = { + padding: props.padding ?? EdgeInsets.zero(), + width: props.width ?? 'auto', + height: props.height ?? 'auto', + flex: { + direction: props.direction ?? FlexDirection.Column, + justify: props.justify ?? FlexJustify.Start, + alignItems: props.align ?? FlexAlign.Start, + gap: props.gap ?? 0, + wrap: 'nowrap' + } + }; + + super(boxModel); + + this.props = { + direction: props.direction ?? FlexDirection.Column, + justify: props.justify ?? FlexJustify.Start, + align: props.align ?? FlexAlign.Start, + gap: props.gap ?? 0, + width: props.width ?? 0, + height: props.height ?? 0, + backgroundColor: props.backgroundColor ?? 0x2a2a3a, + backgroundAlpha: props.backgroundAlpha ?? 1, + borderColor: props.borderColor ?? 0x404050, + borderWidth: props.borderWidth ?? 0, + borderRadius: props.borderRadius ?? 0, + clipContent: props.clipContent ?? false + }; + + // Set font config if provided + if (props.fontConfig) { + this.setFontConfig(props.fontConfig); + } + + // Create background + this.background = new PIXI.Graphics(); + this.container.addChild(this.background); + + // Create content container + this.contentContainer = new PIXI.Container(); + this.container.addChild(this.contentContainer); + + // Create mask if clipping is enabled + if (this.props.clipContent) { + this.contentMask = new PIXI.Graphics(); + this.container.addChild(this.contentMask); + this.contentContainer.mask = this.contentMask; + } + + // Draw initial background + this.drawBackground(); + } + + /** + * Draw the background and border + */ + private drawBackground(): void { + const { width, height, backgroundColor, backgroundAlpha, borderColor, borderWidth, borderRadius } = this.props; + + this.background.clear(); + + // Draw background + if (borderRadius > 0) { + this.background.roundRect(0, 0, width, height, borderRadius); + } else { + this.background.rect(0, 0, width, height); + } + this.background.fill({ color: backgroundColor, alpha: backgroundAlpha }); + + // Draw border + if (borderWidth > 0) { + if (borderRadius > 0) { + this.background.roundRect(0, 0, width, height, borderRadius); + } else { + this.background.rect(0, 0, width, height); + } + this.background.stroke({ color: borderColor, width: borderWidth }); + } + + // Update mask if clipping + if (this.contentMask) { + this.contentMask.clear(); + if (borderRadius > 0) { + this.contentMask.roundRect(0, 0, width, height, borderRadius); + } else { + this.contentMask.rect(0, 0, width, height); + } + this.contentMask.fill({ color: 0xffffff }); + } + } + + /** + * Add a child component + */ + addChild(child: UIComponent): void { + child.parent = this; + this.children.push(child); + this.contentContainer.addChild(child.container); + + // Sync to layout node tree + if (isFlexLayoutParticipant(child)) { + child.layoutNode.parent = this.layoutNode; + this.layoutNode.children.push(child.layoutNode); + } + } + + /** + * Remove a child component + */ + removeChild(child: UIComponent): void { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + this.contentContainer.removeChild(child.container); + child.parent = undefined; + + if (isFlexLayoutParticipant(child)) { + const nodeIndex = this.layoutNode.children.indexOf(child.layoutNode); + if (nodeIndex !== -1) { + this.layoutNode.children.splice(nodeIndex, 1); + } + child.layoutNode.parent = null; + } + } + } + + /** + * Remove all children + */ + removeAllChildren(): void { + for (const child of [...this.children]) { + this.removeChild(child); + } + } + + /** + * Get the content container (for adding raw PIXI objects) + */ + getContentContainer(): PIXI.Container { + return this.contentContainer; + } + + measure(): MeasuredSize { + return { + width: this.props.width, + height: this.props.height + }; + } + + layout(availableWidth: number, availableHeight: number): void { + const width = this.props.width || availableWidth; + const height = this.props.height || availableHeight; + + // Update props if size changed + if (this.props.width !== width || this.props.height !== height) { + this.props.width = width; + this.props.height = height; + this.drawBackground(); + } + + // Calculate content area + const padding = this.boxModel.padding; + const contentX = padding.left; + const contentY = padding.top; + const contentWidth = width - padding.horizontal; + const contentHeight = height - padding.vertical; + + // Position content container + this.contentContainer.position.set(contentX, contentY); + + // Layout children using flex rules + const { direction, justify, align, gap } = this.props; + const isRow = direction === FlexDirection.Row || direction === FlexDirection.RowReverse; + const isReverse = direction === FlexDirection.RowReverse || direction === FlexDirection.ColumnReverse; + + // Measure children + const childSizes: { width: number; height: number }[] = []; + let totalMainSize = 0; + + for (const child of this.children) { + child.layout(contentWidth, contentHeight); + const layout = child.getLayout(); + childSizes.push({ width: layout.width, height: layout.height }); + totalMainSize += isRow ? layout.width : layout.height; + } + + // Add gaps to total + if (this.children.length > 1) { + totalMainSize += gap * (this.children.length - 1); + } + + // Calculate starting position based on justify + const mainAxisSize = isRow ? contentWidth : contentHeight; + const remainingSpace = mainAxisSize - totalMainSize; + + let mainPos = 0; + let spaceBetween = 0; + + switch (justify) { + case FlexJustify.End: + mainPos = remainingSpace; + break; + case FlexJustify.Center: + mainPos = remainingSpace / 2; + break; + case FlexJustify.SpaceBetween: + if (this.children.length > 1) { + spaceBetween = remainingSpace / (this.children.length - 1); + } + break; + case FlexJustify.SpaceAround: + if (this.children.length > 0) { + const space = remainingSpace / this.children.length; + mainPos = space / 2; + spaceBetween = space; + } + break; + case FlexJustify.SpaceEvenly: + if (this.children.length > 0) { + const space = remainingSpace / (this.children.length + 1); + mainPos = space; + spaceBetween = space; + } + break; + } + + // Position children + const orderedChildren = isReverse ? [...this.children].reverse() : this.children; + const orderedSizes = isReverse ? [...childSizes].reverse() : childSizes; + + for (let i = 0; i < orderedChildren.length; i++) { + const child = orderedChildren[i]; + const size = orderedSizes[i]; + + // Calculate cross axis position + const crossAxisSize = isRow ? contentHeight : contentWidth; + const childCrossSize = isRow ? size.height : size.width; + let crossPos = 0; + + switch (align) { + case FlexAlign.End: + crossPos = crossAxisSize - childCrossSize; + break; + case FlexAlign.Center: + crossPos = (crossAxisSize - childCrossSize) / 2; + break; + case FlexAlign.Stretch: + crossPos = 0; + break; + } + + // Set position + if (isRow) { + child.container.position.set(mainPos, crossPos); + mainPos += size.width + gap + spaceBetween; + } else { + child.container.position.set(crossPos, mainPos); + mainPos += size.height + gap + spaceBetween; + } + } + + // Update computed layout + this.computedLayout = { + x: 0, + y: 0, + width, + height, + contentX, + contentY, + contentWidth, + contentHeight + }; + } + + protected render(): void { + // Rendering handled by background and children + } + + /** + * Update background color + */ + setBackgroundColor(color: number, alpha?: number): void { + this.props.backgroundColor = color; + if (alpha !== undefined) { + this.props.backgroundAlpha = alpha; + } + this.drawBackground(); + } + + /** + * Update border + */ + setBorder(color: number, width: number): void { + this.props.borderColor = color; + this.props.borderWidth = width; + this.drawBackground(); + } + + /** + * Update size + */ + setSize(width: number, height: number): void { + this.props.width = width; + this.props.height = height; + this.boxModel.width = width; + this.boxModel.height = height; + this.drawBackground(); + } + + destroy(): void { + this.removeAllChildren(); + super.destroy(); + } +} diff --git a/packages/ui/src/layout/flex-container.ts b/packages/ui/src/layout/flex-container.ts index 3c732d52..02c84fd8 100644 --- a/packages/ui/src/layout/flex-container.ts +++ b/packages/ui/src/layout/flex-container.ts @@ -44,6 +44,10 @@ export interface FlexContainerProps { height?: number | 'fill'; /** Font configuration that children will inherit (like CSS) */ fontConfig?: UIFontConfig; + /** Enable visual debugging to show layout bounds and structure */ + debug?: boolean; + /** Background color for the container */ + backgroundColor?: number; } /** @@ -74,8 +78,12 @@ export interface FlexContainerProps { * ``` */ export class FlexContainer extends UIComponent { - private props: Required>; + private props: Required>; public children: UIComponent[] = []; + private debugEnabled: boolean = false; + private bgColor?: number; + private debugGraphics?: PIXI.Graphics; + private backgroundGraphics?: PIXI.Graphics; constructor(props: FlexContainerProps = {}) { const boxModel: Partial = { @@ -106,6 +114,22 @@ export class FlexContainer extends UIComponent { if (props.fontConfig) { this.setFontConfig(props.fontConfig); } + + // Enable debug mode if requested + this.debugEnabled = props.debug ?? false; + + // Store background color and create graphics if provided + if (props.backgroundColor !== undefined) { + this.bgColor = props.backgroundColor; + this.backgroundGraphics = new PIXI.Graphics(); + this.container.addChildAt(this.backgroundGraphics, 0); + } + + // Create debug graphics if enabled + if (this.debugEnabled) { + this.debugGraphics = new PIXI.Graphics(); + this.container.addChild(this.debugGraphics); + } } /** Add child component. */ @@ -291,6 +315,65 @@ export class FlexContainer extends UIComponent { this.layoutDirty = false; this.render(); + + // Render background and debug visuals + this.renderBackgroundAndDebug(); + } + + /** Render background color and debug visualization */ + private renderBackgroundAndDebug(): void { + const { width, height } = this.computedLayout; + const padding = this.boxModel.padding; + + // Draw background + if (this.backgroundGraphics && this.bgColor !== undefined) { + this.backgroundGraphics.clear(); + this.backgroundGraphics.rect(0, 0, width, height); + this.backgroundGraphics.fill({ color: this.bgColor, alpha: 0.9 }); + } + + // Draw debug visualization + if (this.debugGraphics && this.debugEnabled) { + this.debugGraphics.clear(); + + // Container bounds (outer box) - Blue + this.debugGraphics.rect(0, 0, width, height); + this.debugGraphics.stroke({ color: 0x0088ff, width: 2, alpha: 0.8 }); + + // Content area (inner box after padding) - Green + const contentX = padding.left; + const contentY = padding.top; + const contentWidth = width - padding.horizontal; + const contentHeight = height - padding.vertical; + + this.debugGraphics.rect(contentX, contentY, contentWidth, contentHeight); + this.debugGraphics.stroke({ color: 0x00ff88, width: 1, alpha: 0.6 }); + + // Draw gap lines (Red dashed) + if (this.props.gap > 0 && this.children.length > 1) { + const isRow = this.props.direction === FlexDirection.Row || this.props.direction === FlexDirection.RowReverse; + + this.children.forEach((child, i) => { + if (i > 0) { + const childContainer = child.container; + + if (isRow) { + // Vertical gap line + const gapX = childContainer.x - this.props.gap / 2; + this.debugGraphics!.moveTo(gapX, contentY); + this.debugGraphics!.lineTo(gapX, contentY + contentHeight); + } else { + // Horizontal gap line + const gapY = childContainer.y - this.props.gap / 2; + this.debugGraphics!.moveTo(contentX, gapY); + this.debugGraphics!.lineTo(contentX + contentWidth, gapY); + } + } + }); + + this.debugGraphics.stroke({ color: 0xff4444, width: 1, alpha: 0.5 }); + } + } } /** @internal */ diff --git a/packages/ui/src/services/form-state-manager.ts b/packages/ui/src/services/form-state-manager.ts index 529e13a9..8e42cbb6 100644 --- a/packages/ui/src/services/form-state-manager.ts +++ b/packages/ui/src/services/form-state-manager.ts @@ -84,6 +84,15 @@ export class FormStateManager { } } + /** + * Set the value silently without triggering onChange callback. + * Use this for programmatic updates where the parent is setting the value + * and doesn't need to be notified (e.g., initializing from external state). + */ + setValueSilent(value: T): void { + this.value = value; + } + /** * Whether this is in controlled mode */ diff --git a/packages/ui/src/services/index.ts b/packages/ui/src/services/index.ts index 979c7439..c6cd2bad 100644 --- a/packages/ui/src/services/index.ts +++ b/packages/ui/src/services/index.ts @@ -9,6 +9,5 @@ export { LayoutEngine, SizeConstraints } from './layout-engine'; export { FormStateManager, FormStateConfig } from './form-state-manager'; -export { TextInputHandler, TextInputHandlerConfig } from './text-input-handler'; export { ThemeApplier, ComponentState } from './theme-applier'; diff --git a/packages/ui/src/services/text-input-handler.ts b/packages/ui/src/services/text-input-handler.ts deleted file mode 100644 index 054deb63..00000000 --- a/packages/ui/src/services/text-input-handler.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Text Input Handler Service - * - * Single Responsibility: Handle keyboard input and cursor management for text inputs. - * Composable service - can be used by UITextInput, UITextArea, etc. - * - * @category UI Services - */ - -/** - * Configuration for text input handler - */ -export interface TextInputHandlerConfig { - /** FormStateManager to use as source of truth */ - stateManager: { getValue: () => string; setValue: (value: string) => void }; - /** Maximum character length */ - maxLength?: number; - /** Input type for validation */ - type?: 'text' | 'number'; - /** Whether multi-line (for textarea) */ - multiline?: boolean; -} - -/** - * Text Input Handler - * - * Manages keyboard input, cursor position, and text editing. - * Reusable across UITextInput and UITextArea. - * Uses FormStateManager as the source of truth for value. - */ -export class TextInputHandler { - private stateManager: { getValue: () => string; setValue: (value: string) => void }; - private cursorPosition: number = 0; - private maxLength: number; - private type: 'text' | 'number'; - private multiline: boolean; - - constructor(config: TextInputHandlerConfig) { - this.stateManager = config.stateManager; - this.maxLength = config.maxLength ?? (config.multiline ? 1000 : 100); - this.type = config.type ?? 'text'; - this.multiline = config.multiline ?? false; - this.cursorPosition = this.stateManager.getValue().length; - } - - /** - * Get the current value from state manager - */ - getValue(): string { - return this.stateManager.getValue(); - } - - /** - * Set the value programmatically (updates state manager) - */ - setValue(value: string): void { - this.stateManager.setValue(value); - this.cursorPosition = Math.min(this.cursorPosition, value.length); - } - - /** - * Get the current cursor position - */ - getCursorPosition(): number { - return this.cursorPosition; - } - - /** - * Set the cursor position - */ - setCursorPosition(position: number): void { - const value = this.stateManager.getValue(); - this.cursorPosition = Math.max(0, Math.min(position, value.length)); - } - - /** - * Handle a keyboard event - * Returns true if the event was handled - */ - handleKeyDown(event: KeyboardEvent): boolean { - const key = event.key; - - // Enter key behavior differs for single-line vs multi-line - if (key === 'Enter') { - if (this.multiline) { - // Multi-line: insert newline - this.insertCharacter('\n'); - return true; - } else { - // Single-line: don't handle (let parent handle blur) - return false; - } - } - - if (key === 'Escape') { - // Don't handle escape (let parent handle blur) - return false; - } - - if (key === 'Backspace') { - this.handleBackspace(); - return true; - } - - if (key === 'Delete') { - this.handleDelete(); - return true; - } - - if (key === 'ArrowLeft') { - this.moveCursor(-1); - return true; - } - - if (key === 'ArrowRight') { - this.moveCursor(1); - return true; - } - - if (key === 'ArrowUp' && this.multiline) { - this.moveCursorVertically(-1); - return true; - } - - if (key === 'ArrowDown' && this.multiline) { - this.moveCursorVertically(1); - return true; - } - - if (key === 'Home') { - this.moveToLineStart(); - return true; - } - - if (key === 'End') { - this.moveToLineEnd(); - return true; - } - - // Handle character input - if (key.length === 1 && !event.ctrlKey && !event.metaKey) { - // Type validation - if (this.type === 'number' && !/[0-9.-]/.test(key)) { - return true; // Consume invalid character - } - - this.insertCharacter(key); - return true; - } - - return false; - } - - /** - * Insert a character at the current cursor position - */ - private insertCharacter(char: string): void { - const value = this.stateManager.getValue(); - if (value.length >= this.maxLength) { - return; - } - - const newValue = - value.substring(0, this.cursorPosition) + - char + - value.substring(this.cursorPosition); - this.stateManager.setValue(newValue); - this.cursorPosition++; - } - - /** - * Handle backspace key - */ - private handleBackspace(): void { - if (this.cursorPosition > 0) { - const value = this.stateManager.getValue(); - const newValue = - value.substring(0, this.cursorPosition - 1) + - value.substring(this.cursorPosition); - this.stateManager.setValue(newValue); - this.cursorPosition--; - } - } - - /** - * Handle delete key - */ - private handleDelete(): void { - const value = this.stateManager.getValue(); - if (this.cursorPosition < value.length) { - const newValue = - value.substring(0, this.cursorPosition) + - value.substring(this.cursorPosition + 1); - this.stateManager.setValue(newValue); - } - } - - /** - * Move cursor horizontally - */ - private moveCursor(direction: number): void { - const value = this.stateManager.getValue(); - this.cursorPosition = Math.max(0, Math.min(this.cursorPosition + direction, value.length)); - } - - /** - * Move cursor vertically (for multi-line) - */ - private moveCursorVertically(direction: number): void { - if (!this.multiline) return; - - const value = this.stateManager.getValue(); - const lines = value.split('\n'); - let currentLine = 0; - let positionInLine = 0; - let charCount = 0; - - // Find current line and position - for (let i = 0; i < lines.length; i++) { - if (charCount + lines[i].length >= this.cursorPosition) { - currentLine = i; - positionInLine = this.cursorPosition - charCount; - break; - } - charCount += lines[i].length + 1; // +1 for newline - } - - // Move to target line - const targetLine = Math.max(0, Math.min(lines.length - 1, currentLine + direction)); - if (targetLine === currentLine) return; - - // Calculate new cursor position - let newPosition = 0; - for (let i = 0; i < targetLine; i++) { - newPosition += lines[i].length + 1; - } - newPosition += Math.min(positionInLine, lines[targetLine].length); - - this.cursorPosition = newPosition; - } - - /** - * Move cursor to start of current line - */ - private moveToLineStart(): void { - if (this.multiline) { - const value = this.stateManager.getValue(); - const lineStart = value.lastIndexOf('\n', this.cursorPosition - 1) + 1; - this.cursorPosition = lineStart; - } else { - this.cursorPosition = 0; - } - } - - /** - * Move cursor to end of current line - */ - private moveToLineEnd(): void { - const value = this.stateManager.getValue(); - if (this.multiline) { - let lineEnd = value.indexOf('\n', this.cursorPosition); - if (lineEnd === -1) lineEnd = value.length; - this.cursorPosition = lineEnd; - } else { - this.cursorPosition = value.length; - } - } -} - diff --git a/packages/ui/tests/index.test.ts b/packages/ui/tests/index.test.ts index 6e1223e6..0e97f1fd 100644 --- a/packages/ui/tests/index.test.ts +++ b/packages/ui/tests/index.test.ts @@ -89,10 +89,6 @@ describe('Package exports', () => { expect(UI.FormStateManager).toBeDefined(); }); - it('should export TextInputHandler', () => { - expect(UI.TextInputHandler).toBeDefined(); - }); - it('should export ThemeApplier', () => { expect(UI.ThemeApplier).toBeDefined(); }); diff --git a/packages/ui/ui-feedback.md b/packages/ui/ui-feedback.md new file mode 100644 index 00000000..c93b2b24 --- /dev/null +++ b/packages/ui/ui-feedback.md @@ -0,0 +1,107 @@ +# MoxiJS UI System Feedback & Improvements + +This document tracks feedback and improvement requests for the MoxiJS UI system based on real-world usage in examples and applications. + +## Priority 1 - Critical for Development + +### 1. Debug Mode for FlexContainer +**Status:** Requested +**Use Case:** Sprite Sheet Generator layout debugging +**Description:** Add visual debugging to FlexContainer that shows: +- Container bounds (outer box) +- Content area (inner box after padding) +- Child bounds and positions +- Gap visualization +- Alignment indicators + +**Proposed API:** +```typescript +const container = new FlexContainer({ + direction: FlexDirection.Column, + debug: true // Enable visual debugging +}); +``` + +**Implementation Notes:** +- Add optional `debug?: boolean` to `FlexContainerProps` +- Draw colored overlay graphics showing layout structure +- Should be toggleable at runtime +- Different colors for container vs children +- Label dimensions and positions + +### 2. FlexJustify Support Missing in FlexAlign +**Status:** Identified +**Use Case:** Need SpaceBetween alignment for horizontal layouts +**Description:** FlexAlign only has Start, End, Center, Stretch. Missing justify options like SpaceBetween. + +**Current Issue:** +```typescript +// This doesn't exist: +align: FlexAlign.SpaceBetween // ❌ Not available +``` + +**Options:** +1. Add FlexJustify values to FlexAlign enum +2. Keep separate but make both work on main axis +3. Add `justify` prop that works alongside `align` + +**Note:** FlexJustify already exists but may not be properly wired up. + +## Priority 2 - Nice to Have + +### 3. Better Documentation for Layout System +**Status:** Needed +**Description:** +- More examples showing flex layouts +- Common patterns (toolbars, sidebars, grids) +- Migration guide from manual positioning +- Performance considerations + +### 4. Layout Presets +**Status:** Idea +**Description:** Common layout patterns as presets +```typescript +FlexContainer.toolbar({ gap: 10 }) +FlexContainer.sidebar({ width: 200 }) +FlexContainer.grid({ columns: 3 }) +``` + +## Priority 3 - Future Enhancements + +### 5. Responsive Layouts +**Status:** Future +**Description:** Auto-adjust based on screen size + +### 6. Animation Support for Layout Changes +**Status:** Future +**Description:** Smooth transitions when layout changes + +## Notes from Sprite Sheet Generator Development + +**Date:** 2025-12-10 +**Context:** Building sprite sheet configurator with carousel, title bar, and control panels + +**Key Learnings:** +1. Mixing FlexContainer with manual positioning causes confusion +2. Need debug mode to understand what's happening +3. FlexAlign.SpaceBetween would be very useful for right-aligning panels +4. Documentation needs more real-world examples +5. It's important to dogfood our own system - this example exposed gaps + +**Quote from Developer:** +> "NO! not manual... please we need to fix this in this codebase because of this example, we are IN MoxiJS and we have access to it's source. and can modify it based on this feedback." + +This is the right attitude - improve the framework based on real usage! + +## Action Items + +- [ ] Implement debug mode for FlexContainer +- [ ] Review FlexJustify/FlexAlign relationship +- [ ] Add more layout examples to documentation +- [ ] Create layout testing example that shows all features +- [ ] Consider adding FlexAlign.SpaceBetween or fixing FlexJustify usage + +## Related Files + +- `/packages/ui/src/layout/flex-container.ts` - Main implementation +- `/packages/moxijs-examples/src/examples/05-tools/sprite-sheet-generator.ts` - Use case diff --git a/scripts/generate-llm-toc.js b/scripts/generate-llm-toc.js new file mode 100644 index 00000000..f6bdfd63 --- /dev/null +++ b/scripts/generate-llm-toc.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Generate LLM TOC and inject into all llms.txt files + * + * Usage: node scripts/generate-llm-toc.js + */ + +const fs = require('fs'); +const path = require('path'); + +const PACKAGES = [ + { name: '@moxijs/core', srcPath: 'packages/core/src', llmsFile: 'packages/core/llms.txt' }, + { name: '@moxijs/ui', srcPath: 'packages/ui/src', llmsFile: 'packages/ui/llms.txt' }, +]; + +const TOC_START = ''; +const TOC_END = ''; + +function scanFile(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + const result = { classes: [], interfaces: [] }; + + const classRegex = /export\s+class\s+(\w+)(?:\s+extends\s+([\w<>,\s]+))?\s*\{/g; + let match; + + while ((match = classRegex.exec(content)) !== null) { + const className = match[1]; + const classStart = match.index + match[0].length; + let braceCount = 1; + let classEnd = classStart; + + for (let i = classStart; i < content.length && braceCount > 0; i++) { + if (content[i] === '{') braceCount++; + if (content[i] === '}') braceCount--; + classEnd = i; + } + + const classBody = content.substring(classStart, classEnd); + const methods = extractMethods(classBody); + result.classes.push({ name: className, methods }); + } + + const interfaceRegex = /export\s+interface\s+(\w+)/g; + while ((match = interfaceRegex.exec(content)) !== null) { + result.interfaces.push(match[1]); + } + + return result; +} + +function extractMethods(classBody) { + const methods = new Set(); + const patterns = [ + /^\s{2}(?:public\s+)?(?:async\s+)?(\w+)\s*(?:<[^>]+>)?\s*\([^)]*\)/gm, + /^\s{2}(?:public\s+)?get\s+(\w+)\s*\(\)/gm, + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(classBody)) !== null) { + const m = match[1]; + if (m !== 'constructor' && !m.startsWith('_')) methods.add(m); + } + } + return Array.from(methods).sort(); +} + +function scanDirectory(dirPath) { + const results = { classes: [], interfaces: [] }; + if (!fs.existsSync(dirPath)) return results; + + const scan = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + scan(fullPath); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + const r = scanFile(fullPath); + results.classes.push(...r.classes); + results.interfaces.push(...r.interfaces); + } + } + }; + scan(dirPath); + return results; +} + +function formatPackageTOC(results) { + let toc = `**Classes:** `; + toc += results.classes + .sort((a, b) => a.name.localeCompare(b.name)) + .map(c => `${c.name}(${c.methods.slice(0, 5).join(', ')}${c.methods.length > 5 ? '...' : ''})`) + .join(' | '); + toc += `\n**Interfaces:** ${results.interfaces.sort().join(', ')}\n`; + return toc; +} + +function injectTOC(filePath, toc) { + if (!fs.existsSync(filePath)) { + console.warn(` Skipped: ${filePath} not found`); + return; + } + + let content = fs.readFileSync(filePath, 'utf-8'); + const startIdx = content.indexOf(TOC_START); + const endIdx = content.indexOf(TOC_END); + + if (startIdx !== -1 && endIdx !== -1) { + content = content.substring(0, startIdx + TOC_START.length) + + '\n' + toc + + content.substring(endIdx); + } else { + const firstHeadingEnd = content.indexOf('\n', content.indexOf('#')); + content = content.substring(0, firstHeadingEnd + 1) + + '\n' + TOC_START + '\n' + toc + TOC_END + '\n' + + content.substring(firstHeadingEnd + 1); + } + + fs.writeFileSync(filePath, content); + console.log(` Updated: ${filePath}`); +} + +function main() { + const rootDir = process.cwd(); + console.log('Generating LLM TOCs...\n'); + + // Generate combined TOC for root LLMS.txt + let rootTOC = `## API Quick Reference\n`; + rootTOC += `\n\n`; + + for (const pkg of PACKAGES) { + const results = scanDirectory(path.join(rootDir, pkg.srcPath)); + + // Add to root TOC + rootTOC += `### ${pkg.name}\n`; + rootTOC += formatPackageTOC(results); + rootTOC += '\n'; + + // Inject package-specific TOC + const pkgTOC = `## API Quick Reference\n\n` + formatPackageTOC(results); + injectTOC(path.join(rootDir, pkg.llmsFile), pkgTOC); + } + + // Inject root TOC + injectTOC(path.join(rootDir, 'LLMS.txt'), rootTOC); + + console.log('\nDone!'); +} + +main();