实现滤镜子系统:六种滤镜注册、晶格化滤镜与滤镜面板
- filters/index.ts 注册模糊/亮度/对比度/饱和度/像素化/晶格化六个滤镜 - crystallize.ts 实现抖动网格 Voronoi 晶格化,WebGL 与 Canvas2D 双路径 - FilterPanel.vue 按注册表自动渲染开关与滑杆,rAF 合帧节流 applyFilters
This commit is contained in:
@@ -1,22 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, watch } from "vue";
|
||||
import { FabricImage, filters } from "fabric";
|
||||
import type { FilterDef, FilterParam } from "@/types";
|
||||
import { FILTERS } from "@/editor/filters";
|
||||
import { useEditor } from "@/editor/useEditor";
|
||||
|
||||
/**
|
||||
* 【T3 待实现 — 滤镜面板】
|
||||
* 滤镜面板。选中图片时显示(Sidebar 已按 selectionKind === "image" 挂载)。
|
||||
*
|
||||
* 选中图片时显示。读 `@/editor/filters` 的 FILTERS 注册表,按每个 FilterDef 的
|
||||
* `params` 自动渲染滑杆;开关某个滤镜就往 `image.filters` 里增删,然后
|
||||
* `image.applyFilters()` + `canvas.requestRenderAll()`。
|
||||
*
|
||||
* 注意 `applyFilters()` 是重活,滑杆要节流,不要每个像素移动都跑一遍。
|
||||
* 遍历 FILTERS 注册表,为每个滤镜渲染一个开关 + 按 `params` 自动生成的滑杆。
|
||||
* 开关在 `image.filters` 里增删实例;参数变化先改实例再合帧重算,
|
||||
* 避免 1200×630 的图上每个 input 事件都跑一遍 `applyFilters()`。
|
||||
*/
|
||||
const { activeObject, canvas } = useEditor();
|
||||
|
||||
interface FilterState {
|
||||
enabled: boolean;
|
||||
values: Record<string, number>;
|
||||
}
|
||||
|
||||
const state = reactive<Record<string, FilterState>>({});
|
||||
|
||||
for (const def of FILTERS) {
|
||||
const values: Record<string, number> = {};
|
||||
for (const p of def.params) values[p.key] = p.default;
|
||||
state[def.id] = { enabled: false, values };
|
||||
}
|
||||
|
||||
/** 注册表 id → fabric 滤镜的 type 名(首字母大写即可覆盖当前六种)。 */
|
||||
function fabricTypeOf(def: FilterDef): string {
|
||||
return def.id.charAt(0).toUpperCase() + def.id.slice(1);
|
||||
}
|
||||
|
||||
function currentImage(): FabricImage | null {
|
||||
const obj = activeObject.value;
|
||||
return obj instanceof FabricImage ? obj : null;
|
||||
}
|
||||
|
||||
/** 从当前选中图片反推每个滤镜的启用状态与参数值。 */
|
||||
function readFromImage() {
|
||||
const image = currentImage();
|
||||
const list = image
|
||||
? (image.filters as filters.BaseFilter<string>[])
|
||||
: [];
|
||||
for (const def of FILTERS) {
|
||||
const type = fabricTypeOf(def);
|
||||
const found = list.find((f) => f.type === type);
|
||||
const st = state[def.id];
|
||||
if (found) {
|
||||
st.enabled = true;
|
||||
for (const p of def.params) {
|
||||
const v = (found as unknown as Record<string, unknown>)[p.key];
|
||||
st.values[p.key] = typeof v === "number" ? v : p.default;
|
||||
}
|
||||
} else {
|
||||
st.enabled = false;
|
||||
for (const p of def.params) st.values[p.key] = p.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `applyFilters()` 是重活:用 requestAnimationFrame 合帧,最多每帧跑一次。
|
||||
let rafId: number | null = null;
|
||||
function scheduleApply() {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const image = currentImage();
|
||||
const c = canvas.value;
|
||||
if (!image || !c) return;
|
||||
image.applyFilters();
|
||||
c.requestRenderAll();
|
||||
});
|
||||
}
|
||||
|
||||
function toggle(def: FilterDef) {
|
||||
const image = currentImage();
|
||||
if (!image) return;
|
||||
const type = fabricTypeOf(def);
|
||||
const st = state[def.id];
|
||||
st.enabled = !st.enabled;
|
||||
if (st.enabled) {
|
||||
const instance = def.build({ ...st.values }) as filters.BaseFilter<string>;
|
||||
image.filters.push(instance);
|
||||
} else {
|
||||
image.filters = image.filters.filter((f) => f.type !== type);
|
||||
}
|
||||
scheduleApply();
|
||||
}
|
||||
|
||||
function onParam(def: FilterDef, p: FilterParam, event: Event) {
|
||||
const image = currentImage();
|
||||
if (!image) return;
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
state[def.id].values[p.key] = value;
|
||||
|
||||
const type = fabricTypeOf(def);
|
||||
const instance = (
|
||||
image.filters as filters.BaseFilter<string>[]
|
||||
).find((f) => f.type === type);
|
||||
if (instance) {
|
||||
(instance as unknown as Record<string, unknown>)[p.key] = value;
|
||||
scheduleApply();
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeObject, readFromImage, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="todo">T3 待实现:模糊 / 亮度 / 对比度 / 饱和度 / 像素化 / 晶格化</p>
|
||||
<div class="filter-panel">
|
||||
<div v-for="def in FILTERS" :key="def.id" class="filter">
|
||||
<label class="row toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="state[def.id].enabled"
|
||||
@change="toggle(def)"
|
||||
/>
|
||||
<span>{{ def.label }}</span>
|
||||
</label>
|
||||
|
||||
<div v-if="state[def.id].enabled" class="params">
|
||||
<div v-for="p in def.params" :key="p.key" class="field">
|
||||
<div class="row param-head">
|
||||
<label class="param-label">{{ p.label }}</label>
|
||||
<span class="param-value">{{ state[def.id].values[p.key] }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="p.min"
|
||||
:max="p.max"
|
||||
:step="p.step"
|
||||
:value="state[def.id].values[p.key]"
|
||||
@input="onParam(def, p, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.todo {
|
||||
color: var(--fg-dim);
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.params {
|
||||
padding: 2px 0 8px 24px;
|
||||
}
|
||||
|
||||
.param-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user