// ── Filters Camera: before / after ───────────────────────────────────────
// Widget premium-published and used on the
// Filters Camera landing page inside an <iframe>
const PHOTO = "https://filterscamera.app/sample.jpg";
const MONO = "https://filterscamera.app/fonts/ibm-plex-mono-400.woff2"; // the site's tag font
const HANDLE = 44; // knob size, also the width of the strip you grab
const EDGE = 22; // how close to the border the split may travel
// ↔ drawn as a path centred on the viewBox (y 8..16 around 12), so it sits in the middle
// of the knob. The bundled text fonts have no arrow glyph.
const ARROWS = `<svg viewBox="0 0 24 24"><path d="M3 12 H21 M7 8 L3 12 L7 16 M17 8 L21 12 L17 16" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const TAG_PADDING = new Thickness(9, 4);
const TAG_LEFT = new Thickness(14, 14, 0, 0);
const TAG_RIGHT = new Thickness(0, 14, 14, 0);
// The site's CSS filter functions as colour matrices, same maths as the spec:
// saturate() mixes towards Rec.709 luminance, contrast() pivots on 0.5, brightness() scales.
function cssMatrix(CK) {
const matrix = (m) => CK.ColorFilter.MakeMatrix(m);
const saturate = (s) => matrix([
0.2126 + 0.7874 * s, 0.7152 - 0.7152 * s, 0.0722 - 0.0722 * s, 0, 0,
0.2126 - 0.2126 * s, 0.7152 + 0.2848 * s, 0.0722 - 0.0722 * s, 0, 0,
0.2126 - 0.2126 * s, 0.7152 - 0.7152 * s, 0.0722 + 0.9278 * s, 0, 0,
0, 0, 0, 1, 0]);
const contrast = (c) => matrix([c, 0, 0, 0, 0.5 - 0.5 * c, 0, c, 0, 0, 0.5 - 0.5 * c, 0, 0, c, 0, 0.5 - 0.5 * c, 0, 0, 0, 1, 0]);
const brightness = (b) => matrix([b, 0, 0, 0, 0, 0, b, 0, 0, 0, 0, 0, b, 0, 0, 0, 0, 0, 1, 0]);
// CSS applies a filter list left to right; MakeCompose(outer, inner) runs inner first
const chain = (...filters) => filters.reduce((inner, outer) => CK.ColorFilter.MakeCompose(outer, inner));
return {
before: chain(saturate(0.68), contrast(0.9), brightness(1.02)), // .split .before
after: chain(saturate(1.65), contrast(1.12)), // .split .after
};
}
// small caption in a top corner
function Tag({ text, right, ink, font }) {
return (
<SkiaShape Type="Rectangle" CornerRadius={5} BackgroundColor="#8C080808" UseCache="Image"
Padding={TAG_PADDING} ZIndex={5}
HorizontalOptions={right ? "End" : "Start"} VerticalOptions="Start"
Margin={right ? TAG_RIGHT : TAG_LEFT}>
{/* the site spaces these letters by .16em: CharacterSpacing is not ported to DrawnUi.React yet */}
<SkiaLabel Text={text} FontFamily={font} FontSize={9} TextColor={ink} />
</SkiaShape>
);
}
// hold the embed's "ready" message until the photos are on screen (released in App)
Fiddle.HoldReady();
export default function App() {
const stage = useRef(null); // the whole canvas: its width bounds the split
const reveal = useRef(null); // clipped window onto AFTER, slid to the split
const after = useRef(null); // the graded copy, slid back so its pixels stay put
const divider = useRef(null); // line + knob
const ratio = useRef(0.5); // split as a fraction of the width, so a resize keeps it in place
const [font, setFont] = useState("FontText");
// The page that embeds this keeps its static poster until DrawFiddle posts "ready": held at
// module load (above App), released once both photos have loaded, so the reveal never shows
// the canvas without its picture.
const loaded = useRef(0);
const onImage = () => { if (++loaded.current === 2) Fiddle.Ready(); };
// Colour filters, not shaders: one matrix per pixel at draw time, as cheap as the blit itself.
const filters = useMemo(() => cssMatrix(Core.Super.CK), []);
// The tag font is the site's own woff2, registered once under its own alias; the tags
// re-measure when it lands and show the default face until then.
useEffect(() => {
let alive = true;
fetch(MONO).then((r) => r.arrayBuffer()).then((data) => {
const face = Core.Super.CK.Typeface.MakeFreeTypeFaceFromData(data);
if (!face || !alive) return;
Core.Super.Fonts.set("FontMono", new Map([[400, face]]));
setFont("FontMono");
}).catch(() => {});
return () => { alive = false; };
}, []);
// DrawnUi.React has no Clipping callback yet, so the reveal is a clipped layer slid right by
// the split with the image inside slid left by the same amount: the clip travels, the pixels
// stay. Field writes + RepaintComposition: no React render and no layout pass per drag frame.
const place = (x) => {
const box = stage.current;
const wide = box ? box.DrawingRect.Width / box.RenderingScale : 0;
if (wide <= 0) return;
const split = Math.min(Math.max(x, EDGE), wide - EDGE);
ratio.current = split / wide;
reveal.current.TranslationX = split;
after.current.TranslationX = -split;
divider.current.TranslationX = split - HANDLE / 2;
reveal.current.RepaintComposition();
divider.current.RepaintComposition();
};
// Place the split once the width is known, and again only when it really changes: the hook
// runs on every arrange, and re-placing an unchanged split would request a frame whose
// arrange calls the hook again — a loop that never lets the canvas go idle.
useEffect(() => {
const box = stage.current;
const base = box.OnLayoutChanged.bind(box);
let lastWide = -1;
box.OnLayoutChanged = () => {
base();
const wide = box.DrawingRect.Width / box.RenderingScale;
if (wide === lastWide) return;
lastWide = wide;
place(ratio.current * wide);
};
box.OnLayoutChanged(); // the first layout may already be done by now
}, []);
return (
<SkiaLayer ref={stage}
AccessibilityRole={Aria.RoleButton}
HorizontalOptions = "Center"
WidthRequest = {575}
HeightRequest = {719}
BackgroundColor="#000000">
{/* BEFORE — "usual camera app": saturate(.68) contrast(.9) brightness(1.02) */}
<SkiaImage Success={onImage} Source={PHOTO} Aspect="AspectCover" AddEffect="Custom" PaintColorFilter={filters.before}
HorizontalOptions="Fill" VerticalOptions="Fill" />
{/* AFTER — "Filters Camera": saturate(1.65) contrast(1.12), revealed right of the split */}
<SkiaLayer ref={reveal} IsClippedToBounds HorizontalOptions="Fill" VerticalOptions="Fill">
<SkiaImage Success={onImage} ref={after} Source={PHOTO} Aspect="AspectCover" AddEffect="Custom" PaintColorFilter={filters.after}
HorizontalOptions="Fill" VerticalOptions="Fill" />
</SkiaLayer>
<Tag text="PHONE CAMERA" right={false} ink="#8E8A84" font={font} />
<Tag text="ROMANCE" right ink="#D9ECEAE6" font={font} />
{/* the handle: a 2px line and a round knob, the whole strip is draggable */}
<SkiaLayer ref={divider} ZIndex={4} WidthRequest={HANDLE}
HorizontalOptions="Start" VerticalOptions="Fill"
ConsumeGestures={(me, e) => {
// Down is claimed so the drag keeps arriving here even off the strip.
// Delta is pixels; the split is points.
const type = e.Args.Type;
if (type === "Down") {
e.Consumed = true;
} else if (type === "Panning") {
const box = stage.current;
place(ratio.current * box.DrawingRect.Width / box.RenderingScale + e.Args.Event.Distance.Delta.X / me.RenderingScale);
e.Consumed = true;
}
}}>
<SkiaShape Type="Rectangle" BackgroundColor="#E6ECEAE6" WidthRequest={2}
HorizontalOptions="Center" VerticalOptions="Fill" />
<SkiaShape Type="Circle" BackgroundColor="#ECEAE6" WidthRequest={HANDLE} LockRatio={1}
HorizontalOptions="Center" VerticalOptions="Center">
<SkiaSvg SvgString={ARROWS} WidthRequest={16} LockRatio={1}
HorizontalOptions="Center" VerticalOptions="Center" />
</SkiaShape>
</SkiaLayer>
</SkiaLayer>
);
}