feat: add text and date draw tools
This commit is contained in:
@@ -75,6 +75,14 @@
|
|||||||
pipetteActive={pipetteTargetId === param.id}
|
pipetteActive={pipetteTargetId === param.id}
|
||||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
||||||
/>
|
/>
|
||||||
|
{:else if param.type === 'text'}
|
||||||
|
<TextField
|
||||||
|
id={param.id}
|
||||||
|
label={paramLabel(tool, param)}
|
||||||
|
type="text"
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
bind:value={values[param.id]}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -10,11 +10,20 @@
|
|||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, label, value = $bindable(''), type = 'text', min, max, step, hint }: Props = $props();
|
let { id, label, value = $bindable(''), type = 'text', min, max, step, hint, placeholder }: Props =
|
||||||
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
<Field {id} {label} {hint}>
|
||||||
<input class="control" {id} {type} min={min} max={max} step={step} bind:value />
|
<input class="control" {id} {type} min={min} max={max} step={step} {placeholder} bind:value />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
input.control::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatStamp } from './datefmt';
|
||||||
|
|
||||||
|
const d = new Date(2026, 7, 25, 9, 5, 3);
|
||||||
|
|
||||||
|
describe('formatStamp', () => {
|
||||||
|
it('разворачивает все базовые токены', () => {
|
||||||
|
expect(formatStamp(d, 'YYYY-MM-DD hh:mm:ss')).toBe('2026-08-25 09:05:03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('произвольный текст между токенами сохраняется', () => {
|
||||||
|
expect(formatStamp(d, 'DD.MM.YYYY')).toBe('25.08.2026');
|
||||||
|
expect(formatStamp(d, 'YYYY год, MM месяц')).toBe('2026 год, 08 месяц');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неизвестные последовательности не трогаются', () => {
|
||||||
|
expect(formatStamp(d, 'YYYYYY MMМ')).toBe('2026YY 08М');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const PAD2 = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мини-форматтер штампа даты: токены YYYY MM DD hh mm ss заменяются
|
||||||
|
* значениями локального времени, остальные символы остаются как есть.
|
||||||
|
*/
|
||||||
|
export function formatStamp(date: Date, pattern: string): string {
|
||||||
|
return pattern.replace(/YYYY|MM|DD|hh|mm|ss/g, (token) => {
|
||||||
|
switch (token) {
|
||||||
|
case 'YYYY':
|
||||||
|
return String(date.getFullYear());
|
||||||
|
case 'MM':
|
||||||
|
return PAD2(date.getMonth() + 1);
|
||||||
|
case 'DD':
|
||||||
|
return PAD2(date.getDate());
|
||||||
|
case 'hh':
|
||||||
|
return PAD2(date.getHours());
|
||||||
|
case 'mm':
|
||||||
|
return PAD2(date.getMinutes());
|
||||||
|
case 'ss':
|
||||||
|
return PAD2(date.getSeconds());
|
||||||
|
default:
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ export const en: Dict = {
|
|||||||
noSteps: 'The file has no list of steps',
|
noSteps: 'The file has no list of steps',
|
||||||
paramNumber: 'Parameter "{id}" must be a number',
|
paramNumber: 'Parameter "{id}" must be a number',
|
||||||
paramString: 'Parameter "{id}" must be a string',
|
paramString: 'Parameter "{id}" must be a string',
|
||||||
|
paramBool: 'Parameter "{id}" must be a checkbox value',
|
||||||
resizeSize: 'Width and/or height must be positive',
|
resizeSize: 'Width and/or height must be positive',
|
||||||
cropSize: 'Crop width and height must be positive',
|
cropSize: 'Crop width and height must be positive',
|
||||||
sizePositive: 'Dimensions must be positive and finite'
|
sizePositive: 'Dimensions must be positive and finite'
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const ru: Dict = {
|
|||||||
noSteps: 'В файле нет списка шагов',
|
noSteps: 'В файле нет списка шагов',
|
||||||
paramNumber: 'Параметр "{id}" должен быть числом',
|
paramNumber: 'Параметр "{id}" должен быть числом',
|
||||||
paramString: 'Параметр "{id}" должен быть строкой',
|
paramString: 'Параметр "{id}" должен быть строкой',
|
||||||
|
paramBool: 'Параметр "{id}" должен быть значением флажка',
|
||||||
resizeSize: 'Ширина и/или высота должны быть положительными',
|
resizeSize: 'Ширина и/или высота должны быть положительными',
|
||||||
cropSize: 'Ширина и высота области обрезки должны быть положительными',
|
cropSize: 'Ширина и высота области обрезки должны быть положительными',
|
||||||
sizePositive: 'Размеры должны быть положительными и конечными'
|
sizePositive: 'Размеры должны быть положительными и конечными'
|
||||||
@@ -439,6 +440,68 @@ export const ru: Dict = {
|
|||||||
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'add-text-png': {
|
||||||
|
title: 'Надпись на PNG',
|
||||||
|
description:
|
||||||
|
'Рисует текст на изображении: шрифт, размер, цвет, жирность, позиция на сетке 3×3 и опциональная подложка.',
|
||||||
|
params: {
|
||||||
|
text: 'Текст',
|
||||||
|
fontSize: 'Размер шрифта, px',
|
||||||
|
color: 'Цвет текста',
|
||||||
|
font: 'Шрифт',
|
||||||
|
bold: 'Жирный',
|
||||||
|
position: 'Позиция',
|
||||||
|
margin: 'Отступ, px',
|
||||||
|
plate: 'Подложка',
|
||||||
|
plateColor: 'Цвет подложки',
|
||||||
|
plateOpacity: 'Прозрачность плашки, %'
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' },
|
||||||
|
position: {
|
||||||
|
'top-left': 'Сверху слева',
|
||||||
|
'top-center': 'Сверху по центру',
|
||||||
|
'top-right': 'Сверху справа',
|
||||||
|
'middle-left': 'По центру слева',
|
||||||
|
center: 'По центру',
|
||||||
|
'middle-right': 'По центру справа',
|
||||||
|
'bottom-left': 'Снизу слева',
|
||||||
|
'bottom-center': 'Снизу по центру',
|
||||||
|
'bottom-right': 'Снизу справа'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'date-stamp-png': {
|
||||||
|
title: 'Дата-штамп PNG',
|
||||||
|
description:
|
||||||
|
'Ставит текущую дату и время по строке формата (токены YYYY MM DD hh mm ss). Оформление — как у надписи.',
|
||||||
|
params: {
|
||||||
|
format: 'Формат',
|
||||||
|
fontSize: 'Размер шрифта, px',
|
||||||
|
color: 'Цвет текста',
|
||||||
|
font: 'Шрифт',
|
||||||
|
bold: 'Жирный',
|
||||||
|
position: 'Позиция',
|
||||||
|
margin: 'Отступ, px',
|
||||||
|
plate: 'Подложка',
|
||||||
|
plateColor: 'Цвет подложки',
|
||||||
|
plateOpacity: 'Прозрачность плашки, %'
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' },
|
||||||
|
position: {
|
||||||
|
'top-left': 'Сверху слева',
|
||||||
|
'top-center': 'Сверху по центру',
|
||||||
|
'top-right': 'Сверху справа',
|
||||||
|
'middle-left': 'По центру слева',
|
||||||
|
center: 'По центру',
|
||||||
|
'middle-right': 'По центру справа',
|
||||||
|
'bottom-left': 'Снизу слева',
|
||||||
|
'bottom-center': 'Снизу по центру',
|
||||||
|
'bottom-right': 'Снизу справа'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
'skew-png': {
|
'skew-png': {
|
||||||
title: 'Наклонить PNG',
|
title: 'Наклонить PNG',
|
||||||
description: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
|
description: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
|
||||||
|
|||||||
+152
-1
@@ -52,6 +52,9 @@ import {
|
|||||||
} from './core/color';
|
} from './core/color';
|
||||||
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
|
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
|
||||||
import { decodeSvgText, decodeTextImage, jpegRoundtrip, toBase64, toDataUrl, type OutputMime } from './core/io';
|
import { decodeSvgText, decodeTextImage, jpegRoundtrip, toBase64, toDataUrl, type OutputMime } from './core/io';
|
||||||
|
import { drawTextBlock, type TextFont } from './core/domText';
|
||||||
|
import { formatStamp } from './core/datefmt';
|
||||||
|
import type { Position9 } from './core/textdraw';
|
||||||
import { hexToPixels, pixelsToHex } from './core/text';
|
import { hexToPixels, pixelsToHex } from './core/text';
|
||||||
import { clonePixelImage, type PixelImage } from './core/types';
|
import { clonePixelImage, type PixelImage } from './core/types';
|
||||||
|
|
||||||
@@ -82,7 +85,8 @@ export type ParamDef =
|
|||||||
default: string;
|
default: string;
|
||||||
}
|
}
|
||||||
| { id: string; label: string; type: 'checkbox'; default: boolean }
|
| { id: string; label: string; type: 'checkbox'; default: boolean }
|
||||||
| { id: string; label: string; type: 'color'; default: string };
|
| { id: string; label: string; type: 'color'; default: string }
|
||||||
|
| { id: string; label: string; type: 'text'; default: string; placeholder?: string };
|
||||||
|
|
||||||
export type OutputFormat = {
|
export type OutputFormat = {
|
||||||
mime: OutputMime;
|
mime: OutputMime;
|
||||||
@@ -111,6 +115,8 @@ export type ToolEntry = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
resultType?: 'image' | 'info' | 'text';
|
resultType?: 'image' | 'info' | 'text';
|
||||||
output?: OutputFormat;
|
output?: OutputFormat;
|
||||||
|
/** Инструменту нужен DOM (canvas): исполняется только напрямую, без воркера. */
|
||||||
|
domOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||||
@@ -135,6 +141,14 @@ function str(params: Record<string, unknown>, id: string): string {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bool(params: Record<string, unknown>, id: string): boolean {
|
||||||
|
const v = params[id];
|
||||||
|
if (typeof v !== 'boolean') {
|
||||||
|
throw new ToolError('errors.paramBool', { id });
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
function decodeToPng(id: string, title: string, description: string): ToolEntry {
|
function decodeToPng(id: string, title: string, description: string): ToolEntry {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -855,6 +869,140 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
|
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'add-text-png',
|
||||||
|
title: 'Add text to PNG',
|
||||||
|
description:
|
||||||
|
'Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.',
|
||||||
|
category: 'text',
|
||||||
|
domOnly: true,
|
||||||
|
params: [
|
||||||
|
{ id: 'text', label: 'Text', type: 'text', default: 'Hello!', placeholder: 'Your text' },
|
||||||
|
{ id: 'fontSize', label: 'Font size, px', type: 'slider', min: 8, max: 200, step: 1, default: 48 },
|
||||||
|
{ id: 'color', label: 'Text color', type: 'color', default: '#ffffff' },
|
||||||
|
{
|
||||||
|
id: 'font',
|
||||||
|
label: 'Font',
|
||||||
|
type: 'select',
|
||||||
|
default: 'sans',
|
||||||
|
options: [
|
||||||
|
{ value: 'sans', label: 'Sans-serif' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
{ value: 'mono', label: 'Monospace' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'bold', label: 'Bold', type: 'checkbox', default: true },
|
||||||
|
{
|
||||||
|
id: 'position',
|
||||||
|
label: 'Position',
|
||||||
|
type: 'select',
|
||||||
|
default: 'bottom-right',
|
||||||
|
options: [
|
||||||
|
{ value: 'top-left', label: 'Top left' },
|
||||||
|
{ value: 'top-center', label: 'Top center' },
|
||||||
|
{ value: 'top-right', label: 'Top right' },
|
||||||
|
{ value: 'middle-left', label: 'Middle left' },
|
||||||
|
{ value: 'center', label: 'Center' },
|
||||||
|
{ value: 'middle-right', label: 'Middle right' },
|
||||||
|
{ value: 'bottom-left', label: 'Bottom left' },
|
||||||
|
{ value: 'bottom-center', label: 'Bottom center' },
|
||||||
|
{ value: 'bottom-right', label: 'Bottom right' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'margin', label: 'Margin, px', type: 'slider', min: 0, max: 200, step: 1, default: 24 },
|
||||||
|
{ id: 'plate', label: 'Backing plate', type: 'checkbox', default: false },
|
||||||
|
{ id: 'plateColor', label: 'Plate color', type: 'color', default: '#000000' },
|
||||||
|
{
|
||||||
|
id: 'plateOpacity',
|
||||||
|
label: 'Plate opacity, %',
|
||||||
|
type: 'slider',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
default: 60
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) =>
|
||||||
|
drawTextBlock(img, {
|
||||||
|
text: str(p, 'text'),
|
||||||
|
fontSize: num(p, 'fontSize'),
|
||||||
|
font: str(p, 'font') as TextFont,
|
||||||
|
bold: bool(p, 'bold'),
|
||||||
|
color: str(p, 'color'),
|
||||||
|
opacityPercent: 100,
|
||||||
|
position: str(p, 'position') as Position9,
|
||||||
|
margin: num(p, 'margin'),
|
||||||
|
plateColor: bool(p, 'plate') ? str(p, 'plateColor') : undefined,
|
||||||
|
plateOpacityPercent: num(p, 'plateOpacity')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'date-stamp-png',
|
||||||
|
title: 'Date stamp PNG',
|
||||||
|
description:
|
||||||
|
'Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.',
|
||||||
|
category: 'text',
|
||||||
|
domOnly: true,
|
||||||
|
params: [
|
||||||
|
{ id: 'format', label: 'Format', type: 'text', default: 'YYYY-MM-DD', placeholder: 'YYYY-MM-DD hh:mm' },
|
||||||
|
{ id: 'fontSize', label: 'Font size, px', type: 'slider', min: 8, max: 200, step: 1, default: 32 },
|
||||||
|
{ id: 'color', label: 'Text color', type: 'color', default: '#ffffff' },
|
||||||
|
{
|
||||||
|
id: 'font',
|
||||||
|
label: 'Font',
|
||||||
|
type: 'select',
|
||||||
|
default: 'mono',
|
||||||
|
options: [
|
||||||
|
{ value: 'sans', label: 'Sans-serif' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
{ value: 'mono', label: 'Monospace' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'bold', label: 'Bold', type: 'checkbox', default: false },
|
||||||
|
{
|
||||||
|
id: 'position',
|
||||||
|
label: 'Position',
|
||||||
|
type: 'select',
|
||||||
|
default: 'bottom-right',
|
||||||
|
options: [
|
||||||
|
{ value: 'top-left', label: 'Top left' },
|
||||||
|
{ value: 'top-center', label: 'Top center' },
|
||||||
|
{ value: 'top-right', label: 'Top right' },
|
||||||
|
{ value: 'middle-left', label: 'Middle left' },
|
||||||
|
{ value: 'center', label: 'Center' },
|
||||||
|
{ value: 'middle-right', label: 'Middle right' },
|
||||||
|
{ value: 'bottom-left', label: 'Bottom left' },
|
||||||
|
{ value: 'bottom-center', label: 'Bottom center' },
|
||||||
|
{ value: 'bottom-right', label: 'Bottom right' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'margin', label: 'Margin, px', type: 'slider', min: 0, max: 200, step: 1, default: 20 },
|
||||||
|
{ id: 'plate', label: 'Backing plate', type: 'checkbox', default: true },
|
||||||
|
{ id: 'plateColor', label: 'Plate color', type: 'color', default: '#000000' },
|
||||||
|
{
|
||||||
|
id: 'plateOpacity',
|
||||||
|
label: 'Plate opacity, %',
|
||||||
|
type: 'slider',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
default: 55
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) =>
|
||||||
|
drawTextBlock(img, {
|
||||||
|
text: formatStamp(new Date(), str(p, 'format')),
|
||||||
|
fontSize: num(p, 'fontSize'),
|
||||||
|
font: str(p, 'font') as TextFont,
|
||||||
|
bold: bool(p, 'bold'),
|
||||||
|
color: str(p, 'color'),
|
||||||
|
opacityPercent: 100,
|
||||||
|
position: str(p, 'position') as Position9,
|
||||||
|
margin: num(p, 'margin'),
|
||||||
|
plateColor: bool(p, 'plate') ? str(p, 'plateColor') : undefined,
|
||||||
|
plateOpacityPercent: num(p, 'plateOpacity')
|
||||||
|
})
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'png-is-grayscale',
|
id: 'png-is-grayscale',
|
||||||
title: 'Check: is PNG grayscale?',
|
title: 'Check: is PNG grayscale?',
|
||||||
@@ -1062,6 +1210,9 @@ export function sanitizeParams(
|
|||||||
out[param.id] =
|
out[param.id] =
|
||||||
typeof raw === 'string' && /^#[0-9a-f]{6}$/i.test(raw) ? raw : param.default;
|
typeof raw === 'string' && /^#[0-9a-f]{6}$/i.test(raw) ? raw : param.default;
|
||||||
break;
|
break;
|
||||||
|
case 'text':
|
||||||
|
out[param.id] = typeof raw === 'string' ? raw : param.default;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ToolError } from '../core/errors';
|
|||||||
|
|
||||||
type MaybeRunnable = {
|
type MaybeRunnable = {
|
||||||
id: string;
|
id: string;
|
||||||
|
domOnly?: boolean;
|
||||||
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ export async function executeStep(
|
|||||||
if (!tool.run) {
|
if (!tool.run) {
|
||||||
throw new Error('errors.noImageRun');
|
throw new Error('errors.noImageRun');
|
||||||
}
|
}
|
||||||
|
if (tool.domOnly) {
|
||||||
|
return await runDirect(tool, img, params);
|
||||||
|
}
|
||||||
if (typeof Worker === 'undefined') {
|
if (typeof Worker === 'undefined') {
|
||||||
return await runDirect(tool, img, params);
|
return await runDirect(tool, img, params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
AppWindow,
|
AppWindow,
|
||||||
Binary,
|
Binary,
|
||||||
Blend,
|
Blend,
|
||||||
|
CalendarDays,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Contrast,
|
Contrast,
|
||||||
Crop,
|
Crop,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
SearchCheck,
|
SearchCheck,
|
||||||
Square,
|
Square,
|
||||||
Sun,
|
Sun,
|
||||||
|
Type,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut
|
ZoomOut
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
@@ -94,6 +96,8 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
|||||||
'sharpen-png': Focus,
|
'sharpen-png': Focus,
|
||||||
'remove-background-png': Scissors,
|
'remove-background-png': Scissors,
|
||||||
'add-stroke-png': Square,
|
'add-stroke-png': Square,
|
||||||
|
'add-text-png': Type,
|
||||||
|
'date-stamp-png': CalendarDays,
|
||||||
'find-contour-png': Scan,
|
'find-contour-png': Scan,
|
||||||
'make-thicker-png': ZoomIn,
|
'make-thicker-png': ZoomIn,
|
||||||
'make-thinner-png': ZoomOut,
|
'make-thinner-png': ZoomOut,
|
||||||
|
|||||||
Reference in New Issue
Block a user