tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
fixed fluid text card
unbedenklich
2 weeks ago
cfe2893c
1e11c704
+192
-178
4 changed files
expand all
collapse all
unified
split
.claude
settings.local.json
src
lib
cards
FluidTextCard
FluidTextCard.svelte
FluidTextCardSettings.svelte
index.ts
+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"
0
0
0
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>;
0
0
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;
0
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')!;
0
0
0
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;
0
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);
0
0
0
0
0
0
0
0
0
0
0
0
49
ctx.globalCompositeOperation = 'destination-out';
50
-
ctx.fillText(text, rect.width / 2, rect.height / 2);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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();
0
0
0
0
0
0
0
0
0
70
});
71
72
onDestroy(() => {
73
if (animationId) cancelAnimationFrame(animationId);
74
if (splatIntervalId) clearInterval(splatIntervalId);
0
75
if (resizeObserver) resizeObserver.disconnect();
76
});
77
78
function initFluidSimulation() {
79
if (!fluidCanvas || !maskCanvas || !container) return;
80
81
-
drawOverlayCanvas();
0
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;
0
0
0
0
0
0
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);
0
0
0
0
0
0
0
0
0
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);
0
0
0
0
0
190
}
191
192
return {
···
226
format: number,
227
type: number
228
) {
0
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);
0
0
0
0
0
0
0
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);
0
0
0
0
0
0
0
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 }) {
0
0
0
0
0
0
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();
0
0
0
0
0
0
0
0
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);
0
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);
0
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);
0
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);
0
0
0
0
0
0
0
0
0
0
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();
0
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;
0
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;
0
0
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);
0
0
0
0
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);
0
0
0
0
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;
0
0
0
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
+
}
0
0
0
0
0
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" />
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
+
/>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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',
0
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: ''
0
0
0
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' };