实现滤镜子系统:六种滤镜注册、晶格化滤镜与滤镜面板

- filters/index.ts 注册模糊/亮度/对比度/饱和度/像素化/晶格化六个滤镜
- crystallize.ts 实现抖动网格 Voronoi 晶格化,WebGL 与 Canvas2D 双路径
- FilterPanel.vue 按注册表自动渲染开关与滑杆,rAF 合帧节流 applyFilters
This commit is contained in:
2026-09-04 16:03:06 +08:00
parent 95eeb69ff8
commit 9f2b14572d
3 changed files with 391 additions and 31 deletions
+162 -9
View File
@@ -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>