bg cleaner add
This commit is contained in:
1 parent
18a19acf55
commit
309193a3a7
9 files changed
+1037
-11
No files matched your search
@@ -21,7 +21,8 @@ const viewKey = computed(() => {
|
|||||||
route.name === 'drawio-editor' ||
|
route.name === 'drawio-editor' ||
|
||||||
route.name === 'cad-viewer' ||
|
route.name === 'cad-viewer' ||
|
||||||
route.name === 'model3d-viewer' ||
|
route.name === 'model3d-viewer' ||
|
||||||
route.name === 'tools-scanner'
|
route.name === 'tools-scanner' ||
|
||||||
|
route.name === 'tools-bg-remove'
|
||||||
) {
|
) {
|
||||||
return String(route.name);
|
return String(route.name);
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,7 @@ const viewKey = computed(() => {
|
|||||||
'CadViewerView',
|
'CadViewerView',
|
||||||
'Model3DViewerView',
|
'Model3DViewerView',
|
||||||
'ToolsScannerView',
|
'ToolsScannerView',
|
||||||
|
'ToolsBgRemoveView',
|
||||||
]"
|
]"
|
||||||
:max="10"
|
:max="10"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
/** Nested dragenter/leave safe file drop handlers for a page root. */
|
||||||
|
export function useFileDropZone(onFiles: (files: File[]) => void) {
|
||||||
|
const dropActive = ref(false);
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
function onDragEnter(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
depth += 1;
|
||||||
|
dropActive.value = true;
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
if (depth === 0) dropActive.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
depth = 0;
|
||||||
|
dropActive.value = false;
|
||||||
|
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||||
|
if (files.length) onFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dropActive,
|
||||||
|
onDragEnter,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export type FaviconKind =
|
|||||||
| 'cad'
|
| 'cad'
|
||||||
| 'model3d'
|
| 'model3d'
|
||||||
| 'scanner'
|
| 'scanner'
|
||||||
|
| 'bgremove'
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'csv'
|
| 'csv'
|
||||||
| 'file';
|
| 'file';
|
||||||
@@ -37,6 +38,8 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
|
|||||||
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
|
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
|
||||||
model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z',
|
model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z',
|
||||||
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
|
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
|
||||||
|
bgremove:
|
||||||
|
'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z',
|
||||||
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||||
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
|
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
|
||||||
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||||
@@ -55,6 +58,7 @@ export const ICON_COLORS: Record<FaviconKind, string> = {
|
|||||||
cad: '#5c6bc0',
|
cad: '#5c6bc0',
|
||||||
model3d: '#6d4c41',
|
model3d: '#6d4c41',
|
||||||
scanner: '#00897b',
|
scanner: '#00897b',
|
||||||
|
bgremove: '#5c6bc0',
|
||||||
text: '#607d8b',
|
text: '#607d8b',
|
||||||
csv: '#00838f',
|
csv: '#00838f',
|
||||||
file: '#546e7a',
|
file: '#546e7a',
|
||||||
@@ -114,6 +118,7 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
|||||||
webp: 'scanner',
|
webp: 'scanner',
|
||||||
gif: 'scanner',
|
gif: 'scanner',
|
||||||
bmp: 'scanner',
|
bmp: 'scanner',
|
||||||
|
bgremove: 'bgremove',
|
||||||
txt: 'text',
|
txt: 'text',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
export type BgRemoveMode = 'threshold' | 'flood';
|
||||||
|
|
||||||
|
export interface BgRemoveOptions {
|
||||||
|
/** 0–100: how far from sampled background still counts as background */
|
||||||
|
tolerance: number;
|
||||||
|
/** 0–100: soft edge width beyond hard threshold */
|
||||||
|
feather: number;
|
||||||
|
mode: BgRemoveMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rgb {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BG_REMOVE_OPTIONS: BgRemoveOptions = {
|
||||||
|
tolerance: 28,
|
||||||
|
feather: 12,
|
||||||
|
mode: 'threshold',
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampByte(value: number) {
|
||||||
|
return Math.min(255, Math.max(0, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorDistance(a: Rgb, r: number, g: number, b: number) {
|
||||||
|
return Math.max(Math.abs(r - a.r), Math.abs(g - a.g), Math.abs(b - a.b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]) {
|
||||||
|
if (!values.length) return 0;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2
|
||||||
|
? sorted[mid]!
|
||||||
|
: Math.round((sorted[mid - 1]! + sorted[mid]!) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample corner neighborhoods and take per-channel median as background. */
|
||||||
|
export function sampleCornerBackground(
|
||||||
|
data: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius = 2,
|
||||||
|
): Rgb {
|
||||||
|
const rs: number[] = [];
|
||||||
|
const gs: number[] = [];
|
||||||
|
const bs: number[] = [];
|
||||||
|
const corners: Array<[number, number]> = [
|
||||||
|
[0, 0],
|
||||||
|
[width - 1, 0],
|
||||||
|
[0, height - 1],
|
||||||
|
[width - 1, height - 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [cx, cy] of corners) {
|
||||||
|
for (let dy = -radius; dy <= radius; dy += 1) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||||
|
const x = Math.min(width - 1, Math.max(0, cx + dx));
|
||||||
|
const y = Math.min(height - 1, Math.max(0, cy + dy));
|
||||||
|
const i = (y * width + x) * 4;
|
||||||
|
rs.push(data[i]!);
|
||||||
|
gs.push(data[i + 1]!);
|
||||||
|
bs.push(data[i + 2]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { r: median(rs), g: median(gs), b: median(bs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hardLimit(tolerance: number) {
|
||||||
|
return Math.round((Math.min(100, Math.max(0, tolerance)) / 100) * 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function featherWidth(feather: number) {
|
||||||
|
return Math.round((Math.min(100, Math.max(0, feather)) / 100) * 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alphaForDistance(
|
||||||
|
distance: number,
|
||||||
|
hard: number,
|
||||||
|
soft: number,
|
||||||
|
originalAlpha: number,
|
||||||
|
) {
|
||||||
|
if (distance <= hard) return 0;
|
||||||
|
if (soft <= 0 || distance >= hard + soft) return originalAlpha;
|
||||||
|
const t = (distance - hard) / soft;
|
||||||
|
return clampByte(originalAlpha * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string | Blob | HTMLImageElement | HTMLCanvasElement) {
|
||||||
|
if (source instanceof HTMLCanvasElement) {
|
||||||
|
return Promise.resolve(source);
|
||||||
|
}
|
||||||
|
if (source instanceof HTMLImageElement) {
|
||||||
|
if (source.complete && source.naturalWidth) return Promise.resolve(source);
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
source.onload = () => resolve(source);
|
||||||
|
source.onerror = () =>
|
||||||
|
reject(new Error('이미지를 불러오지 못했습니다.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl =
|
||||||
|
typeof source === 'string' ? null : URL.createObjectURL(source);
|
||||||
|
const src = typeof source === 'string' ? source : objectUrl!;
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
reject(new Error('이미지를 불러오지 못했습니다.'));
|
||||||
|
};
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawToCanvas(
|
||||||
|
image: HTMLImageElement | HTMLCanvasElement,
|
||||||
|
): HTMLCanvasElement {
|
||||||
|
if (image instanceof HTMLCanvasElement) {
|
||||||
|
const copy = document.createElement('canvas');
|
||||||
|
copy.width = image.width;
|
||||||
|
copy.height = image.height;
|
||||||
|
const ctx = copy.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = image.naturalWidth || image.width;
|
||||||
|
canvas.height = image.naturalHeight || image.height;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThreshold(
|
||||||
|
data: Uint8ClampedArray,
|
||||||
|
bg: Rgb,
|
||||||
|
hard: number,
|
||||||
|
soft: number,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
|
||||||
|
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFlood(
|
||||||
|
data: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
bg: Rgb,
|
||||||
|
hard: number,
|
||||||
|
soft: number,
|
||||||
|
) {
|
||||||
|
const total = width * height;
|
||||||
|
const match = new Uint8Array(total);
|
||||||
|
const limit = hard + soft;
|
||||||
|
for (let p = 0; p < total; p += 1) {
|
||||||
|
const i = p * 4;
|
||||||
|
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
|
||||||
|
if (d <= limit) match[p] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Uint8Array(total);
|
||||||
|
const queue = new Int32Array(total);
|
||||||
|
let head = 0;
|
||||||
|
let tail = 0;
|
||||||
|
|
||||||
|
const seed = (x: number, y: number) => {
|
||||||
|
const p = y * width + x;
|
||||||
|
if (!match[p] || visited[p]) return;
|
||||||
|
visited[p] = 1;
|
||||||
|
queue[tail++] = p;
|
||||||
|
};
|
||||||
|
|
||||||
|
seed(0, 0);
|
||||||
|
seed(width - 1, 0);
|
||||||
|
seed(0, height - 1);
|
||||||
|
seed(width - 1, height - 1);
|
||||||
|
|
||||||
|
while (head < tail) {
|
||||||
|
const p = queue[head++]!;
|
||||||
|
const x = p % width;
|
||||||
|
const y = (p / width) | 0;
|
||||||
|
if (x > 0) seed(x - 1, y);
|
||||||
|
if (x + 1 < width) seed(x + 1, y);
|
||||||
|
if (y > 0) seed(x, y - 1);
|
||||||
|
if (y + 1 < height) seed(x, y + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let p = 0; p < total; p += 1) {
|
||||||
|
if (!visited[p]) continue;
|
||||||
|
const i = p * 4;
|
||||||
|
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
|
||||||
|
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeBackgroundBySample(
|
||||||
|
source: string | Blob | HTMLImageElement | HTMLCanvasElement,
|
||||||
|
options: Partial<BgRemoveOptions> = {},
|
||||||
|
): Promise<{
|
||||||
|
blob: Blob;
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
background: Rgb;
|
||||||
|
}> {
|
||||||
|
const opts = { ...DEFAULT_BG_REMOVE_OPTIONS, ...options };
|
||||||
|
const image = await loadImage(source);
|
||||||
|
const canvas = drawToCanvas(image);
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const { data, width, height } = imageData;
|
||||||
|
const background = sampleCornerBackground(data, width, height);
|
||||||
|
const hard = hardLimit(opts.tolerance);
|
||||||
|
const soft = featherWidth(opts.feather);
|
||||||
|
|
||||||
|
if (opts.mode === 'flood') {
|
||||||
|
applyFlood(data, width, height, background, hard, soft);
|
||||||
|
} else {
|
||||||
|
applyThreshold(data, background, hard, soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(value) =>
|
||||||
|
value
|
||||||
|
? resolve(value)
|
||||||
|
: reject(new Error('투명 PNG를 만들지 못했습니다.')),
|
||||||
|
'image/png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
background,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,11 @@ const router = createRouter({
|
|||||||
name: 'tools-scanner',
|
name: 'tools-scanner',
|
||||||
component: () => import('@/views/ToolsScannerView.vue'),
|
component: () => import('@/views/ToolsScannerView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/bg-remove',
|
||||||
|
name: 'tools-bg-remove',
|
||||||
|
component: () => import('@/views/ToolsBgRemoveView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/licenses',
|
path: '/licenses',
|
||||||
name: 'licenses',
|
name: 'licenses',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@mlightcad/cad-simple-viewer';
|
} from '@mlightcad/cad-simple-viewer';
|
||||||
import AppShell from '@/components/AppShell.vue';
|
import AppShell from '@/components/AppShell.vue';
|
||||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||||
|
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||||
import { usePageTitle } from '@/composables/usePageTitle';
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||||
@@ -52,7 +53,12 @@ settings.set('isShowToolbar', true);
|
|||||||
|
|
||||||
function applyFiles(files: File[]) {
|
function applyFiles(files: File[]) {
|
||||||
const file = files.find((item) => /\.(?:dxf|dwg)$/i.test(item.name));
|
const file = files.find((item) => /\.(?:dxf|dwg)$/i.test(item.name));
|
||||||
if (!file) return;
|
if (!file) {
|
||||||
|
if (files.length) {
|
||||||
|
error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
pendingFile.value = file;
|
pendingFile.value = file;
|
||||||
if (fontReady.value) localFile.value = file;
|
if (fontReady.value) localFile.value = file;
|
||||||
filename.value = file.name;
|
filename.value = file.name;
|
||||||
@@ -63,6 +69,14 @@ function applyFiles(files: File[]) {
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dropActive,
|
||||||
|
onDragEnter,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
} = useFileDropZone(applyFiles);
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
applyFiles(consumeViewerFiles('cad'));
|
applyFiles(consumeViewerFiles('cad'));
|
||||||
try {
|
try {
|
||||||
@@ -98,10 +112,6 @@ function onFileSelected(event: Event) {
|
|||||||
const files = Array.from(input.files ?? []);
|
const files = Array.from(input.files ?? []);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
if (!files.some((file) => /\.(?:dxf|dwg)$/i.test(file.name))) {
|
|
||||||
error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyFiles(files);
|
applyFiles(files);
|
||||||
void nextTick();
|
void nextTick();
|
||||||
}
|
}
|
||||||
@@ -126,7 +136,14 @@ function onClose() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell :editor="true">
|
<AppShell :editor="true">
|
||||||
<div class="cad-page">
|
<div
|
||||||
|
class="cad-page"
|
||||||
|
:class="{ 'is-drop-active': dropActive }"
|
||||||
|
@dragenter="onDragEnter"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
:show-save="false"
|
:show-save="false"
|
||||||
@@ -160,9 +177,15 @@ function onClose() {
|
|||||||
/>
|
/>
|
||||||
<div v-if="ready && !localFile && viewerCreated" class="empty-state">
|
<div v-if="ready && !localFile && viewerCreated" class="empty-state">
|
||||||
<strong>CAD 도면을 선택해 주세요.</strong>
|
<strong>CAD 도면을 선택해 주세요.</strong>
|
||||||
<span>DXF와 DWG 파일은 브라우저 안에서만 처리됩니다.</span>
|
<span>
|
||||||
|
DXF와 DWG 파일을 열거나 이 영역으로 끌어다 놓으세요. 처리는
|
||||||
|
브라우저 안에서만 이뤄집니다.
|
||||||
|
</span>
|
||||||
<button type="button" @click="onOpen">파일 열기</button>
|
<button type="button" @click="onOpen">파일 열기</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||||
|
DXF · DWG 파일을 놓으세요
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
@@ -231,6 +254,20 @@ function onClose() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.75rem;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px dashed var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 650;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ const toolTypes = [
|
|||||||
title: '문서 스캐너',
|
title: '문서 스캐너',
|
||||||
subtitle: '코너 보정 · OCR · PDF',
|
subtitle: '코너 보정 · OCR · PDF',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: 'tools-bg-remove',
|
||||||
|
extension: 'bgremove',
|
||||||
|
title: '배경 제거',
|
||||||
|
subtitle: '로고 단색 배경 · 투명 PNG',
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
function onDrop(e: DragEvent) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Model3DCanvas from '@/components/model3d/Model3DCanvas.vue';
|
|||||||
import ModelTreeItem from '@/components/model3d/ModelTreeItem.vue';
|
import ModelTreeItem from '@/components/model3d/ModelTreeItem.vue';
|
||||||
import { MODEL3D_EXTENSIONS } from '@/config/app';
|
import { MODEL3D_EXTENSIONS } from '@/config/app';
|
||||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||||
|
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||||
import { usePageTitle } from '@/composables/usePageTitle';
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||||
import {
|
import {
|
||||||
@@ -135,6 +136,14 @@ function onFilesSelected(event: Event) {
|
|||||||
if (selected.length) applyFiles(selected);
|
if (selected.length) applyFiles(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dropActive,
|
||||||
|
onDragEnter,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
} = useFileDropZone(applyFiles);
|
||||||
|
|
||||||
function onProgress(value: number, label: string) {
|
function onProgress(value: number, label: string) {
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
progressLabel.value = label;
|
progressLabel.value = label;
|
||||||
@@ -238,7 +247,14 @@ function onClose() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell :editor="true">
|
<AppShell :editor="true">
|
||||||
<div class="model-page">
|
<div
|
||||||
|
class="model-page"
|
||||||
|
:class="{ 'is-drop-active': dropActive }"
|
||||||
|
@dragenter="onDragEnter"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
save-label="내보내기"
|
save-label="내보내기"
|
||||||
@@ -314,8 +330,8 @@ function onClose() {
|
|||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<strong>3D 모델을 선택해 주세요.</strong>
|
<strong>3D 모델을 선택해 주세요.</strong>
|
||||||
<span>
|
<span>
|
||||||
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열 수
|
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열거나
|
||||||
있습니다.
|
이 영역으로 끌어다 놓을 수 있습니다.
|
||||||
</span>
|
</span>
|
||||||
<button type="button" @click="onOpen">파일 열기</button>
|
<button type="button" @click="onOpen">파일 열기</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,6 +385,10 @@ function onClose() {
|
|||||||
<progress :value="progressPercent" max="100" />
|
<progress :value="progressPercent" max="100" />
|
||||||
<small>{{ progressPercent }}%</small>
|
<small>{{ progressPercent }}%</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
|
||||||
|
3D 모델 파일을 놓으세요
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="inspector">
|
<aside class="inspector">
|
||||||
@@ -632,6 +652,20 @@ function onClose() {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.75rem;
|
||||||
|
z-index: 25;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px dashed var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 650;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.export-bar {
|
.export-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -0,0 +1,642 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import AppShell from '@/components/AppShell.vue';
|
||||||
|
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||||
|
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||||
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
|
import {
|
||||||
|
DEFAULT_BG_REMOVE_OPTIONS,
|
||||||
|
removeBackgroundBySample,
|
||||||
|
type BgRemoveMode,
|
||||||
|
type Rgb,
|
||||||
|
} from '@/integrations/bgremove/removeBg';
|
||||||
|
import { downloadBlob } from '@/utils/downloadBlob';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ToolsBgRemoveView' });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const filename = ref('배경 제거');
|
||||||
|
const sourceUrl = ref<string | null>(null);
|
||||||
|
const resultUrl = ref<string | null>(null);
|
||||||
|
const background = ref<Rgb | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const notice = ref('');
|
||||||
|
const tolerance = ref(DEFAULT_BG_REMOVE_OPTIONS.tolerance);
|
||||||
|
const feather = ref(DEFAULT_BG_REMOVE_OPTIONS.feather);
|
||||||
|
const mode = ref<BgRemoveMode>(DEFAULT_BG_REMOVE_OPTIONS.mode);
|
||||||
|
const imageUrlInput = ref('');
|
||||||
|
|
||||||
|
usePageTitle(filename);
|
||||||
|
|
||||||
|
const hasImage = computed(() => Boolean(sourceUrl.value));
|
||||||
|
const bgCss = computed(() => {
|
||||||
|
if (!background.value) return '';
|
||||||
|
const { r, g, b } = background.value;
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function revoke(url: string | null) {
|
||||||
|
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(name = filename.value) {
|
||||||
|
return name.replace(/\.[^.]+$/, '') || 'logo';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
void router.push({ name: 'home' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpen() {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageFile(file: File) {
|
||||||
|
if (file.type.startsWith('image/')) return true;
|
||||||
|
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(file: File) {
|
||||||
|
if (!isImageFile(file)) {
|
||||||
|
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG 이미지를 선택해 주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = '';
|
||||||
|
notice.value = '';
|
||||||
|
revoke(sourceUrl.value);
|
||||||
|
revoke(resultUrl.value);
|
||||||
|
sourceUrl.value = URL.createObjectURL(file);
|
||||||
|
resultUrl.value = null;
|
||||||
|
background.value = null;
|
||||||
|
filename.value = file.name;
|
||||||
|
await processImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromUrl(raw = imageUrlInput.value) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
error.value = '이미지 URL을 입력해 주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
error.value = '올바른 이미지 URL을 입력해 주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
error.value = 'http(s) URL만 사용할 수 있습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
notice.value = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(parsed.href, { mode: 'cors' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`이미지를 가져오지 못했습니다 (${response.status}).`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const looksImage =
|
||||||
|
blob.type.startsWith('image/') ||
|
||||||
|
/\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(parsed.pathname);
|
||||||
|
if (!looksImage) {
|
||||||
|
throw new Error('응답이 이미지가 아닙니다.');
|
||||||
|
}
|
||||||
|
const leaf = decodeURIComponent(
|
||||||
|
parsed.pathname.split('/').filter(Boolean).pop() || 'image',
|
||||||
|
);
|
||||||
|
const name = /\.[a-z0-9]+$/i.test(leaf) ? leaf : `${leaf}.png`;
|
||||||
|
const file = new File([blob], name, {
|
||||||
|
type: blob.type || 'image/png',
|
||||||
|
});
|
||||||
|
imageUrlInput.value = parsed.href;
|
||||||
|
await loadFile(file);
|
||||||
|
} catch (reason) {
|
||||||
|
const message =
|
||||||
|
reason instanceof Error ? reason.message : 'URL에서 이미지를 불러오지 못했습니다.';
|
||||||
|
error.value = /Failed to fetch|NetworkError|CORS/i.test(message)
|
||||||
|
? '이미지를 가져오지 못했습니다. CORS를 허용하는 URL인지 확인해 주세요.'
|
||||||
|
: message;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilesSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (file) void loadFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dropActive,
|
||||||
|
onDragEnter,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop: onZoneDrop,
|
||||||
|
} = useFileDropZone((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (file) void loadFile(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function processImage() {
|
||||||
|
if (!sourceUrl.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const result = await removeBackgroundBySample(sourceUrl.value, {
|
||||||
|
tolerance: tolerance.value,
|
||||||
|
feather: feather.value,
|
||||||
|
mode: mode.value,
|
||||||
|
});
|
||||||
|
revoke(resultUrl.value);
|
||||||
|
resultUrl.value = result.url;
|
||||||
|
background.value = result.background;
|
||||||
|
notice.value = '배경을 제거했습니다.';
|
||||||
|
} catch (reason) {
|
||||||
|
error.value =
|
||||||
|
reason instanceof Error ? reason.message : '배경 제거에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPng() {
|
||||||
|
if (!resultUrl.value) return;
|
||||||
|
void fetch(resultUrl.value)
|
||||||
|
.then((response) => response.blob())
|
||||||
|
.then((blob) => downloadBlob(blob, `${baseName()}-transparent.png`));
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(mode, () => {
|
||||||
|
if (sourceUrl.value) void processImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
revoke(sourceUrl.value);
|
||||||
|
revoke(resultUrl.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell :editor="true">
|
||||||
|
<div
|
||||||
|
class="bg-page"
|
||||||
|
:class="{ 'is-drop-active': dropActive }"
|
||||||
|
@dragenter="onDragEnter"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onZoneDrop"
|
||||||
|
>
|
||||||
|
<EditorToolbar
|
||||||
|
:filename="filename"
|
||||||
|
save-label="PNG 저장"
|
||||||
|
:show-save="Boolean(resultUrl)"
|
||||||
|
:busy="busy"
|
||||||
|
@open="onOpen"
|
||||||
|
@save="exportPng"
|
||||||
|
@close="onClose"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/svg+xml,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg"
|
||||||
|
@change="onFilesSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<div
|
||||||
|
v-if="!hasImage"
|
||||||
|
class="empty"
|
||||||
|
:class="{ 'is-drop-active': dropActive }"
|
||||||
|
>
|
||||||
|
<strong>배경 제거</strong>
|
||||||
|
<p>
|
||||||
|
단색 배경 로고 이미지를 열거나 끌어다 놓으면 모서리 색을 기준으로
|
||||||
|
배경을 투명하게 만듭니다. 흰 배경·검은 배경 모두 처리됩니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="onOpen"
|
||||||
|
>
|
||||||
|
이미지 열기
|
||||||
|
</button>
|
||||||
|
<form class="url-form" @submit.prevent="void loadFromUrl()">
|
||||||
|
<input
|
||||||
|
v-model="imageUrlInput"
|
||||||
|
type="url"
|
||||||
|
inputmode="url"
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
:disabled="busy"
|
||||||
|
aria-label="이미지 URL"
|
||||||
|
/>
|
||||||
|
<button type="submit" :disabled="busy">URL 불러오기</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="controls">
|
||||||
|
<form class="url-form url-form--inline" @submit.prevent="void loadFromUrl()">
|
||||||
|
<input
|
||||||
|
v-model="imageUrlInput"
|
||||||
|
type="url"
|
||||||
|
inputmode="url"
|
||||||
|
placeholder="이미지 URL"
|
||||||
|
:disabled="busy"
|
||||||
|
aria-label="이미지 URL"
|
||||||
|
/>
|
||||||
|
<button type="submit" :disabled="busy">URL</button>
|
||||||
|
</form>
|
||||||
|
<label class="field field--mode">
|
||||||
|
<span>모드</span>
|
||||||
|
<select v-model="mode" :disabled="busy">
|
||||||
|
<option value="threshold">전체 유사색 (글자 구멍 포함)</option>
|
||||||
|
<option value="flood">바깥만 (모서리 연결)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__row">
|
||||||
|
허용치
|
||||||
|
<em>{{ tolerance }}</em>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:value="tolerance"
|
||||||
|
:disabled="busy"
|
||||||
|
@input="
|
||||||
|
tolerance = Number(($event.target as HTMLInputElement).value)
|
||||||
|
"
|
||||||
|
@change="void processImage()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__row">
|
||||||
|
가장자리
|
||||||
|
<em>{{ feather }}</em>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:value="feather"
|
||||||
|
:disabled="busy"
|
||||||
|
@input="
|
||||||
|
feather = Number(($event.target as HTMLInputElement).value)
|
||||||
|
"
|
||||||
|
@change="void processImage()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div v-if="background" class="swatch" title="추정 배경색">
|
||||||
|
<span class="swatch__chip" :style="{ background: bgCss }" />
|
||||||
|
<span>배경 {{ bgCss }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="busy || !resultUrl"
|
||||||
|
@click="exportPng"
|
||||||
|
>
|
||||||
|
PNG 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panes">
|
||||||
|
<section class="pane">
|
||||||
|
<h2>원본</h2>
|
||||||
|
<div class="frame">
|
||||||
|
<img :src="sourceUrl!" alt="원본" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="pane">
|
||||||
|
<h2>결과</h2>
|
||||||
|
<div class="frame frame--checker">
|
||||||
|
<img v-if="resultUrl" :src="resultUrl" alt="배경 제거 결과" />
|
||||||
|
<p v-else class="muted">처리 중…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="dropActive && hasImage"
|
||||||
|
class="drop-overlay"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
이미지를 놓아 바꾸기
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bg-page {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
height: 100%;
|
||||||
|
min-height: min(60vh, 28rem);
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty.is-drop-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 28rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: min(100%, 28rem);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form input {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form button {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form--inline {
|
||||||
|
width: auto;
|
||||||
|
flex: 1 1 16rem;
|
||||||
|
margin-top: 0;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form--inline input {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form--inline button {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 10rem;
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--mode {
|
||||||
|
flex: 0 1 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__row em {
|
||||||
|
font-style: normal;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select,
|
||||||
|
.field input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch__chip {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane h2 {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: auto;
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame--checker {
|
||||||
|
background-color: #c8c8c8;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #efefef 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #efefef 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #efefef 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #efefef 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 10px,
|
||||||
|
10px -10px,
|
||||||
|
-10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame img {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.75rem;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px dashed var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 650;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-message {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-message--error {
|
||||||
|
color: #b42318;
|
||||||
|
background: color-mix(in srgb, #b42318 10%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.panes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in new issue
Block a user