your personal website on atproto - mirror blento.app

fixed fluid text card

+192 -178
+4 -1
.claude/settings.local.json
··· 4 "Bash(pnpm check:*)", 5 "mcp__ide__getDiagnostics", 6 "mcp__plugin_svelte_svelte__svelte-autofixer", 7 - "mcp__plugin_svelte_svelte__list-sections" 8 ] 9 } 10 }
··· 4 "Bash(pnpm check:*)", 5 "mcp__ide__getDiagnostics", 6 "mcp__plugin_svelte_svelte__svelte-autofixer", 7 + "mcp__plugin_svelte_svelte__list-sections", 8 + "Bash(pkill:*)", 9 + "Bash(timeout 8 pnpm dev:*)", 10 + "Bash(git checkout:*)" 11 ] 12 } 13 }
+168 -64
src/lib/cards/FluidTextCard/FluidTextCard.svelte
··· 10 let maskCanvas: HTMLCanvasElement; 11 let animationId: number; 12 let splatIntervalId: ReturnType<typeof setInterval>; 13 let isInitialized = $state(false); 14 let resizeObserver: ResizeObserver | null = null; 15 16 // Get text from card data 17 const text = $derived((item.cardData?.text as string) || 'hello'); 18 - const fontWeight = $derived((item.cardData?.fontWeight as string) || '900'); 19 - const fontFamily = $derived((item.cardData?.fontFamily as string) || 'Arial'); 20 - const fontSize = $derived((item.cardData?.fontSize as number) || 0.33); 21 22 // Draw text mask on overlay canvas 23 function drawOverlayCanvas() { 24 if (!maskCanvas || !container) return; 25 26 - const rect = container.getBoundingClientRect(); 27 - if (rect.width === 0 || rect.height === 0) return; 28 29 const dpr = window.devicePixelRatio || 1; 30 31 - maskCanvas.width = rect.width * dpr; 32 - maskCanvas.height = rect.height * dpr; 33 34 const ctx = maskCanvas.getContext('2d')!; 35 ctx.scale(dpr, dpr); 36 37 ctx.fillStyle = 'black'; 38 - ctx.fillRect(0, 0, rect.width, rect.height); 39 40 - const textFontSize = Math.round(rect.width * fontSize); 41 - ctx.font = fontWeight + ' ' + textFontSize + 'px ' + fontFamily; 42 43 ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 44 ctx.lineWidth = 2; 45 - ctx.textBaseline = 'middle'; 46 ctx.textAlign = 'center'; 47 48 - ctx.strokeText(text, rect.width / 2, rect.height / 2); 49 ctx.globalCompositeOperation = 'destination-out'; 50 - ctx.fillText(text, rect.width / 2, rect.height / 2); 51 } 52 53 // Redraw overlay when text settings change (only after initialization) 54 $effect(() => { 55 // Access all reactive values to track them 56 text; 57 - fontWeight; 58 - fontFamily; 59 fontSize; 60 // Only redraw if already initialized 61 if (isInitialized) { 62 - drawOverlayCanvas(); 63 } 64 }); 65 66 onMount(async () => { 67 // Wait for layout to settle 68 await tick(); 69 - initFluidSimulation(); 70 }); 71 72 onDestroy(() => { 73 if (animationId) cancelAnimationFrame(animationId); 74 if (splatIntervalId) clearInterval(splatIntervalId); 75 if (resizeObserver) resizeObserver.disconnect(); 76 }); 77 78 function initFluidSimulation() { 79 if (!fluidCanvas || !maskCanvas || !container) return; 80 81 - drawOverlayCanvas(); 82 83 // Simulation config 84 const config = { ··· 123 deltaY: 0, 124 down: false, 125 moved: false, 126 - color: [30, 0, 300] as [number, number, number] 127 }; 128 } 129 ··· 163 if (!gl) return { gl: null, ext: { supportLinearFiltering: false } as any }; 164 165 let halfFloat: any; 166 - let supportLinearFiltering: any; 167 if (isWebGL2) { 168 gl.getExtension('EXT_color_buffer_float'); 169 - supportLinearFiltering = gl.getExtension('OES_texture_float_linear'); 170 } else { 171 halfFloat = gl.getExtension('OES_texture_half_float'); 172 - supportLinearFiltering = gl.getExtension('OES_texture_half_float_linear'); 173 } 174 175 gl.clearColor(0.0, 0.0, 0.0, 1.0); 176 177 - const halfFloatTexType = isWebGL2 ? gl.HALF_FLOAT : halfFloat?.HALF_FLOAT_OES; 178 let formatRGBA: any; 179 let formatRG: any; 180 let formatR: any; 181 182 if (isWebGL2) { 183 - formatRGBA = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType); 184 - formatRG = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType); 185 - formatR = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType); 186 } else { 187 - formatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType); 188 - formatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType); 189 - formatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType); 190 } 191 192 return { ··· 226 format: number, 227 type: number 228 ) { 229 const texture = gl.createTexture(); 230 gl.bindTexture(gl.TEXTURE_2D, texture); 231 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); ··· 729 730 // Setup blit 731 gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); 732 - gl.bufferData( 733 - gl.ARRAY_BUFFER, 734 - new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), 735 - gl.STATIC_DRAW 736 - ); 737 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); 738 - gl.bufferData( 739 - gl.ELEMENT_ARRAY_BUFFER, 740 - new Uint16Array([0, 1, 2, 0, 2, 3]), 741 - gl.STATIC_DRAW 742 - ); 743 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); 744 gl.enableVertexAttribArray(0); 745 ··· 1029 texType, 1030 gl.NEAREST 1031 ); 1032 - curl = createFBO(simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST); 1033 pressure = createDoubleFBO( 1034 simRes.width, 1035 simRes.height, ··· 1049 const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST; 1050 1051 sunrays = createFBO(res.width, res.height, r.internalFormat, r.format, texType, filtering); 1052 - sunraysTemp = createFBO(res.width, res.height, r.internalFormat, r.format, texType, filtering); 1053 } 1054 1055 function updateKeywords() { ··· 1070 if (fluidCanvas.width !== width || fluidCanvas.height !== height) { 1071 fluidCanvas.width = width; 1072 fluidCanvas.height = height; 1073 - drawOverlayCanvas(); 1074 return true; 1075 } 1076 return false; ··· 1128 return radius; 1129 } 1130 1131 - function splat(x: number, y: number, dx: number, dy: number, color: { r: number; g: number; b: number }) { 1132 splatProgram.bind(); 1133 gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0)); 1134 gl.uniform1f(splatProgram.uniforms.aspectRatio, fluidCanvas.width / fluidCanvas.height); ··· 1328 colorUpdateTimer = wrap(colorUpdateTimer, 0, 1); 1329 pointers.forEach((p) => { 1330 const c = generateColor(); 1331 - p.color = [c.r * 255, c.g * 255, c.b * 255]; 1332 }); 1333 } 1334 } ··· 1355 function update() { 1356 const dt = calcDeltaTime() * (config.RENDER_SPEED ?? 1.0); 1357 if (resizeCanvas()) initFramebuffers(); 1358 updateColors(dt); 1359 applyInputs(); 1360 if (!config.PAUSED) step(dt); ··· 1395 pointer.deltaX = 0; 1396 pointer.deltaY = 0; 1397 const c = generateColor(); 1398 - pointer.color = [c.r * 255, c.g * 255, c.b * 255]; 1399 } 1400 1401 function updatePointerMoveData(pointer: Pointer, posX: number, posY: number) { ··· 1412 pointer.down = false; 1413 } 1414 1415 - // Event handlers 1416 - fluidCanvas.addEventListener('mouseenter', (e) => { 1417 // Create a small burst when mouse enters the card 1418 - const posX = scaleByPixelRatio(e.offsetX); 1419 - const posY = scaleByPixelRatio(e.offsetY); 1420 const x = posX / fluidCanvas.width; 1421 const y = 1.0 - posY / fluidCanvas.height; 1422 const color = generateColor(); ··· 1426 splat(x, y, 300 * (Math.random() - 0.5), 300 * (Math.random() - 0.5), color); 1427 }); 1428 1429 - fluidCanvas.addEventListener('mousedown', (e) => { 1430 - const posX = scaleByPixelRatio(e.offsetX); 1431 - const posY = scaleByPixelRatio(e.offsetY); 1432 let pointer = pointers.find((p) => p.id === -1); 1433 if (!pointer) pointer = PointerPrototype(); 1434 updatePointerDownData(pointer, -1, posX, posY); 1435 }); 1436 1437 - fluidCanvas.addEventListener('mousemove', (e) => { 1438 const pointer = pointers[0]; 1439 - const posX = scaleByPixelRatio(e.offsetX); 1440 - const posY = scaleByPixelRatio(e.offsetY); 1441 updatePointerMoveData(pointer, posX, posY); 1442 // Always create swish effect on hover 1443 if (pointer.moved) { 1444 pointer.moved = false; 1445 // Generate a new color for visual interest 1446 const c = generateColor(); 1447 - pointer.color = [c.r * 255, c.g * 255, c.b * 255]; 1448 - splatPointer(pointer); 1449 } 1450 }); 1451 1452 - fluidCanvas.addEventListener('mouseup', () => { 1453 updatePointerUpData(pointers[0]); 1454 }); 1455 1456 - fluidCanvas.addEventListener('touchstart', (e) => { 1457 e.preventDefault(); 1458 const touches = e.targetTouches; 1459 while (touches.length >= pointers.length) pointers.push(PointerPrototype()); 1460 for (let i = 0; i < touches.length; i++) { 1461 - const rect = fluidCanvas.getBoundingClientRect(); 1462 const posX = scaleByPixelRatio(touches[i].clientX - rect.left); 1463 const posY = scaleByPixelRatio(touches[i].clientY - rect.top); 1464 updatePointerDownData(pointers[i + 1], touches[i].identifier, posX, posY); 1465 } 1466 }); 1467 1468 - fluidCanvas.addEventListener('touchmove', (e) => { 1469 e.preventDefault(); 1470 const touches = e.targetTouches; 1471 for (let i = 0; i < touches.length; i++) { 1472 const pointer = pointers[i + 1]; 1473 if (!pointer.down) continue; 1474 - const rect = fluidCanvas.getBoundingClientRect(); 1475 const posX = scaleByPixelRatio(touches[i].clientX - rect.left); 1476 const posY = scaleByPixelRatio(touches[i].clientY - rect.top); 1477 updatePointerMoveData(pointer, posX, posY); 1478 } 1479 }); 1480 1481 - fluidCanvas.addEventListener('touchend', (e) => { 1482 const touches = e.changedTouches; 1483 for (let i = 0; i < touches.length; i++) { 1484 const pointer = pointers.find((p) => p.id === touches[i].identifier); ··· 1500 // Resize observer - also triggers initial draw 1501 resizeObserver = new ResizeObserver(() => { 1502 resizeCanvas(); 1503 - drawOverlayCanvas(); 1504 }); 1505 resizeObserver.observe(container); 1506
··· 10 let maskCanvas: HTMLCanvasElement; 11 let animationId: number; 12 let splatIntervalId: ReturnType<typeof setInterval>; 13 + let maskDrawRaf = 0; 14 + let maskReady = false; 15 let isInitialized = $state(false); 16 let resizeObserver: ResizeObserver | null = null; 17 18 // Get text from card data 19 const text = $derived((item.cardData?.text as string) || 'hello'); 20 + const fontWeight = '900'; 21 + const fontFamily = 'Arial'; 22 + const fontSize = $derived((item.cardData?.fontSize as number) || 0.13); 23 24 // Draw text mask on overlay canvas 25 function drawOverlayCanvas() { 26 if (!maskCanvas || !container) return; 27 28 + const width = container.clientWidth; 29 + const height = container.clientHeight; 30 + if (width === 0 || height === 0) return; 31 32 const dpr = window.devicePixelRatio || 1; 33 34 + maskCanvas.width = width * dpr; 35 + maskCanvas.height = height * dpr; 36 37 const ctx = maskCanvas.getContext('2d')!; 38 + ctx.setTransform(1, 0, 0, 1, 0, 0); 39 + ctx.globalCompositeOperation = 'source-over'; 40 + ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); 41 ctx.scale(dpr, dpr); 42 43 ctx.fillStyle = 'black'; 44 + ctx.fillRect(0, 0, width, height); 45 46 + // Font size as percentage of container width 47 + const textFontSize = Math.round(width * fontSize); 48 + ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 49 50 ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 51 ctx.lineWidth = 2; 52 ctx.textAlign = 'center'; 53 54 + const metrics = ctx.measureText(text); 55 + let textY = height / 2; 56 + if ( 57 + metrics.actualBoundingBoxAscent !== undefined && 58 + metrics.actualBoundingBoxDescent !== undefined 59 + ) { 60 + ctx.textBaseline = 'alphabetic'; 61 + textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; 62 + } else { 63 + ctx.textBaseline = 'middle'; 64 + } 65 + 66 + ctx.strokeText(text, width / 2, textY); 67 ctx.globalCompositeOperation = 'destination-out'; 68 + ctx.fillText(text, width / 2, textY); 69 + ctx.globalCompositeOperation = 'source-over'; 70 + maskReady = true; 71 + } 72 + 73 + function scheduleMaskDraw() { 74 + const width = container?.clientWidth ?? 0; 75 + const height = container?.clientHeight ?? 0; 76 + if (width > 0 && height > 0) { 77 + drawOverlayCanvas(); 78 + return; 79 + } 80 + if (maskDrawRaf) return; 81 + maskDrawRaf = requestAnimationFrame(() => { 82 + maskDrawRaf = 0; 83 + const nextWidth = container?.clientWidth ?? 0; 84 + const nextHeight = container?.clientHeight ?? 0; 85 + if (nextWidth === 0 || nextHeight === 0) { 86 + scheduleMaskDraw(); 87 + return; 88 + } 89 + drawOverlayCanvas(); 90 + }); 91 } 92 93 // Redraw overlay when text settings change (only after initialization) 94 $effect(() => { 95 // Access all reactive values to track them 96 text; 97 fontSize; 98 // Only redraw if already initialized 99 if (isInitialized) { 100 + scheduleMaskDraw(); 101 } 102 }); 103 104 onMount(async () => { 105 // Wait for layout to settle 106 await tick(); 107 + // Wait for a frame to ensure dimensions are set 108 + requestAnimationFrame(() => { 109 + initFluidSimulation(); 110 + }); 111 + 112 + if (document.fonts?.ready) { 113 + document.fonts.ready.then(() => { 114 + if (isInitialized) scheduleMaskDraw(); 115 + }); 116 + } 117 }); 118 119 onDestroy(() => { 120 if (animationId) cancelAnimationFrame(animationId); 121 if (splatIntervalId) clearInterval(splatIntervalId); 122 + if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); 123 if (resizeObserver) resizeObserver.disconnect(); 124 }); 125 126 function initFluidSimulation() { 127 if (!fluidCanvas || !maskCanvas || !container) return; 128 129 + maskReady = false; 130 + scheduleMaskDraw(); 131 132 // Simulation config 133 const config = { ··· 172 deltaY: 0, 173 down: false, 174 moved: false, 175 + color: [0, 0, 0] as [number, number, number] 176 }; 177 } 178 ··· 212 if (!gl) return { gl: null, ext: { supportLinearFiltering: false } as any }; 213 214 let halfFloat: any; 215 + let supportLinearFiltering = false; 216 if (isWebGL2) { 217 gl.getExtension('EXT_color_buffer_float'); 218 + supportLinearFiltering = !!gl.getExtension('OES_texture_float_linear'); 219 } else { 220 halfFloat = gl.getExtension('OES_texture_half_float'); 221 + supportLinearFiltering = !!gl.getExtension('OES_texture_half_float_linear'); 222 } 223 224 gl.clearColor(0.0, 0.0, 0.0, 1.0); 225 226 + let halfFloatTexType = isWebGL2 ? gl.HALF_FLOAT : halfFloat?.HALF_FLOAT_OES; 227 + let fallbackToUnsignedByte = false; 228 + if (!halfFloatTexType) { 229 + halfFloatTexType = gl.UNSIGNED_BYTE; 230 + supportLinearFiltering = true; 231 + fallbackToUnsignedByte = true; 232 + } 233 let formatRGBA: any; 234 let formatRG: any; 235 let formatR: any; 236 237 if (isWebGL2) { 238 + if (fallbackToUnsignedByte) { 239 + formatRGBA = { internalFormat: gl.RGBA8, format: gl.RGBA }; 240 + formatRG = { internalFormat: gl.RGBA8, format: gl.RGBA }; 241 + formatR = { internalFormat: gl.RGBA8, format: gl.RGBA }; 242 + } else { 243 + formatRGBA = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType); 244 + formatRG = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType); 245 + formatR = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType); 246 + if (!formatRGBA) formatRGBA = { internalFormat: gl.RGBA8, format: gl.RGBA }; 247 + if (!formatRG) formatRG = { internalFormat: gl.RGBA8, format: gl.RGBA }; 248 + if (!formatR) formatR = { internalFormat: gl.RGBA8, format: gl.RGBA }; 249 + } 250 } else { 251 + formatRGBA = { internalFormat: gl.RGBA, format: gl.RGBA }; 252 + formatRG = { internalFormat: gl.RGBA, format: gl.RGBA }; 253 + formatR = { internalFormat: gl.RGBA, format: gl.RGBA }; 254 + if (!fallbackToUnsignedByte) { 255 + formatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatRGBA; 256 + formatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatRG; 257 + formatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatR; 258 + } 259 } 260 261 return { ··· 295 format: number, 296 type: number 297 ) { 298 + if (!type) return false; 299 const texture = gl.createTexture(); 300 gl.bindTexture(gl.TEXTURE_2D, texture); 301 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); ··· 799 800 // Setup blit 801 gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); 802 + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW); 803 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); 804 + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW); 805 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); 806 gl.enableVertexAttribArray(0); 807 ··· 1091 texType, 1092 gl.NEAREST 1093 ); 1094 + curl = createFBO( 1095 + simRes.width, 1096 + simRes.height, 1097 + r.internalFormat, 1098 + r.format, 1099 + texType, 1100 + gl.NEAREST 1101 + ); 1102 pressure = createDoubleFBO( 1103 simRes.width, 1104 simRes.height, ··· 1118 const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST; 1119 1120 sunrays = createFBO(res.width, res.height, r.internalFormat, r.format, texType, filtering); 1121 + sunraysTemp = createFBO( 1122 + res.width, 1123 + res.height, 1124 + r.internalFormat, 1125 + r.format, 1126 + texType, 1127 + filtering 1128 + ); 1129 } 1130 1131 function updateKeywords() { ··· 1146 if (fluidCanvas.width !== width || fluidCanvas.height !== height) { 1147 fluidCanvas.width = width; 1148 fluidCanvas.height = height; 1149 + scheduleMaskDraw(); 1150 return true; 1151 } 1152 return false; ··· 1204 return radius; 1205 } 1206 1207 + function splat( 1208 + x: number, 1209 + y: number, 1210 + dx: number, 1211 + dy: number, 1212 + color: { r: number; g: number; b: number } 1213 + ) { 1214 splatProgram.bind(); 1215 gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0)); 1216 gl.uniform1f(splatProgram.uniforms.aspectRatio, fluidCanvas.width / fluidCanvas.height); ··· 1410 colorUpdateTimer = wrap(colorUpdateTimer, 0, 1); 1411 pointers.forEach((p) => { 1412 const c = generateColor(); 1413 + p.color = [c.r, c.g, c.b]; 1414 }); 1415 } 1416 } ··· 1437 function update() { 1438 const dt = calcDeltaTime() * (config.RENDER_SPEED ?? 1.0); 1439 if (resizeCanvas()) initFramebuffers(); 1440 + if (!maskReady) { 1441 + scheduleMaskDraw(); 1442 + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); 1443 + gl.clearColor(0.0, 0.0, 0.0, 1.0); 1444 + gl.clear(gl.COLOR_BUFFER_BIT); 1445 + animationId = requestAnimationFrame(update); 1446 + return; 1447 + } 1448 updateColors(dt); 1449 applyInputs(); 1450 if (!config.PAUSED) step(dt); ··· 1485 pointer.deltaX = 0; 1486 pointer.deltaY = 0; 1487 const c = generateColor(); 1488 + pointer.color = [c.r, c.g, c.b]; 1489 } 1490 1491 function updatePointerMoveData(pointer: Pointer, posX: number, posY: number) { ··· 1502 pointer.down = false; 1503 } 1504 1505 + // Event handlers - use container so events work over both canvases 1506 + container.addEventListener('mouseenter', (e) => { 1507 // Create a small burst when mouse enters the card 1508 + const rect = container.getBoundingClientRect(); 1509 + const posX = scaleByPixelRatio(e.clientX - rect.left); 1510 + const posY = scaleByPixelRatio(e.clientY - rect.top); 1511 const x = posX / fluidCanvas.width; 1512 const y = 1.0 - posY / fluidCanvas.height; 1513 const color = generateColor(); ··· 1517 splat(x, y, 300 * (Math.random() - 0.5), 300 * (Math.random() - 0.5), color); 1518 }); 1519 1520 + container.addEventListener('mousedown', (e) => { 1521 + const rect = container.getBoundingClientRect(); 1522 + const posX = scaleByPixelRatio(e.clientX - rect.left); 1523 + const posY = scaleByPixelRatio(e.clientY - rect.top); 1524 let pointer = pointers.find((p) => p.id === -1); 1525 if (!pointer) pointer = PointerPrototype(); 1526 updatePointerDownData(pointer, -1, posX, posY); 1527 }); 1528 1529 + container.addEventListener('mousemove', (e) => { 1530 const pointer = pointers[0]; 1531 + const rect = container.getBoundingClientRect(); 1532 + const posX = scaleByPixelRatio(e.clientX - rect.left); 1533 + const posY = scaleByPixelRatio(e.clientY - rect.top); 1534 updatePointerMoveData(pointer, posX, posY); 1535 // Always create swish effect on hover 1536 if (pointer.moved) { 1537 pointer.moved = false; 1538 // Generate a new color for visual interest 1539 const c = generateColor(); 1540 + pointer.color = [c.r, c.g, c.b]; 1541 + splat( 1542 + pointer.texcoordX, 1543 + pointer.texcoordY, 1544 + pointer.deltaX * config.SPLAT_FORCE * 5, 1545 + pointer.deltaY * config.SPLAT_FORCE * 5, 1546 + { 1547 + r: pointer.color[0], 1548 + g: pointer.color[1], 1549 + b: pointer.color[2] 1550 + } 1551 + ); 1552 } 1553 }); 1554 1555 + container.addEventListener('mouseup', () => { 1556 updatePointerUpData(pointers[0]); 1557 }); 1558 1559 + container.addEventListener('touchstart', (e) => { 1560 e.preventDefault(); 1561 const touches = e.targetTouches; 1562 while (touches.length >= pointers.length) pointers.push(PointerPrototype()); 1563 for (let i = 0; i < touches.length; i++) { 1564 + const rect = container.getBoundingClientRect(); 1565 const posX = scaleByPixelRatio(touches[i].clientX - rect.left); 1566 const posY = scaleByPixelRatio(touches[i].clientY - rect.top); 1567 updatePointerDownData(pointers[i + 1], touches[i].identifier, posX, posY); 1568 } 1569 }); 1570 1571 + container.addEventListener('touchmove', (e) => { 1572 e.preventDefault(); 1573 const touches = e.targetTouches; 1574 for (let i = 0; i < touches.length; i++) { 1575 const pointer = pointers[i + 1]; 1576 if (!pointer.down) continue; 1577 + const rect = container.getBoundingClientRect(); 1578 const posX = scaleByPixelRatio(touches[i].clientX - rect.left); 1579 const posY = scaleByPixelRatio(touches[i].clientY - rect.top); 1580 updatePointerMoveData(pointer, posX, posY); 1581 } 1582 }); 1583 1584 + container.addEventListener('touchend', (e) => { 1585 const touches = e.changedTouches; 1586 for (let i = 0; i < touches.length; i++) { 1587 const pointer = pointers.find((p) => p.id === touches[i].identifier); ··· 1603 // Resize observer - also triggers initial draw 1604 resizeObserver = new ResizeObserver(() => { 1605 resizeCanvas(); 1606 + maskReady = false; 1607 + scheduleMaskDraw(); 1608 }); 1609 resizeObserver.observe(container); 1610
+18 -109
src/lib/cards/FluidTextCard/FluidTextCardSettings.svelte
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import type { ContentComponentProps } from '../types'; 4 - import { ToggleGroup, ToggleGroupItem, Button, Input, Label } from '@foxui/core'; 5 6 let { item = $bindable<Item>() }: ContentComponentProps = $props(); 7 8 - const fontWeights = ['400', '500', '600', '700', '800', '900'] as const; 9 - const fontFamilies = [ 10 - 'Arial', 11 - 'Helvetica', 12 - 'Georgia', 13 - 'Times New Roman', 14 - 'Courier New', 15 - 'monospace' 16 - ] as const; 17 18 - const classes = 'size-8 min-w-8 text-xs cursor-pointer'; 19 </script> 20 21 <div class="flex flex-col gap-3"> 22 <div> 23 <Label class="mb-1 text-xs">Text</Label> 24 - <Input 25 - bind:value={item.cardData.text} 26 - placeholder="Enter text" 27 - class="w-full" 28 - /> 29 - </div> 30 - 31 - <div> 32 - <Label class="mb-1 text-xs">Font Weight</Label> 33 - <ToggleGroup 34 - type="single" 35 - bind:value={ 36 - () => item.cardData.fontWeight ?? '900', 37 - (value) => { 38 - if (!value) return; 39 - item.cardData.fontWeight = value; 40 - } 41 - } 42 - > 43 - {#each fontWeights as weight (weight)} 44 - <ToggleGroupItem size="sm" value={weight} class={classes}> 45 - {weight} 46 - </ToggleGroupItem> 47 - {/each} 48 - </ToggleGroup> 49 </div> 50 51 <div> 52 - <Label class="mb-1 text-xs">Font Family</Label> 53 - <select 54 - class="w-full rounded-md border border-base-200 bg-base-50 px-2 py-1.5 text-sm dark:border-base-800 dark:bg-base-900" 55 - value={item.cardData.fontFamily ?? 'Arial'} 56 - onchange={(e) => { 57 - item.cardData.fontFamily = e.currentTarget.value; 58 }} 59 - > 60 - {#each fontFamilies as font (font)} 61 - <option value={font}>{font}</option> 62 - {/each} 63 - </select> 64 - </div> 65 - 66 - <div> 67 - <Label class="mb-1 text-xs">Font Size ({Math.round((item.cardData.fontSize ?? 0.33) * 100)}%)</Label> 68 - <div class="flex items-center gap-2"> 69 - <Button 70 - variant="ghost" 71 - size="sm" 72 - onclick={() => { 73 - item.cardData.fontSize = Math.max((item.cardData.fontSize ?? 0.33) - 0.05, 0.1); 74 - }} 75 - disabled={(item.cardData.fontSize ?? 0.33) <= 0.1} 76 - > 77 - <svg 78 - xmlns="http://www.w3.org/2000/svg" 79 - width="16" 80 - height="16" 81 - viewBox="0 0 24 24" 82 - fill="none" 83 - stroke="currentColor" 84 - stroke-width="2" 85 - stroke-linecap="round" 86 - stroke-linejoin="round" 87 - > 88 - <path d="M5 12h14" /> 89 - </svg> 90 - </Button> 91 - <input 92 - type="range" 93 - min="0.1" 94 - max="0.8" 95 - step="0.01" 96 - value={item.cardData.fontSize ?? 0.33} 97 - oninput={(e) => { 98 - item.cardData.fontSize = parseFloat(e.currentTarget.value); 99 - }} 100 - class="h-2 w-full cursor-pointer appearance-none rounded-lg bg-base-200 dark:bg-base-700" 101 - /> 102 - <Button 103 - variant="ghost" 104 - size="sm" 105 - onclick={() => { 106 - item.cardData.fontSize = Math.min((item.cardData.fontSize ?? 0.33) + 0.05, 0.8); 107 - }} 108 - disabled={(item.cardData.fontSize ?? 0.33) >= 0.8} 109 - > 110 - <svg 111 - xmlns="http://www.w3.org/2000/svg" 112 - width="16" 113 - height="16" 114 - viewBox="0 0 24 24" 115 - fill="none" 116 - stroke="currentColor" 117 - stroke-width="2" 118 - stroke-linecap="round" 119 - stroke-linejoin="round" 120 - > 121 - <path d="M5 12h14" /> 122 - <path d="M12 5v14" /> 123 - </svg> 124 - </Button> 125 - </div> 126 </div> 127 </div>
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import type { ContentComponentProps } from '../types'; 4 + import { Input, Label } from '@foxui/core'; 5 6 let { item = $bindable<Item>() }: ContentComponentProps = $props(); 7 8 + // Initialize fontSize if not set 9 + if (item.cardData.fontSize === undefined) { 10 + item.cardData.fontSize = 0.33; 11 + } 12 13 + const displayPercent = $derived(Math.round((item.cardData.fontSize as number) * 100)); 14 </script> 15 16 <div class="flex flex-col gap-3"> 17 <div> 18 <Label class="mb-1 text-xs">Text</Label> 19 + <Input bind:value={item.cardData.text} placeholder="Enter text" class="w-full" /> 20 </div> 21 22 <div> 23 + <Label class="mb-1 text-xs">Font Size ({displayPercent}%)</Label> 24 + <input 25 + type="range" 26 + min="0.1" 27 + max="0.8" 28 + step="0.01" 29 + value={item.cardData.fontSize ?? 0.33} 30 + oninput={(e) => { 31 + item.cardData.fontSize = parseFloat(e.currentTarget.value); 32 }} 33 + class="bg-base-200 dark:bg-base-700 h-2 w-full cursor-pointer appearance-none rounded-lg" 34 + /> 35 </div> 36 </div>
+2 -4
src/lib/cards/FluidTextCard/index.ts
··· 9 createNew: (card) => { 10 card.cardType = 'fluid-text'; 11 card.cardData = { 12 - text: '', 13 - fontWeight: '900', 14 - fontFamily: 'Arial', 15 - fontSize: 0.33 16 }; 17 card.w = 4; 18 card.h = 2; ··· 23 settingsComponent: FluidTextCardSettings, 24 sidebarButtonText: 'Fluid Text', 25 defaultColor: 'transparent', 26 minW: 2, 27 minH: 2 28 } as CardDefinition & { type: 'fluid-text' };
··· 9 createNew: (card) => { 10 card.cardType = 'fluid-text'; 11 card.cardData = { 12 + text: '' 13 }; 14 card.w = 4; 15 card.h = 2; ··· 20 settingsComponent: FluidTextCardSettings, 21 sidebarButtonText: 'Fluid Text', 22 defaultColor: 'transparent', 23 + allowSetColor: false, 24 minW: 2, 25 minH: 2 26 } as CardDefinition & { type: 'fluid-text' };