From d6f2a35a8d2f32ebf8c899a13f60c3fe89b24143 Mon Sep 17 00:00:00 2001 From: CodeWithDennis Date: Mon, 27 Jul 2026 22:07:15 +0200 Subject: [PATCH 1/9] feat: add overlay layer, per-side backgrounds, and collapsible sidebars Ship pattern-only overlays with optional A/B layers, dual-side backgrounds, and scrollable collapsible sidebar groups. --- README.md | 4 +- resources/css/app.css | 57 ++++++ resources/js/app.js | 347 +++++++++++++++++++++++++++++++++- resources/js/compositor.js | 192 ++++++++++++++++--- resources/js/patterns.js | 131 +++++++++---- resources/views/app.blade.php | 225 ++++++++++++++++++---- 6 files changed, 849 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 068cdee..14d7e74 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ Local builds are unsigned unless you configure Apple or Windows code signing. Pr - Split edge styles (vertical / horizontal / diagonal): straight, wavy, zigzag, scallop, soft, torn, pixel (with density) - Split position, angle, padding, border radius, and optional shadow on overlap - Theme (light / dark / auto), side labels, badge, and swap sides -- Background patterns with custom colors and density +- Background patterns with custom colors and density (optional per-side A/B) +- Overlay pattern marks only (dots, grid, and more) with color, opacity, and density — no base fill; optional per-side A/B +- Collapsible sidebar groups with scrollable panels - Save / load settings presets (images are not stored) - Export PNG or JPG at optional scale (1×, 1.5×, 2×), or transition video (wipe, dissolve, push, iris, reveal → MP4 / WebM) diff --git a/resources/css/app.css b/resources/css/app.css index cbf1e6b..58ffa49 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -70,6 +70,8 @@ @layer base { html { + height: 100%; + overflow: hidden; font-size: 110%; color-scheme: light; } @@ -189,4 +191,59 @@ border: none; border-radius: calc(var(--radius-subtle) - 1px); } + + .sidebar-scroll { + flex: 1 1 0%; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + display: flex; + flex-direction: column; + -webkit-overflow-scrolling: touch; + } + + .sidebar-group { + flex-shrink: 0; + border-bottom: 1px solid var(--color-lumis-panel-line); + background: var(--color-lumis-panel-surface); + } + + .sidebar-group:last-child { + border-bottom: 0; + } + + .sidebar-group.is-collapsed { + background: var(--color-lumis-segment-idle); + } + + .sidebar-group.is-collapsed .sidebar-group-toggle { + color: color-mix(in srgb, var(--color-lumis-ink) 72%, transparent); + } + + .sidebar-group-toggle { + display: flex; + width: 100%; + cursor: pointer; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + text-align: left; + border-radius: 0; + background: transparent; + } + + .sidebar-group-toggle:hover { + background: color-mix(in srgb, var(--color-lumis-ink) 4%, transparent); + } + + .dark .sidebar-group-toggle:hover { + background: color-mix(in srgb, #ffffff 8%, transparent); + } + + .sidebar-group-body { + border-top: 1px solid var(--color-lumis-panel-line); + padding: 0.75rem; + background: var(--color-lumis-panel-surface); + } } diff --git a/resources/js/app.js b/resources/js/app.js index 0a67a2c..f3571fb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,6 +3,16 @@ import { createCompositor } from './compositor.js'; const PRESETS_STORAGE_KEY = 'pairframe.presets'; const THEME_STORAGE_KEY = 'pairframe.theme'; +const PANELS_STORAGE_KEY = 'pairframe.panels.v2'; +const DEFAULT_PANELS = { + layout: true, + split: true, + adjust: false, + labels: false, + background: true, + overlay: false, + export: true, +}; document.addEventListener('alpine:init', () => { Alpine.data('pairframe', () => ({ @@ -50,6 +60,23 @@ document.addEventListener('alpine:init', () => { backgroundBg: '#FAFAFA', backgroundFg: '#E5E5E5', backgroundDensity: 24, + backgroundPerSide: false, + backgroundEditSide: 'a', + backgroundBType: 'dots', + backgroundBBg: '#FAFAFA', + backgroundBFg: '#E5E5E5', + backgroundBDensity: 24, + + overlayType: 'none', + overlayColor: '#171717', + overlayOpacity: 25, + overlayDensity: 24, + overlayPerSide: false, + overlayEditSide: 'a', + overlayBType: 'none', + overlayBColor: '#171717', + overlayBOpacity: 25, + overlayBDensity: 24, exportScale: '1', exportFormat: 'png', @@ -85,6 +112,8 @@ document.addEventListener('alpine:init', () => { { id: 'reveal', label: 'Reveal' }, ], + panelOpen: { ...DEFAULT_PANELS }, + presets: [], presetName: '', activePresetId: null, @@ -134,6 +163,16 @@ document.addEventListener('alpine:init', () => { { id: 'noise', label: 'Noise' }, ], + overlayOptions: [ + { id: 'none', label: 'None' }, + { id: 'dots', label: 'Dots' }, + { id: 'grid', label: 'Grid' }, + { id: 'stripes', label: 'Stripes' }, + { id: 'diagonal', label: 'Diagonal' }, + { id: 'chevron', label: 'Chevron' }, + { id: 'noise', label: 'Noise' }, + ], + init() { this.compositor = createCompositor(); const storedTheme = localStorage.getItem(THEME_STORAGE_KEY); @@ -146,6 +185,7 @@ document.addEventListener('alpine:init', () => { } }; this._themeMedia.addEventListener('change', this._onThemeMedia); + this.loadPanels(); this.loadPresets(); this.$watch( () => [ @@ -172,6 +212,20 @@ document.addEventListener('alpine:init', () => { this.backgroundBg, this.backgroundFg, this.backgroundDensity, + this.backgroundPerSide, + this.backgroundBType, + this.backgroundBBg, + this.backgroundBFg, + this.backgroundBDensity, + this.overlayType, + this.overlayColor, + this.overlayOpacity, + this.overlayDensity, + this.overlayPerSide, + this.overlayBType, + this.overlayBColor, + this.overlayBOpacity, + this.overlayBDensity, this.baseWidth, this.baseHeight, this.imageA, @@ -285,6 +339,31 @@ document.addEventListener('alpine:init', () => { this.diagonalStyle = id; }, + loadPanels() { + try { + const raw = localStorage.getItem(PANELS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + if (!parsed || typeof parsed !== 'object') { + return; + } + for (const key of Object.keys(DEFAULT_PANELS)) { + if (typeof parsed[key] === 'boolean') { + this.panelOpen[key] = parsed[key]; + } + } + } catch { + // keep defaults + } + }, + + togglePanel(id) { + if (!Object.hasOwn(this.panelOpen, id)) { + return; + } + this.panelOpen[id] = !this.panelOpen[id]; + localStorage.setItem(PANELS_STORAGE_KEY, JSON.stringify(this.panelOpen)); + }, + get showsOverlapOffset() { return this.layout === 'overlap' && this.overlapVariant !== 'side'; }, @@ -395,6 +474,20 @@ document.addEventListener('alpine:init', () => { backgroundBg: this.backgroundBg, backgroundFg: this.backgroundFg, backgroundDensity: this.backgroundDensity, + backgroundPerSide: this.backgroundPerSide, + backgroundBType: this.backgroundBType, + backgroundBBg: this.backgroundBBg, + backgroundBFg: this.backgroundBFg, + backgroundBDensity: this.backgroundBDensity, + overlayType: this.overlayType, + overlayColor: this.overlayColor, + overlayOpacity: this.overlayOpacity, + overlayDensity: this.overlayDensity, + overlayPerSide: this.overlayPerSide, + overlayBType: this.overlayBType, + overlayBColor: this.overlayBColor, + overlayBOpacity: this.overlayBOpacity, + overlayBDensity: this.overlayBDensity, exportScale: this.exportScale, exportFormat: this.exportFormat, jpgQuality: this.jpgQuality, @@ -415,6 +508,7 @@ document.addEventListener('alpine:init', () => { const styles = new Set(this.splitStyles.map((item) => item.id)); const overlaps = new Set(this.overlapVariants.map((item) => item.id)); const backgrounds = new Set(this.backgroundOptions.map((item) => item.id)); + const overlays = new Set(this.overlayOptions.map((item) => item.id)); if (layouts.has(settings.layout)) { this.layout = settings.layout; @@ -488,6 +582,64 @@ document.addEventListener('alpine:init', () => { if (Number.isFinite(Number(settings.backgroundDensity))) { this.backgroundDensity = Math.min(80, Math.max(8, Number(settings.backgroundDensity))); } + this.backgroundPerSide = Boolean(settings.backgroundPerSide); + if (backgrounds.has(settings.backgroundBType)) { + this.backgroundBType = settings.backgroundBType; + } else if (this.backgroundPerSide) { + this.backgroundBType = this.backgroundType; + this.backgroundBBg = this.backgroundBg; + this.backgroundBFg = this.backgroundFg; + this.backgroundBDensity = this.backgroundDensity; + } + if (typeof settings.backgroundBBg === 'string') { + this.backgroundBBg = settings.backgroundBBg; + } + if (typeof settings.backgroundBFg === 'string') { + this.backgroundBFg = settings.backgroundBFg; + } + if (Number.isFinite(Number(settings.backgroundBDensity))) { + this.backgroundBDensity = Math.min(80, Math.max(8, Number(settings.backgroundBDensity))); + } + if (!this.backgroundPerSide) { + this.backgroundEditSide = 'a'; + } + if (settings.overlayType === 'solid') { + this.overlayType = 'none'; + } else if (overlays.has(settings.overlayType)) { + this.overlayType = settings.overlayType; + } + if (typeof settings.overlayColor === 'string') { + this.overlayColor = settings.overlayColor; + } + if (Number.isFinite(Number(settings.overlayOpacity))) { + this.overlayOpacity = Math.min(80, Math.max(5, Number(settings.overlayOpacity))); + } + if (Number.isFinite(Number(settings.overlayDensity))) { + this.overlayDensity = Math.min(80, Math.max(8, Number(settings.overlayDensity))); + } + this.overlayPerSide = Boolean(settings.overlayPerSide); + if (settings.overlayBType === 'solid') { + this.overlayBType = 'none'; + } else if (overlays.has(settings.overlayBType)) { + this.overlayBType = settings.overlayBType; + } else if (this.overlayPerSide) { + this.overlayBType = this.overlayType; + this.overlayBColor = this.overlayColor; + this.overlayBOpacity = this.overlayOpacity; + this.overlayBDensity = this.overlayDensity; + } + if (typeof settings.overlayBColor === 'string') { + this.overlayBColor = settings.overlayBColor; + } + if (Number.isFinite(Number(settings.overlayBOpacity))) { + this.overlayBOpacity = Math.min(80, Math.max(5, Number(settings.overlayBOpacity))); + } + if (Number.isFinite(Number(settings.overlayBDensity))) { + this.overlayBDensity = Math.min(80, Math.max(8, Number(settings.overlayBDensity))); + } + if (!this.overlayPerSide) { + this.overlayEditSide = 'a'; + } if (['1', '1.5', '2'].includes(String(settings.exportScale))) { this.exportScale = String(settings.exportScale); } @@ -590,7 +742,20 @@ document.addEventListener('alpine:init', () => { parts.push('labels'); } if (settings.backgroundType) { - parts.push(String(settings.backgroundType)); + parts.push( + settings.backgroundPerSide && settings.backgroundBType + ? `${settings.backgroundType}/${settings.backgroundBType}` + : String(settings.backgroundType), + ); + } + if (settings.overlayPerSide) { + const a = settings.overlayType && settings.overlayType !== 'none' ? settings.overlayType : 'none'; + const b = settings.overlayBType && settings.overlayBType !== 'none' ? settings.overlayBType : 'none'; + if (a !== 'none' || b !== 'none') { + parts.push(`overlay ${a}/${b}`); + } + } else if (settings.overlayType && settings.overlayType !== 'none') { + parts.push(`overlay ${settings.overlayType}`); } return parts.join(' · '); }, @@ -893,9 +1058,189 @@ document.addEventListener('alpine:init', () => { fg: this.backgroundFg, density: Number(this.backgroundDensity) || 24, }, + backgroundB: this.backgroundPerSide + ? { + type: this.backgroundBType, + bg: this.backgroundBBg, + fg: this.backgroundBFg, + density: Number(this.backgroundBDensity) || 24, + } + : null, + overlay: { + type: this.overlayType, + color: this.overlayColor, + opacity: Number(this.overlayOpacity) || 25, + density: Number(this.overlayDensity) || 24, + }, + overlayB: this.overlayPerSide + ? { + type: this.overlayBType, + color: this.overlayBColor, + opacity: Number(this.overlayBOpacity) || 25, + density: Number(this.overlayBDensity) || 24, + } + : null, }; }, + get backgroundSummary() { + if (this.backgroundPerSide) { + return `${this.backgroundType} / ${this.backgroundBType}`; + } + return this.backgroundType; + }, + + get overlaySummary() { + if (this.overlayPerSide) { + return `${this.overlayType} / ${this.overlayBType}`; + } + return this.overlayType; + }, + + get editingBackgroundB() { + return this.backgroundPerSide && this.backgroundEditSide === 'b'; + }, + + get editingOverlayB() { + return this.overlayPerSide && this.overlayEditSide === 'b'; + }, + + get activeBackgroundType() { + return this.editingBackgroundB ? this.backgroundBType : this.backgroundType; + }, + + set activeBackgroundType(value) { + if (this.editingBackgroundB) { + this.backgroundBType = value; + } else { + this.backgroundType = value; + } + }, + + get activeBackgroundBg() { + return this.editingBackgroundB ? this.backgroundBBg : this.backgroundBg; + }, + + set activeBackgroundBg(value) { + if (this.editingBackgroundB) { + this.backgroundBBg = value; + } else { + this.backgroundBg = value; + } + }, + + get activeBackgroundFg() { + return this.editingBackgroundB ? this.backgroundBFg : this.backgroundFg; + }, + + set activeBackgroundFg(value) { + if (this.editingBackgroundB) { + this.backgroundBFg = value; + } else { + this.backgroundFg = value; + } + }, + + get activeBackgroundDensity() { + return this.editingBackgroundB ? this.backgroundBDensity : this.backgroundDensity; + }, + + set activeBackgroundDensity(value) { + if (this.editingBackgroundB) { + this.backgroundBDensity = value; + } else { + this.backgroundDensity = value; + } + }, + + get activeOverlayType() { + return this.editingOverlayB ? this.overlayBType : this.overlayType; + }, + + set activeOverlayType(value) { + if (this.editingOverlayB) { + this.overlayBType = value; + } else { + this.overlayType = value; + } + }, + + get activeOverlayColor() { + return this.editingOverlayB ? this.overlayBColor : this.overlayColor; + }, + + set activeOverlayColor(value) { + if (this.editingOverlayB) { + this.overlayBColor = value; + } else { + this.overlayColor = value; + } + }, + + get activeOverlayOpacity() { + return this.editingOverlayB ? this.overlayBOpacity : this.overlayOpacity; + }, + + set activeOverlayOpacity(value) { + if (this.editingOverlayB) { + this.overlayBOpacity = value; + } else { + this.overlayOpacity = value; + } + }, + + get activeOverlayDensity() { + return this.editingOverlayB ? this.overlayBDensity : this.overlayDensity; + }, + + set activeOverlayDensity(value) { + if (this.editingOverlayB) { + this.overlayBDensity = value; + } else { + this.overlayDensity = value; + } + }, + + setBackgroundPerSide(enabled) { + const on = Boolean(enabled); + if (on && !this.backgroundPerSide) { + this.backgroundBType = this.backgroundType; + this.backgroundBBg = this.backgroundBg; + this.backgroundBFg = this.backgroundFg; + this.backgroundBDensity = this.backgroundDensity; + } + this.backgroundPerSide = on; + if (!on) { + this.backgroundEditSide = 'a'; + } + }, + + setOverlayPerSide(enabled) { + const on = Boolean(enabled); + if (on && !this.overlayPerSide) { + this.overlayBType = this.overlayType; + this.overlayBColor = this.overlayColor; + this.overlayBOpacity = this.overlayOpacity; + this.overlayBDensity = this.overlayDensity; + } + this.overlayPerSide = on; + if (!on) { + this.overlayEditSide = 'a'; + } + }, + + clampActiveBackgroundDensity() { + this.clampSlider(this.editingBackgroundB ? 'backgroundBDensity' : 'backgroundDensity', 8, 80); + }, + + clampActiveOverlayOpacity() { + this.clampSlider(this.editingOverlayB ? 'overlayBOpacity' : 'overlayOpacity', 5, 80); + }, + + clampActiveOverlayDensity() { + this.clampSlider(this.editingOverlayB ? 'overlayBDensity' : 'overlayDensity', 8, 80); + }, + render() { if (!this.compositor) { return; diff --git a/resources/js/compositor.js b/resources/js/compositor.js index ac3a044..fdec7e4 100644 --- a/resources/js/compositor.js +++ b/resources/js/compositor.js @@ -1,4 +1,4 @@ -import { paintBackground, paintSplitMask } from './patterns.js'; +import { paintBackground, paintOverlay, paintSplitMask } from './patterns.js'; function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); @@ -85,12 +85,17 @@ export function createCompositor() { const frameACtx = frameACanvas.getContext('2d', { willReadFrequently: false }); const frameBCanvas = document.createElement('canvas'); const frameBCtx = frameBCanvas.getContext('2d', { willReadFrequently: false }); + const overlayCanvas = document.createElement('canvas'); + const overlayCtx = overlayCanvas.getContext('2d', { willReadFrequently: false }); + const bgBCanvas = document.createElement('canvas'); + const bgBCtx = bgBCanvas.getContext('2d', { willReadFrequently: false }); let lastOptions = null; let bgKey = ''; let sideAKey = ''; let sideBKey = ''; let maskKey = ''; + let overlayKey = ''; let transitionFrameKey = ''; function resolveSides(options) { @@ -102,26 +107,158 @@ export function createCompositor() { return { sideA: light, sideB: dark }; } - function blitBackground(targetCtx, width, height, background) { + function backgroundLayerKey(layer) { + const bg = layer || {}; + return [bg.type || 'solid', bg.bg || '', bg.fg || '', Number(bg.density) || 0].join(':'); + } + + function overlayLayerKey(layer) { + const ov = layer || {}; + return [ov.type || 'none', ov.color || '', Number(ov.opacity) || 0, Number(ov.density) || 0].join(':'); + } + + function splitMaskOptions(options) { + return { + layoutFamily: options.layoutFamily || options.layout, + layoutVariant: options.layoutVariant, + splitPosition: options.splitPosition, + softEdge: options.softEdge, + flipDirection: options.flipDirection, + invertMask: options.invertMask, + maskDensity: options.maskDensity, + diagonalAngle: options.diagonalAngle, + }; + } + + function ensureLayerSideBMask(width, height, options) { + const family = options.layoutFamily || options.layout; + if (family === 'overlap') { + const flip = Boolean(options.flipDirection) || Boolean(options.swapSides); + const key = `overlap-half|${width}|${height}|${flip ? 1 : 0}`; + if (maskKey === key && maskCanvas.width === width && maskCanvas.height === height) { + return maskCanvas; + } + ensureCanvasSize(maskCanvas, width, height); + maskCtx.clearRect(0, 0, width, height); + maskCtx.fillStyle = '#ffffff'; + if (flip) { + maskCtx.fillRect(0, 0, Math.ceil(width / 2), height); + } else { + maskCtx.fillRect(Math.floor(width / 2), 0, Math.ceil(width / 2), height); + } + maskKey = key; + return maskCanvas; + } + + ensureMask(width, height, splitMaskOptions(options)); + return maskCanvas; + } + + function blitBackground(targetCtx, width, height, background, backgroundB, options) { const bg = background || { type: 'solid', bg: '#FAFAFA' }; - const key = [ - width, - height, - bg.type || 'solid', - bg.bg || '', - bg.fg || '', - Number(bg.density) || 0, - ].join('|'); + const hasB = Boolean(backgroundB && typeof backgroundB === 'object'); + const maskPart = hasB + ? [ + options?.layoutFamily || options?.layout || '', + options?.layoutVariant || '', + Number(options?.splitPosition) || 0, + Number(options?.softEdge) || 0, + Boolean(options?.flipDirection) ? 1 : 0, + Boolean(options?.invertMask) ? 1 : 0, + Boolean(options?.swapSides) ? 1 : 0, + Number(options?.maskDensity) || 0, + Number(options?.diagonalAngle) || 0, + ].join('|') + : ''; + const key = [width, height, backgroundLayerKey(bg), hasB ? backgroundLayerKey(backgroundB) : '', maskPart].join( + '|', + ); if (bgKey !== key) { ensureCanvasSize(bgCanvas, width, height); paintBackground(bgCtx, width, height, bg); + + if (hasB) { + ensureCanvasSize(bgBCanvas, width, height); + paintBackground(bgBCtx, width, height, backgroundB); + const mask = ensureLayerSideBMask(width, height, options || {}); + bgBCtx.globalCompositeOperation = 'destination-in'; + bgBCtx.drawImage(mask, 0, 0); + bgBCtx.globalCompositeOperation = 'source-over'; + bgCtx.drawImage(bgBCanvas, 0, 0); + } + bgKey = key; } targetCtx.drawImage(bgCanvas, 0, 0); } + function blitOverlay(targetCtx, width, height, overlay, overlayB, options) { + const layerA = overlay || { type: 'none' }; + const layerB = overlayB && typeof overlayB === 'object' ? overlayB : null; + const typeA = layerA.type || 'none'; + const typeB = layerB ? layerB.type || 'none' : 'none'; + const hasB = Boolean(layerB); + const activeA = typeA && typeA !== 'none'; + const activeB = hasB && typeB && typeB !== 'none'; + + if (!activeA && !activeB) { + return; + } + + if (!hasB) { + const key = [width, height, overlayLayerKey(layerA)].join('|'); + if (overlayKey !== key) { + clearCanvas(overlayCtx, overlayCanvas, width, height); + paintOverlay(overlayCtx, width, height, layerA); + overlayKey = key; + } + targetCtx.drawImage(overlayCanvas, 0, 0); + return; + } + + const maskPart = [ + options?.layoutFamily || options?.layout || '', + options?.layoutVariant || '', + Number(options?.splitPosition) || 0, + Number(options?.softEdge) || 0, + Boolean(options?.flipDirection) ? 1 : 0, + Boolean(options?.invertMask) ? 1 : 0, + Boolean(options?.swapSides) ? 1 : 0, + Number(options?.maskDensity) || 0, + Number(options?.diagonalAngle) || 0, + ].join('|'); + const key = [width, height, overlayLayerKey(layerA), overlayLayerKey(layerB), maskPart].join('|'); + + if (overlayKey !== key) { + clearCanvas(overlayCtx, overlayCanvas, width, height); + const mask = ensureLayerSideBMask(width, height, options || {}); + + if (activeA) { + clearCanvas(sideCtx, sideCanvas, width, height); + paintOverlay(sideCtx, width, height, layerA); + sideCtx.globalCompositeOperation = 'destination-out'; + sideCtx.drawImage(mask, 0, 0); + sideCtx.globalCompositeOperation = 'source-over'; + overlayCtx.drawImage(sideCanvas, 0, 0); + } + + if (activeB) { + clearCanvas(sideCtx, sideCanvas, width, height); + paintOverlay(sideCtx, width, height, layerB); + sideCtx.globalCompositeOperation = 'destination-in'; + sideCtx.drawImage(mask, 0, 0); + sideCtx.globalCompositeOperation = 'source-over'; + overlayCtx.drawImage(sideCanvas, 0, 0); + } + + overlayKey = key; + } + + targetCtx.drawImage(overlayCanvas, 0, 0); + } + function imageCacheKey(image, width, height, fitMode) { if (!image) { return `empty|${width}|${height}|${fitMode}`; @@ -171,7 +308,7 @@ export function createCompositor() { } function renderOverlap(options, width, height, sideA, sideB) { - blitBackground(ctx, width, height, options.background); + blitBackground(ctx, width, height, options.background, options.backgroundB, options); const variant = options.layoutVariant || 'cards'; const offsetXRaw = Number(options.overlapOffsetX); @@ -272,7 +409,7 @@ export function createCompositor() { } function renderSplit(options, width, height, sideA, sideB) { - blitBackground(ctx, width, height, options.background); + blitBackground(ctx, width, height, options.background, options.backgroundB, options); const content = contentPadding(width, height, options); const radiusPct = clamp(Number(options.imageRadius) || 0, 0, 50) / 100; @@ -298,17 +435,7 @@ export function createCompositor() { fitMode, ); - const maskOptions = { - layoutFamily: options.layoutFamily || options.layout, - layoutVariant: options.layoutVariant, - splitPosition: options.splitPosition, - softEdge: options.softEdge, - flipDirection: options.flipDirection, - invertMask: options.invertMask, - maskDensity: options.maskDensity, - diagonalAngle: options.diagonalAngle, - }; - ensureMask(content.width, content.height, maskOptions); + ensureMask(content.width, content.height, splitMaskOptions(options)); ctx.save(); if (radius > 0) { @@ -340,7 +467,14 @@ export function createCompositor() { const { sideA, sideB } = resolveSides(options); if (!sideA && !sideB) { - blitBackground(ctx, width, height, options.background || { type: 'solid', bg: '#FAFAFA' }); + blitBackground( + ctx, + width, + height, + options.background || { type: 'solid', bg: '#FAFAFA' }, + options.backgroundB, + options, + ); ctx.fillStyle = '#A3A3A3'; ctx.font = '500 18px Inter, sans-serif'; ctx.textAlign = 'center'; @@ -355,6 +489,7 @@ export function createCompositor() { renderSplit(options, width, height, sideA || sideB, sideB || sideA); } + blitOverlay(ctx, width, height, options.overlay, options.overlayB, options); paintLabels(ctx, width, height, options); return canvas; @@ -493,7 +628,6 @@ export function createCompositor() { } function transitionFramesCacheKey(options, width, height) { - const bg = options.background || {}; const light = options.lightImage; const dark = options.darkImage; return [ @@ -509,10 +643,10 @@ export function createCompositor() { Number(options.imagePadding) || 0, Number(options.imageRadius) || 0, options.fitMode || 'cover', - bg.type || '', - bg.bg || '', - bg.fg || '', - Number(bg.density) || 0, + backgroundLayerKey(options.background), + options.backgroundB ? backgroundLayerKey(options.backgroundB) : '', + overlayLayerKey(options.overlay), + options.overlayB ? overlayLayerKey(options.overlayB) : '', light?.src || '', light?.naturalWidth || light?.width || 0, light?.naturalHeight || light?.height || 0, diff --git a/resources/js/patterns.js b/resources/js/patterns.js index 4443940..8a3ca0c 100644 --- a/resources/js/patterns.js +++ b/resources/js/patterns.js @@ -22,27 +22,25 @@ function hexToRgb(hex) { }; } -export function paintBackground(ctx, width, height, options = {}) { - const type = options.type || 'solid'; - const bg = options.bg || '#FAFAFA'; - const fg = options.fg || '#E5E5E5'; - const density = clamp(Number(options.density) || 24, 4, 120); - - ctx.save(); - ctx.fillStyle = bg; - ctx.fillRect(0, 0, width, height); - - if (type === 'solid') { - ctx.restore(); - return; - } +/** Deterministic 0..1 noise so wipe frames and cached tiles do not flicker. */ +function hashNoise(n) { + let x = Math.imul(Number(n) ^ 0x9e3779b9, 0x85ebca6b); + x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35); + x = (x ^ (x >>> 16)) >>> 0; + return x / 4294967296; +} - ctx.fillStyle = fg; - ctx.strokeStyle = fg; +/** + * Draw pattern marks only (no opaque base). Used by background + overlay. + * @param {'opaque'|'grain'} noiseMode opaque blends two colors; grain uses color alpha speckles + */ +function paintPatternMarks(ctx, width, height, type, color, density, noiseMode = 'opaque', noiseBg = null) { + const step = density; + ctx.fillStyle = color; + ctx.strokeStyle = color; ctx.lineWidth = 1; if (type === 'dots') { - const step = density; const radius = Math.max(1, density * 0.12); for (let y = step / 2; y < height; y += step) { for (let x = step / 2; x < width; x += step) { @@ -51,8 +49,10 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.fill(); } } - } else if (type === 'grid') { - const step = density; + return; + } + + if (type === 'grid') { ctx.beginPath(); for (let x = 0; x <= width; x += step) { ctx.moveTo(x + 0.5, 0); @@ -63,13 +63,17 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.lineTo(width, y + 0.5); } ctx.stroke(); - } else if (type === 'stripes') { - const step = density; + return; + } + + if (type === 'stripes') { for (let x = -height; x < width; x += step) { ctx.fillRect(x, 0, Math.max(2, step * 0.45), height); } - } else if (type === 'diagonal') { - const step = density; + return; + } + + if (type === 'diagonal') { ctx.lineWidth = Math.max(1, step * 0.2); ctx.beginPath(); for (let i = -height; i < width + height; i += step) { @@ -77,8 +81,10 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.lineTo(i + height, height); } ctx.stroke(); - } else if (type === 'chevron') { - const step = density; + return; + } + + if (type === 'chevron') { const amp = step * 0.45; ctx.lineWidth = Math.max(1.5, step * 0.12); ctx.beginPath(); @@ -91,7 +97,10 @@ export function paintBackground(ctx, width, height, options = {}) { } } ctx.stroke(); - } else if (type === 'noise') { + return; + } + + if (type === 'noise') { const tileSize = 128; const tile = document.createElement('canvas'); tile.width = tileSize; @@ -99,16 +108,27 @@ export function paintBackground(ctx, width, height, options = {}) { const tctx = tile.getContext('2d'); const image = tctx.createImageData(tileSize, tileSize); const data = image.data; - const bgRgb = hexToRgb(bg) || { r: 250, g: 250, b: 250 }; - const fgRgb = hexToRgb(fg) || { r: 229, g: 229, b: 229 }; + const fgRgb = hexToRgb(color) || { r: 229, g: 229, b: 229 }; const strength = clamp(density / 100, 0.08, 0.9); - for (let i = 0; i < data.length; i += 4) { - const t = Math.random() * strength; - data[i] = Math.round(bgRgb.r * (1 - t) + fgRgb.r * t); - data[i + 1] = Math.round(bgRgb.g * (1 - t) + fgRgb.g * t); - data[i + 2] = Math.round(bgRgb.b * (1 - t) + fgRgb.b * t); - data[i + 3] = 255; + + if (noiseMode === 'grain') { + for (let i = 0, p = 0; i < data.length; i += 4, p += 1) { + data[i] = fgRgb.r; + data[i + 1] = fgRgb.g; + data[i + 2] = fgRgb.b; + data[i + 3] = Math.round(hashNoise(p + 17) * strength * 255); + } + } else { + const bgRgb = hexToRgb(noiseBg) || { r: 250, g: 250, b: 250 }; + for (let i = 0, p = 0; i < data.length; i += 4, p += 1) { + const t = hashNoise(p + 3) * strength; + data[i] = Math.round(bgRgb.r * (1 - t) + fgRgb.r * t); + data[i + 1] = Math.round(bgRgb.g * (1 - t) + fgRgb.g * t); + data[i + 2] = Math.round(bgRgb.b * (1 - t) + fgRgb.b * t); + data[i + 3] = 255; + } } + tctx.putImageData(image, 0, 0); const pattern = ctx.createPattern(tile, 'repeat'); if (pattern) { @@ -116,10 +136,45 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.fillRect(0, 0, width, height); } } +} + +export function paintBackground(ctx, width, height, options = {}) { + const type = options.type || 'solid'; + const bg = options.bg || '#FAFAFA'; + const fg = options.fg || '#E5E5E5'; + const density = clamp(Number(options.density) || 24, 4, 120); + + ctx.save(); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, width, height); + + if (type !== 'solid') { + paintPatternMarks(ctx, width, height, type, fg, density, 'opaque', bg); + } ctx.restore(); } +/** + * Translucent pattern marks on top of composed images (no base fill). + * options: { type, color, opacity (0-100), density } + */ +export function paintOverlay(ctx, width, height, options = {}) { + const type = options.type || 'none'; + if (!type || type === 'none' || type === 'solid') { + return; + } + + const color = options.color || '#171717'; + const opacity = clamp((Number(options.opacity) ?? 25) / 100, 0.05, 0.8); + const density = clamp(Number(options.density) || 24, 4, 120); + + ctx.save(); + ctx.globalAlpha = opacity; + paintPatternMarks(ctx, width, height, type, color, density, 'grain'); + ctx.restore(); +} + function blurMask(maskCtx, width, height, softEdge) { if (softEdge <= 0) { return; @@ -135,14 +190,6 @@ function blurMask(maskCtx, width, height, softEdge) { maskCtx.filter = 'none'; } -/** Deterministic 0..1 noise so wipe frames do not flicker. */ -function hashNoise(n) { - let x = Math.imul(Number(n) ^ 0x9e3779b9, 0x85ebca6b); - x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35); - x = (x ^ (x >>> 16)) >>> 0; - return x / 4294967296; -} - /** * Build an alpha mask into maskCtx. * Opaque white = show side B. Transparent = keep side A. diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 74e0fea..69561be 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -86,7 +86,7 @@ } - + {{-- Shown before Vite/Alpine are ready --}}
-
+