@jjlmoya/utils-games-development 1.68.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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +8 -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/tool/gamePixelPerUnitPlanner/bibliography.astro +6 -0
  44. package/src/tool/gamePixelPerUnitPlanner/bibliography.ts +20 -0
  45. package/src/tool/gamePixelPerUnitPlanner/component.astro +56 -0
  46. package/src/tool/gamePixelPerUnitPlanner/controller.ts +127 -0
  47. package/src/tool/gamePixelPerUnitPlanner/dom-views.ts +47 -0
  48. package/src/tool/gamePixelPerUnitPlanner/entry.ts +27 -0
  49. package/src/tool/gamePixelPerUnitPlanner/evaluator.ts +14 -0
  50. package/src/tool/gamePixelPerUnitPlanner/game-pixel-per-unit-planner.css +652 -0
  51. package/src/tool/gamePixelPerUnitPlanner/i18n/de.ts +51 -0
  52. package/src/tool/gamePixelPerUnitPlanner/i18n/en.ts +171 -0
  53. package/src/tool/gamePixelPerUnitPlanner/i18n/es.ts +53 -0
  54. package/src/tool/gamePixelPerUnitPlanner/i18n/fr.ts +33 -0
  55. package/src/tool/gamePixelPerUnitPlanner/i18n/id.ts +33 -0
  56. package/src/tool/gamePixelPerUnitPlanner/i18n/it.ts +33 -0
  57. package/src/tool/gamePixelPerUnitPlanner/i18n/ja.ts +33 -0
  58. package/src/tool/gamePixelPerUnitPlanner/i18n/ko.ts +33 -0
  59. package/src/tool/gamePixelPerUnitPlanner/i18n/nl.ts +33 -0
  60. package/src/tool/gamePixelPerUnitPlanner/i18n/pl.ts +33 -0
  61. package/src/tool/gamePixelPerUnitPlanner/i18n/pt.ts +33 -0
  62. package/src/tool/gamePixelPerUnitPlanner/i18n/ru.ts +33 -0
  63. package/src/tool/gamePixelPerUnitPlanner/i18n/sv.ts +33 -0
  64. package/src/tool/gamePixelPerUnitPlanner/i18n/tr.ts +33 -0
  65. package/src/tool/gamePixelPerUnitPlanner/i18n/zh.ts +33 -0
  66. package/src/tool/gamePixelPerUnitPlanner/index.ts +11 -0
  67. package/src/tool/gamePixelPerUnitPlanner/logic.test.ts +34 -0
  68. package/src/tool/gamePixelPerUnitPlanner/logic.ts +129 -0
  69. package/src/tool/gamePixelPerUnitPlanner/seo.astro +15 -0
  70. package/src/tool/gamePixelPerUnitPlanner/storage.ts +23 -0
  71. package/src/tool/gamePixelPerUnitPlanner/ui.ts +65 -0
  72. package/src/tools.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-games-development",
3
- "version": "1.68.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';
@@ -8,10 +9,11 @@ import { gameUiAccessibilityTester } from '../tool/gameUIAccessibilityTester/ent
8
9
  import { hitboxHurtboxAnimator } from '../tool/hitboxHurtboxAnimator/entry';
9
10
  import { damageFormulaLab } from '../tool/damageFormulaLab/entry';
10
11
  import { gameDeltaTimeFixedTimestepLab } from '../tool/gameDeltaTimeFixedTimestepLab/entry';
12
+ import { gamePixelPerUnitPlanner } from '../tool/gamePixelPerUnitPlanner/entry';
11
13
 
12
14
  export const gamesCategory: GamesCategoryEntry = {
13
15
  icon: 'mdi:gamepad-variant',
14
- tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab],
16
+ tools: [steamCapsuleGenerator, spriteSheetPacker, fixerEditor, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab, gamePixelPerUnitPlanner],
15
17
  i18n: {
16
18
  de: () => import('./i18n/de').then((m) => m.content),
17
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';
@@ -22,7 +24,10 @@ export { damageFormulaLab } from './tool/damageFormulaLab/entry';
22
24
  export type { DamageFormulaLabUI, DamageFormulaLabLocaleContent } from './tool/damageFormulaLab/entry';
23
25
  export { gameDeltaTimeFixedTimestepLab } from './tool/gameDeltaTimeFixedTimestepLab/entry';
24
26
  export type { GameDeltaTimeFixedTimestepLabUI, GameDeltaTimeFixedTimestepLabLocaleContent } from './tool/gameDeltaTimeFixedTimestepLab/entry';
27
+ export { gamePixelPerUnitPlanner } from './tool/gamePixelPerUnitPlanner/entry';
28
+ export type { GamePixelPerUnitPlannerUI, GamePixelPerUnitPlannerLocaleContent } from './tool/gamePixelPerUnitPlanner/entry';
25
29
  export { gamesCategory } from './category';
30
+
26
31
  import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
27
32
  import { itchioGameTester } from './tool/itchioGameTester/entry';
28
33
  import { spriteSheetPacker } from './tool/spriteSheetPacker/entry';
@@ -36,5 +41,7 @@ import { gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester/entr
36
41
  import { hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator/entry';
37
42
  import { damageFormulaLab } from './tool/damageFormulaLab/entry';
38
43
  import { gameDeltaTimeFixedTimestepLab } from './tool/gameDeltaTimeFixedTimestepLab/entry';
44
+ import { gamePixelPerUnitPlanner } from './tool/gamePixelPerUnitPlanner/entry';
45
+ import { fixerEditor } from './tool/fixerEditor/entry';
39
46
 
40
- export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab];
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('thirteen tools are registered', () => {
6
- expect(ALL_TOOLS.length).toBe(13);
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 13 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(13);
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
+ }