@open-assets/open-assets 0.1.11 → 0.1.12
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/README.md +73 -88
- package/bin/open-assets.mjs +113 -320
- package/lib/add.mjs +476 -0
- package/lib/init.mjs +215 -107
- package/lib/list.mjs +66 -0
- package/lib/lockfile.mjs +1 -1
- package/lib/manifest.mjs +125 -0
- package/lib/render.mjs +206 -0
- package/lib/renderer.mjs +19 -5
- package/lib/server.mjs +14 -4
- package/lib/validate.mjs +90 -0
- package/package.json +7 -2
- package/public/img/banner/1280/banner.png +0 -0
- package/public/img/icon/16/icon.png +0 -0
- package/public/img/icon/180/icon.png +0 -0
- package/public/img/icon/192/icon.png +0 -0
- package/public/img/icon/32/icon.png +0 -0
- package/public/img/icon/512/icon.png +0 -0
- package/public/viewer.html +28 -25
- package/skills/open-assets/SKILL.md +99 -52
package/lib/render.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { resolve, join } from "path";
|
|
2
|
+
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
|
|
3
|
+
import { filterCollections, filterTemplates, resolveRenderSizes } from "./manifest.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} RenderDeps
|
|
7
|
+
* @property {(projectDir: string, htmlFile: string, w: number, h: number, sw: number, sh: number) => Promise<Buffer>} renderScreenshot
|
|
8
|
+
* @property {(projectDir: string, collection: object, outputConfig: object) => Promise<string>} runXcodeOutput
|
|
9
|
+
* @property {() => Promise<void>} closeBrowser
|
|
10
|
+
* @property {(projectDir: string) => object} readLockfile
|
|
11
|
+
* @property {(projectDir: string, data: object) => void} writeLockfile
|
|
12
|
+
* @property {(filePath: string) => string|null} computeChecksum
|
|
13
|
+
* @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => boolean} isUpToDate
|
|
14
|
+
* @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => void} recordExport
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render assets according to the manifest and options.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
21
|
+
* @param {object} manifest - Parsed manifest object
|
|
22
|
+
* @param {object} options - Render options from CLI
|
|
23
|
+
* @param {string} [options.collection] - Filter by collection ID
|
|
24
|
+
* @param {string} [options.tag] - Filter by tag
|
|
25
|
+
* @param {string} [options.template] - Filter by template name/label
|
|
26
|
+
* @param {string} [options.size] - Named export size
|
|
27
|
+
* @param {string} [options.format] - Output format override (png or jpg)
|
|
28
|
+
* @param {string|number} [options.width] - Custom width
|
|
29
|
+
* @param {string|number} [options.height] - Custom height
|
|
30
|
+
* @param {boolean} [options.all] - Export at all sizes
|
|
31
|
+
* @param {boolean} [options.force] - Re-render even if unchanged
|
|
32
|
+
* @param {string} [options.output] - Output directory path
|
|
33
|
+
* @param {boolean} [options.quiet] - Suppress logs
|
|
34
|
+
* @param {RenderDeps} deps - Injected dependencies
|
|
35
|
+
* @param {(msg: string) => void} [log] - Optional log function
|
|
36
|
+
* @returns {Promise<{ results: object[], skipped: number, elapsed: string, error?: string }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function renderAssets(projectDir, manifest, options, deps, log) {
|
|
39
|
+
const noop = () => {};
|
|
40
|
+
const _log = log || noop;
|
|
41
|
+
|
|
42
|
+
// Filter collections
|
|
43
|
+
const { collections, error } = filterCollections(manifest.collections, {
|
|
44
|
+
collection: options.collection,
|
|
45
|
+
tag: options.tag,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (error) {
|
|
49
|
+
return { results: [], skipped: 0, elapsed: "0.0s", error };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const outputDir = resolve(options.output || "./exports");
|
|
53
|
+
mkdirSync(outputDir, { recursive: true });
|
|
54
|
+
|
|
55
|
+
const lockData = deps.readLockfile(projectDir);
|
|
56
|
+
const results = [];
|
|
57
|
+
let skipped = 0;
|
|
58
|
+
const startTime = Date.now();
|
|
59
|
+
|
|
60
|
+
for (const col of collections) {
|
|
61
|
+
const sourceW = col.sourceSize.width;
|
|
62
|
+
const sourceH = col.sourceSize.height;
|
|
63
|
+
|
|
64
|
+
const templates = filterTemplates(col.templates, options.template);
|
|
65
|
+
|
|
66
|
+
const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
|
|
67
|
+
width: options.width,
|
|
68
|
+
height: options.height,
|
|
69
|
+
size: options.size,
|
|
70
|
+
all: options.all,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (warning) {
|
|
74
|
+
_log(` Warning: ${warning}`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sizesToRender.length === 0) {
|
|
79
|
+
_log(` No export sizes found for collection "${col.id}"`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const useSubDirs = sizesToRender.length > 1 || options.all;
|
|
84
|
+
|
|
85
|
+
for (const entry of sizesToRender) {
|
|
86
|
+
const w = entry.size.width;
|
|
87
|
+
const h = entry.size.height;
|
|
88
|
+
|
|
89
|
+
const sizeDir = useSubDirs
|
|
90
|
+
? join(outputDir, col.id, entry.name)
|
|
91
|
+
: join(outputDir, col.id);
|
|
92
|
+
mkdirSync(sizeDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
if (sizesToRender.length > 1) {
|
|
95
|
+
_log(`\n ${entry.label} (${w}x${h})`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const template of templates) {
|
|
99
|
+
// Determine output format from outFile extension or default to png
|
|
100
|
+
const outExt = entry.outFile
|
|
101
|
+
? entry.outFile.split(".").pop().toLowerCase()
|
|
102
|
+
: "png";
|
|
103
|
+
const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
|
|
104
|
+
const defaultExt = format === "jpeg" ? "jpg" : "png";
|
|
105
|
+
|
|
106
|
+
const outPath = entry.outFile
|
|
107
|
+
? resolve(projectDir, entry.outFile.replace(/\{template\}/g, template.name))
|
|
108
|
+
: join(sizeDir, `${template.name}.${defaultExt}`);
|
|
109
|
+
|
|
110
|
+
const assetKey = `${col.id}/${template.name}`;
|
|
111
|
+
const variantKey = entry.name;
|
|
112
|
+
const srcPath = resolve(projectDir, template.src);
|
|
113
|
+
const checksum = deps.computeChecksum(srcPath);
|
|
114
|
+
|
|
115
|
+
// Skip if unchanged
|
|
116
|
+
if (
|
|
117
|
+
!options.force &&
|
|
118
|
+
checksum &&
|
|
119
|
+
deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath)
|
|
120
|
+
) {
|
|
121
|
+
_log(
|
|
122
|
+
` Skipping ${template.name} at ${w}x${h} (unchanged)`
|
|
123
|
+
);
|
|
124
|
+
skipped++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_log(
|
|
129
|
+
` Rendering ${template.name} at ${w}x${h}...`
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const isSvg = template.src.endsWith(".svg");
|
|
133
|
+
if (isSvg && w === sourceW && h === sourceH) {
|
|
134
|
+
copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Ensure output directory exists when using outFile
|
|
138
|
+
if (entry.outFile) {
|
|
139
|
+
mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const buffer = await deps.renderScreenshot(
|
|
143
|
+
projectDir,
|
|
144
|
+
template.src,
|
|
145
|
+
w,
|
|
146
|
+
h,
|
|
147
|
+
sourceW,
|
|
148
|
+
sourceH,
|
|
149
|
+
{ format }
|
|
150
|
+
);
|
|
151
|
+
writeFileSync(outPath, buffer);
|
|
152
|
+
results.push({
|
|
153
|
+
collection: col.id,
|
|
154
|
+
template: template.name,
|
|
155
|
+
path: outPath,
|
|
156
|
+
width: w,
|
|
157
|
+
height: h,
|
|
158
|
+
size: buffer.length,
|
|
159
|
+
});
|
|
160
|
+
if (checksum) {
|
|
161
|
+
deps.recordExport(lockData, assetKey, variantKey, checksum, outPath);
|
|
162
|
+
}
|
|
163
|
+
_log(` → ${outPath}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Handle outputs (xcode, copy-source, etc.)
|
|
168
|
+
if (col.outputs && options.all) {
|
|
169
|
+
for (const output of col.outputs) {
|
|
170
|
+
if (output.type === "xcode") {
|
|
171
|
+
_log(`\n Exporting to Xcode: ${output.path}`);
|
|
172
|
+
const path = await deps.runXcodeOutput(projectDir, col, output);
|
|
173
|
+
results.push({ collection: col.id, type: "xcode", path });
|
|
174
|
+
} else if (output.type === "copy-source") {
|
|
175
|
+
const format = output.format || "svg";
|
|
176
|
+
const copyDir = join(outputDir, col.id, format);
|
|
177
|
+
mkdirSync(copyDir, { recursive: true });
|
|
178
|
+
for (const template of templates) {
|
|
179
|
+
if (template.src.endsWith(`.${format}`)) {
|
|
180
|
+
const srcPath = resolve(projectDir, template.src);
|
|
181
|
+
const destPath = join(copyDir, `${template.name}.${format}`);
|
|
182
|
+
if (existsSync(srcPath)) {
|
|
183
|
+
copyFileSync(srcPath, destPath);
|
|
184
|
+
results.push({
|
|
185
|
+
collection: col.id,
|
|
186
|
+
template: template.name,
|
|
187
|
+
type: `copy-${format}`,
|
|
188
|
+
path: destPath,
|
|
189
|
+
});
|
|
190
|
+
_log(` → ${destPath}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Persist lockfile
|
|
200
|
+
deps.writeLockfile(projectDir, lockData);
|
|
201
|
+
|
|
202
|
+
await deps.closeBrowser();
|
|
203
|
+
|
|
204
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
205
|
+
return { results, skipped, elapsed: `${elapsed}s` };
|
|
206
|
+
}
|
package/lib/renderer.mjs
CHANGED
|
@@ -19,8 +19,15 @@ export async function closeBrowser() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Render an HTML file to
|
|
22
|
+
* Render an HTML file to an image buffer at the given dimensions.
|
|
23
23
|
* Scales from sourceSize to target size via CSS zoom.
|
|
24
|
+
* @param {string} projectDir
|
|
25
|
+
* @param {string} htmlFile
|
|
26
|
+
* @param {number} width
|
|
27
|
+
* @param {number} height
|
|
28
|
+
* @param {number} sourceW
|
|
29
|
+
* @param {number} sourceH
|
|
30
|
+
* @param {{ format?: "png"|"jpeg", quality?: number }} [options]
|
|
24
31
|
*/
|
|
25
32
|
export async function renderScreenshot(
|
|
26
33
|
projectDir,
|
|
@@ -28,7 +35,8 @@ export async function renderScreenshot(
|
|
|
28
35
|
width,
|
|
29
36
|
height,
|
|
30
37
|
sourceW,
|
|
31
|
-
sourceH
|
|
38
|
+
sourceH,
|
|
39
|
+
options = {}
|
|
32
40
|
) {
|
|
33
41
|
const b = await getBrowser();
|
|
34
42
|
const page = await b.newPage();
|
|
@@ -48,10 +56,16 @@ export async function renderScreenshot(
|
|
|
48
56
|
await new Promise((r) => setTimeout(r, 200));
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
const
|
|
52
|
-
|
|
59
|
+
const format = options.format || "png";
|
|
60
|
+
const screenshotOpts = {
|
|
61
|
+
type: format,
|
|
53
62
|
clip: { x: 0, y: 0, width, height },
|
|
54
|
-
}
|
|
63
|
+
};
|
|
64
|
+
if (format === "jpeg" && options.quality) {
|
|
65
|
+
screenshotOpts.quality = options.quality;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const buffer = await page.screenshot(screenshotOpts);
|
|
55
69
|
|
|
56
70
|
await page.close();
|
|
57
71
|
return buffer;
|
package/lib/server.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import { createServer } from "http";
|
|
|
2
2
|
import { readFileSync, existsSync, statSync, watch } from "fs";
|
|
3
3
|
import { resolve, join, extname } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { renderScreenshot, runXcodeOutput, closeBrowser } from "./renderer.mjs";
|
|
6
5
|
|
|
7
6
|
const __dirname = resolve(fileURLToPath(import.meta.url), "..");
|
|
8
7
|
const VIEWER_PATH = join(__dirname, "..", "public", "viewer.html");
|
|
@@ -28,7 +27,7 @@ const MIME_TYPES = {
|
|
|
28
27
|
".webm": "video/webm",
|
|
29
28
|
};
|
|
30
29
|
|
|
31
|
-
function getMime(filePath) {
|
|
30
|
+
export function getMime(filePath) {
|
|
32
31
|
return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
33
32
|
}
|
|
34
33
|
|
|
@@ -52,11 +51,22 @@ function readBody(req) {
|
|
|
52
51
|
});
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} projectDir
|
|
56
|
+
* @param {object} [options]
|
|
57
|
+
* @param {{ renderScreenshot: Function, runXcodeOutput: Function, closeBrowser: Function }} [renderer]
|
|
58
|
+
* Optional renderer dependency injection. Falls back to importing ./renderer.mjs.
|
|
59
|
+
*/
|
|
60
|
+
export async function startServer(projectDir, options = {}, renderer) {
|
|
61
|
+
if (!renderer) {
|
|
62
|
+
renderer = await import("./renderer.mjs");
|
|
63
|
+
}
|
|
64
|
+
const { renderScreenshot, runXcodeOutput, closeBrowser } = renderer;
|
|
65
|
+
|
|
56
66
|
const port = options.port || 3200;
|
|
57
67
|
const host = options.host || "localhost";
|
|
58
68
|
const quiet = options.quiet || false;
|
|
59
|
-
const manifestPath = options.manifestPath || resolve(projectDir, "
|
|
69
|
+
const manifestPath = options.manifestPath || resolve(projectDir, "assets.json");
|
|
60
70
|
const staticDirs = (options.staticDirs || []).map((d) => resolve(d));
|
|
61
71
|
|
|
62
72
|
const log = quiet ? () => {} : console.log.bind(console);
|
package/lib/validate.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { flatSizes } from "./manifest.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate a config object and check that referenced files exist.
|
|
7
|
+
* Returns structured results instead of printing/exiting.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} manifest - Parsed config object
|
|
10
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
11
|
+
* @returns {{ checks: Array<{ ok: boolean, message: string }>, errors: number }}
|
|
12
|
+
*/
|
|
13
|
+
export function validateConfig(manifest, projectDir) {
|
|
14
|
+
const checks = [];
|
|
15
|
+
let errors = 0;
|
|
16
|
+
|
|
17
|
+
const ok = (msg) => checks.push({ ok: true, message: msg });
|
|
18
|
+
const fail = (msg) => {
|
|
19
|
+
checks.push({ ok: false, message: msg });
|
|
20
|
+
errors++;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Check top-level fields
|
|
24
|
+
if (manifest.name) {
|
|
25
|
+
ok(`name: "${manifest.name}"`);
|
|
26
|
+
} else {
|
|
27
|
+
fail("Missing top-level 'name' field");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(manifest.collections) && manifest.collections.length > 0) {
|
|
31
|
+
ok(`${manifest.collections.length} collection(s) defined`);
|
|
32
|
+
} else {
|
|
33
|
+
fail("Missing or empty 'collections' array");
|
|
34
|
+
return { checks, errors };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check each collection
|
|
38
|
+
const ids = new Set();
|
|
39
|
+
for (const col of manifest.collections) {
|
|
40
|
+
if (!col.id) {
|
|
41
|
+
fail("Collection missing 'id' field");
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ids.has(col.id)) {
|
|
45
|
+
fail(`Duplicate collection id: "${col.id}"`);
|
|
46
|
+
}
|
|
47
|
+
ids.add(col.id);
|
|
48
|
+
|
|
49
|
+
if (!col.sourceSize || !col.sourceSize.width || !col.sourceSize.height) {
|
|
50
|
+
fail(`Collection "${col.id}" missing or invalid 'sourceSize'`);
|
|
51
|
+
} else {
|
|
52
|
+
ok(`Collection "${col.id}" sourceSize: ${col.sourceSize.width}\u00d7${col.sourceSize.height}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!col.templates || col.templates.length === 0) {
|
|
56
|
+
fail(`Collection "${col.id}" has no templates`);
|
|
57
|
+
} else {
|
|
58
|
+
for (const template of col.templates) {
|
|
59
|
+
if (!template.src) {
|
|
60
|
+
fail(`Collection "${col.id}" has a template without 'src'`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const filePath = resolve(projectDir, template.src);
|
|
64
|
+
if (existsSync(filePath)) {
|
|
65
|
+
ok(`${template.src} exists`);
|
|
66
|
+
} else {
|
|
67
|
+
fail(`${template.src} not found`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sizes = flatSizes(col);
|
|
73
|
+
if (sizes.length > 0) {
|
|
74
|
+
ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validate outputs
|
|
78
|
+
if (col.outputs) {
|
|
79
|
+
for (const output of col.outputs) {
|
|
80
|
+
if (!output.type) {
|
|
81
|
+
fail(`Collection "${col.id}" has an output without 'type'`);
|
|
82
|
+
} else {
|
|
83
|
+
ok(`Output: ${output.type}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { checks, errors };
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-assets/open-assets",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"assets": "node bin/open-assets.mjs render --all -o ./public/img",
|
|
43
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
44
|
+
"release": "./scripts/release.sh"
|
|
45
|
+
},
|
|
41
46
|
"homepage": "https://openassets.dev",
|
|
42
47
|
"dependencies": {
|
|
43
48
|
"commander": "^13.0.0",
|
|
@@ -45,6 +50,6 @@
|
|
|
45
50
|
"puppeteer": "^24.37.5"
|
|
46
51
|
},
|
|
47
52
|
"devDependencies": {
|
|
48
|
-
"
|
|
53
|
+
"jest": "^30.3.0"
|
|
49
54
|
}
|
|
50
55
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/public/viewer.html
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Open Assets</title>
|
|
7
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/img/icon/32/icon.png" />
|
|
8
|
+
<link rel="icon" type="image/png" sizes="16x16" href="/img/icon/16/icon.png" />
|
|
7
9
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
|
8
10
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
|
|
9
11
|
<style>
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
|
|
13
15
|
.toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: rgba(10,10,10,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.06); }
|
|
14
16
|
.toolbar-left { display: flex; align-items: center; gap: 12px; }
|
|
17
|
+
.toolbar-left img.toolbar-icon { width: 20px; height: 20px; border-radius: 4px; }
|
|
15
18
|
.toolbar-left h1 { font-size: 14px; font-weight: 700; color: #fff; }
|
|
16
19
|
.segmented { display: flex; background: rgba(255,255,255,0.06); border-radius: 6px; padding: 2px; gap: 1px; }
|
|
17
20
|
.seg-btn { padding: 4px 14px; border-radius: 5px; border: none; background: transparent; color: #888; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.15s, color 0.15s; font-family: inherit; text-decoration: none; }
|
|
@@ -82,6 +85,7 @@
|
|
|
82
85
|
<body>
|
|
83
86
|
<div class="toolbar">
|
|
84
87
|
<div class="toolbar-left">
|
|
88
|
+
<img class="toolbar-icon" src="/img/icon/32/icon.png" alt="" />
|
|
85
89
|
<h1 id="app-title">Loading...</h1>
|
|
86
90
|
<div class="segmented" id="tab-bar"></div>
|
|
87
91
|
</div>
|
|
@@ -174,7 +178,7 @@
|
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
function flatSizes(col) {
|
|
177
|
-
return
|
|
181
|
+
return col.export || [];
|
|
178
182
|
}
|
|
179
183
|
|
|
180
184
|
// ---- Build collection tab bar ----
|
|
@@ -288,31 +292,30 @@
|
|
|
288
292
|
}
|
|
289
293
|
}
|
|
290
294
|
|
|
291
|
-
// Export sizes
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
295
|
+
// Export sizes
|
|
296
|
+
const sizes = col.export || [];
|
|
297
|
+
if (sizes.length > 0) {
|
|
298
|
+
const sectionLabel = document.createElement('div');
|
|
299
|
+
sectionLabel.className = 'section-label';
|
|
300
|
+
sectionLabel.textContent = 'Sizes';
|
|
301
|
+
exportDropdown.appendChild(sectionLabel);
|
|
302
|
+
}
|
|
299
303
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
304
|
+
for (const entry of sizes) {
|
|
305
|
+
const btn = document.createElement('button');
|
|
306
|
+
btn.innerHTML = `${entry.label} <span class="dim">${entry.size.width} × ${entry.size.height}</span>`;
|
|
307
|
+
btn.addEventListener('click', () => {
|
|
308
|
+
if (hasMultipleTemplates) {
|
|
309
|
+
exportZip(entry.size.width, entry.size.height, sw, sh, entry.name);
|
|
310
|
+
} else {
|
|
311
|
+
exportSinglePNG(col.templates[0], entry.size.width, entry.size.height, sw, sh, entry.name);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
exportDropdown.appendChild(btn);
|
|
312
315
|
}
|
|
313
316
|
|
|
314
317
|
// Custom size
|
|
315
|
-
if (col.
|
|
318
|
+
if (col.export && col.export.length > 0 || col.customExport) {
|
|
316
319
|
exportDropdown.appendChild(document.createElement('hr'));
|
|
317
320
|
|
|
318
321
|
const customLabel = document.createElement('div');
|
|
@@ -320,8 +323,8 @@
|
|
|
320
323
|
customLabel.textContent = 'Custom Size';
|
|
321
324
|
exportDropdown.appendChild(customLabel);
|
|
322
325
|
|
|
323
|
-
const defaultW = col.customExport?.defaultWidth || flatSizes(col)[0]?.width || sw;
|
|
324
|
-
const defaultH = col.customExport?.defaultHeight || flatSizes(col)[0]?.height || sh;
|
|
326
|
+
const defaultW = col.customExport?.defaultWidth || flatSizes(col)[0]?.size?.width || sw;
|
|
327
|
+
const defaultH = col.customExport?.defaultHeight || flatSizes(col)[0]?.size?.height || sh;
|
|
325
328
|
|
|
326
329
|
// Check if square (icon-like) for simplified input
|
|
327
330
|
const isSquare = sw === sh;
|
|
@@ -446,7 +449,7 @@
|
|
|
446
449
|
|
|
447
450
|
// ---- Init ----
|
|
448
451
|
async function init() {
|
|
449
|
-
const res = await fetch('
|
|
452
|
+
const res = await fetch('assets.json');
|
|
450
453
|
manifest = await res.json();
|
|
451
454
|
|
|
452
455
|
// Set title
|