@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
@@ -0,0 +1,200 @@
1
+ import { getAdjustmentState } from './evaluator';
2
+ import { countModified, isModified } from './logic';
3
+ import type { FrameAdjustment, FrameSpec, GridSettings } from './types';
4
+
5
+ export interface EditorRefs {
6
+ shell: HTMLElement;
7
+ sourceCanvas: HTMLCanvasElement;
8
+ resultCanvas: HTMLCanvasElement;
9
+ frameCanvas: HTMLCanvasElement;
10
+ filmstrip: HTMLElement;
11
+ status: HTMLElement;
12
+ sourceName: HTMLElement;
13
+ frameNumber: HTMLElement;
14
+ frameState: HTMLElement;
15
+ frameCount: HTMLElement;
16
+ correctionCount: HTMLElement;
17
+ imageSize: HTMLElement;
18
+ workbench: HTMLElement;
19
+ exportSheet: HTMLButtonElement;
20
+ exportJson: HTMLButtonElement;
21
+ loadProject: HTMLInputElement;
22
+ }
23
+
24
+ export interface RenderInput {
25
+ refs: EditorRefs;
26
+ image: HTMLImageElement | null;
27
+ frames: FrameSpec[];
28
+ adjustments: Record<string, FrameAdjustment>;
29
+ grid: GridSettings;
30
+ selected: number;
31
+ zoom: number;
32
+ sourceName: string;
33
+ statusText: string;
34
+ noImageText: string;
35
+ modifiedText: string;
36
+ unchangedText: string;
37
+ selectFrameText: string;
38
+ }
39
+
40
+ export function renderEditor(input: RenderInput): void {
41
+ const { refs } = input;
42
+ refs.shell.classList.toggle('has-image', Boolean(input.image));
43
+ refs.workbench.hidden = !input.image;
44
+ refs.sourceName.textContent = input.sourceName || input.noImageText;
45
+ refs.shell.style.setProperty('--fe-zoom', `${input.zoom}`);
46
+ refs.status.textContent = input.statusText;
47
+ if (!input.image || input.frames.length === 0) return;
48
+ renderSheet({ canvas: refs.sourceCanvas, image: input.image, frames: input.frames, grid: input.grid, selected: input.selected, corrected: false });
49
+ renderCorrectedSheet({ canvas: refs.resultCanvas, image: input.image, frames: input.frames, adjustments: input.adjustments, grid: input.grid });
50
+ renderActiveFrame(refs.frameCanvas, input.image, input.frames[input.selected], input.adjustments);
51
+ renderFilmstrip(input);
52
+ updateSummary(input);
53
+ }
54
+
55
+ function renderSheet(input: { canvas: HTMLCanvasElement; image: HTMLImageElement; frames: FrameSpec[]; grid: GridSettings; selected: number; corrected: boolean }): void {
56
+ const { canvas, image, frames, grid, selected, corrected } = input;
57
+ canvas.width = image.naturalWidth;
58
+ canvas.height = image.naturalHeight;
59
+ const context = canvas.getContext('2d');
60
+ if (!context) return;
61
+ context.imageSmoothingEnabled = false;
62
+ context.clearRect(0, 0, canvas.width, canvas.height);
63
+ context.drawImage(image, 0, 0);
64
+ context.lineWidth = 1;
65
+ frames.forEach((frame, framePosition) => drawFrameMark({ context, frame, selected: framePosition === selected, corrected, grid }));
66
+ drawGridGuides({ context, width: canvas.width, height: canvas.height, grid, corrected });
67
+ }
68
+
69
+ function drawFrameMark(input: { context: CanvasRenderingContext2D; frame: FrameSpec; selected: boolean; corrected: boolean; grid: GridSettings }): void {
70
+ const { context, frame, selected, corrected } = input;
71
+ context.strokeStyle = getCorrectedStroke(corrected);
72
+ context.lineWidth = selected ? 2 : 1;
73
+ context.fillStyle = selected ? 'rgba(255, 202, 88, 0.18)' : 'rgba(15, 23, 42, 0.08)';
74
+ context.fillRect(frame.sourceX, frame.sourceY, frame.width, frame.height);
75
+ context.strokeRect(frame.sourceX + 0.5, frame.sourceY + 0.5, frame.width - 1, frame.height - 1);
76
+ context.fillStyle = selected ? '#ffca58' : '#ffffff';
77
+ context.font = 'bold 10px sans-serif';
78
+ context.fillText(String(frame.index + 1).padStart(2, '0'), frame.sourceX + 4, frame.sourceY + 13);
79
+ }
80
+
81
+ function getCorrectedStroke(corrected: boolean): string {
82
+ return corrected ? '#68d6c0' : '#ffca58';
83
+ }
84
+
85
+ function renderCorrectedSheet(input: { canvas: HTMLCanvasElement; image: HTMLImageElement; frames: FrameSpec[]; adjustments: Record<string, FrameAdjustment>; grid?: GridSettings }): void {
86
+ const { canvas, image, frames, adjustments, grid } = input;
87
+ canvas.width = image.naturalWidth;
88
+ canvas.height = image.naturalHeight;
89
+ const context = canvas.getContext('2d');
90
+ if (!context) return;
91
+ context.imageSmoothingEnabled = false;
92
+ context.clearRect(0, 0, canvas.width, canvas.height);
93
+ context.drawImage(image, 0, 0);
94
+ frames.forEach((frame) => renderCorrectedFrame({ context, image, frame, adjustment: adjustments[String(frame.index)] }));
95
+ if (grid) drawGridGuides({ context, width: canvas.width, height: canvas.height, grid, corrected: true });
96
+ }
97
+
98
+ function drawGridGuides(input: { context: CanvasRenderingContext2D; width: number; height: number; grid: GridSettings; corrected: boolean }): void {
99
+ const { context, width, height, grid, corrected } = input;
100
+ context.save();
101
+ context.strokeStyle = corrected ? '#68d6c0' : '#ffca58';
102
+ context.lineWidth = 2;
103
+ context.setLineDash([5, 3]);
104
+ for (let row = 0; row < grid.rows; row += 1) {
105
+ for (let column = 0; column < grid.columns; column += 1) {
106
+ const x = grid.marginX + column * (grid.cellWidth + grid.gapX);
107
+ const y = grid.marginY + row * (grid.cellHeight + grid.gapY);
108
+ if (x >= width || y >= height) continue;
109
+ const cellWidth = Math.min(grid.cellWidth, width - x);
110
+ const cellHeight = Math.min(grid.cellHeight, height - y);
111
+ context.strokeRect(x + 1, y + 1, Math.max(0, cellWidth - 2), Math.max(0, cellHeight - 2));
112
+ }
113
+ }
114
+ context.restore();
115
+ }
116
+
117
+ function renderCorrectedFrame(input: { context: CanvasRenderingContext2D; image: HTMLImageElement; frame: FrameSpec; adjustment: FrameAdjustment | undefined }): void {
118
+ const { context, image, frame } = input;
119
+ const adjustment = input.adjustment ?? frame.adjustment;
120
+ context.save();
121
+ context.beginPath();
122
+ context.rect(frame.sourceX, frame.sourceY, frame.width, frame.height);
123
+ context.clip();
124
+ context.clearRect(frame.sourceX, frame.sourceY, frame.width, frame.height);
125
+ context.drawImage(image, frame.sourceX + adjustment.crop.x, frame.sourceY + adjustment.crop.y, adjustment.crop.width, adjustment.crop.height, frame.sourceX + adjustment.offsetX, frame.sourceY + adjustment.offsetY, adjustment.crop.width, adjustment.crop.height);
126
+ context.restore();
127
+ }
128
+
129
+ function renderActiveFrame(canvas: HTMLCanvasElement, image: HTMLImageElement, frame: FrameSpec | undefined, adjustments: Record<string, FrameAdjustment>): void {
130
+ if (!frame) return;
131
+ canvas.width = frame.width;
132
+ canvas.height = frame.height;
133
+ const context = canvas.getContext('2d');
134
+ if (!context) return;
135
+ const adjustment = adjustments[String(frame.index)] ?? frame.adjustment;
136
+ context.imageSmoothingEnabled = false;
137
+ context.clearRect(0, 0, canvas.width, canvas.height);
138
+ context.save();
139
+ context.beginPath();
140
+ context.rect(0, 0, canvas.width, canvas.height);
141
+ context.clip();
142
+ context.drawImage(image, frame.sourceX + adjustment.crop.x, frame.sourceY + adjustment.crop.y, adjustment.crop.width, adjustment.crop.height, adjustment.offsetX, adjustment.offsetY, adjustment.crop.width, adjustment.crop.height);
143
+ context.restore();
144
+ context.strokeStyle = '#ffca58';
145
+ context.strokeRect(adjustment.offsetX + 0.5, adjustment.offsetY + 0.5, adjustment.crop.width - 1, adjustment.crop.height - 1);
146
+ }
147
+
148
+ function renderFilmstrip(input: RenderInput): void {
149
+ const { refs, frames, adjustments, selected, modifiedText, unchangedText, selectFrameText } = input;
150
+ refs.filmstrip.replaceChildren();
151
+ refs.filmstrip.style.setProperty('--fe-frame-columns', String(input.grid.columns));
152
+ frames.forEach((frame, framePosition) => {
153
+ const button = document.createElement('button');
154
+ const adjustment = adjustments[String(frame.index)] ?? frame.adjustment;
155
+ const state = getAdjustmentState(adjustment, frame);
156
+ button.type = 'button';
157
+ button.className = `fe-frame-button${framePosition === selected ? ' is-selected' : ''}${state === 'modified' ? ' is-modified' : ''}`;
158
+ button.dataset.frame = String(framePosition);
159
+ button.style.gridRow = String(frame.row + 1);
160
+ button.style.gridColumn = String(frame.column + 1);
161
+ button.ariaLabel = `${selectFrameText} ${frame.index + 1}, ${state === 'modified' ? modifiedText : unchangedText}`;
162
+ button.textContent = String(frame.index + 1).padStart(2, '0');
163
+ refs.filmstrip.append(button);
164
+ });
165
+ }
166
+
167
+ function updateSummary(input: RenderInput): void {
168
+ const { refs, frames, adjustments, selected, sourceName, image } = input;
169
+ const selectedFrame = frames[selected];
170
+ const selectedAdjustment = getSelectedAdjustment(selectedFrame, adjustments);
171
+ refs.frameNumber.textContent = getFrameNumber(selectedFrame, frames.length);
172
+ refs.frameState.textContent = getFrameState(selectedFrame, selectedAdjustment, input);
173
+ refs.frameCount.textContent = String(frames.length);
174
+ refs.correctionCount.textContent = String(countModified(frames, adjustments));
175
+ refs.imageSize.textContent = image ? `${image.naturalWidth} x ${image.naturalHeight} px` : '0 x 0 px';
176
+ refs.sourceName.textContent = sourceName || input.noImageText;
177
+ }
178
+
179
+ function getSelectedAdjustment(frame: FrameSpec | undefined, adjustments: Record<string, FrameAdjustment>): FrameAdjustment | null {
180
+ return frame ? adjustments[String(frame.index)] ?? frame.adjustment : null;
181
+ }
182
+
183
+ function getFrameNumber(frame: FrameSpec | undefined, total: number): string {
184
+ return frame ? `${frame.index + 1} / ${total}` : '0 / 0';
185
+ }
186
+
187
+ function getFrameState(frame: FrameSpec | undefined, adjustment: FrameAdjustment | null, input: RenderInput): string {
188
+ if (frame && adjustment && isModified(adjustment, frame)) return input.modifiedText;
189
+ return input.unchangedText;
190
+ }
191
+
192
+ export function drawPreviewFrame(input: { canvas: HTMLCanvasElement; image: HTMLImageElement; frame: FrameSpec; adjustment: FrameAdjustment }): void {
193
+ renderActiveFrame(input.canvas, input.image, input.frame, { [String(input.frame.index)]: input.adjustment });
194
+ }
195
+
196
+ export function createExportCanvas(image: HTMLImageElement, frames: FrameSpec[], adjustments: Record<string, FrameAdjustment>): HTMLCanvasElement {
197
+ const canvas = document.createElement('canvas');
198
+ renderCorrectedSheet({ canvas, image, frames, adjustments });
199
+ return canvas;
200
+ }
@@ -0,0 +1,28 @@
1
+ import type { GamesToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { FixerEditorUI } from './ui';
3
+
4
+ export type { FixerEditorUI };
5
+
6
+ export type FixerEditorLocaleContent = ToolLocaleContent<FixerEditorUI>;
7
+
8
+ export const fixerEditor: GamesToolEntry<FixerEditorUI> = {
9
+ id: 'sprite-sheet-fixer-editor',
10
+ icons: { bg: 'mdi:gamepad-variant', fg: 'mdi:crop-free' },
11
+ i18n: {
12
+ de: () => import('./i18n/de').then((m) => m.content),
13
+ en: () => import('./i18n/en').then((m) => m.content),
14
+ es: () => import('./i18n/es').then((m) => m.content),
15
+ fr: () => import('./i18n/fr').then((m) => m.content),
16
+ id: () => import('./i18n/id').then((m) => m.content),
17
+ it: () => import('./i18n/it').then((m) => m.content),
18
+ ja: () => import('./i18n/ja').then((m) => m.content),
19
+ ko: () => import('./i18n/ko').then((m) => m.content),
20
+ nl: () => import('./i18n/nl').then((m) => m.content),
21
+ pl: () => import('./i18n/pl').then((m) => m.content),
22
+ pt: () => import('./i18n/pt').then((m) => m.content),
23
+ ru: () => import('./i18n/ru').then((m) => m.content),
24
+ sv: () => import('./i18n/sv').then((m) => m.content),
25
+ tr: () => import('./i18n/tr').then((m) => m.content),
26
+ zh: () => import('./i18n/zh').then((m) => m.content),
27
+ },
28
+ };
@@ -0,0 +1,17 @@
1
+ import { countModified } from './logic';
2
+ import type { FrameAdjustment, FrameSpec } from './types';
3
+
4
+ export interface EditorSummary {
5
+ frameCount: number;
6
+ modifiedCount: number;
7
+ imageSize: string;
8
+ }
9
+
10
+ export function summarizeEditor(input: { frames: FrameSpec[]; adjustments: Record<string, FrameAdjustment>; imageWidth: number; imageHeight: number }): EditorSummary {
11
+ return { frameCount: input.frames.length, modifiedCount: countModified(input.frames, input.adjustments), imageSize: `${input.imageWidth} x ${input.imageHeight} px` };
12
+ }
13
+
14
+ export function getAdjustmentState(adjustment: FrameAdjustment, frame: FrameSpec): 'modified' | 'unchanged' {
15
+ const changed = adjustment.crop.x !== 0 || adjustment.crop.y !== 0 || adjustment.crop.width !== frame.width || adjustment.crop.height !== frame.height || adjustment.offsetX !== 0 || adjustment.offsetY !== 0;
16
+ return changed ? 'modified' : 'unchanged';
17
+ }
@@ -0,0 +1,98 @@
1
+ import { exportMetadata, exportProject, exportSheet } from './exporter';
2
+ import { cloneAdjustments, createFrameSpecs, parseProjectFile } from './logic';
3
+ import type { ControllerContext, EditorInputs } from './controller';
4
+
5
+ export interface EventActions {
6
+ loadImage: (file: File) => void;
7
+ updateGrid: (inputs: EditorInputs, changed: keyof EditorInputs) => void;
8
+ updateSelectedAdjustment: (inputs: EditorInputs) => void;
9
+ selectFrame: (index: number) => void;
10
+ nudge: (axis: 'x' | 'y', direction: number) => void;
11
+ trimFrame: () => void;
12
+ trimAll: () => void;
13
+ autoDetect: () => void;
14
+ resetFrame: () => void;
15
+ resetAll: () => void;
16
+ undo: () => void;
17
+ redo: () => void;
18
+ applyProject: (project: ReturnType<typeof parseProjectFile>) => void;
19
+ }
20
+
21
+ function required<T extends Element>(root: ParentNode, id: string): T {
22
+ const element = root.querySelector(`#${id}`);
23
+ if (!element) throw new Error(`Missing fixer-editor element: ${id}`);
24
+ return element as T;
25
+ }
26
+
27
+ function handleProjectFile(context: ControllerContext, file: File, applyProject: EventActions['applyProject']): void {
28
+ file.text().then((text) => {
29
+ const project = parseProjectFile(JSON.parse(text));
30
+ if (!project) { context.refs.status.textContent = context.ui.statusInvalidProject; return; }
31
+ if (context.state.image) applyProject(project);
32
+ else { context.state.pendingProject = project; context.refs.status.textContent = context.ui.statusProjectNeedsImage; }
33
+ }).catch(() => { context.refs.status.textContent = context.ui.statusInvalidProject; });
34
+ }
35
+
36
+ function bindFileEvents(context: ControllerContext, actions: EventActions): void {
37
+ const { refs } = context;
38
+ const fileInput = required<HTMLInputElement>(refs.shell, 'fe-file-input');
39
+ const dropZone = required<HTMLElement>(refs.shell, 'fe-drop-zone');
40
+ dropZone.onclick = () => fileInput.click();
41
+ fileInput.onchange = () => { const file = fileInput.files?.[0]; if (file) actions.loadImage(file); };
42
+ dropZone.ondragover = (event) => { event.preventDefault(); dropZone.classList.add('is-dragging'); };
43
+ dropZone.ondragleave = () => dropZone.classList.remove('is-dragging');
44
+ dropZone.ondrop = (event) => { event.preventDefault(); dropZone.classList.remove('is-dragging'); const file = event.dataTransfer?.files[0]; if (file) actions.loadImage(file); };
45
+ dropZone.onkeydown = (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); fileInput.click(); } };
46
+ }
47
+
48
+ function bindFrameEvents(context: ControllerContext, inputs: EditorInputs, actions: EventActions): void {
49
+ const { refs } = context;
50
+ const gridInputIds: Array<keyof EditorInputs> = ['columns', 'rows', 'cell-width', 'cell-height', 'margin-x', 'margin-y', 'gap-x', 'gap-y'];
51
+ gridInputIds.forEach((id) => { inputs[id].onchange = () => actions.updateGrid(inputs, id); });
52
+ const adjustmentInputIds: Array<keyof EditorInputs> = ['crop-x', 'crop-y', 'crop-width', 'crop-height', 'offset-x', 'offset-y'];
53
+ adjustmentInputIds.forEach((id) => { inputs[id].onchange = () => actions.updateSelectedAdjustment(inputs); });
54
+ refs.filmstrip.onclick = (event: MouseEvent) => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-frame]'); if (button) actions.selectFrame(Number(button.dataset.frame)); };
55
+ refs.shell.querySelectorAll<HTMLButtonElement>('.fe-nudge').forEach((button: HTMLButtonElement) => { button.onclick = () => actions.nudge(button.dataset.axis as 'x' | 'y', Number(button.dataset.direction)); });
56
+ refs.shell.querySelectorAll<HTMLButtonElement>('.fe-step').forEach((button: HTMLButtonElement) => { button.onclick = () => { context.state.step = Number(button.dataset.step); refs.shell.querySelectorAll<HTMLButtonElement>('.fe-step').forEach((item: HTMLButtonElement) => item.classList.toggle('is-active', item === button)); context.persistDraft(); }; });
57
+ required<HTMLButtonElement>(refs.shell, 'fe-trim').onclick = actions.trimFrame;
58
+ required<HTMLButtonElement>(refs.shell, 'fe-trim-all').onclick = actions.trimAll;
59
+ required<HTMLButtonElement>(refs.shell, 'fe-reset-frame').onclick = actions.resetFrame;
60
+ }
61
+
62
+ function bindHistoryEvents(context: ControllerContext, actions: EventActions): void {
63
+ const { refs } = context;
64
+ required<HTMLButtonElement>(refs.shell, 'fe-auto-detect').onclick = actions.autoDetect;
65
+ required<HTMLButtonElement>(refs.shell, 'fe-reset-all').onclick = actions.resetAll;
66
+ required<HTMLButtonElement>(refs.shell, 'fe-undo').onclick = actions.undo;
67
+ required<HTMLButtonElement>(refs.shell, 'fe-redo').onclick = actions.redo;
68
+ }
69
+
70
+ function bindOutputEvents(context: ControllerContext, actions: EventActions): void {
71
+ const { refs, state } = context;
72
+ required<HTMLButtonElement>(refs.shell, 'fe-save-project').onclick = () => exportProject(context);
73
+ required<HTMLButtonElement>(refs.shell, 'fe-load-project-button').onclick = () => refs.loadProject.click();
74
+ refs.loadProject.onchange = () => { const file = refs.loadProject.files?.[0]; if (file) handleProjectFile(context, file, actions.applyProject); };
75
+ refs.exportJson.onclick = () => exportMetadata(context);
76
+ refs.exportSheet.onclick = () => exportSheet(context);
77
+ required<HTMLInputElement>(refs.shell, 'fe-zoom').oninput = (event) => { state.zoom = Number((event.target as HTMLInputElement).value); required<HTMLOutputElement>(refs.shell, 'fe-zoom-value').value = `${state.zoom}x`; context.refs.shell.style.setProperty('--fe-zoom', `${state.zoom}`); context.persistDraft(); };
78
+ required<HTMLInputElement>(refs.shell, 'fe-fps').oninput = (event) => { state.fps = Number((event.target as HTMLInputElement).value); required<HTMLOutputElement>(refs.shell, 'fe-fps-value').value = String(state.fps); context.persistDraft(); };
79
+ required<HTMLButtonElement>(refs.shell, 'fe-play').onclick = () => { state.playing = true; context.animate(); };
80
+ required<HTMLButtonElement>(refs.shell, 'fe-pause').onclick = () => { state.playing = false; if (state.animationId) window.clearTimeout(state.animationId); state.animationId = null; };
81
+ window.addEventListener('keydown', (event) => { if (!state.image || event.target instanceof HTMLInputElement || event.target instanceof HTMLButtonElement) return; const directions: Record<string, ['x' | 'y', number]> = { ArrowLeft: ['x', -1], ArrowRight: ['x', 1], ArrowUp: ['y', -1], ArrowDown: ['y', 1] }; const direction = directions[event.key]; if (direction) { event.preventDefault(); actions.nudge(direction[0], direction[1]); } });
82
+ }
83
+
84
+ export function bindEvents(input: { context: ControllerContext; inputs: EditorInputs; actions: EventActions }): void {
85
+ bindFileEvents(input.context, input.actions);
86
+ bindFrameEvents(input.context, input.inputs, input.actions);
87
+ bindHistoryEvents(input.context, input.actions);
88
+ bindOutputEvents(input.context, input.actions);
89
+ }
90
+
91
+ export function applyProjectToContext(context: ControllerContext, project: ReturnType<typeof parseProjectFile>): void {
92
+ if (!project) return;
93
+ context.state.grid = project.grid;
94
+ context.state.adjustments = cloneAdjustments(project.adjustments);
95
+ context.state.frames = context.state.image ? createFrameSpecs(context.state.image.naturalWidth, context.state.image.naturalHeight, context.state.grid) : [];
96
+ context.state.selected = 0;
97
+ context.updateView(context.ui.statusLoaded);
98
+ }
@@ -0,0 +1,42 @@
1
+ import { createExportCanvas } from './dom-views';
2
+ import { createFrameMetadata, createProjectFile } from './logic';
3
+ import type { ControllerContext } from './controller';
4
+ import type { ProjectFile } from './types';
5
+
6
+ function downloadBlob(blob: Blob, name: string): void {
7
+ const url = URL.createObjectURL(blob);
8
+ const link = document.createElement('a');
9
+ link.href = url;
10
+ link.download = name;
11
+ link.click();
12
+ URL.revokeObjectURL(url);
13
+ }
14
+
15
+ function projectFor(context: ControllerContext): ProjectFile | null {
16
+ const { state } = context;
17
+ if (!state.image) return null;
18
+ return createProjectFile({ sourceName: state.sourceName, sourceWidth: state.image.naturalWidth, sourceHeight: state.image.naturalHeight, grid: state.grid, adjustments: state.adjustments });
19
+ }
20
+
21
+ export function exportProject(context: ControllerContext): void {
22
+ const project = projectFor(context);
23
+ if (!project) return;
24
+ context.persistDraft();
25
+ downloadBlob(new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' }), 'fixer-editor-project.json');
26
+ context.refs.status.textContent = context.ui.statusSaved;
27
+ }
28
+
29
+ export function exportMetadata(context: ControllerContext): void {
30
+ const project = projectFor(context);
31
+ if (!project) return;
32
+ const frames = createFrameMetadata(context.state.frames, context.state.adjustments);
33
+ downloadBlob(new Blob([JSON.stringify({ ...project, frames }, null, 2)], { type: 'application/json' }), 'fixer-editor-metadata.json');
34
+ context.refs.status.textContent = context.ui.statusExported;
35
+ }
36
+
37
+ export function exportSheet(context: ControllerContext): void {
38
+ if (!context.state.image) return;
39
+ const canvas = createExportCanvas(context.state.image, context.state.frames, context.state.adjustments);
40
+ canvas.toBlob((blob) => { if (blob) downloadBlob(blob, 'fixer-editor-corrected.png'); }, 'image/png');
41
+ context.refs.status.textContent = context.ui.statusExported;
42
+ }