@jjlmoya/utils-games-development 1.69.0 → 1.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +9 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/fixerEditor/auto-trim.ts +37 -0
  8. package/src/tool/fixerEditor/bibliography.astro +6 -0
  9. package/src/tool/fixerEditor/bibliography.ts +6 -0
  10. package/src/tool/fixerEditor/component.astro +114 -0
  11. package/src/tool/fixerEditor/controller.ts +275 -0
  12. package/src/tool/fixerEditor/dom-views.ts +200 -0
  13. package/src/tool/fixerEditor/entry.ts +28 -0
  14. package/src/tool/fixerEditor/evaluator.ts +17 -0
  15. package/src/tool/fixerEditor/events.ts +98 -0
  16. package/src/tool/fixerEditor/exporter.ts +42 -0
  17. package/src/tool/fixerEditor/fixer-editor.css +603 -0
  18. package/src/tool/fixerEditor/i18n/de.ts +82 -0
  19. package/src/tool/fixerEditor/i18n/en.ts +114 -0
  20. package/src/tool/fixerEditor/i18n/es.ts +82 -0
  21. package/src/tool/fixerEditor/i18n/fr.ts +82 -0
  22. package/src/tool/fixerEditor/i18n/id.ts +82 -0
  23. package/src/tool/fixerEditor/i18n/it.ts +82 -0
  24. package/src/tool/fixerEditor/i18n/ja.ts +81 -0
  25. package/src/tool/fixerEditor/i18n/ko.ts +81 -0
  26. package/src/tool/fixerEditor/i18n/localized.ts +391 -0
  27. package/src/tool/fixerEditor/i18n/nl.ts +82 -0
  28. package/src/tool/fixerEditor/i18n/pl.ts +82 -0
  29. package/src/tool/fixerEditor/i18n/pt.ts +82 -0
  30. package/src/tool/fixerEditor/i18n/ru.ts +82 -0
  31. package/src/tool/fixerEditor/i18n/sv.ts +82 -0
  32. package/src/tool/fixerEditor/i18n/tr.ts +82 -0
  33. package/src/tool/fixerEditor/i18n/zh.ts +80 -0
  34. package/src/tool/fixerEditor/index.ts +11 -0
  35. package/src/tool/fixerEditor/logic.test.ts +69 -0
  36. package/src/tool/fixerEditor/logic.ts +234 -0
  37. package/src/tool/fixerEditor/runtime.ts +88 -0
  38. package/src/tool/fixerEditor/seo.astro +15 -0
  39. package/src/tool/fixerEditor/state.ts +10 -0
  40. package/src/tool/fixerEditor/storage.ts +68 -0
  41. package/src/tool/fixerEditor/types.ts +70 -0
  42. package/src/tool/fixerEditor/ui.ts +71 -0
  43. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-games-development",
3
- "version": "1.69.0",
3
+ "version": "1.70.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,6 +1,7 @@
1
1
  import type { GamesCategoryEntry } from '../types';
2
2
  import { steamCapsuleGenerator } from '../tool/steamCapsuleGenerator/entry';
3
3
  import { spriteSheetPacker } from '../tool/spriteSheetPacker/entry';
4
+ import { fixerEditor } from '../tool/fixerEditor/entry';
4
5
  import { audioLoopPointFinder } from '../tool/audioLoopPointFinder/entry';
5
6
  import { saveFileEditor } from '../tool/saveFileEditor/entry';
6
7
  import { steamBbcodeTranslator } from '../tool/steamBbcodeTranslator/entry';
@@ -12,7 +13,7 @@ import { gamePixelPerUnitPlanner } from '../tool/gamePixelPerUnitPlanner/entry';
12
13
 
13
14
  export const gamesCategory: GamesCategoryEntry = {
14
15
  icon: 'mdi:gamepad-variant',
15
- tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab, gamePixelPerUnitPlanner],
16
+ tools: [steamCapsuleGenerator, spriteSheetPacker, fixerEditor, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab, gamePixelPerUnitPlanner],
16
17
  i18n: {
17
18
  de: () => import('./i18n/de').then((m) => m.content),
18
19
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -4,6 +4,8 @@ export { itchioGameTester } from './tool/itchioGameTester/entry';
4
4
  export type { ItchioGameTesterUI, ItchioGameTesterLocaleContent } from './tool/itchioGameTester/entry';
5
5
  export { spriteSheetPacker } from './tool/spriteSheetPacker/entry';
6
6
  export type { SpriteSheetPackerUI, SpriteSheetPackerLocaleContent } from './tool/spriteSheetPacker/entry';
7
+ export { fixerEditor } from './tool/fixerEditor/entry';
8
+ export type { FixerEditorUI, FixerEditorLocaleContent } from './tool/fixerEditor/entry';
7
9
  export { audioLoopPointFinder } from './tool/audioLoopPointFinder/entry';
8
10
  export type { AudioLoopPointFinderUI, AudioLoopPointFinderLocaleContent } from './tool/audioLoopPointFinder/entry';
9
11
  export { saveFileEditor } from './tool/saveFileEditor/entry';
@@ -40,5 +42,6 @@ import { hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator/entry';
40
42
  import { damageFormulaLab } from './tool/damageFormulaLab/entry';
41
43
  import { gameDeltaTimeFixedTimestepLab } from './tool/gameDeltaTimeFixedTimestepLab/entry';
42
44
  import { gamePixelPerUnitPlanner } from './tool/gamePixelPerUnitPlanner/entry';
45
+ import { fixerEditor } from './tool/fixerEditor/entry';
43
46
 
44
- export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab, gamePixelPerUnitPlanner];
47
+ export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab, gamePixelPerUnitPlanner, fixerEditor];
package/src/index.ts CHANGED
@@ -23,5 +23,6 @@ export { PIXEL_ART_PALETTE_SWAPPER_TOOL, pixelArtPaletteSwapper } from './tool/p
23
23
  export { GAME_UI_ACCESSIBILITY_TESTER_TOOL, gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester';
24
24
  export { HITBOX_HURTBOX_ANIMATOR_TOOL, hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator';
25
25
  export { DAMAGE_FORMULA_LAB_TOOL, damageFormulaLab } from './tool/damageFormulaLab';
26
+ export { FIXER_EDITOR_TOOL, fixerEditor } from './tool/fixerEditor';
26
27
 
27
28
  export type { ToolLocaleContent as GamesToolLocaleContent } from './types';
@@ -1,8 +1,15 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { ALL_TOOLS } from '../tools';
3
+ import { fixerEditor } from '../tool/fixerEditor/entry';
3
4
 
4
5
  describe('Locale Completeness Validation', () => {
5
- it('fourteen tools are registered', () => {
6
- expect(ALL_TOOLS.length).toBe(14);
6
+ it('fifteen tools are registered', () => {
7
+ expect(ALL_TOOLS.length).toBe(15);
8
+ });
9
+
10
+ it('sprite sheet fixer editor exposes every supported locale', () => {
11
+ expect(Object.keys(fixerEditor.i18n).sort()).toEqual([
12
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
13
+ ]);
7
14
  });
8
15
  });
@@ -4,8 +4,8 @@ import { gamesCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 14 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(14);
7
+ it('should have 15 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(15);
9
9
  });
10
10
 
11
11
  it('gamesCategory should be defined', () => {
@@ -0,0 +1,37 @@
1
+ import { trimTransparent, updateAdjustment } from './logic';
2
+ import type { ControllerContext } from './controller';
3
+ import type { FrameSpec } from './types';
4
+
5
+ export function trimFrame(context: ControllerContext): void {
6
+ const { state, ui } = context;
7
+ const frame = state.frames[state.selected];
8
+ if (!frame || !state.image) return;
9
+ const crop = getTrimCrop(state.image, frame);
10
+ if (!crop) return;
11
+ context.recordHistory();
12
+ state.adjustments[String(frame.index)] = updateAdjustment(frame, state.adjustments[String(frame.index)] ?? frame.adjustment, { crop });
13
+ context.updateView(ui.ready);
14
+ }
15
+
16
+ export function trimAll(context: ControllerContext): void {
17
+ const { state, ui } = context;
18
+ if (!state.image || state.frames.length === 0) return;
19
+ const image = state.image;
20
+ context.recordHistory();
21
+ state.frames.forEach((frame) => {
22
+ const crop = getTrimCrop(image, frame);
23
+ if (crop) state.adjustments[String(frame.index)] = updateAdjustment(frame, state.adjustments[String(frame.index)] ?? frame.adjustment, { crop });
24
+ });
25
+ context.updateView(ui.ready);
26
+ }
27
+
28
+ function getTrimCrop(image: HTMLImageElement, frame: FrameSpec) {
29
+ const sourceCanvas = document.createElement('canvas');
30
+ sourceCanvas.width = frame.width;
31
+ sourceCanvas.height = frame.height;
32
+ const source = sourceCanvas.getContext('2d');
33
+ if (!source) return null;
34
+ source.imageSmoothingEnabled = false;
35
+ source.drawImage(image, frame.sourceX, frame.sourceY, frame.width, frame.height, 0, 0, frame.width, frame.height);
36
+ return trimTransparent(source.getImageData(0, 0, frame.width, frame.height).data, frame.width, frame.height);
37
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ { name: 'Cocos Creator 3.8 API: SpriteFrame', url: 'https://docs.cocos.com/creator/3.8/api/zh/class/SpriteFrame' },
5
+ { name: 'Godot Engine 4.x documentation: Sprite2D', url: 'https://docs.godotengine.org/es/4.x/classes/class_sprite2d.html' },
6
+ ];
@@ -0,0 +1,114 @@
1
+ ---
2
+ import type { FixerEditorUI } from './ui';
3
+ import './fixer-editor.css';
4
+
5
+ interface Props {
6
+ ui: FixerEditorUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div class="fe-shell" data-tool="fixer-editor" data-ui={JSON.stringify(ui)}>
13
+ <div class="fe-command-rail">
14
+ <div class="fe-upload-zone" id="fe-drop-zone" tabindex="0" role="button" aria-label={ui.uploadTitle}>
15
+ <span class="fe-upload-mark" aria-hidden="true">+</span>
16
+ <span class="fe-upload-copy"><strong>{ui.uploadTitle}</strong><small>{ui.uploadHint}</small></span>
17
+ <span class="fe-button fe-primary fe-upload-cta">{ui.chooseImage}</span>
18
+ <input id="fe-file-input" class="fe-hidden" type="file" accept="image/png,image/jpeg,image/webp" aria-label={ui.chooseImage} />
19
+ </div>
20
+ <div class="fe-source-meta">
21
+ <span class="fe-file-name"><span>{ui.sourceFile}</span> <strong id="fe-source-name">{ui.noImage}</strong></span>
22
+ <span id="fe-status" class="fe-status" role="status" aria-live="polite">{ui.statusEmpty}</span>
23
+ </div>
24
+ </div>
25
+
26
+ <div id="fe-workbench" class="fe-workbench" hidden>
27
+ <aside class="fe-panel fe-grid-panel">
28
+ <div class="fe-panel-label">01</div>
29
+ <h2>{ui.gridSettings}</h2>
30
+ <div class="fe-field-grid">
31
+ <label><span>{ui.columns}</span><input id="fe-columns" type="number" min="1" max="128" value="4" /></label>
32
+ <label><span>{ui.rows}</span><input id="fe-rows" type="number" min="1" max="128" value="2" /></label>
33
+ <label><span>{ui.cellWidth}</span><input id="fe-cell-width" type="number" min="1" max="4096" value="64" /></label>
34
+ <label><span>{ui.cellHeight}</span><input id="fe-cell-height" type="number" min="1" max="4096" value="64" /></label>
35
+ <label><span>{ui.marginX}</span><input id="fe-margin-x" type="number" min="0" max="4096" value="0" /></label>
36
+ <label><span>{ui.marginY}</span><input id="fe-margin-y" type="number" min="0" max="4096" value="0" /></label>
37
+ <label><span>{ui.gapX}</span><input id="fe-gap-x" type="number" min="0" max="4096" value="0" /></label>
38
+ <label><span>{ui.gapY}</span><input id="fe-gap-y" type="number" min="0" max="4096" value="0" /></label>
39
+ </div>
40
+ <div class="fe-panel-actions">
41
+ <button id="fe-auto-detect" class="fe-button fe-primary" type="button">{ui.autoDetectGrid}</button>
42
+ <button id="fe-reset-all" class="fe-button fe-quiet" type="button">{ui.resetAll}</button>
43
+ <button id="fe-undo" class="fe-button fe-quiet" type="button">{ui.undo}</button>
44
+ <button id="fe-redo" class="fe-button fe-quiet" type="button">{ui.redo}</button>
45
+ </div>
46
+ </aside>
47
+
48
+ <main class="fe-stage-panel">
49
+ <div class="fe-stage-heading">
50
+ <div><span class="fe-panel-label">02</span><h2>{ui.sheetView}</h2></div>
51
+ <label class="fe-zoom"><span>{ui.zoom}</span><input id="fe-zoom" type="range" min="1" max="8" value="3" /><output id="fe-zoom-value">3x</output></label>
52
+ </div>
53
+ <div class="fe-sheet-pair">
54
+ <figure class="fe-sheet-view"><figcaption><span class="fe-source-mark"></span>{ui.sourceLabel}</figcaption><div class="fe-canvas-wrap"><canvas id="fe-source-canvas" width="1" height="1" aria-label={ui.sourceLabel}></canvas></div></figure>
55
+ <figure class="fe-sheet-view fe-corrected-view"><figcaption><span class="fe-result-mark"></span>{ui.correctedView}</figcaption><div class="fe-canvas-wrap"><canvas id="fe-result-canvas" width="1" height="1" aria-label={ui.resultLabel}></canvas></div></figure>
56
+ </div>
57
+ <div class="fe-stage-footer"><span><strong>{ui.imageSize}</strong> <span id="fe-image-size">0 x 0 px</span></span><span>{ui.metadataNote}</span></div>
58
+ <div class="fe-strip-heading"><span class="fe-panel-label">03</span><h2>{ui.frameStrip}</h2><span class="fe-count"><span id="fe-frame-count">0</span> {ui.frameCount}</span></div>
59
+ <div id="fe-filmstrip" class="fe-filmstrip" role="list" aria-label={ui.frameStrip}></div>
60
+ </main>
61
+
62
+ <aside class="fe-panel fe-inspector-panel">
63
+ <div class="fe-panel-label">04</div>
64
+ <div class="fe-inspector-heading"><h2>{ui.activeFrame}</h2><strong id="fe-frame-number">0 / 0</strong></div>
65
+ <div class="fe-active-canvas-wrap"><canvas id="fe-frame-canvas" width="64" height="64" aria-label={ui.activeFrame}></canvas></div>
66
+ <div class="fe-frame-state" id="fe-frame-state">{ui.unchanged}</div>
67
+ <div class="fe-crop-grid">
68
+ <label><span>{ui.cropX}</span><input id="fe-crop-x" type="number" min="0" value="0" /></label>
69
+ <label><span>{ui.cropY}</span><input id="fe-crop-y" type="number" min="0" value="0" /></label>
70
+ <label><span>{ui.cropWidth}</span><input id="fe-crop-width" type="number" min="1" value="64" /></label>
71
+ <label><span>{ui.cropHeight}</span><input id="fe-crop-height" type="number" min="1" value="64" /></label>
72
+ <label><span>{ui.offsetX}</span><input id="fe-offset-x" type="number" value="0" /></label>
73
+ <label><span>{ui.offsetY}</span><input id="fe-offset-y" type="number" value="0" /></label>
74
+ </div>
75
+ <div class="fe-nudge-heading"><span>{ui.nudgeStep}</span><div class="fe-step-buttons"><button class="fe-step is-active" data-step="1" type="button">{ui.stepOne}</button><button class="fe-step" data-step="2" type="button">{ui.stepTwo}</button><button class="fe-step" data-step="4" type="button">{ui.stepFour}</button></div></div>
76
+ <div class="fe-nudge-pad" aria-label={ui.frameTools}>
77
+ <span></span><button class="fe-nudge" data-axis="y" data-direction="-1" type="button" aria-label={ui.moveUp}>↑</button><span></span>
78
+ <button class="fe-nudge" data-axis="x" data-direction="-1" type="button" aria-label={ui.moveLeft}>←</button><span class="fe-crosshair" aria-hidden="true"></span><button class="fe-nudge" data-axis="x" data-direction="1" type="button" aria-label={ui.moveRight}>→</button>
79
+ <span></span><button class="fe-nudge" data-axis="y" data-direction="1" type="button" aria-label={ui.moveDown}>↓</button><span></span>
80
+ </div>
81
+ <div class="fe-inspector-actions"><button id="fe-trim" class="fe-button fe-primary" type="button">{ui.trimTransparent}</button><button id="fe-trim-all" class="fe-button fe-quiet" type="button">{ui.trimAll}</button><button id="fe-reset-frame" class="fe-button fe-quiet" type="button">{ui.resetFrame}</button></div>
82
+ </aside>
83
+ </div>
84
+
85
+ <div id="fe-tools" class="fe-output-bar" hidden>
86
+ <div class="fe-output-copy">
87
+ <strong>{ui.history}</strong>
88
+ <div class="fe-output-stat"><span id="fe-correction-count">0</span><span>{ui.correctionCount}</span></div>
89
+ </div>
90
+ <div class="fe-playback">
91
+ <span class="fe-flipbook-label">{ui.flipbook}</span>
92
+ <button id="fe-play" class="fe-button fe-quiet" type="button">{ui.play}</button>
93
+ <button id="fe-pause" class="fe-button fe-quiet" type="button">{ui.pause}</button>
94
+ <label class="fe-fps"><span>{ui.fps}</span><input id="fe-fps" type="range" min="1" max="30" value="12" /><output id="fe-fps-value">12</output></label>
95
+ </div>
96
+ <div class="fe-output-actions">
97
+ <button id="fe-save-project" class="fe-button fe-quiet" type="button">{ui.saveProject}</button>
98
+ <button id="fe-load-project-button" class="fe-button fe-quiet" type="button">{ui.loadProject}</button>
99
+ <input id="fe-load-project" class="fe-hidden" type="file" accept="application/json,.json" aria-label={ui.loadProject} />
100
+ <button id="fe-export-json" class="fe-button fe-quiet" type="button">{ui.exportJson}</button>
101
+ <button id="fe-export-sheet" class="fe-button fe-primary" type="button">{ui.exportSheet}</button>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ <script>
107
+ import { initFixerEditor } from './controller';
108
+
109
+ if (document.readyState === 'loading') {
110
+ document.addEventListener('DOMContentLoaded', initFixerEditor, { once: true });
111
+ } else {
112
+ initFixerEditor();
113
+ }
114
+ </script>
@@ -0,0 +1,275 @@
1
+ import { renderEditor } from './dom-views';
2
+ import { summarizeEditor } from './evaluator';
3
+ import { createDefaultAdjustment, createFrameSpecs, createHistoryState, createProjectFile, DEFAULT_GRID, nudgeAdjustment, recalculateGrid, updateAdjustment, cloneAdjustments, normalizeGrid } from './logic';
4
+ import { trimAll, trimFrame } from './auto-trim';
5
+ import { createInitialState } from './state';
6
+ import { applyProjectToContext, bindEvents } from './events';
7
+ import { animate, autoDetectGrid, loadImage, restoreDraftImage } from './runtime';
8
+ import { loadDraft, saveDraft } from './storage';
9
+ import type { FixerEditorUI } from './ui';
10
+ import type { DraftSession, FrameAdjustment, FrameSpec, GridSettings, HistoryState, ProjectFile } from './types';
11
+ import type { EditorRefs } from './dom-views';
12
+
13
+ export interface EditorState {
14
+ image: HTMLImageElement | null;
15
+ imageDataUrl?: string;
16
+ sourceName: string;
17
+ grid: GridSettings;
18
+ frames: FrameSpec[];
19
+ adjustments: Record<string, FrameAdjustment>;
20
+ selected: number;
21
+ step: number;
22
+ zoom: number;
23
+ fps: number;
24
+ playing: boolean;
25
+ animationId: number | null;
26
+ history: HistoryState[];
27
+ future: HistoryState[];
28
+ pendingProject: ProjectFile | null;
29
+ }
30
+
31
+ export interface ControllerContext {
32
+ refs: EditorRefs;
33
+ ui: FixerEditorUI;
34
+ state: EditorState;
35
+ tools: HTMLElement;
36
+ updateView: (statusText: string) => void;
37
+ animate: () => void;
38
+ persistDraft: () => void;
39
+ recordHistory: () => void;
40
+ }
41
+
42
+ export interface EditorInputs {
43
+ columns: HTMLInputElement;
44
+ rows: HTMLInputElement;
45
+ 'cell-width': HTMLInputElement;
46
+ 'cell-height': HTMLInputElement;
47
+ 'margin-x': HTMLInputElement;
48
+ 'margin-y': HTMLInputElement;
49
+ 'gap-x': HTMLInputElement;
50
+ 'gap-y': HTMLInputElement;
51
+ 'crop-x': HTMLInputElement;
52
+ 'crop-y': HTMLInputElement;
53
+ 'crop-width': HTMLInputElement;
54
+ 'crop-height': HTMLInputElement;
55
+ 'offset-x': HTMLInputElement;
56
+ 'offset-y': HTMLInputElement;
57
+ }
58
+
59
+ function required<T extends Element>(root: ParentNode, id: string): T {
60
+ const element = root.querySelector(`#${id}`);
61
+ if (!element) throw new Error(`Missing fixer-editor element: ${id}`);
62
+ return element as T;
63
+ }
64
+
65
+ function getRefs(shell: HTMLElement): EditorRefs {
66
+ return {
67
+ shell,
68
+ sourceCanvas: required<HTMLCanvasElement>(shell, 'fe-source-canvas'),
69
+ resultCanvas: required<HTMLCanvasElement>(shell, 'fe-result-canvas'),
70
+ frameCanvas: required<HTMLCanvasElement>(shell, 'fe-frame-canvas'),
71
+ filmstrip: required<HTMLElement>(shell, 'fe-filmstrip'),
72
+ status: required<HTMLElement>(shell, 'fe-status'),
73
+ sourceName: required<HTMLElement>(shell, 'fe-source-name'),
74
+ frameNumber: required<HTMLElement>(shell, 'fe-frame-number'),
75
+ frameState: required<HTMLElement>(shell, 'fe-frame-state'),
76
+ frameCount: required<HTMLElement>(shell, 'fe-frame-count'),
77
+ correctionCount: required<HTMLElement>(shell, 'fe-correction-count'),
78
+ imageSize: required<HTMLElement>(shell, 'fe-image-size'),
79
+ workbench: required<HTMLElement>(shell, 'fe-workbench'),
80
+ exportSheet: required<HTMLButtonElement>(shell, 'fe-export-sheet'),
81
+ exportJson: required<HTMLButtonElement>(shell, 'fe-export-json'),
82
+ loadProject: required<HTMLInputElement>(shell, 'fe-load-project'),
83
+ };
84
+ }
85
+
86
+ function getInputs(shell: HTMLElement): EditorInputs {
87
+ return {
88
+ columns: required<HTMLInputElement>(shell, 'fe-columns'),
89
+ rows: required<HTMLInputElement>(shell, 'fe-rows'),
90
+ 'cell-width': required<HTMLInputElement>(shell, 'fe-cell-width'),
91
+ 'cell-height': required<HTMLInputElement>(shell, 'fe-cell-height'),
92
+ 'margin-x': required<HTMLInputElement>(shell, 'fe-margin-x'),
93
+ 'margin-y': required<HTMLInputElement>(shell, 'fe-margin-y'),
94
+ 'gap-x': required<HTMLInputElement>(shell, 'fe-gap-x'),
95
+ 'gap-y': required<HTMLInputElement>(shell, 'fe-gap-y'),
96
+ 'crop-x': required<HTMLInputElement>(shell, 'fe-crop-x'),
97
+ 'crop-y': required<HTMLInputElement>(shell, 'fe-crop-y'),
98
+ 'crop-width': required<HTMLInputElement>(shell, 'fe-crop-width'),
99
+ 'crop-height': required<HTMLInputElement>(shell, 'fe-crop-height'),
100
+ 'offset-x': required<HTMLInputElement>(shell, 'fe-offset-x'),
101
+ 'offset-y': required<HTMLInputElement>(shell, 'fe-offset-y'),
102
+ };
103
+ }
104
+
105
+ function readGrid(inputs: EditorInputs): GridSettings {
106
+ return normalizeGrid({ columns: Number(inputs.columns.value), rows: Number(inputs.rows.value), cellWidth: Number(inputs['cell-width'].value), cellHeight: Number(inputs['cell-height'].value), marginX: Number(inputs['margin-x'].value), marginY: Number(inputs['margin-y'].value), gapX: Number(inputs['gap-x'].value), gapY: Number(inputs['gap-y'].value) });
107
+ }
108
+
109
+ function writeGrid(inputs: EditorInputs, grid: GridSettings): void {
110
+ inputs.columns.value = String(grid.columns);
111
+ inputs.rows.value = String(grid.rows);
112
+ inputs['cell-width'].value = String(grid.cellWidth);
113
+ inputs['cell-height'].value = String(grid.cellHeight);
114
+ inputs['margin-x'].value = String(grid.marginX);
115
+ inputs['margin-y'].value = String(grid.marginY);
116
+ inputs['gap-x'].value = String(grid.gapX);
117
+ inputs['gap-y'].value = String(grid.gapY);
118
+ }
119
+
120
+ function getAdjustment(state: EditorState): FrameAdjustment {
121
+ const frame = state.frames[state.selected];
122
+ return frame ? state.adjustments[String(frame.index)] ?? createDefaultAdjustment(frame.width, frame.height) : createDefaultAdjustment(1, 1);
123
+ }
124
+
125
+ function writeAdjustment(shell: HTMLElement, adjustment: FrameAdjustment): void {
126
+ const values: Record<string, number> = { 'crop-x': adjustment.crop.x, 'crop-y': adjustment.crop.y, 'crop-width': adjustment.crop.width, 'crop-height': adjustment.crop.height, 'offset-x': adjustment.offsetX, 'offset-y': adjustment.offsetY };
127
+ Object.entries(values).forEach(([id, value]) => { required<HTMLInputElement>(shell, `fe-${id}`).value = String(value); });
128
+ }
129
+
130
+ function snapshot(state: EditorState): HistoryState {
131
+ return createHistoryState(state.grid, state.adjustments, state.selected);
132
+ }
133
+
134
+ function recordHistory(state: EditorState): void {
135
+ state.history.push(snapshot(state));
136
+ state.history = state.history.slice(-40);
137
+ state.future = [];
138
+ }
139
+
140
+ function restoreState(context: ControllerContext, historyState: HistoryState): void {
141
+ const { state } = context;
142
+ state.grid = historyState.grid;
143
+ state.adjustments = cloneAdjustments(historyState.adjustments);
144
+ state.selected = Math.min(historyState.selected, Math.max(0, state.frames.length - 1));
145
+ state.frames = state.image ? createFrameSpecs(state.image.naturalWidth, state.image.naturalHeight, state.grid) : [];
146
+ updateView(context, context.ui.ready);
147
+ }
148
+
149
+ function updateView(context: ControllerContext, statusText: string): void {
150
+ const { refs, state, ui } = context;
151
+ const summary = state.image ? summarizeEditor({ frames: state.frames, adjustments: state.adjustments, imageWidth: state.image.naturalWidth, imageHeight: state.image.naturalHeight }) : null;
152
+ context.tools.hidden = !state.image;
153
+ renderEditor({ refs, image: state.image, frames: state.frames, adjustments: state.adjustments, grid: state.grid, selected: state.selected, zoom: state.zoom, sourceName: state.sourceName, statusText, noImageText: ui.noImage, modifiedText: ui.modified, unchangedText: ui.unchanged, selectFrameText: ui.selectFrame });
154
+ if (state.image) writeGrid(getInputs(context.refs.shell), state.grid);
155
+ if (summary) refs.correctionCount.textContent = String(summary.modifiedCount);
156
+ if (state.frames[state.selected]) writeAdjustment(context.refs.shell, getAdjustment(state));
157
+ context.persistDraft();
158
+ }
159
+
160
+ function getDraftSession(state: EditorState): DraftSession {
161
+ return { selected: state.selected, step: state.step, zoom: state.zoom, fps: state.fps };
162
+ }
163
+
164
+ function persistState(context: ControllerContext): void {
165
+ const { state } = context;
166
+ if (!state.image) return;
167
+ const project = createProjectFile({ sourceName: state.sourceName, sourceWidth: state.image.naturalWidth, sourceHeight: state.image.naturalHeight, grid: state.grid, adjustments: state.adjustments });
168
+ saveDraft(project, getDraftSession(state));
169
+ }
170
+
171
+ function updateGrid(context: ControllerContext, inputs: EditorInputs, changed: keyof EditorInputs): void {
172
+ const { state, ui } = context;
173
+ recordHistory(state);
174
+ state.grid = readGrid(inputs);
175
+ if (state.image && changed === 'cell-width') state.grid = recalculateGrid(state.grid, state.image.naturalWidth, state.image.naturalHeight, 'columns');
176
+ if (state.image && changed === 'cell-height') state.grid = recalculateGrid(state.grid, state.image.naturalWidth, state.image.naturalHeight, 'rows');
177
+ writeGrid(inputs, state.grid);
178
+ state.frames = state.image ? createFrameSpecs(state.image.naturalWidth, state.image.naturalHeight, state.grid) : [];
179
+ state.selected = Math.min(state.selected, Math.max(0, state.frames.length - 1));
180
+ updateView(context, ui.ready);
181
+ }
182
+
183
+ function updateSelectedAdjustment(context: ControllerContext, inputs: EditorInputs): void {
184
+ const { state, ui } = context;
185
+ const frame = state.frames[state.selected];
186
+ if (!frame) return;
187
+ recordHistory(state);
188
+ const patch = { crop: { x: Number(inputs['crop-x'].value), y: Number(inputs['crop-y'].value), width: Number(inputs['crop-width'].value), height: Number(inputs['crop-height'].value) }, offsetX: Number(inputs['offset-x'].value), offsetY: Number(inputs['offset-y'].value) };
189
+ state.adjustments[String(frame.index)] = updateAdjustment(frame, getAdjustment(state), patch);
190
+ updateView(context, ui.ready);
191
+ }
192
+
193
+ function selectFrame(context: ControllerContext, index: number): void {
194
+ const { state, ui } = context;
195
+ state.selected = Math.max(0, Math.min(index, state.frames.length - 1));
196
+ updateView(context, ui.ready);
197
+ }
198
+
199
+ function nudge(context: ControllerContext, axis: 'x' | 'y', direction: number): void {
200
+ const { state, ui } = context;
201
+ const frame = state.frames[state.selected];
202
+ if (!frame) return;
203
+ recordHistory(state);
204
+ state.adjustments[String(frame.index)] = nudgeAdjustment(frame, getAdjustment(state), axis, direction * state.step);
205
+ updateView(context, ui.ready);
206
+ }
207
+
208
+ function resetFrame(context: ControllerContext): void {
209
+ const { state, ui } = context;
210
+ const frame = state.frames[state.selected];
211
+ if (!frame) return;
212
+ recordHistory(state);
213
+ state.adjustments[String(frame.index)] = createDefaultAdjustment(frame.width, frame.height);
214
+ updateView(context, ui.ready);
215
+ }
216
+
217
+ function resetAll(context: ControllerContext): void {
218
+ const { state, ui } = context;
219
+ recordHistory(state);
220
+ state.adjustments = {};
221
+ state.grid = DEFAULT_GRID;
222
+ state.frames = state.image ? createFrameSpecs(state.image.naturalWidth, state.image.naturalHeight, state.grid) : [];
223
+ state.selected = 0;
224
+ updateView(context, ui.ready);
225
+ }
226
+
227
+ function undo(context: ControllerContext): void {
228
+ const { state } = context;
229
+ const previous = state.history.pop();
230
+ if (!previous) return;
231
+ state.future.push(snapshot(state));
232
+ restoreState(context, previous);
233
+ }
234
+
235
+ function redo(context: ControllerContext): void {
236
+ const { state } = context;
237
+ const next = state.future.pop();
238
+ if (!next) return;
239
+ state.history.push(snapshot(state));
240
+ restoreState(context, next);
241
+ }
242
+
243
+ function createActions(context: ControllerContext) {
244
+ return {
245
+ loadImage: loadImage.bind(null, context),
246
+ updateGrid: updateGrid.bind(null, context),
247
+ updateSelectedAdjustment: updateSelectedAdjustment.bind(null, context),
248
+ selectFrame: selectFrame.bind(null, context),
249
+ nudge: nudge.bind(null, context),
250
+ trimFrame: trimFrame.bind(null, context),
251
+ trimAll: trimAll.bind(null, context),
252
+ resetFrame: resetFrame.bind(null, context),
253
+ resetAll: resetAll.bind(null, context),
254
+ undo: undo.bind(null, context),
255
+ redo: redo.bind(null, context),
256
+ applyProject: applyProjectToContext.bind(null, context),
257
+ };
258
+ }
259
+
260
+ export function initFixerEditor(): void {
261
+ const shell = document.querySelector<HTMLElement>('[data-tool="fixer-editor"]');
262
+ if (!shell || shell.dataset.initialized) return;
263
+ shell.dataset.initialized = 'true';
264
+ const ui = JSON.parse(shell.dataset.ui ?? '{}') as FixerEditorUI;
265
+ const refs = getRefs(shell);
266
+ const tools = required<HTMLElement>(shell, 'fe-tools');
267
+ const draft = loadDraft();
268
+ const state = createInitialState(draft);
269
+ const context: ControllerContext = { refs, ui, state, tools, updateView: (statusText) => updateView(context, statusText), animate: () => animate(context), persistDraft: () => persistState(context), recordHistory: () => recordHistory(state) };
270
+ const inputs = getInputs(shell);
271
+ writeGrid(inputs, state.grid);
272
+ updateView(context, ui.statusEmpty);
273
+ bindEvents({ context, inputs, actions: { ...createActions(context), autoDetect: () => autoDetectGrid(context) } });
274
+ if (state.imageDataUrl) restoreDraftImage(context, state.imageDataUrl, state.sourceName);
275
+ }