@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.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -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/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
|
+
}
|