@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.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +8 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +9 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/fixerEditor/auto-trim.ts +37 -0
- package/src/tool/fixerEditor/bibliography.astro +6 -0
- package/src/tool/fixerEditor/bibliography.ts +6 -0
- package/src/tool/fixerEditor/component.astro +114 -0
- package/src/tool/fixerEditor/controller.ts +275 -0
- package/src/tool/fixerEditor/dom-views.ts +200 -0
- package/src/tool/fixerEditor/entry.ts +28 -0
- package/src/tool/fixerEditor/evaluator.ts +17 -0
- package/src/tool/fixerEditor/events.ts +98 -0
- package/src/tool/fixerEditor/exporter.ts +42 -0
- package/src/tool/fixerEditor/fixer-editor.css +603 -0
- package/src/tool/fixerEditor/i18n/de.ts +82 -0
- package/src/tool/fixerEditor/i18n/en.ts +114 -0
- package/src/tool/fixerEditor/i18n/es.ts +82 -0
- package/src/tool/fixerEditor/i18n/fr.ts +82 -0
- package/src/tool/fixerEditor/i18n/id.ts +82 -0
- package/src/tool/fixerEditor/i18n/it.ts +82 -0
- package/src/tool/fixerEditor/i18n/ja.ts +81 -0
- package/src/tool/fixerEditor/i18n/ko.ts +81 -0
- package/src/tool/fixerEditor/i18n/localized.ts +391 -0
- package/src/tool/fixerEditor/i18n/nl.ts +82 -0
- package/src/tool/fixerEditor/i18n/pl.ts +82 -0
- package/src/tool/fixerEditor/i18n/pt.ts +82 -0
- package/src/tool/fixerEditor/i18n/ru.ts +82 -0
- package/src/tool/fixerEditor/i18n/sv.ts +82 -0
- package/src/tool/fixerEditor/i18n/tr.ts +82 -0
- package/src/tool/fixerEditor/i18n/zh.ts +80 -0
- package/src/tool/fixerEditor/index.ts +11 -0
- package/src/tool/fixerEditor/logic.test.ts +69 -0
- package/src/tool/fixerEditor/logic.ts +234 -0
- package/src/tool/fixerEditor/runtime.ts +88 -0
- package/src/tool/fixerEditor/seo.astro +15 -0
- package/src/tool/fixerEditor/state.ts +10 -0
- package/src/tool/fixerEditor/storage.ts +68 -0
- package/src/tool/fixerEditor/types.ts +70 -0
- package/src/tool/fixerEditor/ui.ts +71 -0
- package/src/tool/gamePixelPerUnitPlanner/bibliography.astro +6 -0
- package/src/tool/gamePixelPerUnitPlanner/bibliography.ts +20 -0
- package/src/tool/gamePixelPerUnitPlanner/component.astro +56 -0
- package/src/tool/gamePixelPerUnitPlanner/controller.ts +127 -0
- package/src/tool/gamePixelPerUnitPlanner/dom-views.ts +47 -0
- package/src/tool/gamePixelPerUnitPlanner/entry.ts +27 -0
- package/src/tool/gamePixelPerUnitPlanner/evaluator.ts +14 -0
- package/src/tool/gamePixelPerUnitPlanner/game-pixel-per-unit-planner.css +652 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/de.ts +51 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/en.ts +171 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/es.ts +53 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/fr.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/id.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/it.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/ja.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/ko.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/nl.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/pl.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/pt.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/ru.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/sv.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/tr.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/i18n/zh.ts +33 -0
- package/src/tool/gamePixelPerUnitPlanner/index.ts +11 -0
- package/src/tool/gamePixelPerUnitPlanner/logic.test.ts +34 -0
- package/src/tool/gamePixelPerUnitPlanner/logic.ts +129 -0
- package/src/tool/gamePixelPerUnitPlanner/seo.astro +15 -0
- package/src/tool/gamePixelPerUnitPlanner/storage.ts +23 -0
- package/src/tool/gamePixelPerUnitPlanner/ui.ts +65 -0
- package/src/tools.ts +4 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -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('
|
|
6
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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
|
+
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
|
+
}
|