239 lines
7.9 KiB
Vue
239 lines
7.9 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from "vue";
|
||
|
||
const props = defineProps<{
|
||
onAddText: () => void;
|
||
onAddCircle: () => void;
|
||
onAddRectangle: () => void;
|
||
onClear: () => void;
|
||
onImportImage: (file: File) => Promise<void>;
|
||
onFillChange: (fill: string) => void;
|
||
onStrokeChange: (stroke: string) => void;
|
||
activeFill: string | null;
|
||
activeStroke: string | null;
|
||
canStyleSelection: boolean;
|
||
zoom: number;
|
||
minZoom: number;
|
||
maxZoom: number;
|
||
onZoomChange: (zoom: number) => void;
|
||
onZoomIn: () => void;
|
||
onZoomOut: () => void;
|
||
onZoomReset: () => void;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: "request-image"): void;
|
||
}>();
|
||
|
||
const fileInput = ref<HTMLInputElement | null>(null);
|
||
const fillValue = ref(props.activeFill ?? "#111827");
|
||
const strokeValue = ref(props.activeStroke ?? "#3b82f6");
|
||
const zoomSliderValue = ref(Math.round(props.zoom * 100));
|
||
|
||
watch(
|
||
() => props.activeFill,
|
||
(next) => {
|
||
fillValue.value = next ?? "#111827";
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.activeStroke,
|
||
(next) => {
|
||
strokeValue.value = next ?? "#3b82f6";
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.zoom,
|
||
(next) => {
|
||
zoomSliderValue.value = Math.round(next * 100);
|
||
}
|
||
);
|
||
|
||
const openFilePicker = () => {
|
||
if (!fileInput.value) {
|
||
return;
|
||
}
|
||
fileInput.value.value = "";
|
||
fileInput.value.click();
|
||
};
|
||
|
||
const handleFileChange = async (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const files = input.files;
|
||
if (!files || !files.length) {
|
||
return;
|
||
}
|
||
const [file] = files;
|
||
if (!file) {
|
||
return;
|
||
}
|
||
await props.onImportImage(file);
|
||
};
|
||
|
||
const stylingDisabled = computed(() => !props.canStyleSelection);
|
||
|
||
const handleFillChange = (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const value = input.value;
|
||
fillValue.value = value;
|
||
props.onFillChange(value);
|
||
};
|
||
|
||
const handleStrokeChange = (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const value = input.value;
|
||
strokeValue.value = value;
|
||
props.onStrokeChange(value);
|
||
};
|
||
|
||
const handleZoomInput = (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const value = Number(input.value);
|
||
if (Number.isNaN(value)) {
|
||
return;
|
||
}
|
||
zoomSliderValue.value = value;
|
||
props.onZoomChange(value / 100);
|
||
};
|
||
|
||
const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
|
||
</script>
|
||
|
||
<template>
|
||
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/30">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||
Canvas Tools
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
class="rounded-lg border border-red-500/40 px-3 py-1 text-xs font-medium text-red-300 transition hover:bg-red-500/10"
|
||
@click="props.onClear"
|
||
>
|
||
Clear Canvas
|
||
</button>
|
||
</div>
|
||
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<button
|
||
type="button"
|
||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||
@click="props.onAddText"
|
||
>
|
||
<span class="mb-2 text-3xl font-semibold">T</span>
|
||
<span class="text-xs uppercase tracking-wide">Add Text</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||
@click="props.onAddCircle"
|
||
>
|
||
<span class="mb-2 text-3xl font-semibold">●</span>
|
||
<span class="text-xs uppercase tracking-wide">Add Circle</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||
@click="props.onAddRectangle"
|
||
>
|
||
<span class="mb-2 text-3xl font-semibold">▭</span>
|
||
<span class="text-xs uppercase tracking-wide">Add Rectangle</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||
@click="openFilePicker"
|
||
>
|
||
<span class="mb-2 text-3xl font-semibold">⇪</span>
|
||
<span class="text-xs uppercase tracking-wide">Upload Image</span>
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||
class="hidden"
|
||
@change="handleFileChange"
|
||
/>
|
||
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||
Styling
|
||
</h4>
|
||
<span
|
||
v-if="stylingDisabled"
|
||
class="text-[11px] font-medium uppercase tracking-wide text-slate-500"
|
||
>
|
||
Select an object
|
||
</span>
|
||
</div>
|
||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||
Fill
|
||
<input
|
||
type="color"
|
||
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
|
||
:disabled="stylingDisabled"
|
||
:value="fillValue"
|
||
@input="handleFillChange"
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||
Stroke
|
||
<input
|
||
type="color"
|
||
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
|
||
:disabled="stylingDisabled"
|
||
:value="strokeValue"
|
||
@input="handleStrokeChange"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||
View
|
||
</h4>
|
||
<span class="text-[11px] font-medium uppercase tracking-wide text-slate-300">
|
||
{{ zoomLabel }}
|
||
</span>
|
||
</div>
|
||
<div class="mt-4 flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
|
||
@click="props.onZoomOut"
|
||
>
|
||
–
|
||
</button>
|
||
<input
|
||
type="range"
|
||
class="flex-1 accent-sky-500"
|
||
:min="Math.round(props.minZoom * 100)"
|
||
:max="Math.round(props.maxZoom * 100)"
|
||
step="5"
|
||
:value="zoomSliderValue"
|
||
@input="handleZoomInput"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
|
||
@click="props.onZoomIn"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="mt-3 w-full rounded-lg border border-slate-700/60 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||
@click="props.onZoomReset"
|
||
>
|
||
Reset Zoom
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|