diff --git a/components/Block.tsx b/components/Block.tsx index d58accc..9eabb4d 100644 --- a/components/Block.tsx +++ b/components/Block.tsx @@ -15,6 +15,7 @@ import { import { motion } from 'framer-motion'; import { getSocialPlatformOption, inferSocialPlatformFromUrl } from '../socialPlatforms'; import { openSafeUrl, isValidYouTubeChannelId, isValidLocationString } from '../utils/security'; +import FluidTextEffect from './FluidTextEffect'; // Apple TV style 3D tilt effect hook const useTiltEffect = (isEnabled: boolean = true) => { @@ -1117,6 +1118,15 @@ const Block: React.FC = ({ )} + ) : block.type === BlockType.FLUID_TEXT ? ( +
+ +
) : isRichYoutube ? ( /* YOUTUBE SINGLE VIDEO - Clean design with just play button */
diff --git a/components/BlockPreview.tsx b/components/BlockPreview.tsx index 361ac43..98247db 100644 --- a/components/BlockPreview.tsx +++ b/components/BlockPreview.tsx @@ -3,6 +3,7 @@ import { BlockData, BlockType } from '../types'; import { Youtube, Play, Loader2 } from 'lucide-react'; import { getSocialPlatformOption, inferSocialPlatformFromUrl } from '../socialPlatforms'; import { openSafeUrl, isValidYouTubeChannelId, isValidLocationString } from '../utils/security'; +import FluidTextEffect from './FluidTextEffect'; // Apple TV style 3D tilt effect hook const useTiltEffect = (isEnabled: boolean = true) => { @@ -465,6 +466,15 @@ const BlockPreview: React.FC = ({
)} + ) : block.type === BlockType.FLUID_TEXT ? ( +
+ +
) : isRichYoutube ? ( /* YOUTUBE SINGLE */
diff --git a/components/Builder.tsx b/components/Builder.tsx index fb4e2b8..a6d55e8 100644 --- a/components/Builder.tsx +++ b/components/Builder.tsx @@ -631,6 +631,7 @@ const Builder: React.FC = ({ onBack }) => { const getSpans = () => { if (type === BlockType.SOCIAL_ICON) return { colSpan: 1, rowSpan: 1 }; if (type === BlockType.SPACER) return { colSpan: 9, rowSpan: 1 }; + if (type === BlockType.FLUID_TEXT) return { colSpan: 6, rowSpan: 3 }; return { colSpan: 3, rowSpan: 3 }; // Regular blocks take 3x3 cells }; const { colSpan, rowSpan } = getSpans(); @@ -647,7 +648,9 @@ const Builder: React.FC = ({ onBack }) => { ? 'Location' : type === BlockType.SPACER ? 'Spacer' - : 'New Block', + : type === BlockType.FLUID_TEXT + ? 'Fluid Text' + : 'New Block', content: '', colSpan, rowSpan, @@ -656,14 +659,17 @@ const Builder: React.FC = ({ onBack }) => { ? 'bg-transparent' : type === BlockType.SOCIAL_ICON ? 'bg-gray-100' - : 'bg-white', - textColor: 'text-gray-900', + : type === BlockType.FLUID_TEXT + ? 'bg-violet-500' + : 'bg-white', + textColor: type === BlockType.FLUID_TEXT ? 'text-white' : 'text-gray-900', gridColumn: gridPosition.col, gridRow: gridPosition.row, ...(type === BlockType.SOCIAL ? { socialPlatform: 'x' as const, socialHandle: '' } : {}), ...(type === BlockType.SOCIAL_ICON ? { socialPlatform: 'instagram' as const, socialHandle: '' } : {}), + ...(type === BlockType.FLUID_TEXT ? { fluidTextFontSize: 0.33 } : {}), }; handleSetBlocks([...blocks, newBlock]); setEditingBlockId(newBlock.id); diff --git a/components/EditorSidebar.tsx b/components/EditorSidebar.tsx index 948e525..c876c59 100644 --- a/components/EditorSidebar.tsx +++ b/components/EditorSidebar.tsx @@ -20,6 +20,7 @@ import { List, Palette, CheckCircle2, + Droplets, } from 'lucide-react'; import { buildSocialUrl, @@ -307,7 +308,7 @@ const EditorSidebar: React.FC = ({ htmlFor="block-title-input" className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2" > - Title + {editingBlock.type === BlockType.FLUID_TEXT ? 'Text' : 'Title'} = ({ className="w-full bg-gray-50 border border-gray-200 rounded-xl p-3.5 focus:ring-2 focus:ring-black/5 focus:border-black focus:outline-none transition-all font-medium" value={editingBlock.title || ''} onChange={(e) => updateBlock({ ...editingBlock, title: e.target.value })} - placeholder="Label your block" + placeholder={ + editingBlock.type === BlockType.FLUID_TEXT + ? 'Enter text' + : 'Label your block' + } />
)} @@ -728,6 +733,27 @@ const EditorSidebar: React.FC = ({ )} {/* 4. CONTENT FIELDS (Standard) */} + {editingBlock.type === BlockType.FLUID_TEXT && ( +
+ + + updateBlock({ + ...editingBlock, + fluidTextFontSize: Number(e.target.value), + }) + } + className="w-full h-2 cursor-pointer appearance-none rounded-lg bg-gray-200" + /> +
+ )} {(editingBlock.type === BlockType.LINK || editingBlock.type === BlockType.MEDIA || editingBlock.type === BlockType.MAP) && ( @@ -913,6 +939,12 @@ const EditorSidebar: React.FC = ({ { type: BlockType.SOCIAL, label: 'Social', icon: Github, color: 'bg-violet-600' }, { type: BlockType.MEDIA, label: 'Media', icon: ImageIcon, color: 'bg-pink-600' }, { type: BlockType.TEXT, label: 'Note', icon: TypeIcon, color: 'bg-emerald-600' }, + { + type: BlockType.FLUID_TEXT, + label: 'Text Fluid Effect', + icon: Droplets, + color: 'bg-indigo-600', + }, { type: BlockType.MAP, label: 'Map', icon: MapPin, color: 'bg-amber-500' }, { type: BlockType.SPACER, diff --git a/components/FluidTextEffect.tsx b/components/FluidTextEffect.tsx new file mode 100644 index 0000000..541da69 --- /dev/null +++ b/components/FluidTextEffect.tsx @@ -0,0 +1,1952 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { BASE_COLORS } from '../constants'; + +type FluidTextEffectProps = { + text: string; + fontSize?: number; + colorClass?: string; + customBackground?: string; +}; + +type RGB = { r: number; g: number; b: number }; + +type CompileShaderFn = (type: number, source: string, keywords?: string[]) => WebGLShader; +type CreateProgramFn = (vs: WebGLShader, fs: WebGLShader) => WebGLProgram; +type GetUniformsFn = (program: WebGLProgram) => Record; + +type FBO = { + texture: WebGLTexture; + fbo: WebGLFramebuffer; + width: number; + height: number; + texelSizeX: number; + texelSizeY: number; + attach: (id: number) => number; +}; + +type DoubleFBO = { + width: number; + height: number; + texelSizeX: number; + texelSizeY: number; + read: FBO; + write: FBO; + swap: () => void; +}; + +const DEFAULT_FONT_SIZE = 0.33; +const COLOR_CACHE = new Map(); + +function clamp01(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function rgbToHex({ r, g, b }: RGB) { + const toHex = (v: number) => Math.round(v).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +function parseRgbString(input: string) { + const match = input.replace(/\s+/g, '').match(/^rgba?\((\d+),(\d+),(\d+)(?:,([0-9.]+))?\)$/i); + if (!match) return null; + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + }; +} + +function parseHexColor(input: string) { + const match = input.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!match) return null; + const value = match[1]; + if (value.length === 3) { + const r = parseInt(value[0] + value[0], 16); + const g = parseInt(value[1] + value[1], 16); + const b = parseInt(value[2] + value[2], 16); + return { r, g, b }; + } + const r = parseInt(value.slice(0, 2), 16); + const g = parseInt(value.slice(2, 4), 16); + const b = parseInt(value.slice(4, 6), 16); + return { r, g, b }; +} + +function extractFirstColor(input: string) { + if (!input) return null; + const rgbMatch = input.match(/rgba?\([^)]+\)/i); + if (rgbMatch) { + const rgb = parseRgbString(rgbMatch[0]); + if (rgb) return rgbToHex(rgb); + } + const hexMatch = input.match(/#([0-9a-f]{3}|[0-9a-f]{6})/i); + if (hexMatch) return hexMatch[0]; + return null; +} + +function resolveColorFromClass(colorClass?: string) { + if (!colorClass) return null; + if (COLOR_CACHE.has(colorClass)) return COLOR_CACHE.get(colorClass) || null; + + const el = document.createElement('div'); + el.className = `${colorClass} hidden`; + document.body.appendChild(el); + const computed = getComputedStyle(el).backgroundColor; + document.body.removeChild(el); + const rgb = parseRgbString(computed); + if (!rgb) return null; + const hex = rgbToHex(rgb); + COLOR_CACHE.set(colorClass, hex); + return hex; +} + +function resolveBaseColor(colorClass?: string, customBackground?: string) { + if (customBackground) { + const extracted = extractFirstColor(customBackground); + if (extracted) return extracted; + } + + if (colorClass) { + const base = BASE_COLORS.find((c) => c.bg === colorClass); + if (base) { + const extracted = extractFirstColor(base.hex); + if (extracted) return extracted; + } + const resolved = resolveColorFromClass(colorClass); + if (resolved) return resolved; + } + + return '#ffffff'; +} + +function getHexCSSVar(name: string) { + const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + if (!raw) return '#000000'; + const hex = extractFirstColor(raw); + if (hex) return hex; + const rgb = parseRgbString(raw); + return rgb ? rgbToHex(rgb) : '#000000'; +} + +function colorToHue(color: string) { + const rgb = parseHexColor(color); + if (!rgb) return 0; + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + if (delta === 0) return 0; + let hue = 0; + if (max === r) hue = ((g - b) / delta) % 6; + else if (max === g) hue = (b - r) / delta + 2; + else hue = (r - g) / delta + 4; + hue *= 60; + if (hue < 0) hue += 360; + return hue; +} + +class Material { + private gl: WebGL2RenderingContext; + private compileShader: CompileShaderFn; + private createProgramFn: CreateProgramFn; + private getUniformsFn: GetUniformsFn; + vertexShader: WebGLShader; + fragmentShaderSource: string; + programs: Record = {}; + activeProgram: WebGLProgram | null = null; + uniforms: Record = {}; + + constructor( + gl: WebGL2RenderingContext, + vertexShader: WebGLShader, + fragmentShaderSource: string, + compileShader: CompileShaderFn, + createProgramFn: CreateProgramFn, + getUniformsFn: GetUniformsFn + ) { + this.gl = gl; + this.compileShader = compileShader; + this.createProgramFn = createProgramFn; + this.getUniformsFn = getUniformsFn; + this.vertexShader = vertexShader; + this.fragmentShaderSource = fragmentShaderSource; + } + + setKeywords(keywords: string[]) { + let hash = 0; + for (let i = 0; i < keywords.length; i++) hash += hashCode(keywords[i]); + + let program = this.programs[hash]; + if (!program) { + const fragmentShader = this.compileShader( + this.gl.FRAGMENT_SHADER, + this.fragmentShaderSource, + keywords + ); + program = this.createProgramFn(this.vertexShader, fragmentShader); + this.programs[hash] = program; + } + + if (program === this.activeProgram) return; + + this.uniforms = this.getUniformsFn(program); + this.activeProgram = program; + } + + bind() { + this.gl.useProgram(this.activeProgram); + } +} + +class Program { + private gl: WebGL2RenderingContext; + uniforms: Record = {}; + program: WebGLProgram; + + constructor( + gl: WebGL2RenderingContext, + vertexShader: WebGLShader, + fragmentShader: WebGLShader, + createProgramFn: CreateProgramFn, + getUniformsFn: GetUniformsFn + ) { + this.gl = gl; + this.program = createProgramFn(vertexShader, fragmentShader); + this.uniforms = getUniformsFn(this.program); + } + + bind() { + this.gl.useProgram(this.program); + } +} + +function hashCode(s: string) { + if (s.length === 0) return 0; + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = (hash << 5) - hash + s.charCodeAt(i); + hash |= 0; + } + return hash; +} + +const FluidTextEffect: React.FC = ({ + text, + fontSize = DEFAULT_FONT_SIZE, + colorClass, + customBackground, +}) => { + const containerRef = useRef(null); + const fluidCanvasRef = useRef(null); + const maskCanvasRef = useRef(null); + const shadowCanvasRef = useRef(null); + const scheduleMaskDrawRef = useRef<() => void>(() => {}); + const isInitializedRef = useRef(false); + const textRef = useRef(text?.trim() || 'Text'); + const fontSizeRef = useRef( + Number.isFinite(fontSize) ? clamp01(fontSize) : DEFAULT_FONT_SIZE + ); + + const resolvedText = text?.trim() || 'Text'; + const resolvedFontSize = Number.isFinite(fontSize) ? clamp01(fontSize) : DEFAULT_FONT_SIZE; + + const isTransparent = useMemo(() => { + if (colorClass === 'bg-transparent') return true; + if (customBackground) return customBackground.toLowerCase().includes('transparent'); + return false; + }, [colorClass, customBackground]); + + useEffect(() => { + const container = containerRef.current; + const fluidCanvas = fluidCanvasRef.current; + const maskCanvas = maskCanvasRef.current; + const shadowCanvas = shadowCanvasRef.current; + if (!container || !fluidCanvas || !maskCanvas || !shadowCanvas) return; + + let animationId = 0; + let splatIntervalId: ReturnType | null = null; + let maskDrawRaf = 0; + let maskReady = false; + let resizeObserver: ResizeObserver | null = null; + let themeObserver: MutationObserver | null = null; + let handleMouseEnter: ((e: MouseEvent) => void) | null = null; + let handleMouseDown: ((e: MouseEvent) => void) | null = null; + let handleMouseMove: ((e: MouseEvent) => void) | null = null; + let handleMouseUp: (() => void) | null = null; + let handleTouchStart: ((e: TouchEvent) => void) | null = null; + let handleTouchMove: ((e: TouchEvent) => void) | null = null; + let handleTouchEnd: ((e: TouchEvent) => void) | null = null; + + const fontWeight = '900'; + const fontFamily = 'Arial'; + + function drawOverlayCanvas() { + const width = container.clientWidth; + const height = container.clientHeight; + if (width === 0 || height === 0) return; + + const dpr = window.devicePixelRatio || 1; + const isDark = document.documentElement.classList.contains('dark'); + + if (shadowCanvas && isTransparent) { + shadowCanvas.width = width * dpr; + shadowCanvas.height = height * dpr; + const shadowCtx = shadowCanvas.getContext('2d'); + if (!shadowCtx) return; + shadowCtx.setTransform(1, 0, 0, 1, 0, 0); + shadowCtx.clearRect(0, 0, shadowCanvas.width, shadowCanvas.height); + shadowCtx.scale(dpr, dpr); + + const textFontSize = Math.round(width * fontSizeRef.current); + shadowCtx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; + shadowCtx.textAlign = 'center'; + + const metrics = shadowCtx.measureText(textRef.current); + let textY = height / 2; + if ( + metrics.actualBoundingBoxAscent !== undefined && + metrics.actualBoundingBoxDescent !== undefined + ) { + shadowCtx.textBaseline = 'alphabetic'; + textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; + } else { + shadowCtx.textBaseline = 'middle'; + } + + shadowCtx.fillStyle = getHexCSSVar(isDark ? '--color-base-950' : '--color-base-200'); + shadowCtx.fillText(textRef.current, width / 2, textY); + } else if (shadowCanvas) { + shadowCanvas.width = 1; + shadowCanvas.height = 1; + } + + maskCanvas.width = width * dpr; + maskCanvas.height = height * dpr; + + const ctx = maskCanvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + ctx.scale(dpr, dpr); + + const bgColor = isTransparent + ? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50') + : 'black'; + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, width, height); + + const textFontSize = Math.round(width * fontSizeRef.current); + ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; + + ctx.lineWidth = 3; + ctx.textAlign = 'center'; + + const metrics = ctx.measureText(textRef.current); + let textY = height / 2; + if ( + metrics.actualBoundingBoxAscent !== undefined && + metrics.actualBoundingBoxDescent !== undefined + ) { + ctx.textBaseline = 'alphabetic'; + textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; + } else { + ctx.textBaseline = 'middle'; + } + + if (isTransparent) { + ctx.globalCompositeOperation = 'destination-out'; + ctx.globalAlpha = 0.7; + ctx.strokeStyle = 'white'; + ctx.strokeText(textRef.current, width / 2, textY); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + + ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'; + ctx.strokeText(textRef.current, width / 2, textY); + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.strokeText(textRef.current, width / 2, textY); + } + + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillText(textRef.current, width / 2, textY); + ctx.globalCompositeOperation = 'source-over'; + maskReady = true; + } + + function scheduleMaskDraw() { + const width = container.clientWidth; + const height = container.clientHeight; + if (width > 0 && height > 0) { + drawOverlayCanvas(); + return; + } + if (maskDrawRaf) return; + maskDrawRaf = requestAnimationFrame(() => { + maskDrawRaf = 0; + const nextWidth = container.clientWidth; + const nextHeight = container.clientHeight; + if (nextWidth === 0 || nextHeight === 0) { + scheduleMaskDraw(); + return; + } + drawOverlayCanvas(); + }); + } + + scheduleMaskDrawRef.current = scheduleMaskDraw; + + function initFluidSimulation(startHue: number, endHue: number) { + if (!fluidCanvas || !maskCanvas || !container) return; + if (fluidCanvas.clientWidth === 0 || fluidCanvas.clientHeight === 0) { + requestAnimationFrame(() => initFluidSimulation(startHue, endHue)); + return; + } + + maskReady = false; + scheduleMaskDraw(); + + const config = { + SIM_RESOLUTION: 128, + DYE_RESOLUTION: 1024, + CAPTURE_RESOLUTION: 512, + DENSITY_DISSIPATION: 1.0, + VELOCITY_DISSIPATION: 0.1, + PRESSURE: 0.8, + PRESSURE_ITERATIONS: 20, + CURL: 30, + SPLAT_RADIUS: 0.25, + SPLAT_FORCE: 1000, + SHADING: true, + COLORFUL: true, + COLOR_UPDATE_SPEED: 10, + PAUSED: false, + BACK_COLOR: { r: 0, g: 0, b: 0 }, + TRANSPARENT: isTransparent, + BLOOM: false, + BLOOM_ITERATIONS: 8, + BLOOM_RESOLUTION: 256, + BLOOM_INTENSITY: 0.8, + BLOOM_THRESHOLD: 0.8, + BLOOM_SOFT_KNEE: 0.7, + SUNRAYS: true, + SUNRAYS_RESOLUTION: 196, + SUNRAYS_WEIGHT: 1.0, + START_HUE: startHue, + END_HUE: endHue, + RENDER_SPEED: 0.4, + } as const; + + function PointerPrototype() { + return { + id: -1, + texcoordX: 0, + texcoordY: 0, + prevTexcoordX: 0, + prevTexcoordY: 0, + deltaX: 0, + deltaY: 0, + down: false, + moved: false, + color: [0, 0, 0] as [number, number, number], + }; + } + + type Pointer = ReturnType; + const pointers: Pointer[] = [PointerPrototype()]; + const splatStack: number[] = []; + + const { gl: glMaybeNull, ext } = getWebGLContext(fluidCanvas); + if (!glMaybeNull) return; + const gl = glMaybeNull; + + if (isMobile()) { + (config as any).DYE_RESOLUTION = 512; + } + if (!ext.supportLinearFiltering) { + (config as any).DYE_RESOLUTION = 512; + (config as any).SHADING = false; + (config as any).BLOOM = false; + (config as any).SUNRAYS = false; + } + + function getWebGLContext(canvas: HTMLCanvasElement) { + const params = { + alpha: true, + depth: false, + stencil: true, + antialias: false, + preserveDrawingBuffer: false, + }; + + let gl = canvas.getContext('webgl2', params) as WebGL2RenderingContext | null; + const isWebGL2 = !!gl; + if (!isWebGL2) { + gl = (canvas.getContext('webgl', params) || + canvas.getContext('experimental-webgl', params)) as WebGL2RenderingContext | null; + } + + if (!gl) return { gl: null, ext: { supportLinearFiltering: false } as any }; + + let halfFloat: any; + let supportLinearFiltering = false; + if (isWebGL2) { + gl.getExtension('EXT_color_buffer_float'); + supportLinearFiltering = !!gl.getExtension('OES_texture_float_linear'); + } else { + halfFloat = gl.getExtension('OES_texture_half_float'); + supportLinearFiltering = !!gl.getExtension('OES_texture_half_float_linear'); + } + + gl.clearColor(0.0, 0.0, 0.0, 1.0); + + let halfFloatTexType = isWebGL2 ? gl.HALF_FLOAT : halfFloat?.HALF_FLOAT_OES; + let fallbackToUnsignedByte = false; + if (!halfFloatTexType) { + halfFloatTexType = gl.UNSIGNED_BYTE; + supportLinearFiltering = true; + fallbackToUnsignedByte = true; + } + let formatRGBA: any; + let formatRG: any; + let formatR: any; + + if (isWebGL2) { + if (fallbackToUnsignedByte) { + formatRGBA = { internalFormat: gl.RGBA8, format: gl.RGBA }; + formatRG = { internalFormat: gl.RGBA8, format: gl.RGBA }; + formatR = { internalFormat: gl.RGBA8, format: gl.RGBA }; + } else { + formatRGBA = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType); + formatRG = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType); + formatR = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType); + if (!formatRGBA) formatRGBA = { internalFormat: gl.RGBA8, format: gl.RGBA }; + if (!formatRG) formatRG = { internalFormat: gl.RGBA8, format: gl.RGBA }; + if (!formatR) formatR = { internalFormat: gl.RGBA8, format: gl.RGBA }; + } + } else { + formatRGBA = { internalFormat: gl.RGBA, format: gl.RGBA }; + formatRG = { internalFormat: gl.RGBA, format: gl.RGBA }; + formatR = { internalFormat: gl.RGBA, format: gl.RGBA }; + if (!fallbackToUnsignedByte) { + formatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatRGBA; + formatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatRG; + formatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatR; + } + } + + return { + gl, + ext: { + formatRGBA, + formatRG, + formatR, + halfFloatTexType, + supportLinearFiltering, + }, + }; + } + + function getSupportedFormat( + gl: WebGL2RenderingContext, + internalFormat: number, + format: number, + type: number + ): { internalFormat: number; format: number } | null { + if (!supportRenderTextureFormat(gl, internalFormat, format, type)) { + switch (internalFormat) { + case gl.R16F: + return getSupportedFormat(gl, gl.RG16F, gl.RG, type); + case gl.RG16F: + return getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, type); + default: + return null; + } + } + return { internalFormat, format }; + } + + function supportRenderTextureFormat( + gl: WebGL2RenderingContext, + internalFormat: number, + format: number, + type: number + ) { + if (!type) return false; + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null); + + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + + const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + return status === gl.FRAMEBUFFER_COMPLETE; + } + + function isMobile() { + return /Mobi|Android/i.test(navigator.userAgent); + } + + function createWebGLProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader) { + const program = gl.createProgram()!; + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.warn(gl.getProgramInfoLog(program)); + } + + return program; + } + + function getUniforms(program: WebGLProgram) { + const uniforms: Record = {}; + const uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); + for (let i = 0; i < uniformCount; i++) { + const uniformName = gl.getActiveUniform(program, i)!.name; + uniforms[uniformName] = gl.getUniformLocation(program, uniformName); + } + return uniforms; + } + + function compileShader(type: number, source: string, keywords?: string[]) { + source = addKeywords(source, keywords); + + const shader = gl.createShader(type)!; + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.warn(gl.getShaderInfoLog(shader)); + } + + return shader; + } + + function addKeywords(source: string, keywords?: string[]) { + if (!keywords) return source; + let keywordsString = ''; + keywords.forEach((keyword) => { + keywordsString += '#define ' + keyword + '\n'; + }); + return keywordsString + source; + } + + const baseVertexShader = compileShader( + gl.VERTEX_SHADER, + ` + precision highp float; + attribute vec2 aPosition; + varying vec2 vUv; + varying vec2 vL; + varying vec2 vR; + varying vec2 vT; + varying vec2 vB; + uniform vec2 texelSize; + void main () { + vUv = aPosition * 0.5 + 0.5; + vL = vUv - vec2(texelSize.x, 0.0); + vR = vUv + vec2(texelSize.x, 0.0); + vT = vUv + vec2(0.0, texelSize.y); + vB = vUv - vec2(0.0, texelSize.y); + gl_Position = vec4(aPosition, 0.0, 1.0); + } + ` + ); + + const blurVertexShader = compileShader( + gl.VERTEX_SHADER, + ` + precision highp float; + attribute vec2 aPosition; + varying vec2 vUv; + varying vec2 vL; + varying vec2 vR; + uniform vec2 texelSize; + void main () { + vUv = aPosition * 0.5 + 0.5; + float offset = 1.33333333; + vL = vUv - texelSize * offset; + vR = vUv + texelSize * offset; + gl_Position = vec4(aPosition, 0.0, 1.0); + } + ` + ); + + const blurShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + precision mediump sampler2D; + varying vec2 vUv; + varying vec2 vL; + varying vec2 vR; + uniform sampler2D uTexture; + void main () { + vec4 sum = texture2D(uTexture, vUv) * 0.29411764; + sum += texture2D(uTexture, vL) * 0.35294117; + sum += texture2D(uTexture, vR) * 0.35294117; + gl_FragColor = sum; + } + ` + ); + + const copyShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + precision mediump sampler2D; + varying highp vec2 vUv; + uniform sampler2D uTexture; + void main () { + gl_FragColor = texture2D(uTexture, vUv); + } + ` + ); + + const clearShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + precision mediump sampler2D; + varying highp vec2 vUv; + uniform sampler2D uTexture; + uniform float value; + void main () { + gl_FragColor = value * texture2D(uTexture, vUv); + } + ` + ); + + const colorShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + uniform vec4 color; + void main () { + gl_FragColor = color; + } + ` + ); + + const displayShaderSource = ` + precision highp float; + precision highp sampler2D; + varying vec2 vUv; + varying vec2 vL; + varying vec2 vR; + varying vec2 vT; + varying vec2 vB; + uniform sampler2D uTexture; + uniform sampler2D uBloom; + uniform sampler2D uSunrays; + uniform sampler2D uDithering; + uniform vec2 ditherScale; + uniform vec2 texelSize; + vec3 linearToGamma (vec3 color) { + color = max(color, vec3(0)); + return max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0)); + } + void main () { + vec3 c = texture2D(uTexture, vUv).rgb; + #ifdef SHADING + vec3 lc = texture2D(uTexture, vL).rgb; + vec3 rc = texture2D(uTexture, vR).rgb; + vec3 tc = texture2D(uTexture, vT).rgb; + vec3 bc = texture2D(uTexture, vB).rgb; + float dx = length(rc) - length(lc); + float dy = length(tc) - length(bc); + vec3 n = normalize(vec3(dx, dy, length(texelSize))); + vec3 l = vec3(0.0, 0.0, 1.0); + float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0); + c *= diffuse; + #endif + #ifdef BLOOM + vec3 bloom = texture2D(uBloom, vUv).rgb; + #endif + #ifdef SUNRAYS + float sunrays = texture2D(uSunrays, vUv).r; + c *= sunrays; + #ifdef BLOOM + bloom *= sunrays; + #endif + #endif + #ifdef BLOOM + float noise = texture2D(uDithering, vUv * ditherScale).r; + noise = noise * 2.0 - 1.0; + bloom += noise / 255.0; + bloom = linearToGamma(bloom); + c += bloom; + #endif + float a = max(c.r, max(c.g, c.b)); + gl_FragColor = vec4(c, a); + } + `; + + const splatShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision highp float; + precision highp sampler2D; + varying vec2 vUv; + uniform sampler2D uTarget; + uniform float aspectRatio; + uniform vec3 color; + uniform vec2 point; + uniform float radius; + void main () { + vec2 p = vUv - point.xy; + p.x *= aspectRatio; + vec3 splat = exp(-dot(p, p) / radius) * color; + vec3 base = texture2D(uTarget, vUv).xyz; + gl_FragColor = vec4(base + splat, 1.0); + } + ` + ); + + const advectionShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision highp float; + precision highp sampler2D; + varying vec2 vUv; + uniform sampler2D uVelocity; + uniform sampler2D uSource; + uniform vec2 texelSize; + uniform vec2 dyeTexelSize; + uniform float dt; + uniform float dissipation; + vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) { + vec2 st = uv / tsize - 0.5; + vec2 iuv = floor(st); + vec2 fuv = fract(st); + vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize); + vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize); + vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize); + vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize); + return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y); + } + void main () { + #ifdef MANUAL_FILTERING + vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize; + vec4 result = bilerp(uSource, coord, dyeTexelSize); + #else + vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize; + vec4 result = texture2D(uSource, coord); + #endif + float decay = 1.0 + dissipation * dt; + gl_FragColor = result / decay; + }`, + ext.supportLinearFiltering ? undefined : ['MANUAL_FILTERING'] + ); + + const divergenceShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + precision mediump sampler2D; + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uVelocity; + void main () { + float L = texture2D(uVelocity, vL).x; + float R = texture2D(uVelocity, vR).x; + float T = texture2D(uVelocity, vT).y; + float B = texture2D(uVelocity, vB).y; + vec2 C = texture2D(uVelocity, vUv).xy; + if (vL.x < 0.0) { L = -C.x; } + if (vR.x > 1.0) { R = -C.x; } + if (vT.y > 1.0) { T = -C.y; } + if (vB.y < 0.0) { B = -C.y; } + float div = 0.5 * (R - L + T - B); + gl_FragColor = vec4(div, 0.0, 0.0, 1.0); + } + ` + ); + + const curlShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + precision mediump sampler2D; + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uVelocity; + void main () { + float L = texture2D(uVelocity, vL).y; + float R = texture2D(uVelocity, vR).y; + float T = texture2D(uVelocity, vT).x; + float B = texture2D(uVelocity, vB).x; + float vorticity = R - L - T + B; + gl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0); + } + ` + ); + + const vorticityShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision highp float; + precision highp sampler2D; + varying vec2 vUv; + varying vec2 vL; + varying vec2 vR; + varying vec2 vT; + varying vec2 vB; + uniform sampler2D uVelocity; + uniform sampler2D uCurl; + uniform float curl; + uniform float dt; + void main () { + float L = texture2D(uCurl, vL).x; + float R = texture2D(uCurl, vR).x; + float T = texture2D(uCurl, vT).x; + float B = texture2D(uCurl, vB).x; + float C = texture2D(uCurl, vUv).x; + vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); + force /= length(force) + 0.0001; + force *= curl * C; + force.y *= -1.0; + vec2 velocity = texture2D(uVelocity, vUv).xy; + velocity += force * dt; + velocity = min(max(velocity, -1000.0), 1000.0); + gl_FragColor = vec4(velocity, 0.0, 1.0); + } + ` + ); + + const pressureShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + precision mediump sampler2D; + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uPressure; + uniform sampler2D uDivergence; + void main () { + float L = texture2D(uPressure, vL).x; + float R = texture2D(uPressure, vR).x; + float T = texture2D(uPressure, vT).x; + float B = texture2D(uPressure, vB).x; + float C = texture2D(uPressure, vUv).x; + float divergence = texture2D(uDivergence, vUv).x; + float pressure = (L + R + B + T - divergence) * 0.25; + gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0); + } + ` + ); + + const gradientSubtractShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; + precision mediump sampler2D; + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uPressure; + uniform sampler2D uVelocity; + void main () { + float L = texture2D(uPressure, vL).x; + float R = texture2D(uPressure, vR).x; + float T = texture2D(uPressure, vT).x; + float B = texture2D(uPressure, vB).x; + vec2 velocity = texture2D(uVelocity, vUv).xy; + velocity.xy -= vec2(R - L, T - B); + gl_FragColor = vec4(velocity, 0.0, 1.0); + } + ` + ); + + const sunraysMaskShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision highp float; + precision highp sampler2D; + varying vec2 vUv; + uniform sampler2D uTexture; + void main () { + vec4 c = texture2D(uTexture, vUv); + float br = max(c.r, max(c.g, c.b)); + c.a = 1.0 - min(max(br * 20.0, 0.0), 0.8); + gl_FragColor = c; + } + ` + ); + + const sunraysShader = compileShader( + gl.FRAGMENT_SHADER, + ` + precision highp float; + precision highp sampler2D; + varying vec2 vUv; + uniform sampler2D uTexture; + uniform float weight; + #define ITERATIONS 16 + void main () { + float Density = 0.3; + float Decay = 0.95; + float Exposure = 0.7; + vec2 coord = vUv; + vec2 dir = vUv - 0.5; + dir *= 1.0 / float(ITERATIONS) * Density; + float illuminationDecay = 1.0; + float color = texture2D(uTexture, vUv).a; + for (int i = 0; i < ITERATIONS; i++) { + coord -= dir; + float col = texture2D(uTexture, coord).a; + color += col * illuminationDecay * weight; + illuminationDecay *= Decay; + } + gl_FragColor = vec4(color * Exposure, 0.0, 0.0, 1.0); + } + ` + ); + + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), + gl.STATIC_DRAW + ); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(0); + + let dye: DoubleFBO; + let velocity: DoubleFBO; + let divergence: FBO; + let curl: FBO; + let pressure: DoubleFBO; + let sunrays: FBO; + let sunraysTemp: FBO; + + const blurProgram = new Program( + gl, + blurVertexShader, + blurShader, + createWebGLProgram, + getUniforms + ); + const copyProgram = new Program( + gl, + baseVertexShader, + copyShader, + createWebGLProgram, + getUniforms + ); + const clearProgram = new Program( + gl, + baseVertexShader, + clearShader, + createWebGLProgram, + getUniforms + ); + const colorProgram = new Program( + gl, + baseVertexShader, + colorShader, + createWebGLProgram, + getUniforms + ); + const splatProgram = new Program( + gl, + baseVertexShader, + splatShader, + createWebGLProgram, + getUniforms + ); + const advectionProgram = new Program( + gl, + baseVertexShader, + advectionShader, + createWebGLProgram, + getUniforms + ); + const divergenceProgram = new Program( + gl, + baseVertexShader, + divergenceShader, + createWebGLProgram, + getUniforms + ); + const curlProgram = new Program( + gl, + baseVertexShader, + curlShader, + createWebGLProgram, + getUniforms + ); + const vorticityProgram = new Program( + gl, + baseVertexShader, + vorticityShader, + createWebGLProgram, + getUniforms + ); + const pressureProgram = new Program( + gl, + baseVertexShader, + pressureShader, + createWebGLProgram, + getUniforms + ); + const gradienSubtractProgram = new Program( + gl, + baseVertexShader, + gradientSubtractShader, + createWebGLProgram, + getUniforms + ); + const sunraysMaskProgram = new Program( + gl, + baseVertexShader, + sunraysMaskShader, + createWebGLProgram, + getUniforms + ); + const sunraysProgram = new Program( + gl, + baseVertexShader, + sunraysShader, + createWebGLProgram, + getUniforms + ); + + const displayMaterial = new Material( + gl, + baseVertexShader, + displayShaderSource, + compileShader, + createWebGLProgram, + getUniforms + ); + + function getResolution(resolution: number) { + let aspectRatio = gl.drawingBufferWidth / gl.drawingBufferHeight; + if (aspectRatio < 1) aspectRatio = 1.0 / aspectRatio; + const min = Math.round(resolution); + const max = Math.round(resolution * aspectRatio); + if (gl.drawingBufferWidth > gl.drawingBufferHeight) return { width: max, height: min }; + return { width: min, height: max }; + } + + function createFBO( + w: number, + h: number, + internalFormat: number, + format: number, + type: number, + param: number + ): FBO { + gl.activeTexture(gl.TEXTURE0); + const texture = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, w, h, 0, format, type, null); + + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + gl.viewport(0, 0, w, h); + gl.clear(gl.COLOR_BUFFER_BIT); + + const texelSizeX = 1.0 / w; + const texelSizeY = 1.0 / h; + + return { + texture, + fbo, + width: w, + height: h, + texelSizeX, + texelSizeY, + attach(id: number) { + gl.activeTexture(gl.TEXTURE0 + id); + gl.bindTexture(gl.TEXTURE_2D, texture); + return id; + }, + }; + } + + function createDoubleFBO( + w: number, + h: number, + internalFormat: number, + format: number, + type: number, + param: number + ): DoubleFBO { + let fbo1 = createFBO(w, h, internalFormat, format, type, param); + let fbo2 = createFBO(w, h, internalFormat, format, type, param); + + return { + width: w, + height: h, + texelSizeX: fbo1.texelSizeX, + texelSizeY: fbo1.texelSizeY, + get read() { + return fbo1; + }, + set read(value) { + fbo1 = value; + }, + get write() { + return fbo2; + }, + set write(value) { + fbo2 = value; + }, + swap() { + const temp = fbo1; + fbo1 = fbo2; + fbo2 = temp; + }, + }; + } + + function resizeFBO( + target: FBO, + w: number, + h: number, + internalFormat: number, + format: number, + type: number, + param: number + ) { + const newFBO = createFBO(w, h, internalFormat, format, type, param); + copyProgram.bind(); + gl.uniform1i(copyProgram.uniforms.uTexture, target.attach(0)); + blit(newFBO); + return newFBO; + } + + function resizeDoubleFBO( + target: DoubleFBO, + w: number, + h: number, + internalFormat: number, + format: number, + type: number, + param: number + ) { + if (target.width === w && target.height === h) return target; + target.read = resizeFBO(target.read, w, h, internalFormat, format, type, param); + target.write = createFBO(w, h, internalFormat, format, type, param); + target.width = w; + target.height = h; + target.texelSizeX = 1.0 / w; + target.texelSizeY = 1.0 / h; + return target; + } + + function initFramebuffers() { + const simRes = getResolution(config.SIM_RESOLUTION); + const dyeRes = getResolution(config.DYE_RESOLUTION); + + const texType = ext.halfFloatTexType; + const rgba = ext.formatRGBA; + const rg = ext.formatRG; + const r = ext.formatR; + const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST; + + gl.disable(gl.BLEND); + + if (!dye) { + dye = createDoubleFBO( + dyeRes.width, + dyeRes.height, + rgba.internalFormat, + rgba.format, + texType, + filtering + ); + } else { + dye = resizeDoubleFBO( + dye, + dyeRes.width, + dyeRes.height, + rgba.internalFormat, + rgba.format, + texType, + filtering + ); + } + + if (!velocity) { + velocity = createDoubleFBO( + simRes.width, + simRes.height, + rg.internalFormat, + rg.format, + texType, + filtering + ); + } else { + velocity = resizeDoubleFBO( + velocity, + simRes.width, + simRes.height, + rg.internalFormat, + rg.format, + texType, + filtering + ); + } + + divergence = createFBO( + simRes.width, + simRes.height, + r.internalFormat, + r.format, + texType, + gl.NEAREST + ); + curl = createFBO( + simRes.width, + simRes.height, + r.internalFormat, + r.format, + texType, + gl.NEAREST + ); + pressure = createDoubleFBO( + simRes.width, + simRes.height, + r.internalFormat, + r.format, + texType, + gl.NEAREST + ); + + initSunraysFramebuffers(); + } + + function initSunraysFramebuffers() { + const res = getResolution(config.SUNRAYS_RESOLUTION); + const texType = ext.halfFloatTexType; + const r = ext.formatR; + const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST; + + sunrays = createFBO(res.width, res.height, r.internalFormat, r.format, texType, filtering); + sunraysTemp = createFBO( + res.width, + res.height, + r.internalFormat, + r.format, + texType, + filtering + ); + } + + function updateKeywords() { + const displayKeywords: string[] = []; + if ((config as any).SHADING) displayKeywords.push('SHADING'); + if ((config as any).SUNRAYS) displayKeywords.push('SUNRAYS'); + displayMaterial.setKeywords(displayKeywords); + } + + function scaleByPixelRatio(input: number) { + const pixelRatio = window.devicePixelRatio || 1; + return Math.floor(input * pixelRatio); + } + + function resizeCanvas() { + const width = scaleByPixelRatio(fluidCanvas.clientWidth); + const height = scaleByPixelRatio(fluidCanvas.clientHeight); + if (width === 0 || height === 0) return false; + if (fluidCanvas.width !== width || fluidCanvas.height !== height) { + fluidCanvas.width = width; + fluidCanvas.height = height; + scheduleMaskDraw(); + return true; + } + return false; + } + + function HSVtoRGB(h: number, s: number, v: number) { + let r = 0, + g = 0, + b = 0; + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + + switch (i % 6) { + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + case 5: + r = v; + g = p; + b = q; + break; + } + + return { r, g, b }; + } + + function generateColor() { + const c = HSVtoRGB( + Math.random() * ((config as any).END_HUE - (config as any).START_HUE) + + (config as any).START_HUE, + 1.0, + 1.0 + ); + c.r *= 0.15; + c.g *= 0.15; + c.b *= 0.15; + return c; + } + + function correctRadius(radius: number) { + const aspectRatio = fluidCanvas.width / fluidCanvas.height; + if (aspectRatio > 1) radius *= aspectRatio; + return radius; + } + + function splat( + x: number, + y: number, + dx: number, + dy: number, + color: { r: number; g: number; b: number } + ) { + splatProgram.bind(); + gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0)); + gl.uniform1f(splatProgram.uniforms.aspectRatio, fluidCanvas.width / fluidCanvas.height); + gl.uniform2f(splatProgram.uniforms.point, x, y); + gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0.0); + gl.uniform1f( + splatProgram.uniforms.radius, + correctRadius((config as any).SPLAT_RADIUS / 100.0) + ); + blit(velocity.write); + velocity.swap(); + + gl.uniform1i(splatProgram.uniforms.uTarget, dye.read.attach(0)); + gl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b); + blit(dye.write); + dye.swap(); + } + + function multipleSplats(amount: number) { + for (let i = 0; i < amount; i++) { + const color = generateColor(); + color.r *= 10.0; + color.g *= 10.0; + color.b *= 10.0; + const x = Math.random(); + const y = Math.random() < 0.5 ? 0.95 : 0.05; + const dx = 300 * (Math.random() - 0.5); + const dy = 3000 * (Math.random() - 0.5); + splat(x, y, dx, dy, color); + } + } + + function splatPointer(pointer: Pointer) { + const dx = pointer.deltaX * (config as any).SPLAT_FORCE * 12; + const dy = pointer.deltaY * (config as any).SPLAT_FORCE * 12; + splat(pointer.texcoordX, pointer.texcoordY, dx, dy, { + r: pointer.color[0], + g: pointer.color[1], + b: pointer.color[2], + }); + } + + function step(dt: number) { + gl.disable(gl.BLEND); + + curlProgram.bind(); + gl.uniform2f(curlProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY); + gl.uniform1i(curlProgram.uniforms.uVelocity, velocity.read.attach(0)); + blit(curl); + + vorticityProgram.bind(); + gl.uniform2f(vorticityProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY); + gl.uniform1i(vorticityProgram.uniforms.uVelocity, velocity.read.attach(0)); + gl.uniform1i(vorticityProgram.uniforms.uCurl, curl.attach(1)); + gl.uniform1f(vorticityProgram.uniforms.curl, (config as any).CURL); + gl.uniform1f(vorticityProgram.uniforms.dt, dt); + blit(velocity.write); + velocity.swap(); + + divergenceProgram.bind(); + gl.uniform2f( + divergenceProgram.uniforms.texelSize, + velocity.texelSizeX, + velocity.texelSizeY + ); + gl.uniform1i(divergenceProgram.uniforms.uVelocity, velocity.read.attach(0)); + blit(divergence); + + clearProgram.bind(); + gl.uniform1i(clearProgram.uniforms.uTexture, pressure.read.attach(0)); + gl.uniform1f(clearProgram.uniforms.value, (config as any).PRESSURE); + blit(pressure.write); + pressure.swap(); + + pressureProgram.bind(); + gl.uniform2f(pressureProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY); + gl.uniform1i(pressureProgram.uniforms.uDivergence, divergence.attach(0)); + for (let i = 0; i < (config as any).PRESSURE_ITERATIONS; i++) { + gl.uniform1i(pressureProgram.uniforms.uPressure, pressure.read.attach(1)); + blit(pressure.write); + pressure.swap(); + } + + gradienSubtractProgram.bind(); + gl.uniform2f( + gradienSubtractProgram.uniforms.texelSize, + velocity.texelSizeX, + velocity.texelSizeY + ); + gl.uniform1i(gradienSubtractProgram.uniforms.uPressure, pressure.read.attach(0)); + gl.uniform1i(gradienSubtractProgram.uniforms.uVelocity, velocity.read.attach(1)); + blit(velocity.write); + velocity.swap(); + + advectionProgram.bind(); + gl.uniform2f(advectionProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY); + if (!ext.supportLinearFiltering) { + gl.uniform2f( + advectionProgram.uniforms.dyeTexelSize, + velocity.texelSizeX, + velocity.texelSizeY + ); + } + const velocityId = velocity.read.attach(0); + gl.uniform1i(advectionProgram.uniforms.uVelocity, velocityId); + gl.uniform1i(advectionProgram.uniforms.uSource, velocityId); + gl.uniform1f(advectionProgram.uniforms.dt, dt); + gl.uniform1f(advectionProgram.uniforms.dissipation, (config as any).VELOCITY_DISSIPATION); + blit(velocity.write); + velocity.swap(); + + if (!ext.supportLinearFiltering) { + gl.uniform2f(advectionProgram.uniforms.dyeTexelSize, dye.texelSizeX, dye.texelSizeY); + } + gl.uniform1i(advectionProgram.uniforms.uVelocity, velocity.read.attach(0)); + gl.uniform1i(advectionProgram.uniforms.uSource, dye.read.attach(1)); + gl.uniform1f(advectionProgram.uniforms.dissipation, (config as any).DENSITY_DISSIPATION); + blit(dye.write); + dye.swap(); + } + + function blur(target: FBO, temp: FBO, iterations: number) { + blurProgram.bind(); + for (let i = 0; i < iterations; i++) { + gl.uniform2f(blurProgram.uniforms.texelSize, target.texelSizeX, 0.0); + gl.uniform1i(blurProgram.uniforms.uTexture, target.attach(0)); + blit(temp); + + gl.uniform2f(blurProgram.uniforms.texelSize, 0.0, target.texelSizeY); + gl.uniform1i(blurProgram.uniforms.uTexture, temp.attach(0)); + blit(target); + } + } + + function applySunrays(source: FBO, mask: FBO, destination: FBO) { + gl.disable(gl.BLEND); + sunraysMaskProgram.bind(); + gl.uniform1i(sunraysMaskProgram.uniforms.uTexture, source.attach(0)); + blit(mask); + + sunraysProgram.bind(); + gl.uniform1f(sunraysProgram.uniforms.weight, (config as any).SUNRAYS_WEIGHT); + gl.uniform1i(sunraysProgram.uniforms.uTexture, mask.attach(0)); + blit(destination); + } + + function drawColor(target: FBO | null, color: { r: number; g: number; b: number }) { + colorProgram.bind(); + gl.uniform4f(colorProgram.uniforms.color, color.r, color.g, color.b, 1); + blit(target); + } + + function drawDisplay(target: FBO | null) { + const width = target === null ? gl.drawingBufferWidth : target.width; + const height = target === null ? gl.drawingBufferHeight : target.height; + + displayMaterial.bind(); + if ((config as any).SHADING) { + gl.uniform2f(displayMaterial.uniforms.texelSize, 1.0 / width, 1.0 / height); + } + gl.uniform1i(displayMaterial.uniforms.uTexture, dye.read.attach(0)); + if ((config as any).SUNRAYS) { + gl.uniform1i(displayMaterial.uniforms.uSunrays, sunrays.attach(3)); + } + blit(target); + } + + function render(target: FBO | null) { + if ((config as any).SUNRAYS) { + applySunrays(dye.read, dye.write, sunrays); + blur(sunrays, sunraysTemp, 1); + } + + if (target === null || !(config as any).TRANSPARENT) { + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.enable(gl.BLEND); + } else { + gl.disable(gl.BLEND); + } + + if (!(config as any).TRANSPARENT) { + drawColor(target, { + r: (config as any).BACK_COLOR.r / 255, + g: (config as any).BACK_COLOR.g / 255, + b: (config as any).BACK_COLOR.b / 255, + }); + } + drawDisplay(target); + } + + function wrap(value: number, min: number, max: number) { + const range = max - min; + if (range === 0) return min; + return ((value - min) % range) + min; + } + + let lastUpdateTime = Date.now(); + let colorUpdateTimer = 0.0; + + function updateColors(dt: number) { + if (!(config as any).COLORFUL) return; + colorUpdateTimer += dt * (config as any).COLOR_UPDATE_SPEED; + if (colorUpdateTimer >= 1) { + colorUpdateTimer = wrap(colorUpdateTimer, 0, 1); + pointers.forEach((p) => { + const c = generateColor(); + p.color = [c.r, c.g, c.b]; + }); + } + } + + function applyInputs() { + if (splatStack.length > 0) multipleSplats(splatStack.pop()!); + + pointers.forEach((p) => { + if (p.moved) { + p.moved = false; + splatPointer(p); + } + }); + } + + function calcDeltaTime() { + const now = Date.now(); + let dt = (now - lastUpdateTime) / 1000; + dt = Math.min(dt, 0.033); + lastUpdateTime = now; + return dt; + } + + function update() { + const dt = calcDeltaTime() * ((config as any).RENDER_SPEED ?? 1.0); + if (resizeCanvas()) initFramebuffers(); + if (!maskReady) { + scheduleMaskDraw(); + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + gl.clearColor(0.0, 0.0, 0.0, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + animationId = requestAnimationFrame(update); + return; + } + updateColors(dt); + applyInputs(); + if (!(config as any).PAUSED) step(dt); + render(null); + animationId = requestAnimationFrame(update); + } + + function correctDeltaX(delta: number) { + const aspectRatio = fluidCanvas.width / fluidCanvas.height; + if (aspectRatio < 1) delta *= aspectRatio; + return delta; + } + + function correctDeltaY(delta: number) { + const aspectRatio = fluidCanvas.width / fluidCanvas.height; + if (aspectRatio > 1) delta /= aspectRatio; + return delta; + } + + function updatePointerDownData(pointer: Pointer, id: number, posX: number, posY: number) { + pointer.id = id; + pointer.down = true; + pointer.moved = false; + pointer.texcoordX = posX / fluidCanvas.width; + pointer.texcoordY = 1.0 - posY / fluidCanvas.height; + pointer.prevTexcoordX = pointer.texcoordX; + pointer.prevTexcoordY = pointer.texcoordY; + pointer.deltaX = 0; + pointer.deltaY = 0; + const c = generateColor(); + pointer.color = [c.r, c.g, c.b]; + } + + function updatePointerMoveData(pointer: Pointer, posX: number, posY: number) { + pointer.prevTexcoordX = pointer.texcoordX; + pointer.prevTexcoordY = pointer.texcoordY; + pointer.texcoordX = posX / fluidCanvas.width; + pointer.texcoordY = 1.0 - posY / fluidCanvas.height; + pointer.deltaX = correctDeltaX(pointer.texcoordX - pointer.prevTexcoordX); + pointer.deltaY = correctDeltaY(pointer.texcoordY - pointer.prevTexcoordY); + pointer.moved = Math.abs(pointer.deltaX) > 0 || Math.abs(pointer.deltaY) > 0; + } + + function updatePointerUpData(pointer: Pointer) { + pointer.down = false; + } + + handleMouseEnter = (e: MouseEvent) => { + if (fluidCanvas.width === 0 || fluidCanvas.height === 0) return; + const rect = container.getBoundingClientRect(); + const posX = scaleByPixelRatio(e.clientX - rect.left); + const posY = scaleByPixelRatio(e.clientY - rect.top); + const x = posX / fluidCanvas.width; + const y = 1.0 - posY / fluidCanvas.height; + const color = generateColor(); + color.r *= 10.0; + color.g *= 10.0; + color.b *= 10.0; + splat(x, y, 300 * (Math.random() - 0.5), 300 * (Math.random() - 0.5), color); + }; + + handleMouseDown = (e: MouseEvent) => { + if (fluidCanvas.width === 0 || fluidCanvas.height === 0) return; + const rect = container.getBoundingClientRect(); + const posX = scaleByPixelRatio(e.clientX - rect.left); + const posY = scaleByPixelRatio(e.clientY - rect.top); + let pointer = pointers.find((p) => p.id === -1); + if (!pointer) pointer = PointerPrototype(); + updatePointerDownData(pointer, -1, posX, posY); + }; + + handleMouseMove = (e: MouseEvent) => { + if (fluidCanvas.width === 0 || fluidCanvas.height === 0) return; + const pointer = pointers[0]; + const rect = container.getBoundingClientRect(); + const posX = scaleByPixelRatio(e.clientX - rect.left); + const posY = scaleByPixelRatio(e.clientY - rect.top); + updatePointerMoveData(pointer, posX, posY); + if (pointer.moved) { + pointer.moved = false; + const c = generateColor(); + pointer.color = [c.r, c.g, c.b]; + splat( + pointer.texcoordX, + pointer.texcoordY, + pointer.deltaX * (config as any).SPLAT_FORCE * 12, + pointer.deltaY * (config as any).SPLAT_FORCE * 12, + { + r: pointer.color[0], + g: pointer.color[1], + b: pointer.color[2], + } + ); + } + }; + + handleMouseUp = () => { + updatePointerUpData(pointers[0]); + }; + + handleTouchStart = (e: TouchEvent) => { + if (fluidCanvas.width === 0 || fluidCanvas.height === 0) return; + e.preventDefault(); + const touches = e.targetTouches; + while (touches.length >= pointers.length) pointers.push(PointerPrototype()); + for (let i = 0; i < touches.length; i++) { + const rect = container.getBoundingClientRect(); + const posX = scaleByPixelRatio(touches[i].clientX - rect.left); + const posY = scaleByPixelRatio(touches[i].clientY - rect.top); + updatePointerDownData(pointers[i + 1], touches[i].identifier, posX, posY); + } + }; + + handleTouchMove = (e: TouchEvent) => { + if (fluidCanvas.width === 0 || fluidCanvas.height === 0) return; + e.preventDefault(); + const touches = e.targetTouches; + for (let i = 0; i < touches.length; i++) { + const pointer = pointers[i + 1]; + if (!pointer.down) continue; + const rect = container.getBoundingClientRect(); + const posX = scaleByPixelRatio(touches[i].clientX - rect.left); + const posY = scaleByPixelRatio(touches[i].clientY - rect.top); + updatePointerMoveData(pointer, posX, posY); + } + }; + + handleTouchEnd = (e: TouchEvent) => { + const touches = e.changedTouches; + for (let i = 0; i < touches.length; i++) { + const pointer = pointers.find((p) => p.id === touches[i].identifier); + if (pointer) updatePointerUpData(pointer); + } + }; + + if (handleMouseEnter) container.addEventListener('mouseenter', handleMouseEnter); + if (handleMouseDown) container.addEventListener('mousedown', handleMouseDown); + if (handleMouseMove) container.addEventListener('mousemove', handleMouseMove); + if (handleMouseUp) container.addEventListener('mouseup', handleMouseUp); + if (handleTouchStart) + container.addEventListener('touchstart', handleTouchStart, { passive: false }); + if (handleTouchMove) + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + if (handleTouchEnd) container.addEventListener('touchend', handleTouchEnd); + + function blit(target: FBO | null, clear = false) { + if (target === null) { + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } else { + gl.viewport(0, 0, target.width, target.height); + gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo); + } + if (clear) { + gl.clearColor(0.0, 0.0, 0.0, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + } + gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); + } + + updateKeywords(); + initFramebuffers(); + multipleSplats(25); + update(); + + splatIntervalId = setInterval(() => { + multipleSplats(5); + }, 500); + + resizeObserver = new ResizeObserver(() => { + resizeCanvas(); + maskReady = false; + scheduleMaskDraw(); + }); + resizeObserver.observe(container); + + isInitializedRef.current = true; + } + + const computedColor = resolveBaseColor(colorClass, customBackground); + const hue = colorToHue(computedColor) / 360; + + requestAnimationFrame(() => { + initFluidSimulation(hue, hue - 0.3); + }); + + if (document.fonts?.ready) { + document.fonts.ready.then(() => { + if (isInitializedRef.current) scheduleMaskDraw(); + }); + } + + if (isTransparent) { + themeObserver = new MutationObserver(() => { + if (isInitializedRef.current) scheduleMaskDraw(); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + } + + return () => { + if (animationId) cancelAnimationFrame(animationId); + if (splatIntervalId) clearInterval(splatIntervalId); + if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); + if (resizeObserver) resizeObserver.disconnect(); + if (themeObserver) themeObserver.disconnect(); + if (handleMouseEnter) container.removeEventListener('mouseenter', handleMouseEnter); + if (handleMouseDown) container.removeEventListener('mousedown', handleMouseDown); + if (handleMouseMove) container.removeEventListener('mousemove', handleMouseMove); + if (handleMouseUp) container.removeEventListener('mouseup', handleMouseUp); + if (handleTouchStart) container.removeEventListener('touchstart', handleTouchStart); + if (handleTouchMove) container.removeEventListener('touchmove', handleTouchMove); + if (handleTouchEnd) container.removeEventListener('touchend', handleTouchEnd); + }; + }, [colorClass, customBackground, isTransparent]); + + useEffect(() => { + textRef.current = resolvedText; + fontSizeRef.current = resolvedFontSize; + if (isInitializedRef.current) { + scheduleMaskDrawRef.current(); + } + }, [resolvedText, resolvedFontSize, isTransparent]); + + return ( +
+ + + +
+ ); +}; + +export default FluidTextEffect; diff --git a/components/PreviewPage.tsx b/components/PreviewPage.tsx index 30b256d..74b7b70 100644 --- a/components/PreviewPage.tsx +++ b/components/PreviewPage.tsx @@ -7,6 +7,10 @@ import { getMobileLayout, MOBILE_GRID_CONFIG } from '../utils/mobileLayout'; const PreviewPage: React.FC = () => { const [bento, setBento] = useState(null); + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === 'undefined') return true; + return window.innerWidth >= 1024; + }); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -17,6 +21,18 @@ const PreviewPage: React.FC = () => { setBento(resolved); }, []); + useEffect(() => { + const media = window.matchMedia('(min-width: 1024px)'); + const update = () => setIsDesktop(media.matches); + update(); + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + } + media.addListener(update); + return () => media.removeListener(update); + }, []); + // Avatar style helpers const getAvatarStyle = (style?: AvatarStyle): React.CSSProperties => { const s = style || { @@ -117,166 +133,170 @@ const PreviewPage: React.FC = () => {
{/* Desktop Layout - Matches Builder */} -
- {/* Fixed Sidebar */} -
-
-
-
- {profile.avatarUrl ? ( - {profile.name} - ) : ( -
- {profile.name.charAt(0)} -
- )} + {isDesktop && ( +
+ {/* Fixed Sidebar */} +
+
+
+
+ {profile.avatarUrl ? ( + {profile.name} + ) : ( +
+ {profile.name.charAt(0)} +
+ )} +
+
+
+

+ {profile.name} +

+

+ {profile.bio || '—'} +

+ {renderSocialIcons()}
-
-
-

- {profile.name} -

-

- {profile.bio || '—'} -

- {renderSocialIcons()}
-
- {/* Grid Content */} -
-
- {blocks.map((block, index) => ( - {}} - onDelete={() => {}} - onDragStart={() => {}} - onDragEnter={() => {}} - onDragEnd={() => {}} - onDrop={() => {}} - enableTiltEffect={true} - previewMode={true} - /> - ))} + {/* Grid Content */} +
+
+ {blocks.map((block, index) => ( + {}} + onDelete={() => {}} + onDragStart={() => {}} + onDragEnter={() => {}} + onDragEnd={() => {}} + onDrop={() => {}} + enableTiltEffect={true} + previewMode={true} + /> + ))} +
-
+ )} {/* Mobile Layout - Matches Builder mobile preview */} -
- {/* Centered Profile */} -
-
- {profile.avatarUrl ? ( - {profile.name} - ) : ( -
- {profile.name.charAt(0)} + {!isDesktop && ( +
+ {/* Centered Profile */} +
+
+ {profile.avatarUrl ? ( + {profile.name} + ) : ( +
+ {profile.name.charAt(0)} +
+ )} +
+

+ {profile.name} +

+

+ {profile.bio} +

+ {profile.showSocialInHeader && profile.socialAccounts?.length > 0 && ( +
+ {profile.socialAccounts.map((account) => { + const option = getSocialPlatformOption(account.platform); + if (!option) return null; + const BrandIcon = option.brandIcon; + const FallbackIcon = option.icon; + const url = buildSocialUrl(account.platform, account.handle); + const showCount = profile.showFollowerCount && account.followerCount; + return ( + + + {BrandIcon ? : } + + {showCount && ( + + {formatFollowerCount(account.followerCount)} + + )} + + ); + })}
)}
-

- {profile.name} -

-

- {profile.bio} -

- {profile.showSocialInHeader && profile.socialAccounts?.length > 0 && ( -
- {profile.socialAccounts.map((account) => { - const option = getSocialPlatformOption(account.platform); - if (!option) return null; - const BrandIcon = option.brandIcon; - const FallbackIcon = option.icon; - const url = buildSocialUrl(account.platform, account.handle); - const showCount = profile.showFollowerCount && account.followerCount; + + {/* Mobile Grid - 2 columns adaptive */} +
+
+ {sortedBlocks.map((block) => { + const mobileLayout = getMobileLayout(block); return ( - - - {BrandIcon ? : } - - {showCount && ( - - {formatFollowerCount(account.followerCount)} - - )} - + {}} + onDelete={() => {}} + onDragStart={() => {}} + onDragEnter={() => {}} + onDragEnd={() => {}} + onDrop={() => {}} + enableTiltEffect={true} + previewMode={true} + /> +
); })}
- )} -
- - {/* Mobile Grid - 2 columns adaptive */} -
-
- {sortedBlocks.map((block) => { - const mobileLayout = getMobileLayout(block); - return ( -
- {}} - onDelete={() => {}} - onDragStart={() => {}} - onDragEnter={() => {}} - onDragEnd={() => {}} - onDrop={() => {}} - enableTiltEffect={true} - previewMode={true} - /> -
- ); - })}
-
+ )} {/* Footer */} {profile.showBranding !== false && ( diff --git a/docs/usage/blocks.md b/docs/usage/blocks.md index a02d33b..56011d7 100644 --- a/docs/usage/blocks.md +++ b/docs/usage/blocks.md @@ -1,6 +1,6 @@ # Block Types -OpenBento includes 7 block types to create your perfect bento layout. +OpenBento includes 9 block types to create your perfect bento layout. ## Social Block @@ -63,6 +63,24 @@ Add text content to your bento. - Announcements - Contact info +## Fluid Text Block + +Add a dynamic text effect powered by a GPU fluid simulation. + +**Features:** +- Real-time WebGL fluid distortion +- Text rendered as a fluid mask +- Adjustable font size + +**How to use:** +1. Add a Text Fluid Effect block +2. Edit the text in the sidebar +3. Adjust the Font Size slider + +**Tips:** +- Best at 6×3 or 6×6 sizes +- Keep the text short for the cleanest effect + ## Media Block Display images and GIFs. diff --git a/types.ts b/types.ts index 609f147..50c79ab 100644 --- a/types.ts +++ b/types.ts @@ -1,6 +1,7 @@ export enum BlockType { LINK = 'LINK', TEXT = 'TEXT', + FLUID_TEXT = 'FLUID_TEXT', MEDIA = 'MEDIA', // Images, GIFs, videos SOCIAL = 'SOCIAL', SOCIAL_ICON = 'SOCIAL_ICON', // Small icon-only social block for 9x9 grid @@ -59,6 +60,7 @@ export interface BlockData { customBackground?: string; // Raw CSS value (hex or gradient) textColor?: string; // 'text-black' or 'text-white' rotation?: number; // Removed usage, kept for type safety if needed, or remove. + fluidTextFontSize?: number; // 0.1 - 0.8 scale for Fluid Text // Grid positioning (explicit placement) gridColumn?: number; // 1-based column start position