feat: add comprehensive text formatting system with gradients and retro fonts
- commit
- 5f5cd68185f1f6e5e1dfa4780305c48e4d0a14fc
- author
- Kit · · 2025-07-03 19:55 UTC
- parent
- d5464c3
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
7 files · +1968 -0
A src/lib/components/color-picker-256.svelte +453 -0
| @@ -0,0 +1,453 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting'; | |
| 3 | + | ||
| 4 | + | // Props | |
| 5 | + | let { | |
| 6 | + | selectedColor = $bindable('#000000'), | |
| 7 | + | selectedGradient = $bindable(undefined), | |
| 8 | + | onColorChange, | |
| 9 | + | onGradientChange, | |
| 10 | + | compact = true | |
| 11 | + | } = $props<{ | |
| 12 | + | selectedColor?: string; | |
| 13 | + | selectedGradient?: string[]; | |
| 14 | + | onColorChange?: (color: string) => void; | |
| 15 | + | onGradientChange?: (gradient: string[]) => void; | |
| 16 | + | compact?: boolean; | |
| 17 | + | }>(); | |
| 18 | + | ||
| 19 | + | // State | |
| 20 | + | let showPicker = $state(false); | |
| 21 | + | let gradientColors = $state<string[]>([selectedColor || '#000000']); | |
| 22 | + | let activeGradientSlot = $state(0); | |
| 23 | + | ||
| 24 | + | // Generate color palette | |
| 25 | + | const colors256 = generate256Colors(); | |
| 26 | + | const classicColors = Object.values(CLASSIC_COLORS); | |
| 27 | + | ||
| 28 | + | // Initialize gradient colors from props | |
| 29 | + | $effect(() => { | |
| 30 | + | if (selectedGradient && selectedGradient.length > 0) { | |
| 31 | + | gradientColors = [...selectedGradient]; | |
| 32 | + | } else if (selectedColor) { | |
| 33 | + | gradientColors = [selectedColor]; | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | // Get current display color/gradient | |
| 38 | + | let displayStyle = $derived(() => { | |
| 39 | + | if (gradientColors.length > 1) { | |
| 40 | + | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | |
| 41 | + | } | |
| 42 | + | return `background: ${gradientColors[0] || '#000000'}`; | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | // Handle color selection | |
| 46 | + | function selectColor(color: string) { | |
| 47 | + | if (activeGradientSlot < gradientColors.length) { | |
| 48 | + | gradientColors[activeGradientSlot] = color; | |
| 49 | + | gradientColors = [...gradientColors]; // Trigger reactivity | |
| 50 | + | ||
| 51 | + | if (gradientColors.length === 1) { | |
| 52 | + | // Single color mode | |
| 53 | + | selectedColor = color; | |
| 54 | + | selectedGradient = undefined; | |
| 55 | + | onColorChange?.(color); | |
| 56 | + | } else { | |
| 57 | + | // Gradient mode | |
| 58 | + | selectedGradient = [...gradientColors]; | |
| 59 | + | selectedColor = gradientColors[0]; | |
| 60 | + | onGradientChange?.(gradientColors); | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Gradient management | |
| 66 | + | function addGradientColor() { | |
| 67 | + | if (gradientColors.length < 3) { | |
| 68 | + | const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF'; | |
| 69 | + | gradientColors = [...gradientColors, newColor]; | |
| 70 | + | activeGradientSlot = gradientColors.length - 1; | |
| 71 | + | selectedGradient = [...gradientColors]; | |
| 72 | + | onGradientChange?.(gradientColors); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | function removeGradientColor(index: number) { | |
| 77 | + | if (gradientColors.length > 1) { | |
| 78 | + | gradientColors = gradientColors.filter((_, i) => i !== index); | |
| 79 | + | activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1); | |
| 80 | + | ||
| 81 | + | if (gradientColors.length === 1) { | |
| 82 | + | // Back to single color mode | |
| 83 | + | selectedColor = gradientColors[0]; | |
| 84 | + | selectedGradient = undefined; | |
| 85 | + | onColorChange?.(gradientColors[0]); | |
| 86 | + | } else { | |
| 87 | + | selectedGradient = [...gradientColors]; | |
| 88 | + | onGradientChange?.(gradientColors); | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | function setActiveSlot(index: number) { | |
| 94 | + | activeGradientSlot = index; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Close picker | |
| 98 | + | function closePicker() { | |
| 99 | + | showPicker = false; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Handle click outside to close | |
| 103 | + | function handleClickOutside(event: MouseEvent) { | |
| 104 | + | const target = event.target as HTMLElement; | |
| 105 | + | const colorPickerContainer = document.querySelector('.color-picker-container'); | |
| 106 | + | ||
| 107 | + | if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) { | |
| 108 | + | closePicker(); | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Add/remove click outside listener | |
| 113 | + | $effect(() => { | |
| 114 | + | if (showPicker) { | |
| 115 | + | document.addEventListener('click', handleClickOutside); | |
| 116 | + | return () => { | |
| 117 | + | document.removeEventListener('click', handleClickOutside); | |
| 118 | + | }; | |
| 119 | + | } | |
| 120 | + | }); | |
| 121 | + | </script> | |
| 122 | + | ||
| 123 | + | <div class="color-picker-container"> | |
| 124 | + | <!-- Color picker button --> | |
| 125 | + | <button | |
| 126 | + | onclick={(e) => { | |
| 127 | + | e.stopPropagation(); | |
| 128 | + | showPicker = !showPicker; | |
| 129 | + | }} | |
| 130 | + | title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'} | |
| 131 | + | style="width: 20px; height: 20px; padding: 0;" | |
| 132 | + | > | |
| 133 | + | <div | |
| 134 | + | class="color-indicator" | |
| 135 | + | style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;" | |
| 136 | + | ></div> | |
| 137 | + | </button> | |
| 138 | + | ||
| 139 | + | <!-- Color picker dropdown --> | |
| 140 | + | {#if showPicker} | |
| 141 | + | <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}> | |
| 142 | + | <!-- Gradient controls --> | |
| 143 | + | <div class="gradient-controls"> | |
| 144 | + | <div class="gradient-slots"> | |
| 145 | + | {#each gradientColors as color, index} | |
| 146 | + | <button | |
| 147 | + | class="gradient-slot" | |
| 148 | + | class:active={activeGradientSlot === index} | |
| 149 | + | style="background-color: {color} !important;" | |
| 150 | + | onclick={() => setActiveSlot(index)} | |
| 151 | + | title="Color {index + 1} - Click to select this color slot" | |
| 152 | + | > | |
| 153 | + | {#if gradientColors.length > 1} | |
| 154 | + | <span | |
| 155 | + | class="remove-slot" | |
| 156 | + | onclick={(e) => { | |
| 157 | + | e.stopPropagation(); | |
| 158 | + | removeGradientColor(index); | |
| 159 | + | }} | |
| 160 | + | title="Remove this color" | |
| 161 | + | role="button" | |
| 162 | + | tabindex="0" | |
| 163 | + | >×</span> | |
| 164 | + | {/if} | |
| 165 | + | </button> | |
| 166 | + | {/each} | |
| 167 | + | ||
| 168 | + | {#if gradientColors.length < 3} | |
| 169 | + | <button | |
| 170 | + | class="add-slot" | |
| 171 | + | onclick={addGradientColor} | |
| 172 | + | title="Add another color to create gradient" | |
| 173 | + | >+</button> | |
| 174 | + | {/if} | |
| 175 | + | </div> | |
| 176 | + | ||
| 177 | + | {#if gradientColors.length > 1} | |
| 178 | + | <div class="gradient-preview" style="background: linear-gradient(90deg, {gradientColors.join(', ')});"></div> | |
| 179 | + | {/if} | |
| 180 | + | </div> | |
| 181 | + | ||
| 182 | + | <!-- Classic colors (first row) --> | |
| 183 | + | <div class="color-section"> | |
| 184 | + | <div class="section-title">Classic Colors</div> | |
| 185 | + | <div class="color-row"> | |
| 186 | + | {#each classicColors as color} | |
| 187 | + | <button | |
| 188 | + | class="color-swatch classic-swatch" | |
| 189 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 190 | + | onclick={() => selectColor(color)} | |
| 191 | + | title={color} | |
| 192 | + | ></button> | |
| 193 | + | {/each} | |
| 194 | + | </div> | |
| 195 | + | </div> | |
| 196 | + | ||
| 197 | + | <!-- 256-color palette --> | |
| 198 | + | <div class="color-section"> | |
| 199 | + | <div class="section-title">Full Palette</div> | |
| 200 | + | <div class="color-grid"> | |
| 201 | + | {#each colors256 as color} | |
| 202 | + | <button | |
| 203 | + | class="color-swatch" | |
| 204 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |
| 205 | + | onclick={() => selectColor(color)} | |
| 206 | + | title={color} | |
| 207 | + | ></button> | |
| 208 | + | {/each} | |
| 209 | + | </div> | |
| 210 | + | </div> | |
| 211 | + | ||
| 212 | + | <!-- Close button --> | |
| 213 | + | <div class="picker-footer"> | |
| 214 | + | <button onclick={closePicker}>Close</button> | |
| 215 | + | </div> | |
| 216 | + | </div> | |
| 217 | + | {/if} | |
| 218 | + | </div> | |
| 219 | + | ||
| 220 | + | <style> | |
| 221 | + | .color-picker-container { | |
| 222 | + | position: relative; | |
| 223 | + | display: inline-block; | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | .picker-dropdown { | |
| 227 | + | position: absolute; | |
| 228 | + | top: 100%; | |
| 229 | + | right: 0; /* Changed from left: 0 to prevent cutoff */ | |
| 230 | + | z-index: 1000; | |
| 231 | + | padding: 4px; | |
| 232 | + | margin-top: 2px; | |
| 233 | + | background: #ece9d8; /* Authentic XP dialog background */ | |
| 234 | + | width: 420px; | |
| 235 | + | max-height: 380px; | |
| 236 | + | overflow-y: auto; | |
| 237 | + | border: 2px outset #c0c0c0; | |
| 238 | + | box-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| 239 | + | box-sizing: border-box; | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /* Responsive positioning for mobile/small screens */ | |
| 243 | + | @media (max-width: 768px) { | |
| 244 | + | .picker-dropdown { | |
| 245 | + | position: fixed; | |
| 246 | + | top: 50%; | |
| 247 | + | left: 50%; | |
| 248 | + | transform: translate(-50%, -50%); | |
| 249 | + | right: auto; | |
| 250 | + | width: 90vw; | |
| 251 | + | max-width: 420px; | |
| 252 | + | max-height: 85vh; | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | .gradient-controls { | |
| 257 | + | margin-bottom: 6px; | |
| 258 | + | padding: 4px; | |
| 259 | + | border: 1px inset #dfdfdf; | |
| 260 | + | background: #ece9d8; | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | .gradient-slots { | |
| 264 | + | display: flex; | |
| 265 | + | gap: 4px; | |
| 266 | + | margin-bottom: 4px; | |
| 267 | + | align-items: center; | |
| 268 | + | flex-wrap: wrap; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | .gradient-slot { | |
| 272 | + | width: 24px; | |
| 273 | + | height: 24px; | |
| 274 | + | border: 2px outset #c0c0c0 !important; | |
| 275 | + | cursor: pointer; | |
| 276 | + | position: relative; | |
| 277 | + | padding: 0 !important; | |
| 278 | + | margin: 0 !important; | |
| 279 | + | background-image: none !important; | |
| 280 | + | min-width: 24px !important; | |
| 281 | + | min-height: 24px !important; | |
| 282 | + | max-width: 24px !important; | |
| 283 | + | max-height: 24px !important; | |
| 284 | + | box-sizing: border-box !important; | |
| 285 | + | flex-shrink: 0; | |
| 286 | + | transition: all 0.2s ease; | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | .gradient-slot.active { | |
| 290 | + | border: 2px inset #c0c0c0 !important; | |
| 291 | + | box-shadow: inset 0 0 0 1px #000 !important; | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | .gradient-slot:hover { | |
| 295 | + | transform: scale(1.1); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | .gradient-slot .remove-slot { | |
| 299 | + | position: absolute; | |
| 300 | + | top: -4px; | |
| 301 | + | right: -4px; | |
| 302 | + | width: 12px !important; | |
| 303 | + | height: 12px !important; | |
| 304 | + | min-width: 12px !important; | |
| 305 | + | min-height: 12px !important; | |
| 306 | + | max-width: 12px !important; | |
| 307 | + | max-height: 12px !important; | |
| 308 | + | background-color: #808080 !important; | |
| 309 | + | color: white !important; | |
| 310 | + | border: 1px solid #000 !important; | |
| 311 | + | font-size: 9px !important; | |
| 312 | + | line-height: 1 !important; | |
| 313 | + | cursor: pointer !important; | |
| 314 | + | display: inline-flex !important; | |
| 315 | + | align-items: center !important; | |
| 316 | + | justify-content: center !important; | |
| 317 | + | border-radius: 0 !important; | |
| 318 | + | padding: 0 !important; | |
| 319 | + | margin: 0 !important; | |
| 320 | + | font-weight: normal !important; | |
| 321 | + | font-family: 'MS Sans Serif', sans-serif !important; | |
| 322 | + | z-index: 10; | |
| 323 | + | opacity: 0; | |
| 324 | + | transition: opacity 0.15s ease; | |
| 325 | + | text-align: center !important; | |
| 326 | + | box-sizing: border-box !important; | |
| 327 | + | user-select: none; | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | .gradient-slot:hover .remove-slot { | |
| 331 | + | opacity: 0.8; | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | .gradient-slot .remove-slot:hover { | |
| 335 | + | background-color: #000 !important; | |
| 336 | + | opacity: 1; | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | .add-slot { | |
| 340 | + | width: 24px; | |
| 341 | + | height: 24px; | |
| 342 | + | border: 2px dashed #666 !important; | |
| 343 | + | background: #f0f0f0 !important; | |
| 344 | + | cursor: pointer; | |
| 345 | + | font-weight: bold; | |
| 346 | + | padding: 0 !important; | |
| 347 | + | margin: 0 !important; | |
| 348 | + | min-width: 24px !important; | |
| 349 | + | min-height: 24px !important; | |
| 350 | + | max-width: 24px !important; | |
| 351 | + | max-height: 24px !important; | |
| 352 | + | background-image: none !important; | |
| 353 | + | box-sizing: border-box !important; | |
| 354 | + | flex-shrink: 0; | |
| 355 | + | display: flex !important; | |
| 356 | + | align-items: center !important; | |
| 357 | + | justify-content: center !important; | |
| 358 | + | font-size: 14px !important; | |
| 359 | + | color: #666 !important; | |
| 360 | + | transition: all 0.2s ease; | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | .add-slot:hover { | |
| 364 | + | background: #e0e0e0 !important; | |
| 365 | + | border-color: #333 !important; | |
| 366 | + | transform: scale(1.05); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | .gradient-preview { | |
| 370 | + | height: 12px; | |
| 371 | + | border: 1px inset #dfdfdf; | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | .color-section { | |
| 375 | + | margin-bottom: 6px; | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | .section-title { | |
| 379 | + | font-size: 10px; | |
| 380 | + | font-weight: bold; | |
| 381 | + | margin-bottom: 2px; | |
| 382 | + | color: #000; | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | .color-row { | |
| 386 | + | display: flex; | |
| 387 | + | flex-wrap: wrap; | |
| 388 | + | gap: 2px; | |
| 389 | + | margin-bottom: 4px; | |
| 390 | + | padding: 3px; | |
| 391 | + | background: #ece9d8; | |
| 392 | + | border: 1px inset #dfdfdf; | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | .classic-swatch { | |
| 396 | + | width: 18px !important; | |
| 397 | + | height: 18px !important; | |
| 398 | + | min-width: 18px !important; | |
| 399 | + | min-height: 18px !important; | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | .color-grid { | |
| 403 | + | display: grid; | |
| 404 | + | grid-template-columns: repeat(16, 1fr); | |
| 405 | + | gap: 2px; | |
| 406 | + | width: 100%; | |
| 407 | + | padding: 3px; | |
| 408 | + | background: #ece9d8; | |
| 409 | + | border: 1px inset #dfdfdf; | |
| 410 | + | box-sizing: border-box; | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | .color-swatch { | |
| 414 | + | width: 18px !important; | |
| 415 | + | height: 18px !important; | |
| 416 | + | min-width: 18px !important; | |
| 417 | + | min-height: 18px !important; | |
| 418 | + | border: 1px solid #808080 !important; /* Override xp.css */ | |
| 419 | + | cursor: pointer; | |
| 420 | + | padding: 0 !important; | |
| 421 | + | margin: 0 !important; | |
| 422 | + | box-sizing: border-box !important; | |
| 423 | + | outline: none !important; | |
| 424 | + | /* Remove any xp.css button styling */ | |
| 425 | + | background-image: none !important; | |
| 426 | + | background-color: var(--swatch-color, #000000) !important; | |
| 427 | + | font-size: 0 !important; /* Hide any text */ | |
| 428 | + | line-height: 0 !important; | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | .color-swatch:hover { | |
| 432 | + | border: 2px solid #000000 !important; | |
| 433 | + | transform: scale(1.1) !important; | |
| 434 | + | z-index: 5; | |
| 435 | + | position: relative; | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | .color-swatch:active { | |
| 439 | + | border: 2px solid #ffffff !important; | |
| 440 | + | transform: scale(0.95) !important; | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | .picker-footer { | |
| 444 | + | text-align: center; | |
| 445 | + | padding-top: 4px; | |
| 446 | + | border-top: 1px solid #808080; | |
| 447 | + | margin-top: 4px; | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | .color-indicator { | |
| 451 | + | border-radius: 1px; | |
| 452 | + | } | |
| 453 | + | </style>⏎̸ | |
| @@ -0,0 +1,453 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting'; | |||
| 3 | + | ||||
| 4 | + | // Props | |||
| 5 | + | let { | |||
| 6 | + | selectedColor = $bindable('#000000'), | |||
| 7 | + | selectedGradient = $bindable(undefined), | |||
| 8 | + | onColorChange, | |||
| 9 | + | onGradientChange, | |||
| 10 | + | compact = true | |||
| 11 | + | } = $props<{ | |||
| 12 | + | selectedColor?: string; | |||
| 13 | + | selectedGradient?: string[]; | |||
| 14 | + | onColorChange?: (color: string) => void; | |||
| 15 | + | onGradientChange?: (gradient: string[]) => void; | |||
| 16 | + | compact?: boolean; | |||
| 17 | + | }>(); | |||
| 18 | + | ||||
| 19 | + | // State | |||
| 20 | + | let showPicker = $state(false); | |||
| 21 | + | let gradientColors = $state<string[]>([selectedColor || '#000000']); | |||
| 22 | + | let activeGradientSlot = $state(0); | |||
| 23 | + | ||||
| 24 | + | // Generate color palette | |||
| 25 | + | const colors256 = generate256Colors(); | |||
| 26 | + | const classicColors = Object.values(CLASSIC_COLORS); | |||
| 27 | + | ||||
| 28 | + | // Initialize gradient colors from props | |||
| 29 | + | $effect(() => { | |||
| 30 | + | if (selectedGradient && selectedGradient.length > 0) { | |||
| 31 | + | gradientColors = [...selectedGradient]; | |||
| 32 | + | } else if (selectedColor) { | |||
| 33 | + | gradientColors = [selectedColor]; | |||
| 34 | + | } | |||
| 35 | + | }); | |||
| 36 | + | ||||
| 37 | + | // Get current display color/gradient | |||
| 38 | + | let displayStyle = $derived(() => { | |||
| 39 | + | if (gradientColors.length > 1) { | |||
| 40 | + | return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`; | |||
| 41 | + | } | |||
| 42 | + | return `background: ${gradientColors[0] || '#000000'}`; | |||
| 43 | + | }); | |||
| 44 | + | ||||
| 45 | + | // Handle color selection | |||
| 46 | + | function selectColor(color: string) { | |||
| 47 | + | if (activeGradientSlot < gradientColors.length) { | |||
| 48 | + | gradientColors[activeGradientSlot] = color; | |||
| 49 | + | gradientColors = [...gradientColors]; // Trigger reactivity | |||
| 50 | + | ||||
| 51 | + | if (gradientColors.length === 1) { | |||
| 52 | + | // Single color mode | |||
| 53 | + | selectedColor = color; | |||
| 54 | + | selectedGradient = undefined; | |||
| 55 | + | onColorChange?.(color); | |||
| 56 | + | } else { | |||
| 57 | + | // Gradient mode | |||
| 58 | + | selectedGradient = [...gradientColors]; | |||
| 59 | + | selectedColor = gradientColors[0]; | |||
| 60 | + | onGradientChange?.(gradientColors); | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Gradient management | |||
| 66 | + | function addGradientColor() { | |||
| 67 | + | if (gradientColors.length < 3) { | |||
| 68 | + | const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF'; | |||
| 69 | + | gradientColors = [...gradientColors, newColor]; | |||
| 70 | + | activeGradientSlot = gradientColors.length - 1; | |||
| 71 | + | selectedGradient = [...gradientColors]; | |||
| 72 | + | onGradientChange?.(gradientColors); | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | function removeGradientColor(index: number) { | |||
| 77 | + | if (gradientColors.length > 1) { | |||
| 78 | + | gradientColors = gradientColors.filter((_, i) => i !== index); | |||
| 79 | + | activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1); | |||
| 80 | + | ||||
| 81 | + | if (gradientColors.length === 1) { | |||
| 82 | + | // Back to single color mode | |||
| 83 | + | selectedColor = gradientColors[0]; | |||
| 84 | + | selectedGradient = undefined; | |||
| 85 | + | onColorChange?.(gradientColors[0]); | |||
| 86 | + | } else { | |||
| 87 | + | selectedGradient = [...gradientColors]; | |||
| 88 | + | onGradientChange?.(gradientColors); | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | function setActiveSlot(index: number) { | |||
| 94 | + | activeGradientSlot = index; | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Close picker | |||
| 98 | + | function closePicker() { | |||
| 99 | + | showPicker = false; | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Handle click outside to close | |||
| 103 | + | function handleClickOutside(event: MouseEvent) { | |||
| 104 | + | const target = event.target as HTMLElement; | |||
| 105 | + | const colorPickerContainer = document.querySelector('.color-picker-container'); | |||
| 106 | + | ||||
| 107 | + | if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) { | |||
| 108 | + | closePicker(); | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Add/remove click outside listener | |||
| 113 | + | $effect(() => { | |||
| 114 | + | if (showPicker) { | |||
| 115 | + | document.addEventListener('click', handleClickOutside); | |||
| 116 | + | return () => { | |||
| 117 | + | document.removeEventListener('click', handleClickOutside); | |||
| 118 | + | }; | |||
| 119 | + | } | |||
| 120 | + | }); | |||
| 121 | + | </script> | |||
| 122 | + | ||||
| 123 | + | <div class="color-picker-container"> | |||
| 124 | + | <!-- Color picker button --> | |||
| 125 | + | <button | |||
| 126 | + | onclick={(e) => { | |||
| 127 | + | e.stopPropagation(); | |||
| 128 | + | showPicker = !showPicker; | |||
| 129 | + | }} | |||
| 130 | + | title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'} | |||
| 131 | + | style="width: 20px; height: 20px; padding: 0;" | |||
| 132 | + | > | |||
| 133 | + | <div | |||
| 134 | + | class="color-indicator" | |||
| 135 | + | style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;" | |||
| 136 | + | ></div> | |||
| 137 | + | </button> | |||
| 138 | + | ||||
| 139 | + | <!-- Color picker dropdown --> | |||
| 140 | + | {#if showPicker} | |||
| 141 | + | <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}> | |||
| 142 | + | <!-- Gradient controls --> | |||
| 143 | + | <div class="gradient-controls"> | |||
| 144 | + | <div class="gradient-slots"> | |||
| 145 | + | {#each gradientColors as color, index} | |||
| 146 | + | <button | |||
| 147 | + | class="gradient-slot" | |||
| 148 | + | class:active={activeGradientSlot === index} | |||
| 149 | + | style="background-color: {color} !important;" | |||
| 150 | + | onclick={() => setActiveSlot(index)} | |||
| 151 | + | title="Color {index + 1} - Click to select this color slot" | |||
| 152 | + | > | |||
| 153 | + | {#if gradientColors.length > 1} | |||
| 154 | + | <span | |||
| 155 | + | class="remove-slot" | |||
| 156 | + | onclick={(e) => { | |||
| 157 | + | e.stopPropagation(); | |||
| 158 | + | removeGradientColor(index); | |||
| 159 | + | }} | |||
| 160 | + | title="Remove this color" | |||
| 161 | + | role="button" | |||
| 162 | + | tabindex="0" | |||
| 163 | + | >×</span> | |||
| 164 | + | {/if} | |||
| 165 | + | </button> | |||
| 166 | + | {/each} | |||
| 167 | + | ||||
| 168 | + | {#if gradientColors.length < 3} | |||
| 169 | + | <button | |||
| 170 | + | class="add-slot" | |||
| 171 | + | onclick={addGradientColor} | |||
| 172 | + | title="Add another color to create gradient" | |||
| 173 | + | >+</button> | |||
| 174 | + | {/if} | |||
| 175 | + | </div> | |||
| 176 | + | ||||
| 177 | + | {#if gradientColors.length > 1} | |||
| 178 | + | <div class="gradient-preview" style="background: linear-gradient(90deg, {gradientColors.join(', ')});"></div> | |||
| 179 | + | {/if} | |||
| 180 | + | </div> | |||
| 181 | + | ||||
| 182 | + | <!-- Classic colors (first row) --> | |||
| 183 | + | <div class="color-section"> | |||
| 184 | + | <div class="section-title">Classic Colors</div> | |||
| 185 | + | <div class="color-row"> | |||
| 186 | + | {#each classicColors as color} | |||
| 187 | + | <button | |||
| 188 | + | class="color-swatch classic-swatch" | |||
| 189 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |||
| 190 | + | onclick={() => selectColor(color)} | |||
| 191 | + | title={color} | |||
| 192 | + | ></button> | |||
| 193 | + | {/each} | |||
| 194 | + | </div> | |||
| 195 | + | </div> | |||
| 196 | + | ||||
| 197 | + | <!-- 256-color palette --> | |||
| 198 | + | <div class="color-section"> | |||
| 199 | + | <div class="section-title">Full Palette</div> | |||
| 200 | + | <div class="color-grid"> | |||
| 201 | + | {#each colors256 as color} | |||
| 202 | + | <button | |||
| 203 | + | class="color-swatch" | |||
| 204 | + | style="--swatch-color: {color}; background-color: {color} !important;" | |||
| 205 | + | onclick={() => selectColor(color)} | |||
| 206 | + | title={color} | |||
| 207 | + | ></button> | |||
| 208 | + | {/each} | |||
| 209 | + | </div> | |||
| 210 | + | </div> | |||
| 211 | + | ||||
| 212 | + | <!-- Close button --> | |||
| 213 | + | <div class="picker-footer"> | |||
| 214 | + | <button onclick={closePicker}>Close</button> | |||
| 215 | + | </div> | |||
| 216 | + | </div> | |||
| 217 | + | {/if} | |||
| 218 | + | </div> | |||
| 219 | + | ||||
| 220 | + | <style> | |||
| 221 | + | .color-picker-container { | |||
| 222 | + | position: relative; | |||
| 223 | + | display: inline-block; | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | .picker-dropdown { | |||
| 227 | + | position: absolute; | |||
| 228 | + | top: 100%; | |||
| 229 | + | right: 0; /* Changed from left: 0 to prevent cutoff */ | |||
| 230 | + | z-index: 1000; | |||
| 231 | + | padding: 4px; | |||
| 232 | + | margin-top: 2px; | |||
| 233 | + | background: #ece9d8; /* Authentic XP dialog background */ | |||
| 234 | + | width: 420px; | |||
| 235 | + | max-height: 380px; | |||
| 236 | + | overflow-y: auto; | |||
| 237 | + | border: 2px outset #c0c0c0; | |||
| 238 | + | box-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |||
| 239 | + | box-sizing: border-box; | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | /* Responsive positioning for mobile/small screens */ | |||
| 243 | + | @media (max-width: 768px) { | |||
| 244 | + | .picker-dropdown { | |||
| 245 | + | position: fixed; | |||
| 246 | + | top: 50%; | |||
| 247 | + | left: 50%; | |||
| 248 | + | transform: translate(-50%, -50%); | |||
| 249 | + | right: auto; | |||
| 250 | + | width: 90vw; | |||
| 251 | + | max-width: 420px; | |||
| 252 | + | max-height: 85vh; | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | .gradient-controls { | |||
| 257 | + | margin-bottom: 6px; | |||
| 258 | + | padding: 4px; | |||
| 259 | + | border: 1px inset #dfdfdf; | |||
| 260 | + | background: #ece9d8; | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | .gradient-slots { | |||
| 264 | + | display: flex; | |||
| 265 | + | gap: 4px; | |||
| 266 | + | margin-bottom: 4px; | |||
| 267 | + | align-items: center; | |||
| 268 | + | flex-wrap: wrap; | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | .gradient-slot { | |||
| 272 | + | width: 24px; | |||
| 273 | + | height: 24px; | |||
| 274 | + | border: 2px outset #c0c0c0 !important; | |||
| 275 | + | cursor: pointer; | |||
| 276 | + | position: relative; | |||
| 277 | + | padding: 0 !important; | |||
| 278 | + | margin: 0 !important; | |||
| 279 | + | background-image: none !important; | |||
| 280 | + | min-width: 24px !important; | |||
| 281 | + | min-height: 24px !important; | |||
| 282 | + | max-width: 24px !important; | |||
| 283 | + | max-height: 24px !important; | |||
| 284 | + | box-sizing: border-box !important; | |||
| 285 | + | flex-shrink: 0; | |||
| 286 | + | transition: all 0.2s ease; | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | .gradient-slot.active { | |||
| 290 | + | border: 2px inset #c0c0c0 !important; | |||
| 291 | + | box-shadow: inset 0 0 0 1px #000 !important; | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | .gradient-slot:hover { | |||
| 295 | + | transform: scale(1.1); | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | .gradient-slot .remove-slot { | |||
| 299 | + | position: absolute; | |||
| 300 | + | top: -4px; | |||
| 301 | + | right: -4px; | |||
| 302 | + | width: 12px !important; | |||
| 303 | + | height: 12px !important; | |||
| 304 | + | min-width: 12px !important; | |||
| 305 | + | min-height: 12px !important; | |||
| 306 | + | max-width: 12px !important; | |||
| 307 | + | max-height: 12px !important; | |||
| 308 | + | background-color: #808080 !important; | |||
| 309 | + | color: white !important; | |||
| 310 | + | border: 1px solid #000 !important; | |||
| 311 | + | font-size: 9px !important; | |||
| 312 | + | line-height: 1 !important; | |||
| 313 | + | cursor: pointer !important; | |||
| 314 | + | display: inline-flex !important; | |||
| 315 | + | align-items: center !important; | |||
| 316 | + | justify-content: center !important; | |||
| 317 | + | border-radius: 0 !important; | |||
| 318 | + | padding: 0 !important; | |||
| 319 | + | margin: 0 !important; | |||
| 320 | + | font-weight: normal !important; | |||
| 321 | + | font-family: 'MS Sans Serif', sans-serif !important; | |||
| 322 | + | z-index: 10; | |||
| 323 | + | opacity: 0; | |||
| 324 | + | transition: opacity 0.15s ease; | |||
| 325 | + | text-align: center !important; | |||
| 326 | + | box-sizing: border-box !important; | |||
| 327 | + | user-select: none; | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | .gradient-slot:hover .remove-slot { | |||
| 331 | + | opacity: 0.8; | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | .gradient-slot .remove-slot:hover { | |||
| 335 | + | background-color: #000 !important; | |||
| 336 | + | opacity: 1; | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | .add-slot { | |||
| 340 | + | width: 24px; | |||
| 341 | + | height: 24px; | |||
| 342 | + | border: 2px dashed #666 !important; | |||
| 343 | + | background: #f0f0f0 !important; | |||
| 344 | + | cursor: pointer; | |||
| 345 | + | font-weight: bold; | |||
| 346 | + | padding: 0 !important; | |||
| 347 | + | margin: 0 !important; | |||
| 348 | + | min-width: 24px !important; | |||
| 349 | + | min-height: 24px !important; | |||
| 350 | + | max-width: 24px !important; | |||
| 351 | + | max-height: 24px !important; | |||
| 352 | + | background-image: none !important; | |||
| 353 | + | box-sizing: border-box !important; | |||
| 354 | + | flex-shrink: 0; | |||
| 355 | + | display: flex !important; | |||
| 356 | + | align-items: center !important; | |||
| 357 | + | justify-content: center !important; | |||
| 358 | + | font-size: 14px !important; | |||
| 359 | + | color: #666 !important; | |||
| 360 | + | transition: all 0.2s ease; | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | .add-slot:hover { | |||
| 364 | + | background: #e0e0e0 !important; | |||
| 365 | + | border-color: #333 !important; | |||
| 366 | + | transform: scale(1.05); | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | .gradient-preview { | |||
| 370 | + | height: 12px; | |||
| 371 | + | border: 1px inset #dfdfdf; | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | .color-section { | |||
| 375 | + | margin-bottom: 6px; | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | .section-title { | |||
| 379 | + | font-size: 10px; | |||
| 380 | + | font-weight: bold; | |||
| 381 | + | margin-bottom: 2px; | |||
| 382 | + | color: #000; | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | .color-row { | |||
| 386 | + | display: flex; | |||
| 387 | + | flex-wrap: wrap; | |||
| 388 | + | gap: 2px; | |||
| 389 | + | margin-bottom: 4px; | |||
| 390 | + | padding: 3px; | |||
| 391 | + | background: #ece9d8; | |||
| 392 | + | border: 1px inset #dfdfdf; | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | .classic-swatch { | |||
| 396 | + | width: 18px !important; | |||
| 397 | + | height: 18px !important; | |||
| 398 | + | min-width: 18px !important; | |||
| 399 | + | min-height: 18px !important; | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | .color-grid { | |||
| 403 | + | display: grid; | |||
| 404 | + | grid-template-columns: repeat(16, 1fr); | |||
| 405 | + | gap: 2px; | |||
| 406 | + | width: 100%; | |||
| 407 | + | padding: 3px; | |||
| 408 | + | background: #ece9d8; | |||
| 409 | + | border: 1px inset #dfdfdf; | |||
| 410 | + | box-sizing: border-box; | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | .color-swatch { | |||
| 414 | + | width: 18px !important; | |||
| 415 | + | height: 18px !important; | |||
| 416 | + | min-width: 18px !important; | |||
| 417 | + | min-height: 18px !important; | |||
| 418 | + | border: 1px solid #808080 !important; /* Override xp.css */ | |||
| 419 | + | cursor: pointer; | |||
| 420 | + | padding: 0 !important; | |||
| 421 | + | margin: 0 !important; | |||
| 422 | + | box-sizing: border-box !important; | |||
| 423 | + | outline: none !important; | |||
| 424 | + | /* Remove any xp.css button styling */ | |||
| 425 | + | background-image: none !important; | |||
| 426 | + | background-color: var(--swatch-color, #000000) !important; | |||
| 427 | + | font-size: 0 !important; /* Hide any text */ | |||
| 428 | + | line-height: 0 !important; | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | .color-swatch:hover { | |||
| 432 | + | border: 2px solid #000000 !important; | |||
| 433 | + | transform: scale(1.1) !important; | |||
| 434 | + | z-index: 5; | |||
| 435 | + | position: relative; | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | .color-swatch:active { | |||
| 439 | + | border: 2px solid #ffffff !important; | |||
| 440 | + | transform: scale(0.95) !important; | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | .picker-footer { | |||
| 444 | + | text-align: center; | |||
| 445 | + | padding-top: 4px; | |||
| 446 | + | border-top: 1px solid #808080; | |||
| 447 | + | margin-top: 4px; | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | .color-indicator { | |||
| 451 | + | border-radius: 1px; | |||
| 452 | + | } | |||
| 453 | + | </style>⏎̸ | |||
A src/lib/components/formatted-message.svelte +249 -0
| @@ -0,0 +1,249 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import type { EnrichedMessage } from '../types/chat'; | |
| 4 | + | import type { TextStyle } from '../types/text-formatting'; | |
| 5 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 6 | + | import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting'; | |
| 7 | + | ||
| 8 | + | // Props | |
| 9 | + | let { | |
| 10 | + | message, | |
| 11 | + | allowFormatting = true | |
| 12 | + | } = $props<{ | |
| 13 | + | message: EnrichedMessage; | |
| 14 | + | allowFormatting?: boolean; | |
| 15 | + | }>(); | |
| 16 | + | ||
| 17 | + | // State | |
| 18 | + | let formattedContent = $state(''); | |
| 19 | + | let isProcessing = $state(false); | |
| 20 | + | let hasError = $state(false); | |
| 21 | + | ||
| 22 | + | // Process message formatting | |
| 23 | + | async function processMessage() { | |
| 24 | + | if (!allowFormatting || !message.hasFormatting) { | |
| 25 | + | formattedContent = escapeHtml(message.content); | |
| 26 | + | return; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | isProcessing = true; | |
| 30 | + | hasError = false; | |
| 31 | + | ||
| 32 | + | try { | |
| 33 | + | // Parse style data if available | |
| 34 | + | let style: TextStyle = DEFAULT_TEXT_STYLE; | |
| 35 | + | if (message.styleData) { | |
| 36 | + | try { | |
| 37 | + | style = JSON.parse(message.styleData); | |
| 38 | + | console.debug('Parsed message style:', style, 'for message:', message.content); | |
| 39 | + | } catch (parseError) { | |
| 40 | + | console.warn('Failed to parse message style data:', parseError); | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Check if this is a gradient message | |
| 45 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 46 | + | // Use character-by-character gradient for better compatibility | |
| 47 | + | console.debug('Creating gradient text with colors:', style.gradient, 'for text:', message.content); | |
| 48 | + | formattedContent = createGradientText(message.content, style.gradient, 'gradient-text-static', style); | |
| 49 | + | console.debug('Generated gradient HTML:', formattedContent); | |
| 50 | + | return; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Regular formatted text (no gradient) | |
| 54 | + | const styleWithoutGradient = { ...style, gradient: undefined }; | |
| 55 | + | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | |
| 56 | + | ||
| 57 | + | } catch (error) { | |
| 58 | + | console.error('Message formatting error:', error); | |
| 59 | + | hasError = true; | |
| 60 | + | formattedContent = escapeHtml(message.content); | |
| 61 | + | } finally { | |
| 62 | + | isProcessing = false; | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Process message when it changes | |
| 67 | + | $effect(() => { | |
| 68 | + | processMessage(); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | // Utility function to escape HTML | |
| 72 | + | function escapeHtml(text: string): string { | |
| 73 | + | const div = document.createElement('div'); | |
| 74 | + | div.textContent = text; | |
| 75 | + | return div.innerHTML; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Generate inline styles for the message | |
| 79 | + | function getMessageStyles(): string { | |
| 80 | + | if (!message.hasFormatting || !message.styleData) { | |
| 81 | + | return ''; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | try { | |
| 85 | + | const style: TextStyle = JSON.parse(message.styleData); | |
| 86 | + | // For gradient messages, we still need some styles but not font-family (use class instead) | |
| 87 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 88 | + | // Return only non-color and non-font-family styles for gradient messages | |
| 89 | + | const styles: string[] = []; | |
| 90 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 91 | + | if (style.bold) styles.push(`font-weight: bold`); | |
| 92 | + | if (style.italic) styles.push(`font-style: italic`); | |
| 93 | + | const decorations: string[] = []; | |
| 94 | + | if (style.underline) decorations.push('underline'); | |
| 95 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 96 | + | if (decorations.length > 0) { | |
| 97 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 98 | + | } | |
| 99 | + | return styles.join('; '); | |
| 100 | + | } | |
| 101 | + | // For non-gradient messages, exclude font-family (use class instead) | |
| 102 | + | const styles: string[] = []; | |
| 103 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 104 | + | if (style.color) styles.push(`color: ${style.color}`); | |
| 105 | + | if (style.bold) styles.push(`font-weight: bold`); | |
| 106 | + | if (style.italic) styles.push(`font-style: italic`); | |
| 107 | + | const decorations: string[] = []; | |
| 108 | + | if (style.underline) decorations.push('underline'); | |
| 109 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 110 | + | if (decorations.length > 0) { | |
| 111 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 112 | + | } | |
| 113 | + | return styles.join('; '); | |
| 114 | + | } catch (error) { | |
| 115 | + | console.warn('Failed to generate message styles:', error); | |
| 116 | + | return ''; | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Get CSS classes for the message | |
| 121 | + | function getMessageClasses(): string { | |
| 122 | + | const classes = ['formatted-message']; | |
| 123 | + | ||
| 124 | + | if (!message.hasFormatting || !message.styleData) { | |
| 125 | + | return classes.join(' '); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | try { | |
| 129 | + | const style: TextStyle = JSON.parse(message.styleData); | |
| 130 | + | ||
| 131 | + | // Add font family class | |
| 132 | + | classes.push(`retro-font-${style.fontFamily}`); | |
| 133 | + | ||
| 134 | + | // Add style classes | |
| 135 | + | if (style.bold) classes.push('style-bold'); | |
| 136 | + | if (style.italic) classes.push('style-italic'); | |
| 137 | + | if (style.underline) classes.push('style-underline'); | |
| 138 | + | if (style.strikethrough) classes.push('style-strikethrough'); | |
| 139 | + | ||
| 140 | + | // Add gradient class if applicable | |
| 141 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 142 | + | classes.push('gradient-text-static'); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | } catch (error) { | |
| 146 | + | console.warn('Failed to parse message style for classes:', error); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | return classes.join(' '); | |
| 150 | + | } | |
| 151 | + | </script> | |
| 152 | + | ||
| 153 | + | {#if isProcessing} | |
| 154 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 155 | + | <span class="processing">...</span> | |
| 156 | + | </span> | |
| 157 | + | {:else if hasError} | |
| 158 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 159 | + | <span class="error" title="Formatting error - showing plain text">{message.content}</span> | |
| 160 | + | </span> | |
| 161 | + | {:else} | |
| 162 | + | <!-- For gradient messages, render without wrapper to avoid conflicts --> | |
| 163 | + | {#if message.hasFormatting && message.styleData} | |
| 164 | + | {@const parsedStyle = (() => { | |
| 165 | + | try { | |
| 166 | + | return JSON.parse(message.styleData); | |
| 167 | + | } catch { | |
| 168 | + | return null; | |
| 169 | + | } | |
| 170 | + | })()} | |
| 171 | + | {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1} | |
| 172 | + | <!-- Gradient messages are already wrapped properly --> | |
| 173 | + | {@html formattedContent} | |
| 174 | + | {:else} | |
| 175 | + | <!-- Regular formatted messages --> | |
| 176 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |
| 177 | + | {@html formattedContent} | |
| 178 | + | </span> | |
| 179 | + | {/if} | |
| 180 | + | {:else} | |
| 181 | + | <!-- Plain messages --> | |
| 182 | + | <span class={getMessageClasses()}> | |
| 183 | + | {@html formattedContent} | |
| 184 | + | </span> | |
| 185 | + | {/if} | |
| 186 | + | {/if} | |
| 187 | + | ||
| 188 | + | <style> | |
| 189 | + | .processing { | |
| 190 | + | color: #666; | |
| 191 | + | font-style: italic; | |
| 192 | + | animation: pulse 1s infinite; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | .error { | |
| 196 | + | color: #cc0000; | |
| 197 | + | border-bottom: 1px dotted #cc0000; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | @keyframes pulse { | |
| 201 | + | 0%, 100% { opacity: 1; } | |
| 202 | + | 50% { opacity: 0.5; } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /* Ensure formatted messages don't break layout */ | |
| 206 | + | :global(.formatted-message) { | |
| 207 | + | word-break: break-word; | |
| 208 | + | overflow-wrap: break-word; | |
| 209 | + | hyphens: auto; | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /* Improve gradient text readability */ | |
| 213 | + | :global(.formatted-message.gradient-text-static) { | |
| 214 | + | font-weight: bold; | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /* Ensure gradient spans are visible and preserve their colors */ | |
| 218 | + | :global(.gradient-text-static .gradient-char) { | |
| 219 | + | display: inline; | |
| 220 | + | opacity: 1 !important; | |
| 221 | + | margin: 0 !important; | |
| 222 | + | padding: 0 !important; | |
| 223 | + | letter-spacing: 0 !important; | |
| 224 | + | -webkit-text-fill-color: initial !important; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /* Don't override inline styles for gradient chars - removed to let inline styles work */ | |
| 228 | + | ||
| 229 | + | /* Remove any blur or gray effects */ | |
| 230 | + | :global(.formatted-message) { | |
| 231 | + | opacity: 1 !important; | |
| 232 | + | filter: none !important; | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /* Don't inherit color for gradient text spans */ | |
| 236 | + | :global(.formatted-message:not(.gradient-text-static)) { | |
| 237 | + | color: inherit; | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /* Ensure accessibility for screen readers */ | |
| 241 | + | :global(.formatted-message[aria-label]) { | |
| 242 | + | position: relative; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /* Performance optimization for complex messages */ | |
| 246 | + | :global(.formatted-message) { | |
| 247 | + | contain: layout style; | |
| 248 | + | } | |
| 249 | + | </style>⏎̸ | |
| @@ -0,0 +1,249 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import type { EnrichedMessage } from '../types/chat'; | |||
| 4 | + | import type { TextStyle } from '../types/text-formatting'; | |||
| 5 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |||
| 6 | + | import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting'; | |||
| 7 | + | ||||
| 8 | + | // Props | |||
| 9 | + | let { | |||
| 10 | + | message, | |||
| 11 | + | allowFormatting = true | |||
| 12 | + | } = $props<{ | |||
| 13 | + | message: EnrichedMessage; | |||
| 14 | + | allowFormatting?: boolean; | |||
| 15 | + | }>(); | |||
| 16 | + | ||||
| 17 | + | // State | |||
| 18 | + | let formattedContent = $state(''); | |||
| 19 | + | let isProcessing = $state(false); | |||
| 20 | + | let hasError = $state(false); | |||
| 21 | + | ||||
| 22 | + | // Process message formatting | |||
| 23 | + | async function processMessage() { | |||
| 24 | + | if (!allowFormatting || !message.hasFormatting) { | |||
| 25 | + | formattedContent = escapeHtml(message.content); | |||
| 26 | + | return; | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | isProcessing = true; | |||
| 30 | + | hasError = false; | |||
| 31 | + | ||||
| 32 | + | try { | |||
| 33 | + | // Parse style data if available | |||
| 34 | + | let style: TextStyle = DEFAULT_TEXT_STYLE; | |||
| 35 | + | if (message.styleData) { | |||
| 36 | + | try { | |||
| 37 | + | style = JSON.parse(message.styleData); | |||
| 38 | + | console.debug('Parsed message style:', style, 'for message:', message.content); | |||
| 39 | + | } catch (parseError) { | |||
| 40 | + | console.warn('Failed to parse message style data:', parseError); | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Check if this is a gradient message | |||
| 45 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |||
| 46 | + | // Use character-by-character gradient for better compatibility | |||
| 47 | + | console.debug('Creating gradient text with colors:', style.gradient, 'for text:', message.content); | |||
| 48 | + | formattedContent = createGradientText(message.content, style.gradient, 'gradient-text-static', style); | |||
| 49 | + | console.debug('Generated gradient HTML:', formattedContent); | |||
| 50 | + | return; | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Regular formatted text (no gradient) | |||
| 54 | + | const styleWithoutGradient = { ...style, gradient: undefined }; | |||
| 55 | + | formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting); | |||
| 56 | + | ||||
| 57 | + | } catch (error) { | |||
| 58 | + | console.error('Message formatting error:', error); | |||
| 59 | + | hasError = true; | |||
| 60 | + | formattedContent = escapeHtml(message.content); | |||
| 61 | + | } finally { | |||
| 62 | + | isProcessing = false; | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Process message when it changes | |||
| 67 | + | $effect(() => { | |||
| 68 | + | processMessage(); | |||
| 69 | + | }); | |||
| 70 | + | ||||
| 71 | + | // Utility function to escape HTML | |||
| 72 | + | function escapeHtml(text: string): string { | |||
| 73 | + | const div = document.createElement('div'); | |||
| 74 | + | div.textContent = text; | |||
| 75 | + | return div.innerHTML; | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Generate inline styles for the message | |||
| 79 | + | function getMessageStyles(): string { | |||
| 80 | + | if (!message.hasFormatting || !message.styleData) { | |||
| 81 | + | return ''; | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | try { | |||
| 85 | + | const style: TextStyle = JSON.parse(message.styleData); | |||
| 86 | + | // For gradient messages, we still need some styles but not font-family (use class instead) | |||
| 87 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |||
| 88 | + | // Return only non-color and non-font-family styles for gradient messages | |||
| 89 | + | const styles: string[] = []; | |||
| 90 | + | styles.push(`font-size: ${style.fontSize}px`); | |||
| 91 | + | if (style.bold) styles.push(`font-weight: bold`); | |||
| 92 | + | if (style.italic) styles.push(`font-style: italic`); | |||
| 93 | + | const decorations: string[] = []; | |||
| 94 | + | if (style.underline) decorations.push('underline'); | |||
| 95 | + | if (style.strikethrough) decorations.push('line-through'); | |||
| 96 | + | if (decorations.length > 0) { | |||
| 97 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |||
| 98 | + | } | |||
| 99 | + | return styles.join('; '); | |||
| 100 | + | } | |||
| 101 | + | // For non-gradient messages, exclude font-family (use class instead) | |||
| 102 | + | const styles: string[] = []; | |||
| 103 | + | styles.push(`font-size: ${style.fontSize}px`); | |||
| 104 | + | if (style.color) styles.push(`color: ${style.color}`); | |||
| 105 | + | if (style.bold) styles.push(`font-weight: bold`); | |||
| 106 | + | if (style.italic) styles.push(`font-style: italic`); | |||
| 107 | + | const decorations: string[] = []; | |||
| 108 | + | if (style.underline) decorations.push('underline'); | |||
| 109 | + | if (style.strikethrough) decorations.push('line-through'); | |||
| 110 | + | if (decorations.length > 0) { | |||
| 111 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |||
| 112 | + | } | |||
| 113 | + | return styles.join('; '); | |||
| 114 | + | } catch (error) { | |||
| 115 | + | console.warn('Failed to generate message styles:', error); | |||
| 116 | + | return ''; | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Get CSS classes for the message | |||
| 121 | + | function getMessageClasses(): string { | |||
| 122 | + | const classes = ['formatted-message']; | |||
| 123 | + | ||||
| 124 | + | if (!message.hasFormatting || !message.styleData) { | |||
| 125 | + | return classes.join(' '); | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | try { | |||
| 129 | + | const style: TextStyle = JSON.parse(message.styleData); | |||
| 130 | + | ||||
| 131 | + | // Add font family class | |||
| 132 | + | classes.push(`retro-font-${style.fontFamily}`); | |||
| 133 | + | ||||
| 134 | + | // Add style classes | |||
| 135 | + | if (style.bold) classes.push('style-bold'); | |||
| 136 | + | if (style.italic) classes.push('style-italic'); | |||
| 137 | + | if (style.underline) classes.push('style-underline'); | |||
| 138 | + | if (style.strikethrough) classes.push('style-strikethrough'); | |||
| 139 | + | ||||
| 140 | + | // Add gradient class if applicable | |||
| 141 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |||
| 142 | + | classes.push('gradient-text-static'); | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | } catch (error) { | |||
| 146 | + | console.warn('Failed to parse message style for classes:', error); | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | return classes.join(' '); | |||
| 150 | + | } | |||
| 151 | + | </script> | |||
| 152 | + | ||||
| 153 | + | {#if isProcessing} | |||
| 154 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |||
| 155 | + | <span class="processing">...</span> | |||
| 156 | + | </span> | |||
| 157 | + | {:else if hasError} | |||
| 158 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |||
| 159 | + | <span class="error" title="Formatting error - showing plain text">{message.content}</span> | |||
| 160 | + | </span> | |||
| 161 | + | {:else} | |||
| 162 | + | <!-- For gradient messages, render without wrapper to avoid conflicts --> | |||
| 163 | + | {#if message.hasFormatting && message.styleData} | |||
| 164 | + | {@const parsedStyle = (() => { | |||
| 165 | + | try { | |||
| 166 | + | return JSON.parse(message.styleData); | |||
| 167 | + | } catch { | |||
| 168 | + | return null; | |||
| 169 | + | } | |||
| 170 | + | })()} | |||
| 171 | + | {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1} | |||
| 172 | + | <!-- Gradient messages are already wrapped properly --> | |||
| 173 | + | {@html formattedContent} | |||
| 174 | + | {:else} | |||
| 175 | + | <!-- Regular formatted messages --> | |||
| 176 | + | <span class={getMessageClasses()} style={getMessageStyles()}> | |||
| 177 | + | {@html formattedContent} | |||
| 178 | + | </span> | |||
| 179 | + | {/if} | |||
| 180 | + | {:else} | |||
| 181 | + | <!-- Plain messages --> | |||
| 182 | + | <span class={getMessageClasses()}> | |||
| 183 | + | {@html formattedContent} | |||
| 184 | + | </span> | |||
| 185 | + | {/if} | |||
| 186 | + | {/if} | |||
| 187 | + | ||||
| 188 | + | <style> | |||
| 189 | + | .processing { | |||
| 190 | + | color: #666; | |||
| 191 | + | font-style: italic; | |||
| 192 | + | animation: pulse 1s infinite; | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | .error { | |||
| 196 | + | color: #cc0000; | |||
| 197 | + | border-bottom: 1px dotted #cc0000; | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | @keyframes pulse { | |||
| 201 | + | 0%, 100% { opacity: 1; } | |||
| 202 | + | 50% { opacity: 0.5; } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | /* Ensure formatted messages don't break layout */ | |||
| 206 | + | :global(.formatted-message) { | |||
| 207 | + | word-break: break-word; | |||
| 208 | + | overflow-wrap: break-word; | |||
| 209 | + | hyphens: auto; | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | /* Improve gradient text readability */ | |||
| 213 | + | :global(.formatted-message.gradient-text-static) { | |||
| 214 | + | font-weight: bold; | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | /* Ensure gradient spans are visible and preserve their colors */ | |||
| 218 | + | :global(.gradient-text-static .gradient-char) { | |||
| 219 | + | display: inline; | |||
| 220 | + | opacity: 1 !important; | |||
| 221 | + | margin: 0 !important; | |||
| 222 | + | padding: 0 !important; | |||
| 223 | + | letter-spacing: 0 !important; | |||
| 224 | + | -webkit-text-fill-color: initial !important; | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | /* Don't override inline styles for gradient chars - removed to let inline styles work */ | |||
| 228 | + | ||||
| 229 | + | /* Remove any blur or gray effects */ | |||
| 230 | + | :global(.formatted-message) { | |||
| 231 | + | opacity: 1 !important; | |||
| 232 | + | filter: none !important; | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | /* Don't inherit color for gradient text spans */ | |||
| 236 | + | :global(.formatted-message:not(.gradient-text-static)) { | |||
| 237 | + | color: inherit; | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | /* Ensure accessibility for screen readers */ | |||
| 241 | + | :global(.formatted-message[aria-label]) { | |||
| 242 | + | position: relative; | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | /* Performance optimization for complex messages */ | |||
| 246 | + | :global(.formatted-message) { | |||
| 247 | + | contain: layout style; | |||
| 248 | + | } | |||
| 249 | + | </style>⏎̸ | |||
A src/lib/components/text-formatting-toolbar.svelte +216 -0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { | |
| 3 | + | RETRO_FONTS, | |
| 4 | + | CLASSIC_COLORS, | |
| 5 | + | GRADIENT_PRESETS, | |
| 6 | + | DEFAULT_TEXT_STYLE, | |
| 7 | + | type TextStyle, | |
| 8 | + | type FontFamily | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | + | ||
| 12 | + | // Props | |
| 13 | + | let { | |
| 14 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 15 | + | onPreviewText, | |
| 16 | + | compact = true, | |
| 17 | + | showFontSelector = false, | |
| 18 | + | showGradients = true | |
| 19 | + | } = $props<{ | |
| 20 | + | style?: TextStyle; | |
| 21 | + | onPreviewText?: (text: string) => void; | |
| 22 | + | compact?: boolean; | |
| 23 | + | showFontSelector?: boolean; | |
| 24 | + | showGradients?: boolean; | |
| 25 | + | }>(); | |
| 26 | + | ||
| 27 | + | // State | |
| 28 | + | let currentColor = $state(style.color || '#000000'); | |
| 29 | + | let currentGradient = $state(style.gradient); | |
| 30 | + | ||
| 31 | + | // Toggle functions | |
| 32 | + | function toggleBold() { | |
| 33 | + | style = { ...style, bold: !style.bold }; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | function toggleItalic() { | |
| 37 | + | style = { ...style, italic: !style.italic }; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | function toggleUnderline() { | |
| 41 | + | style = { ...style, underline: !style.underline }; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function handleColorChange(color: string) { | |
| 45 | + | currentColor = color; | |
| 46 | + | style = { ...style, color, gradient: undefined }; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | function handleGradientChange(gradient: string[]) { | |
| 50 | + | currentGradient = gradient; | |
| 51 | + | style = { ...style, gradient, color: gradient[0] }; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | function selectFont(fontFamily: FontFamily) { | |
| 55 | + | style = { ...style, fontFamily }; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Keyboard shortcuts handler | |
| 59 | + | function handleKeydown(event: KeyboardEvent) { | |
| 60 | + | if (event.ctrlKey || event.metaKey) { | |
| 61 | + | switch (event.key.toLowerCase()) { | |
| 62 | + | case 'b': | |
| 63 | + | event.preventDefault(); | |
| 64 | + | toggleBold(); | |
| 65 | + | break; | |
| 66 | + | case 'i': | |
| 67 | + | event.preventDefault(); | |
| 68 | + | toggleItalic(); | |
| 69 | + | break; | |
| 70 | + | case 'u': | |
| 71 | + | event.preventDefault(); | |
| 72 | + | toggleUnderline(); | |
| 73 | + | break; | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Preview text change | |
| 79 | + | function previewTextChanged() { | |
| 80 | + | if (onPreviewText) { | |
| 81 | + | onPreviewText('Sample formatted text'); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Update internal state when style prop changes | |
| 86 | + | $effect(() => { | |
| 87 | + | currentColor = style.color || '#000000'; | |
| 88 | + | currentGradient = style.gradient; | |
| 89 | + | }); | |
| 90 | + | </script> | |
| 91 | + | ||
| 92 | + | <svelte:window onkeydown={handleKeydown} /> | |
| 93 | + | ||
| 94 | + | <div class="text-formatting-toolbar sunken-panel" class:compact> | |
| 95 | + | ||
| 96 | + | <!-- Font selector (if enabled) --> | |
| 97 | + | {#if showFontSelector} | |
| 98 | + | <select | |
| 99 | + | value={style.fontFamily} | |
| 100 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |
| 101 | + | title="Font Family" | |
| 102 | + | > | |
| 103 | + | {#each Object.entries(RETRO_FONTS) as [key, font]} | |
| 104 | + | <option value={key} style="font-family: {font.stack};">{font.name}</option> | |
| 105 | + | {/each} | |
| 106 | + | </select> | |
| 107 | + | {/if} | |
| 108 | + | ||
| 109 | + | <!-- Size slider --> | |
| 110 | + | <div class="size-control"> | |
| 111 | + | <input | |
| 112 | + | type="range" | |
| 113 | + | min="8" | |
| 114 | + | max="18" | |
| 115 | + | bind:value={style.fontSize} | |
| 116 | + | class="size-slider" | |
| 117 | + | title="Font Size: {style.fontSize}px" | |
| 118 | + | /> | |
| 119 | + | <span class="size-display">{style.fontSize}</span> | |
| 120 | + | </div> | |
| 121 | + | ||
| 122 | + | <!-- Style toggle buttons --> | |
| 123 | + | <div class="style-toggles"> | |
| 124 | + | <button | |
| 125 | + | onclick={toggleBold} | |
| 126 | + | title="Bold (Ctrl+B)" | |
| 127 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 128 | + | > | |
| 129 | + | <strong>B</strong> | |
| 130 | + | </button> | |
| 131 | + | ||
| 132 | + | <button | |
| 133 | + | onclick={toggleItalic} | |
| 134 | + | title="Italic (Ctrl+I)" | |
| 135 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 136 | + | > | |
| 137 | + | <em>I</em> | |
| 138 | + | </button> | |
| 139 | + | ||
| 140 | + | <button | |
| 141 | + | onclick={toggleUnderline} | |
| 142 | + | title="Underline (Ctrl+U)" | |
| 143 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |
| 144 | + | > | |
| 145 | + | <u>U</u> | |
| 146 | + | </button> | |
| 147 | + | </div> | |
| 148 | + | ||
| 149 | + | <!-- Color controls --> | |
| 150 | + | <div class="color-controls"> | |
| 151 | + | <ColorPicker256 | |
| 152 | + | bind:selectedColor={currentColor} | |
| 153 | + | bind:selectedGradient={currentGradient} | |
| 154 | + | onColorChange={handleColorChange} | |
| 155 | + | onGradientChange={handleGradientChange} | |
| 156 | + | {compact} | |
| 157 | + | /> | |
| 158 | + | </div> | |
| 159 | + | ||
| 160 | + | ||
| 161 | + | </div> | |
| 162 | + | ||
| 163 | + | <style> | |
| 164 | + | .text-formatting-toolbar { | |
| 165 | + | display: flex; | |
| 166 | + | align-items: center; | |
| 167 | + | gap: 4px; | |
| 168 | + | padding: 2px; | |
| 169 | + | font-size: 11px; | |
| 170 | + | position: relative; | |
| 171 | + | flex-wrap: wrap; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | .size-control { | |
| 175 | + | display: flex; | |
| 176 | + | align-items: center; | |
| 177 | + | gap: 2px; | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | .size-slider { | |
| 181 | + | width: 50px; | |
| 182 | + | height: 12px; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | .size-display { | |
| 186 | + | font-size: 9px; | |
| 187 | + | color: #666; | |
| 188 | + | min-width: 18px; | |
| 189 | + | text-align: center; | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | .style-toggles { | |
| 193 | + | display: flex; | |
| 194 | + | gap: 1px; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | .color-controls { | |
| 198 | + | display: flex; | |
| 199 | + | gap: 1px; | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /* Fix font dropdown text alignment */ | |
| 203 | + | select { | |
| 204 | + | height: 20px; | |
| 205 | + | line-height: 1; | |
| 206 | + | vertical-align: middle; | |
| 207 | + | padding: 1px 4px; | |
| 208 | + | font-size: 11px; | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | select option { | |
| 212 | + | padding: 2px 4px; | |
| 213 | + | line-height: normal; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | </style>⏎̸ | |
| @@ -0,0 +1,216 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { | |||
| 3 | + | RETRO_FONTS, | |||
| 4 | + | CLASSIC_COLORS, | |||
| 5 | + | GRADIENT_PRESETS, | |||
| 6 | + | DEFAULT_TEXT_STYLE, | |||
| 7 | + | type TextStyle, | |||
| 8 | + | type FontFamily | |||
| 9 | + | } from '../types/text-formatting'; | |||
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |||
| 11 | + | ||||
| 12 | + | // Props | |||
| 13 | + | let { | |||
| 14 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |||
| 15 | + | onPreviewText, | |||
| 16 | + | compact = true, | |||
| 17 | + | showFontSelector = false, | |||
| 18 | + | showGradients = true | |||
| 19 | + | } = $props<{ | |||
| 20 | + | style?: TextStyle; | |||
| 21 | + | onPreviewText?: (text: string) => void; | |||
| 22 | + | compact?: boolean; | |||
| 23 | + | showFontSelector?: boolean; | |||
| 24 | + | showGradients?: boolean; | |||
| 25 | + | }>(); | |||
| 26 | + | ||||
| 27 | + | // State | |||
| 28 | + | let currentColor = $state(style.color || '#000000'); | |||
| 29 | + | let currentGradient = $state(style.gradient); | |||
| 30 | + | ||||
| 31 | + | // Toggle functions | |||
| 32 | + | function toggleBold() { | |||
| 33 | + | style = { ...style, bold: !style.bold }; | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | function toggleItalic() { | |||
| 37 | + | style = { ...style, italic: !style.italic }; | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | function toggleUnderline() { | |||
| 41 | + | style = { ...style, underline: !style.underline }; | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | function handleColorChange(color: string) { | |||
| 45 | + | currentColor = color; | |||
| 46 | + | style = { ...style, color, gradient: undefined }; | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | function handleGradientChange(gradient: string[]) { | |||
| 50 | + | currentGradient = gradient; | |||
| 51 | + | style = { ...style, gradient, color: gradient[0] }; | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | function selectFont(fontFamily: FontFamily) { | |||
| 55 | + | style = { ...style, fontFamily }; | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Keyboard shortcuts handler | |||
| 59 | + | function handleKeydown(event: KeyboardEvent) { | |||
| 60 | + | if (event.ctrlKey || event.metaKey) { | |||
| 61 | + | switch (event.key.toLowerCase()) { | |||
| 62 | + | case 'b': | |||
| 63 | + | event.preventDefault(); | |||
| 64 | + | toggleBold(); | |||
| 65 | + | break; | |||
| 66 | + | case 'i': | |||
| 67 | + | event.preventDefault(); | |||
| 68 | + | toggleItalic(); | |||
| 69 | + | break; | |||
| 70 | + | case 'u': | |||
| 71 | + | event.preventDefault(); | |||
| 72 | + | toggleUnderline(); | |||
| 73 | + | break; | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Preview text change | |||
| 79 | + | function previewTextChanged() { | |||
| 80 | + | if (onPreviewText) { | |||
| 81 | + | onPreviewText('Sample formatted text'); | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Update internal state when style prop changes | |||
| 86 | + | $effect(() => { | |||
| 87 | + | currentColor = style.color || '#000000'; | |||
| 88 | + | currentGradient = style.gradient; | |||
| 89 | + | }); | |||
| 90 | + | </script> | |||
| 91 | + | ||||
| 92 | + | <svelte:window onkeydown={handleKeydown} /> | |||
| 93 | + | ||||
| 94 | + | <div class="text-formatting-toolbar sunken-panel" class:compact> | |||
| 95 | + | ||||
| 96 | + | <!-- Font selector (if enabled) --> | |||
| 97 | + | {#if showFontSelector} | |||
| 98 | + | <select | |||
| 99 | + | value={style.fontFamily} | |||
| 100 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |||
| 101 | + | title="Font Family" | |||
| 102 | + | > | |||
| 103 | + | {#each Object.entries(RETRO_FONTS) as [key, font]} | |||
| 104 | + | <option value={key} style="font-family: {font.stack};">{font.name}</option> | |||
| 105 | + | {/each} | |||
| 106 | + | </select> | |||
| 107 | + | {/if} | |||
| 108 | + | ||||
| 109 | + | <!-- Size slider --> | |||
| 110 | + | <div class="size-control"> | |||
| 111 | + | <input | |||
| 112 | + | type="range" | |||
| 113 | + | min="8" | |||
| 114 | + | max="18" | |||
| 115 | + | bind:value={style.fontSize} | |||
| 116 | + | class="size-slider" | |||
| 117 | + | title="Font Size: {style.fontSize}px" | |||
| 118 | + | /> | |||
| 119 | + | <span class="size-display">{style.fontSize}</span> | |||
| 120 | + | </div> | |||
| 121 | + | ||||
| 122 | + | <!-- Style toggle buttons --> | |||
| 123 | + | <div class="style-toggles"> | |||
| 124 | + | <button | |||
| 125 | + | onclick={toggleBold} | |||
| 126 | + | title="Bold (Ctrl+B)" | |||
| 127 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |||
| 128 | + | > | |||
| 129 | + | <strong>B</strong> | |||
| 130 | + | </button> | |||
| 131 | + | ||||
| 132 | + | <button | |||
| 133 | + | onclick={toggleItalic} | |||
| 134 | + | title="Italic (Ctrl+I)" | |||
| 135 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |||
| 136 | + | > | |||
| 137 | + | <em>I</em> | |||
| 138 | + | </button> | |||
| 139 | + | ||||
| 140 | + | <button | |||
| 141 | + | onclick={toggleUnderline} | |||
| 142 | + | title="Underline (Ctrl+U)" | |||
| 143 | + | style="width: 20px; height: 20px; padding: 0; font-size: 10px;" | |||
| 144 | + | > | |||
| 145 | + | <u>U</u> | |||
| 146 | + | </button> | |||
| 147 | + | </div> | |||
| 148 | + | ||||
| 149 | + | <!-- Color controls --> | |||
| 150 | + | <div class="color-controls"> | |||
| 151 | + | <ColorPicker256 | |||
| 152 | + | bind:selectedColor={currentColor} | |||
| 153 | + | bind:selectedGradient={currentGradient} | |||
| 154 | + | onColorChange={handleColorChange} | |||
| 155 | + | onGradientChange={handleGradientChange} | |||
| 156 | + | {compact} | |||
| 157 | + | /> | |||
| 158 | + | </div> | |||
| 159 | + | ||||
| 160 | + | ||||
| 161 | + | </div> | |||
| 162 | + | ||||
| 163 | + | <style> | |||
| 164 | + | .text-formatting-toolbar { | |||
| 165 | + | display: flex; | |||
| 166 | + | align-items: center; | |||
| 167 | + | gap: 4px; | |||
| 168 | + | padding: 2px; | |||
| 169 | + | font-size: 11px; | |||
| 170 | + | position: relative; | |||
| 171 | + | flex-wrap: wrap; | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | .size-control { | |||
| 175 | + | display: flex; | |||
| 176 | + | align-items: center; | |||
| 177 | + | gap: 2px; | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | .size-slider { | |||
| 181 | + | width: 50px; | |||
| 182 | + | height: 12px; | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | .size-display { | |||
| 186 | + | font-size: 9px; | |||
| 187 | + | color: #666; | |||
| 188 | + | min-width: 18px; | |||
| 189 | + | text-align: center; | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | .style-toggles { | |||
| 193 | + | display: flex; | |||
| 194 | + | gap: 1px; | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | .color-controls { | |||
| 198 | + | display: flex; | |||
| 199 | + | gap: 1px; | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | /* Fix font dropdown text alignment */ | |||
| 203 | + | select { | |||
| 204 | + | height: 20px; | |||
| 205 | + | line-height: 1; | |||
| 206 | + | vertical-align: middle; | |||
| 207 | + | padding: 1px 4px; | |||
| 208 | + | font-size: 11px; | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | select option { | |||
| 212 | + | padding: 2px 4px; | |||
| 213 | + | line-height: normal; | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | </style>⏎̸ | |||
A src/lib/components/text-style-selector.svelte +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { | |
| 3 | + | RETRO_FONTS, | |
| 4 | + | CLASSIC_COLORS, | |
| 5 | + | DEFAULT_TEXT_STYLE, | |
| 6 | + | type TextStyle, | |
| 7 | + | type FontFamily, | |
| 8 | + | generateCSSStyle | |
| 9 | + | } from '../types/text-formatting'; | |
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |
| 11 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |
| 12 | + | ||
| 13 | + | // Props | |
| 14 | + | let { | |
| 15 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |
| 16 | + | previewText = "Sample text preview", | |
| 17 | + | showPreview = true, | |
| 18 | + | compact = false | |
| 19 | + | } = $props<{ | |
| 20 | + | style?: TextStyle; | |
| 21 | + | previewText?: string; | |
| 22 | + | showPreview?: boolean; | |
| 23 | + | compact?: boolean; | |
| 24 | + | }>(); | |
| 25 | + | ||
| 26 | + | // Local state | |
| 27 | + | let currentColor = $state(style.color || '#000000'); | |
| 28 | + | let currentGradient = $state(style.gradient); | |
| 29 | + | let formattedPreview = $state(''); | |
| 30 | + | let isProcessing = $state(false); | |
| 31 | + | ||
| 32 | + | // Update preview when style changes | |
| 33 | + | $effect(() => { | |
| 34 | + | if (!showPreview) return; | |
| 35 | + | ||
| 36 | + | async function updatePreview() { | |
| 37 | + | isProcessing = true; | |
| 38 | + | try { | |
| 39 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |
| 40 | + | formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient'); | |
| 41 | + | } else { | |
| 42 | + | formattedPreview = await formatText(previewText, style, false); | |
| 43 | + | } | |
| 44 | + | } catch (error) { | |
| 45 | + | console.error('Preview formatting error:', error); | |
| 46 | + | formattedPreview = previewText; | |
| 47 | + | } finally { | |
| 48 | + | isProcessing = false; | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | updatePreview(); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | // Font family selection | |
| 56 | + | function selectFont(fontFamily: FontFamily) { | |
| 57 | + | style = { ...style, fontFamily }; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Color selection | |
| 61 | + | function handleColorChange(color: string) { | |
| 62 | + | currentColor = color; | |
| 63 | + | style = { ...style, color, gradient: undefined }; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Gradient selection | |
| 67 | + | function handleGradientChange(gradient: string[]) { | |
| 68 | + | currentGradient = gradient; | |
| 69 | + | style = { ...style, gradient, color: gradient[0] }; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Update internal state when style prop changes | |
| 73 | + | $effect(() => { | |
| 74 | + | currentColor = style.color || '#000000'; | |
| 75 | + | currentGradient = style.gradient; | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | // Toggle style options | |
| 79 | + | function toggleBold() { | |
| 80 | + | style = { ...style, bold: !style.bold }; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | function toggleItalic() { | |
| 84 | + | style = { ...style, italic: !style.italic }; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function toggleUnderline() { | |
| 88 | + | style = { ...style, underline: !style.underline }; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | function toggleStrikethrough() { | |
| 92 | + | style = { ...style, strikethrough: !style.strikethrough }; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Font size adjustment | |
| 96 | + | function updateFontSize(size: number) { | |
| 97 | + | style = { ...style, fontSize: Math.max(8, Math.min(18, size)) }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | </script> | |
| 101 | + | ||
| 102 | + | <div class="text-style-selector" class:compact> | |
| 103 | + | <!-- Font Family Selection --> | |
| 104 | + | <div class="style-group"> | |
| 105 | + | <label>Font:</label> | |
| 106 | + | <select | |
| 107 | + | bind:value={style.fontFamily} | |
| 108 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |
| 109 | + | > | |
| 110 | + | {#each Object.entries(RETRO_FONTS) as [key, font]} | |
| 111 | + | <option value={key} style="font-family: {font.stack}"> | |
| 112 | + | {font.name} | |
| 113 | + | </option> | |
| 114 | + | {/each} | |
| 115 | + | </select> | |
| 116 | + | </div> | |
| 117 | + | ||
| 118 | + | <!-- Font Size --> | |
| 119 | + | <div class="style-group"> | |
| 120 | + | <label>Size:</label> | |
| 121 | + | <input | |
| 122 | + | type="range" | |
| 123 | + | min="8" | |
| 124 | + | max="18" | |
| 125 | + | bind:value={style.fontSize} | |
| 126 | + | oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))} | |
| 127 | + | /> | |
| 128 | + | <span class="size-display">{style.fontSize}px</span> | |
| 129 | + | </div> | |
| 130 | + | ||
| 131 | + | <!-- Style Toggles --> | |
| 132 | + | <div class="style-group toggles"> | |
| 133 | + | <button | |
| 134 | + | class="style-toggle" | |
| 135 | + | class:active={style.bold} | |
| 136 | + | onclick={toggleBold} | |
| 137 | + | title="Bold (Ctrl+B)" | |
| 138 | + | > | |
| 139 | + | <strong>B</strong> | |
| 140 | + | </button> | |
| 141 | + | <button | |
| 142 | + | class="style-toggle" | |
| 143 | + | class:active={style.italic} | |
| 144 | + | onclick={toggleItalic} | |
| 145 | + | title="Italic (Ctrl+I)" | |
| 146 | + | > | |
| 147 | + | <em>I</em> | |
| 148 | + | </button> | |
| 149 | + | <button | |
| 150 | + | class="style-toggle" | |
| 151 | + | class:active={style.underline} | |
| 152 | + | onclick={toggleUnderline} | |
| 153 | + | title="Underline (Ctrl+U)" | |
| 154 | + | > | |
| 155 | + | <u>U</u> | |
| 156 | + | </button> | |
| 157 | + | <button | |
| 158 | + | class="style-toggle" | |
| 159 | + | class:active={style.strikethrough} | |
| 160 | + | onclick={toggleStrikethrough} | |
| 161 | + | title="Strikethrough" | |
| 162 | + | > | |
| 163 | + | <s>S</s> | |
| 164 | + | </button> | |
| 165 | + | </div> | |
| 166 | + | ||
| 167 | + | <!-- Color & Gradient Selection --> | |
| 168 | + | <div class="style-group"> | |
| 169 | + | <label>Color:</label> | |
| 170 | + | <div class="color-controls"> | |
| 171 | + | <ColorPicker256 | |
| 172 | + | bind:selectedColor={currentColor} | |
| 173 | + | bind:selectedGradient={currentGradient} | |
| 174 | + | onColorChange={handleColorChange} | |
| 175 | + | onGradientChange={handleGradientChange} | |
| 176 | + | compact={false} | |
| 177 | + | /> | |
| 178 | + | </div> | |
| 179 | + | </div> | |
| 180 | + | ||
| 181 | + | ||
| 182 | + | <!-- Preview --> | |
| 183 | + | {#if showPreview} | |
| 184 | + | <div class="preview-section"> | |
| 185 | + | <label>Preview:</label> | |
| 186 | + | <div | |
| 187 | + | class="preview-text" | |
| 188 | + | style={generateCSSStyle(style)} | |
| 189 | + | > | |
| 190 | + | {#if isProcessing} | |
| 191 | + | <span class="loading">Processing...</span> | |
| 192 | + | {:else} | |
| 193 | + | {@html formattedPreview} | |
| 194 | + | {/if} | |
| 195 | + | </div> | |
| 196 | + | </div> | |
| 197 | + | {/if} | |
| 198 | + | </div> | |
| 199 | + | ||
| 200 | + | <style> | |
| 201 | + | .text-style-selector { | |
| 202 | + | background: #f0f0f0; | |
| 203 | + | border: 2px inset #dfdfdf; | |
| 204 | + | padding: 8px; | |
| 205 | + | font-family: 'MS Sans Serif', sans-serif; | |
| 206 | + | font-size: 11px; | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | .text-style-selector.compact { | |
| 210 | + | padding: 4px; | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | .style-group { | |
| 214 | + | display: flex; | |
| 215 | + | align-items: center; | |
| 216 | + | gap: 8px; | |
| 217 | + | margin-bottom: 8px; | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | .style-group label { | |
| 221 | + | font-weight: bold; | |
| 222 | + | min-width: 50px; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | .style-group select { | |
| 226 | + | flex: 1; | |
| 227 | + | padding: 2px; | |
| 228 | + | border: 1px inset #dfdfdf; | |
| 229 | + | background: white; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | .style-group input[type="range"] { | |
| 233 | + | flex: 1; | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | .size-display { | |
| 237 | + | min-width: 35px; | |
| 238 | + | font-size: 10px; | |
| 239 | + | color: #666; | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | .toggles { | |
| 243 | + | gap: 4px; | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | .style-toggle { | |
| 247 | + | width: 24px; | |
| 248 | + | height: 24px; | |
| 249 | + | border: 1px outset #dfdfdf; | |
| 250 | + | background: #f0f0f0; | |
| 251 | + | cursor: pointer; | |
| 252 | + | display: flex; | |
| 253 | + | align-items: center; | |
| 254 | + | justify-content: center; | |
| 255 | + | font-size: 12px; | |
| 256 | + | font-weight: bold; | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | .style-toggle:hover { | |
| 260 | + | background: #e0e0e0; | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | .style-toggle.active { | |
| 264 | + | border: 1px inset #dfdfdf; | |
| 265 | + | background: #d0d0d0; | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | .color-controls { | |
| 269 | + | display: flex; | |
| 270 | + | gap: 4px; | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | .preview-section { | |
| 274 | + | margin-top: 12px; | |
| 275 | + | padding-top: 8px; | |
| 276 | + | border-top: 1px solid #ccc; | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | .preview-section label { | |
| 280 | + | display: block; | |
| 281 | + | margin-bottom: 4px; | |
| 282 | + | font-weight: bold; | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | .preview-text { | |
| 286 | + | padding: 8px; | |
| 287 | + | border: 1px inset #dfdfdf; | |
| 288 | + | background: white; | |
| 289 | + | min-height: 24px; | |
| 290 | + | line-height: 1.4; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | .loading { | |
| 294 | + | color: #666; | |
| 295 | + | font-style: italic; | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | /* Gradient text support */ | |
| 299 | + | :global(.preview-gradient) { | |
| 300 | + | display: inline; | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | /* Compact mode adjustments */ | |
| 304 | + | .compact .style-group { | |
| 305 | + | margin-bottom: 4px; | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | .compact .style-group label { | |
| 309 | + | min-width: 40px; | |
| 310 | + | font-size: 10px; | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | .compact .style-toggle { | |
| 314 | + | width: 20px; | |
| 315 | + | height: 20px; | |
| 316 | + | font-size: 10px; | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | .compact .color-button, .compact .gradient-button { | |
| 320 | + | font-size: 9px; | |
| 321 | + | padding: 1px 4px; | |
| 322 | + | } | |
| 323 | + | </style>⏎̸ | |
| @@ -0,0 +1,323 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { | |||
| 3 | + | RETRO_FONTS, | |||
| 4 | + | CLASSIC_COLORS, | |||
| 5 | + | DEFAULT_TEXT_STYLE, | |||
| 6 | + | type TextStyle, | |||
| 7 | + | type FontFamily, | |||
| 8 | + | generateCSSStyle | |||
| 9 | + | } from '../types/text-formatting'; | |||
| 10 | + | import ColorPicker256 from './color-picker-256.svelte'; | |||
| 11 | + | import { formatText, createGradientText } from '../utils/text-formatter'; | |||
| 12 | + | ||||
| 13 | + | // Props | |||
| 14 | + | let { | |||
| 15 | + | style = $bindable(DEFAULT_TEXT_STYLE), | |||
| 16 | + | previewText = "Sample text preview", | |||
| 17 | + | showPreview = true, | |||
| 18 | + | compact = false | |||
| 19 | + | } = $props<{ | |||
| 20 | + | style?: TextStyle; | |||
| 21 | + | previewText?: string; | |||
| 22 | + | showPreview?: boolean; | |||
| 23 | + | compact?: boolean; | |||
| 24 | + | }>(); | |||
| 25 | + | ||||
| 26 | + | // Local state | |||
| 27 | + | let currentColor = $state(style.color || '#000000'); | |||
| 28 | + | let currentGradient = $state(style.gradient); | |||
| 29 | + | let formattedPreview = $state(''); | |||
| 30 | + | let isProcessing = $state(false); | |||
| 31 | + | ||||
| 32 | + | // Update preview when style changes | |||
| 33 | + | $effect(() => { | |||
| 34 | + | if (!showPreview) return; | |||
| 35 | + | ||||
| 36 | + | async function updatePreview() { | |||
| 37 | + | isProcessing = true; | |||
| 38 | + | try { | |||
| 39 | + | if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) { | |||
| 40 | + | formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient'); | |||
| 41 | + | } else { | |||
| 42 | + | formattedPreview = await formatText(previewText, style, false); | |||
| 43 | + | } | |||
| 44 | + | } catch (error) { | |||
| 45 | + | console.error('Preview formatting error:', error); | |||
| 46 | + | formattedPreview = previewText; | |||
| 47 | + | } finally { | |||
| 48 | + | isProcessing = false; | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | updatePreview(); | |||
| 53 | + | }); | |||
| 54 | + | ||||
| 55 | + | // Font family selection | |||
| 56 | + | function selectFont(fontFamily: FontFamily) { | |||
| 57 | + | style = { ...style, fontFamily }; | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Color selection | |||
| 61 | + | function handleColorChange(color: string) { | |||
| 62 | + | currentColor = color; | |||
| 63 | + | style = { ...style, color, gradient: undefined }; | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Gradient selection | |||
| 67 | + | function handleGradientChange(gradient: string[]) { | |||
| 68 | + | currentGradient = gradient; | |||
| 69 | + | style = { ...style, gradient, color: gradient[0] }; | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Update internal state when style prop changes | |||
| 73 | + | $effect(() => { | |||
| 74 | + | currentColor = style.color || '#000000'; | |||
| 75 | + | currentGradient = style.gradient; | |||
| 76 | + | }); | |||
| 77 | + | ||||
| 78 | + | // Toggle style options | |||
| 79 | + | function toggleBold() { | |||
| 80 | + | style = { ...style, bold: !style.bold }; | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | function toggleItalic() { | |||
| 84 | + | style = { ...style, italic: !style.italic }; | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | function toggleUnderline() { | |||
| 88 | + | style = { ...style, underline: !style.underline }; | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | function toggleStrikethrough() { | |||
| 92 | + | style = { ...style, strikethrough: !style.strikethrough }; | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Font size adjustment | |||
| 96 | + | function updateFontSize(size: number) { | |||
| 97 | + | style = { ...style, fontSize: Math.max(8, Math.min(18, size)) }; | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | </script> | |||
| 101 | + | ||||
| 102 | + | <div class="text-style-selector" class:compact> | |||
| 103 | + | <!-- Font Family Selection --> | |||
| 104 | + | <div class="style-group"> | |||
| 105 | + | <label>Font:</label> | |||
| 106 | + | <select | |||
| 107 | + | bind:value={style.fontFamily} | |||
| 108 | + | onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)} | |||
| 109 | + | > | |||
| 110 | + | {#each Object.entries(RETRO_FONTS) as [key, font]} | |||
| 111 | + | <option value={key} style="font-family: {font.stack}"> | |||
| 112 | + | {font.name} | |||
| 113 | + | </option> | |||
| 114 | + | {/each} | |||
| 115 | + | </select> | |||
| 116 | + | </div> | |||
| 117 | + | ||||
| 118 | + | <!-- Font Size --> | |||
| 119 | + | <div class="style-group"> | |||
| 120 | + | <label>Size:</label> | |||
| 121 | + | <input | |||
| 122 | + | type="range" | |||
| 123 | + | min="8" | |||
| 124 | + | max="18" | |||
| 125 | + | bind:value={style.fontSize} | |||
| 126 | + | oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))} | |||
| 127 | + | /> | |||
| 128 | + | <span class="size-display">{style.fontSize}px</span> | |||
| 129 | + | </div> | |||
| 130 | + | ||||
| 131 | + | <!-- Style Toggles --> | |||
| 132 | + | <div class="style-group toggles"> | |||
| 133 | + | <button | |||
| 134 | + | class="style-toggle" | |||
| 135 | + | class:active={style.bold} | |||
| 136 | + | onclick={toggleBold} | |||
| 137 | + | title="Bold (Ctrl+B)" | |||
| 138 | + | > | |||
| 139 | + | <strong>B</strong> | |||
| 140 | + | </button> | |||
| 141 | + | <button | |||
| 142 | + | class="style-toggle" | |||
| 143 | + | class:active={style.italic} | |||
| 144 | + | onclick={toggleItalic} | |||
| 145 | + | title="Italic (Ctrl+I)" | |||
| 146 | + | > | |||
| 147 | + | <em>I</em> | |||
| 148 | + | </button> | |||
| 149 | + | <button | |||
| 150 | + | class="style-toggle" | |||
| 151 | + | class:active={style.underline} | |||
| 152 | + | onclick={toggleUnderline} | |||
| 153 | + | title="Underline (Ctrl+U)" | |||
| 154 | + | > | |||
| 155 | + | <u>U</u> | |||
| 156 | + | </button> | |||
| 157 | + | <button | |||
| 158 | + | class="style-toggle" | |||
| 159 | + | class:active={style.strikethrough} | |||
| 160 | + | onclick={toggleStrikethrough} | |||
| 161 | + | title="Strikethrough" | |||
| 162 | + | > | |||
| 163 | + | <s>S</s> | |||
| 164 | + | </button> | |||
| 165 | + | </div> | |||
| 166 | + | ||||
| 167 | + | <!-- Color & Gradient Selection --> | |||
| 168 | + | <div class="style-group"> | |||
| 169 | + | <label>Color:</label> | |||
| 170 | + | <div class="color-controls"> | |||
| 171 | + | <ColorPicker256 | |||
| 172 | + | bind:selectedColor={currentColor} | |||
| 173 | + | bind:selectedGradient={currentGradient} | |||
| 174 | + | onColorChange={handleColorChange} | |||
| 175 | + | onGradientChange={handleGradientChange} | |||
| 176 | + | compact={false} | |||
| 177 | + | /> | |||
| 178 | + | </div> | |||
| 179 | + | </div> | |||
| 180 | + | ||||
| 181 | + | ||||
| 182 | + | <!-- Preview --> | |||
| 183 | + | {#if showPreview} | |||
| 184 | + | <div class="preview-section"> | |||
| 185 | + | <label>Preview:</label> | |||
| 186 | + | <div | |||
| 187 | + | class="preview-text" | |||
| 188 | + | style={generateCSSStyle(style)} | |||
| 189 | + | > | |||
| 190 | + | {#if isProcessing} | |||
| 191 | + | <span class="loading">Processing...</span> | |||
| 192 | + | {:else} | |||
| 193 | + | {@html formattedPreview} | |||
| 194 | + | {/if} | |||
| 195 | + | </div> | |||
| 196 | + | </div> | |||
| 197 | + | {/if} | |||
| 198 | + | </div> | |||
| 199 | + | ||||
| 200 | + | <style> | |||
| 201 | + | .text-style-selector { | |||
| 202 | + | background: #f0f0f0; | |||
| 203 | + | border: 2px inset #dfdfdf; | |||
| 204 | + | padding: 8px; | |||
| 205 | + | font-family: 'MS Sans Serif', sans-serif; | |||
| 206 | + | font-size: 11px; | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | .text-style-selector.compact { | |||
| 210 | + | padding: 4px; | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | .style-group { | |||
| 214 | + | display: flex; | |||
| 215 | + | align-items: center; | |||
| 216 | + | gap: 8px; | |||
| 217 | + | margin-bottom: 8px; | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | .style-group label { | |||
| 221 | + | font-weight: bold; | |||
| 222 | + | min-width: 50px; | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | .style-group select { | |||
| 226 | + | flex: 1; | |||
| 227 | + | padding: 2px; | |||
| 228 | + | border: 1px inset #dfdfdf; | |||
| 229 | + | background: white; | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | .style-group input[type="range"] { | |||
| 233 | + | flex: 1; | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | .size-display { | |||
| 237 | + | min-width: 35px; | |||
| 238 | + | font-size: 10px; | |||
| 239 | + | color: #666; | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | .toggles { | |||
| 243 | + | gap: 4px; | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | .style-toggle { | |||
| 247 | + | width: 24px; | |||
| 248 | + | height: 24px; | |||
| 249 | + | border: 1px outset #dfdfdf; | |||
| 250 | + | background: #f0f0f0; | |||
| 251 | + | cursor: pointer; | |||
| 252 | + | display: flex; | |||
| 253 | + | align-items: center; | |||
| 254 | + | justify-content: center; | |||
| 255 | + | font-size: 12px; | |||
| 256 | + | font-weight: bold; | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | .style-toggle:hover { | |||
| 260 | + | background: #e0e0e0; | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | .style-toggle.active { | |||
| 264 | + | border: 1px inset #dfdfdf; | |||
| 265 | + | background: #d0d0d0; | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | .color-controls { | |||
| 269 | + | display: flex; | |||
| 270 | + | gap: 4px; | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | .preview-section { | |||
| 274 | + | margin-top: 12px; | |||
| 275 | + | padding-top: 8px; | |||
| 276 | + | border-top: 1px solid #ccc; | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | .preview-section label { | |||
| 280 | + | display: block; | |||
| 281 | + | margin-bottom: 4px; | |||
| 282 | + | font-weight: bold; | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | .preview-text { | |||
| 286 | + | padding: 8px; | |||
| 287 | + | border: 1px inset #dfdfdf; | |||
| 288 | + | background: white; | |||
| 289 | + | min-height: 24px; | |||
| 290 | + | line-height: 1.4; | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | .loading { | |||
| 294 | + | color: #666; | |||
| 295 | + | font-style: italic; | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | /* Gradient text support */ | |||
| 299 | + | :global(.preview-gradient) { | |||
| 300 | + | display: inline; | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | /* Compact mode adjustments */ | |||
| 304 | + | .compact .style-group { | |||
| 305 | + | margin-bottom: 4px; | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | .compact .style-group label { | |||
| 309 | + | min-width: 40px; | |||
| 310 | + | font-size: 10px; | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | .compact .style-toggle { | |||
| 314 | + | width: 20px; | |||
| 315 | + | height: 20px; | |||
| 316 | + | font-size: 10px; | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | .compact .color-button, .compact .gradient-button { | |||
| 320 | + | font-size: 9px; | |||
| 321 | + | padding: 1px 4px; | |||
| 322 | + | } | |||
| 323 | + | </style>⏎̸ | |||
A src/lib/styles/fonts.css +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | /* Windows Classic Font Fallbacks */ | |
| 2 | + | /* | |
| 3 | + | * These @font-face declarations ensure consistent font rendering | |
| 4 | + | * by providing proper fallbacks for classic Windows fonts | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | /* Tahoma - Primary UI font */ | |
| 8 | + | @font-face { | |
| 9 | + | font-family: 'Tahoma Web'; | |
| 10 | + | src: local('Tahoma'), local('Tahoma Regular'); | |
| 11 | + | font-weight: normal; | |
| 12 | + | font-style: normal; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | @font-face { | |
| 16 | + | font-family: 'Tahoma Web'; | |
| 17 | + | src: local('Tahoma Bold'); | |
| 18 | + | font-weight: bold; | |
| 19 | + | font-style: normal; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /* Verdana - Secondary UI font */ | |
| 23 | + | @font-face { | |
| 24 | + | font-family: 'Verdana Web'; | |
| 25 | + | src: local('Verdana'), local('Verdana Regular'); | |
| 26 | + | font-weight: normal; | |
| 27 | + | font-style: normal; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | @font-face { | |
| 31 | + | font-family: 'Verdana Web'; | |
| 32 | + | src: local('Verdana Bold'); | |
| 33 | + | font-weight: bold; | |
| 34 | + | font-style: normal; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /* Arial - General purpose */ | |
| 38 | + | @font-face { | |
| 39 | + | font-family: 'Arial Web'; | |
| 40 | + | src: local('Arial'), local('Arial Regular'); | |
| 41 | + | font-weight: normal; | |
| 42 | + | font-style: normal; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | @font-face { | |
| 46 | + | font-family: 'Arial Web'; | |
| 47 | + | src: local('Arial Bold'); | |
| 48 | + | font-weight: bold; | |
| 49 | + | font-style: normal; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /* Times New Roman - Serif font */ | |
| 53 | + | @font-face { | |
| 54 | + | font-family: 'Times New Roman Web'; | |
| 55 | + | src: local('Times New Roman'), local('Times New Roman Regular'); | |
| 56 | + | font-weight: normal; | |
| 57 | + | font-style: normal; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | @font-face { | |
| 61 | + | font-family: 'Times New Roman Web'; | |
| 62 | + | src: local('Times New Roman Bold'); | |
| 63 | + | font-weight: bold; | |
| 64 | + | font-style: normal; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /* Courier New - Monospace */ | |
| 68 | + | @font-face { | |
| 69 | + | font-family: 'Courier New Web'; | |
| 70 | + | src: local('Courier New'), local('Courier New Regular'); | |
| 71 | + | font-weight: normal; | |
| 72 | + | font-style: normal; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | @font-face { | |
| 76 | + | font-family: 'Courier New Web'; | |
| 77 | + | src: local('Courier New Bold'); | |
| 78 | + | font-weight: bold; | |
| 79 | + | font-style: normal; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /* Comic Sans MS - Fun font */ | |
| 83 | + | @font-face { | |
| 84 | + | font-family: 'Comic Sans MS Web'; | |
| 85 | + | src: local('Comic Sans MS'), local('Comic Sans MS Regular'); | |
| 86 | + | font-weight: normal; | |
| 87 | + | font-style: normal; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | @font-face { | |
| 91 | + | font-family: 'Comic Sans MS Web'; | |
| 92 | + | src: local('Comic Sans MS Bold'); | |
| 93 | + | font-weight: bold; | |
| 94 | + | font-style: normal; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /* Ensure font-display for better performance */ | |
| 98 | + | @font-face { | |
| 99 | + | font-display: swap; | |
| 100 | + | }⏎̸ | |
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | /* Windows Classic Font Fallbacks */ | |||
| 2 | + | /* | |||
| 3 | + | * These @font-face declarations ensure consistent font rendering | |||
| 4 | + | * by providing proper fallbacks for classic Windows fonts | |||
| 5 | + | */ | |||
| 6 | + | ||||
| 7 | + | /* Tahoma - Primary UI font */ | |||
| 8 | + | @font-face { | |||
| 9 | + | font-family: 'Tahoma Web'; | |||
| 10 | + | src: local('Tahoma'), local('Tahoma Regular'); | |||
| 11 | + | font-weight: normal; | |||
| 12 | + | font-style: normal; | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | @font-face { | |||
| 16 | + | font-family: 'Tahoma Web'; | |||
| 17 | + | src: local('Tahoma Bold'); | |||
| 18 | + | font-weight: bold; | |||
| 19 | + | font-style: normal; | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | /* Verdana - Secondary UI font */ | |||
| 23 | + | @font-face { | |||
| 24 | + | font-family: 'Verdana Web'; | |||
| 25 | + | src: local('Verdana'), local('Verdana Regular'); | |||
| 26 | + | font-weight: normal; | |||
| 27 | + | font-style: normal; | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | @font-face { | |||
| 31 | + | font-family: 'Verdana Web'; | |||
| 32 | + | src: local('Verdana Bold'); | |||
| 33 | + | font-weight: bold; | |||
| 34 | + | font-style: normal; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | /* Arial - General purpose */ | |||
| 38 | + | @font-face { | |||
| 39 | + | font-family: 'Arial Web'; | |||
| 40 | + | src: local('Arial'), local('Arial Regular'); | |||
| 41 | + | font-weight: normal; | |||
| 42 | + | font-style: normal; | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | @font-face { | |||
| 46 | + | font-family: 'Arial Web'; | |||
| 47 | + | src: local('Arial Bold'); | |||
| 48 | + | font-weight: bold; | |||
| 49 | + | font-style: normal; | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | /* Times New Roman - Serif font */ | |||
| 53 | + | @font-face { | |||
| 54 | + | font-family: 'Times New Roman Web'; | |||
| 55 | + | src: local('Times New Roman'), local('Times New Roman Regular'); | |||
| 56 | + | font-weight: normal; | |||
| 57 | + | font-style: normal; | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | @font-face { | |||
| 61 | + | font-family: 'Times New Roman Web'; | |||
| 62 | + | src: local('Times New Roman Bold'); | |||
| 63 | + | font-weight: bold; | |||
| 64 | + | font-style: normal; | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | /* Courier New - Monospace */ | |||
| 68 | + | @font-face { | |||
| 69 | + | font-family: 'Courier New Web'; | |||
| 70 | + | src: local('Courier New'), local('Courier New Regular'); | |||
| 71 | + | font-weight: normal; | |||
| 72 | + | font-style: normal; | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | @font-face { | |||
| 76 | + | font-family: 'Courier New Web'; | |||
| 77 | + | src: local('Courier New Bold'); | |||
| 78 | + | font-weight: bold; | |||
| 79 | + | font-style: normal; | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | /* Comic Sans MS - Fun font */ | |||
| 83 | + | @font-face { | |||
| 84 | + | font-family: 'Comic Sans MS Web'; | |||
| 85 | + | src: local('Comic Sans MS'), local('Comic Sans MS Regular'); | |||
| 86 | + | font-weight: normal; | |||
| 87 | + | font-style: normal; | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | @font-face { | |||
| 91 | + | font-family: 'Comic Sans MS Web'; | |||
| 92 | + | src: local('Comic Sans MS Bold'); | |||
| 93 | + | font-weight: bold; | |||
| 94 | + | font-style: normal; | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | /* Ensure font-display for better performance */ | |||
| 98 | + | @font-face { | |||
| 99 | + | font-display: swap; | |||
| 100 | + | }⏎̸ | |||
A src/lib/types/text-formatting.ts +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | // Font and styling configuration for chat messages | |
| 2 | + | ||
| 3 | + | // Retro Windows fonts | |
| 4 | + | // Primary font is Pixelated MS Sans Serif from xp.css which works for all users | |
| 5 | + | // Secondary fonts use local system fonts with proper fallbacks | |
| 6 | + | export const RETRO_FONTS = { | |
| 7 | + | pixelated: { name: 'Pixelated MS Sans Serif', stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' }, | |
| 8 | + | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 9 | + | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 10 | + | comicSans: { name: 'Comic Sans MS', stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' }, | |
| 11 | + | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | |
| 12 | + | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | |
| 13 | + | courierNew: { name: 'Courier New', stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' }, | |
| 14 | + | dos: { name: 'Perfect DOS VGA 437', stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' } | |
| 15 | + | } as const; | |
| 16 | + | ||
| 17 | + | export type FontFamily = keyof typeof RETRO_FONTS; | |
| 18 | + | ||
| 19 | + | // Generate 256-color web-safe palette ordered by hue | |
| 20 | + | export function generate256Colors(): string[] { | |
| 21 | + | const colors: string[] = []; | |
| 22 | + | ||
| 23 | + | // Start with grayscale colors | |
| 24 | + | colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF'); | |
| 25 | + | ||
| 26 | + | // Then add colors in a more logical order by hue | |
| 27 | + | // Red hues | |
| 28 | + | colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000'); | |
| 29 | + | colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC'); | |
| 30 | + | ||
| 31 | + | // Orange hues | |
| 32 | + | colors.push('#663300', '#993300', '#CC3300', '#FF3300'); | |
| 33 | + | colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99'); | |
| 34 | + | ||
| 35 | + | // Yellow hues | |
| 36 | + | colors.push('#666600', '#999900', '#CCCC00', '#FFFF00'); | |
| 37 | + | colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC'); | |
| 38 | + | ||
| 39 | + | // Green hues | |
| 40 | + | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | |
| 41 | + | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | |
| 42 | + | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); | |
| 43 | + | ||
| 44 | + | // Cyan hues | |
| 45 | + | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | |
| 46 | + | colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF'); | |
| 47 | + | colors.push('#336666', '#669999', '#99CCCC'); | |
| 48 | + | ||
| 49 | + | // Blue hues | |
| 50 | + | colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF'); | |
| 51 | + | colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF'); | |
| 52 | + | colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF'); | |
| 53 | + | ||
| 54 | + | // Purple hues | |
| 55 | + | colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF'); | |
| 56 | + | colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF'); | |
| 57 | + | colors.push('#663366', '#996699', '#CC99CC'); | |
| 58 | + | ||
| 59 | + | // Pink hues | |
| 60 | + | colors.push('#660033', '#990066', '#CC0099', '#FF0099'); | |
| 61 | + | colors.push('#FF3399', '#FF66CC', '#FF99CC'); | |
| 62 | + | ||
| 63 | + | // Browns | |
| 64 | + | colors.push('#663333', '#996633', '#CC9966', '#996666'); | |
| 65 | + | ||
| 66 | + | // Now fill in any missing combinations to get full 216 web-safe colors | |
| 67 | + | const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF']; | |
| 68 | + | const existingColors = new Set(colors); | |
| 69 | + | ||
| 70 | + | for (const r of webSafeValues) { | |
| 71 | + | for (const g of webSafeValues) { | |
| 72 | + | for (const b of webSafeValues) { | |
| 73 | + | const color = `#${r}${g}${b}`.toUpperCase(); | |
| 74 | + | if (!existingColors.has(color)) { | |
| 75 | + | colors.push(color); | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return colors; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Classic retro colors for backward compatibility | |
| 85 | + | export const CLASSIC_COLORS = { | |
| 86 | + | black: '#000000', | |
| 87 | + | darkBlue: '#000080', | |
| 88 | + | green: '#008000', | |
| 89 | + | teal: '#008080', | |
| 90 | + | darkRed: '#800000', | |
| 91 | + | purple: '#800080', | |
| 92 | + | brown: '#808000', | |
| 93 | + | silver: '#C0C0C0', | |
| 94 | + | gray: '#808080', | |
| 95 | + | blue: '#0000FF', | |
| 96 | + | lime: '#00FF00', | |
| 97 | + | aqua: '#00FFFF', | |
| 98 | + | red: '#FF0000', | |
| 99 | + | fuchsia: '#FF00FF', | |
| 100 | + | yellow: '#FFFF00', | |
| 101 | + | white: '#FFFFFF' | |
| 102 | + | } as const; | |
| 103 | + | ||
| 104 | + | export type ClassicColor = keyof typeof CLASSIC_COLORS; | |
| 105 | + | ||
| 106 | + | // Gradient presets inspired by AOL faders | |
| 107 | + | export const GRADIENT_PRESETS = { | |
| 108 | + | rainbow: ['#FF0000', '#FF8000', '#FFFF00', '#80FF00', '#00FF00', '#00FF80', '#00FFFF', '#0080FF', '#0000FF', '#8000FF', '#FF00FF', '#FF0080'], | |
| 109 | + | fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'], | |
| 110 | + | ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'], | |
| 111 | + | sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'], | |
| 112 | + | neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'], | |
| 113 | + | forest: ['#006400', '#228B22', '#32CD32', '#90EE90'], | |
| 114 | + | royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD'] | |
| 115 | + | } as const; | |
| 116 | + | ||
| 117 | + | export type GradientPreset = keyof typeof GRADIENT_PRESETS; | |
| 118 | + | ||
| 119 | + | // Text styling options | |
| 120 | + | export interface TextStyle { | |
| 121 | + | fontFamily: FontFamily; | |
| 122 | + | fontSize: number; // in pixels | |
| 123 | + | color?: string; // hex color string | |
| 124 | + | gradient?: string[]; // array of 1-3 hex colors for gradients | |
| 125 | + | bold: boolean; | |
| 126 | + | italic: boolean; | |
| 127 | + | underline: boolean; | |
| 128 | + | strikethrough: boolean; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Default text style | |
| 132 | + | export const DEFAULT_TEXT_STYLE: TextStyle = { | |
| 133 | + | fontFamily: 'pixelated', | |
| 134 | + | fontSize: 14, | |
| 135 | + | color: '#000000', | |
| 136 | + | bold: false, | |
| 137 | + | italic: false, | |
| 138 | + | underline: false, | |
| 139 | + | strikethrough: false | |
| 140 | + | }; | |
| 141 | + | ||
| 142 | + | // User preferences for text formatting | |
| 143 | + | export interface UserTextPreferences { | |
| 144 | + | defaultStyle: TextStyle; | |
| 145 | + | allowFormatting: boolean; | |
| 146 | + | maxMessageLength: number; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Default user preferences | |
| 150 | + | export const DEFAULT_USER_PREFERENCES: UserTextPreferences = { | |
| 151 | + | defaultStyle: DEFAULT_TEXT_STYLE, | |
| 152 | + | allowFormatting: true, | |
| 153 | + | maxMessageLength: 500 | |
| 154 | + | }; | |
| 155 | + | ||
| 156 | + | // Hex color validation | |
| 157 | + | export function isValidHexColor(color: string): boolean { | |
| 158 | + | return /^#[0-9A-Fa-f]{6}$/.test(color); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // Formatting validation | |
| 162 | + | export function validateTextStyle(style: Partial<TextStyle>): TextStyle { | |
| 163 | + | return { | |
| 164 | + | fontFamily: style.fontFamily && style.fontFamily in RETRO_FONTS ? style.fontFamily : DEFAULT_TEXT_STYLE.fontFamily, | |
| 165 | + | fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)), | |
| 166 | + | color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color, | |
| 167 | + | gradient: style.gradient && Array.isArray(style.gradient) && style.gradient.length <= 3 && style.gradient.every(isValidHexColor) ? style.gradient : undefined, | |
| 168 | + | bold: Boolean(style.bold), | |
| 169 | + | italic: Boolean(style.italic), | |
| 170 | + | underline: Boolean(style.underline), | |
| 171 | + | strikethrough: Boolean(style.strikethrough) | |
| 172 | + | }; | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Generate CSS style string from TextStyle | |
| 176 | + | export function generateCSSStyle(style: TextStyle): string { | |
| 177 | + | const styles: string[] = []; | |
| 178 | + | ||
| 179 | + | // Font family | |
| 180 | + | styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`); | |
| 181 | + | ||
| 182 | + | // Font size | |
| 183 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 184 | + | ||
| 185 | + | // Color or gradient | |
| 186 | + | if (style.gradient && style.gradient.length > 1) { | |
| 187 | + | const gradientStops = style.gradient.join(', '); | |
| 188 | + | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); | |
| 189 | + | styles.push(`-webkit-background-clip: text`); | |
| 190 | + | styles.push(`-webkit-text-fill-color: transparent`); | |
| 191 | + | styles.push(`background-clip: text`); | |
| 192 | + | } else if (style.color) { | |
| 193 | + | styles.push(`color: ${style.color}`); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Font weight | |
| 197 | + | if (style.bold) { | |
| 198 | + | // Use correct weight for Tahoma (200) vs standard bold (700) | |
| 199 | + | if (style.fontFamily === 'tahoma') { | |
| 200 | + | styles.push(`font-weight: 200`); | |
| 201 | + | } else { | |
| 202 | + | styles.push(`font-weight: 700`); | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // Font style | |
| 207 | + | if (style.italic) { | |
| 208 | + | styles.push(`font-style: italic`); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // Text decoration | |
| 212 | + | const decorations: string[] = []; | |
| 213 | + | if (style.underline) decorations.push('underline'); | |
| 214 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 215 | + | if (decorations.length > 0) { | |
| 216 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | return styles.join('; '); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Generate CSS style string for input fields (without font-family which should be handled by classes) | |
| 223 | + | export function generateInputCSSStyle(style: TextStyle): string { | |
| 224 | + | const styles: string[] = []; | |
| 225 | + | ||
| 226 | + | // Font size | |
| 227 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 228 | + | ||
| 229 | + | // Color handling for inputs | |
| 230 | + | if (style.gradient && style.gradient.length > 1) { | |
| 231 | + | // Apply gradient background with text clipping for gradient effect | |
| 232 | + | const gradientStops = style.gradient.join(', '); | |
| 233 | + | styles.push(`background: linear-gradient(to right, ${gradientStops})`); | |
| 234 | + | styles.push(`-webkit-background-clip: text`); | |
| 235 | + | styles.push(`-webkit-text-fill-color: transparent`); | |
| 236 | + | styles.push(`background-clip: text`); | |
| 237 | + | styles.push(`letter-spacing: 0`); | |
| 238 | + | styles.push(`word-spacing: 0`); | |
| 239 | + | } else if (style.gradient && style.gradient.length === 1) { | |
| 240 | + | // Single color from gradient array | |
| 241 | + | styles.push(`color: ${style.gradient[0]}`); | |
| 242 | + | } else if (style.color) { | |
| 243 | + | styles.push(`color: ${style.color}`); | |
| 244 | + | } else { | |
| 245 | + | // Fallback to black if no color is set | |
| 246 | + | styles.push(`color: #000000`); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // Font weight | |
| 250 | + | if (style.bold) { | |
| 251 | + | // Use correct weight for Tahoma (200) vs standard bold (700) | |
| 252 | + | if (style.fontFamily === 'tahoma') { | |
| 253 | + | styles.push(`font-weight: 200`); | |
| 254 | + | } else { | |
| 255 | + | styles.push(`font-weight: 700`); | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Font style | |
| 260 | + | if (style.italic) { | |
| 261 | + | styles.push(`font-style: italic`); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // Text decoration | |
| 265 | + | const decorations: string[] = []; | |
| 266 | + | if (style.underline) decorations.push('underline'); | |
| 267 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 268 | + | if (decorations.length > 0) { | |
| 269 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | return styles.join('; '); | |
| 273 | + | }⏎̸ | |
| @@ -0,0 +1,273 @@ | |||||
| 1 | + | // Font and styling configuration for chat messages | |||
| 2 | + | ||||
| 3 | + | // Retro Windows fonts | |||
| 4 | + | // Primary font is Pixelated MS Sans Serif from xp.css which works for all users | |||
| 5 | + | // Secondary fonts use local system fonts with proper fallbacks | |||
| 6 | + | export const RETRO_FONTS = { | |||
| 7 | + | pixelated: { name: 'Pixelated MS Sans Serif', stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' }, | |||
| 8 | + | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |||
| 9 | + | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |||
| 10 | + | comicSans: { name: 'Comic Sans MS', stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' }, | |||
| 11 | + | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | |||
| 12 | + | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | |||
| 13 | + | courierNew: { name: 'Courier New', stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' }, | |||
| 14 | + | dos: { name: 'Perfect DOS VGA 437', stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' } | |||
| 15 | + | } as const; | |||
| 16 | + | ||||
| 17 | + | export type FontFamily = keyof typeof RETRO_FONTS; | |||
| 18 | + | ||||
| 19 | + | // Generate 256-color web-safe palette ordered by hue | |||
| 20 | + | export function generate256Colors(): string[] { | |||
| 21 | + | const colors: string[] = []; | |||
| 22 | + | ||||
| 23 | + | // Start with grayscale colors | |||
| 24 | + | colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF'); | |||
| 25 | + | ||||
| 26 | + | // Then add colors in a more logical order by hue | |||
| 27 | + | // Red hues | |||
| 28 | + | colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000'); | |||
| 29 | + | colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC'); | |||
| 30 | + | ||||
| 31 | + | // Orange hues | |||
| 32 | + | colors.push('#663300', '#993300', '#CC3300', '#FF3300'); | |||
| 33 | + | colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99'); | |||
| 34 | + | ||||
| 35 | + | // Yellow hues | |||
| 36 | + | colors.push('#666600', '#999900', '#CCCC00', '#FFFF00'); | |||
| 37 | + | colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC'); | |||
| 38 | + | ||||
| 39 | + | // Green hues | |||
| 40 | + | colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00'); | |||
| 41 | + | colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC'); | |||
| 42 | + | colors.push('#336633', '#669966', '#99CC99', '#CCFFCC'); | |||
| 43 | + | ||||
| 44 | + | // Cyan hues | |||
| 45 | + | colors.push('#006666', '#009999', '#00CCCC', '#00FFFF'); | |||
| 46 | + | colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF'); | |||
| 47 | + | colors.push('#336666', '#669999', '#99CCCC'); | |||
| 48 | + | ||||
| 49 | + | // Blue hues | |||
| 50 | + | colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF'); | |||
| 51 | + | colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF'); | |||
| 52 | + | colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF'); | |||
| 53 | + | ||||
| 54 | + | // Purple hues | |||
| 55 | + | colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF'); | |||
| 56 | + | colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF'); | |||
| 57 | + | colors.push('#663366', '#996699', '#CC99CC'); | |||
| 58 | + | ||||
| 59 | + | // Pink hues | |||
| 60 | + | colors.push('#660033', '#990066', '#CC0099', '#FF0099'); | |||
| 61 | + | colors.push('#FF3399', '#FF66CC', '#FF99CC'); | |||
| 62 | + | ||||
| 63 | + | // Browns | |||
| 64 | + | colors.push('#663333', '#996633', '#CC9966', '#996666'); | |||
| 65 | + | ||||
| 66 | + | // Now fill in any missing combinations to get full 216 web-safe colors | |||
| 67 | + | const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF']; | |||
| 68 | + | const existingColors = new Set(colors); | |||
| 69 | + | ||||
| 70 | + | for (const r of webSafeValues) { | |||
| 71 | + | for (const g of webSafeValues) { | |||
| 72 | + | for (const b of webSafeValues) { | |||
| 73 | + | const color = `#${r}${g}${b}`.toUpperCase(); | |||
| 74 | + | if (!existingColors.has(color)) { | |||
| 75 | + | colors.push(color); | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return colors; | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Classic retro colors for backward compatibility | |||
| 85 | + | export const CLASSIC_COLORS = { | |||
| 86 | + | black: '#000000', | |||
| 87 | + | darkBlue: '#000080', | |||
| 88 | + | green: '#008000', | |||
| 89 | + | teal: '#008080', | |||
| 90 | + | darkRed: '#800000', | |||
| 91 | + | purple: '#800080', | |||
| 92 | + | brown: '#808000', | |||
| 93 | + | silver: '#C0C0C0', | |||
| 94 | + | gray: '#808080', | |||
| 95 | + | blue: '#0000FF', | |||
| 96 | + | lime: '#00FF00', | |||
| 97 | + | aqua: '#00FFFF', | |||
| 98 | + | red: '#FF0000', | |||
| 99 | + | fuchsia: '#FF00FF', | |||
| 100 | + | yellow: '#FFFF00', | |||
| 101 | + | white: '#FFFFFF' | |||
| 102 | + | } as const; | |||
| 103 | + | ||||
| 104 | + | export type ClassicColor = keyof typeof CLASSIC_COLORS; | |||
| 105 | + | ||||
| 106 | + | // Gradient presets inspired by AOL faders | |||
| 107 | + | export const GRADIENT_PRESETS = { | |||
| 108 | + | rainbow: ['#FF0000', '#FF8000', '#FFFF00', '#80FF00', '#00FF00', '#00FF80', '#00FFFF', '#0080FF', '#0000FF', '#8000FF', '#FF00FF', '#FF0080'], | |||
| 109 | + | fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'], | |||
| 110 | + | ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'], | |||
| 111 | + | sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'], | |||
| 112 | + | neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'], | |||
| 113 | + | forest: ['#006400', '#228B22', '#32CD32', '#90EE90'], | |||
| 114 | + | royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD'] | |||
| 115 | + | } as const; | |||
| 116 | + | ||||
| 117 | + | export type GradientPreset = keyof typeof GRADIENT_PRESETS; | |||
| 118 | + | ||||
| 119 | + | // Text styling options | |||
| 120 | + | export interface TextStyle { | |||
| 121 | + | fontFamily: FontFamily; | |||
| 122 | + | fontSize: number; // in pixels | |||
| 123 | + | color?: string; // hex color string | |||
| 124 | + | gradient?: string[]; // array of 1-3 hex colors for gradients | |||
| 125 | + | bold: boolean; | |||
| 126 | + | italic: boolean; | |||
| 127 | + | underline: boolean; | |||
| 128 | + | strikethrough: boolean; | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Default text style | |||
| 132 | + | export const DEFAULT_TEXT_STYLE: TextStyle = { | |||
| 133 | + | fontFamily: 'pixelated', | |||
| 134 | + | fontSize: 14, | |||
| 135 | + | color: '#000000', | |||
| 136 | + | bold: false, | |||
| 137 | + | italic: false, | |||
| 138 | + | underline: false, | |||
| 139 | + | strikethrough: false | |||
| 140 | + | }; | |||
| 141 | + | ||||
| 142 | + | // User preferences for text formatting | |||
| 143 | + | export interface UserTextPreferences { | |||
| 144 | + | defaultStyle: TextStyle; | |||
| 145 | + | allowFormatting: boolean; | |||
| 146 | + | maxMessageLength: number; | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Default user preferences | |||
| 150 | + | export const DEFAULT_USER_PREFERENCES: UserTextPreferences = { | |||
| 151 | + | defaultStyle: DEFAULT_TEXT_STYLE, | |||
| 152 | + | allowFormatting: true, | |||
| 153 | + | maxMessageLength: 500 | |||
| 154 | + | }; | |||
| 155 | + | ||||
| 156 | + | // Hex color validation | |||
| 157 | + | export function isValidHexColor(color: string): boolean { | |||
| 158 | + | return /^#[0-9A-Fa-f]{6}$/.test(color); | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // Formatting validation | |||
| 162 | + | export function validateTextStyle(style: Partial<TextStyle>): TextStyle { | |||
| 163 | + | return { | |||
| 164 | + | fontFamily: style.fontFamily && style.fontFamily in RETRO_FONTS ? style.fontFamily : DEFAULT_TEXT_STYLE.fontFamily, | |||
| 165 | + | fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)), | |||
| 166 | + | color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color, | |||
| 167 | + | gradient: style.gradient && Array.isArray(style.gradient) && style.gradient.length <= 3 && style.gradient.every(isValidHexColor) ? style.gradient : undefined, | |||
| 168 | + | bold: Boolean(style.bold), | |||
| 169 | + | italic: Boolean(style.italic), | |||
| 170 | + | underline: Boolean(style.underline), | |||
| 171 | + | strikethrough: Boolean(style.strikethrough) | |||
| 172 | + | }; | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // Generate CSS style string from TextStyle | |||
| 176 | + | export function generateCSSStyle(style: TextStyle): string { | |||
| 177 | + | const styles: string[] = []; | |||
| 178 | + | ||||
| 179 | + | // Font family | |||
| 180 | + | styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`); | |||
| 181 | + | ||||
| 182 | + | // Font size | |||
| 183 | + | styles.push(`font-size: ${style.fontSize}px`); | |||
| 184 | + | ||||
| 185 | + | // Color or gradient | |||
| 186 | + | if (style.gradient && style.gradient.length > 1) { | |||
| 187 | + | const gradientStops = style.gradient.join(', '); | |||
| 188 | + | styles.push(`background: linear-gradient(45deg, ${gradientStops})`); | |||
| 189 | + | styles.push(`-webkit-background-clip: text`); | |||
| 190 | + | styles.push(`-webkit-text-fill-color: transparent`); | |||
| 191 | + | styles.push(`background-clip: text`); | |||
| 192 | + | } else if (style.color) { | |||
| 193 | + | styles.push(`color: ${style.color}`); | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Font weight | |||
| 197 | + | if (style.bold) { | |||
| 198 | + | // Use correct weight for Tahoma (200) vs standard bold (700) | |||
| 199 | + | if (style.fontFamily === 'tahoma') { | |||
| 200 | + | styles.push(`font-weight: 200`); | |||
| 201 | + | } else { | |||
| 202 | + | styles.push(`font-weight: 700`); | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // Font style | |||
| 207 | + | if (style.italic) { | |||
| 208 | + | styles.push(`font-style: italic`); | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // Text decoration | |||
| 212 | + | const decorations: string[] = []; | |||
| 213 | + | if (style.underline) decorations.push('underline'); | |||
| 214 | + | if (style.strikethrough) decorations.push('line-through'); | |||
| 215 | + | if (decorations.length > 0) { | |||
| 216 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | return styles.join('; '); | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Generate CSS style string for input fields (without font-family which should be handled by classes) | |||
| 223 | + | export function generateInputCSSStyle(style: TextStyle): string { | |||
| 224 | + | const styles: string[] = []; | |||
| 225 | + | ||||
| 226 | + | // Font size | |||
| 227 | + | styles.push(`font-size: ${style.fontSize}px`); | |||
| 228 | + | ||||
| 229 | + | // Color handling for inputs | |||
| 230 | + | if (style.gradient && style.gradient.length > 1) { | |||
| 231 | + | // Apply gradient background with text clipping for gradient effect | |||
| 232 | + | const gradientStops = style.gradient.join(', '); | |||
| 233 | + | styles.push(`background: linear-gradient(to right, ${gradientStops})`); | |||
| 234 | + | styles.push(`-webkit-background-clip: text`); | |||
| 235 | + | styles.push(`-webkit-text-fill-color: transparent`); | |||
| 236 | + | styles.push(`background-clip: text`); | |||
| 237 | + | styles.push(`letter-spacing: 0`); | |||
| 238 | + | styles.push(`word-spacing: 0`); | |||
| 239 | + | } else if (style.gradient && style.gradient.length === 1) { | |||
| 240 | + | // Single color from gradient array | |||
| 241 | + | styles.push(`color: ${style.gradient[0]}`); | |||
| 242 | + | } else if (style.color) { | |||
| 243 | + | styles.push(`color: ${style.color}`); | |||
| 244 | + | } else { | |||
| 245 | + | // Fallback to black if no color is set | |||
| 246 | + | styles.push(`color: #000000`); | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Font weight | |||
| 250 | + | if (style.bold) { | |||
| 251 | + | // Use correct weight for Tahoma (200) vs standard bold (700) | |||
| 252 | + | if (style.fontFamily === 'tahoma') { | |||
| 253 | + | styles.push(`font-weight: 200`); | |||
| 254 | + | } else { | |||
| 255 | + | styles.push(`font-weight: 700`); | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Font style | |||
| 260 | + | if (style.italic) { | |||
| 261 | + | styles.push(`font-style: italic`); | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | // Text decoration | |||
| 265 | + | const decorations: string[] = []; | |||
| 266 | + | if (style.underline) decorations.push('underline'); | |||
| 267 | + | if (style.strikethrough) decorations.push('line-through'); | |||
| 268 | + | if (decorations.length > 0) { | |||
| 269 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | return styles.join('; '); | |||
| 273 | + | }⏎̸ | |||
A src/lib/utils/text-formatter.ts +354 -0
| @@ -0,0 +1,354 @@ | |||
| 1 | + | import { unified } from 'unified'; | |
| 2 | + | import remarkParse from 'remark-parse'; | |
| 3 | + | import remarkRehype from 'remark-rehype'; | |
| 4 | + | import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; | |
| 5 | + | import rehypeStringify from 'rehype-stringify'; | |
| 6 | + | import type { TextStyle } from '../types/text-formatting'; | |
| 7 | + | import { generateCSSStyle, validateTextStyle } from '../types/text-formatting'; | |
| 8 | + | ||
| 9 | + | // Define allowed HTML tags and attributes for security | |
| 10 | + | const ALLOWED_SCHEMA = { | |
| 11 | + | ...defaultSchema, | |
| 12 | + | tagNames: [ | |
| 13 | + | ...(defaultSchema.tagNames || []), | |
| 14 | + | 'span', | |
| 15 | + | 'strong', | |
| 16 | + | 'em', | |
| 17 | + | 'u', | |
| 18 | + | 's', | |
| 19 | + | 'mark' | |
| 20 | + | ], | |
| 21 | + | attributes: { | |
| 22 | + | ...defaultSchema.attributes, | |
| 23 | + | span: ['style', 'class', 'data-gradient'], | |
| 24 | + | strong: ['style', 'class'], | |
| 25 | + | em: ['style', 'class'], | |
| 26 | + | u: ['style', 'class'], | |
| 27 | + | s: ['style', 'class'], | |
| 28 | + | mark: ['style', 'class'] | |
| 29 | + | }, | |
| 30 | + | protocols: { | |
| 31 | + | ...defaultSchema.protocols, | |
| 32 | + | style: [] | |
| 33 | + | } | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | // Custom rehype plugin to apply text styles | |
| 37 | + | function rehypeApplyStyles(userStyle: TextStyle) { | |
| 38 | + | return function(tree: any) { | |
| 39 | + | // We don't need to apply styles here anymore | |
| 40 | + | // The formatted-message component handles all styling | |
| 41 | + | return tree; | |
| 42 | + | }; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // BBCode-style formatting patterns | |
| 46 | + | const BB_CODE_PATTERNS: Array<{ | |
| 47 | + | pattern: RegExp; | |
| 48 | + | replacement: string; | |
| 49 | + | }> = [ | |
| 50 | + | // Bold | |
| 51 | + | { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' }, | |
| 52 | + | ||
| 53 | + | // Italic | |
| 54 | + | { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' }, | |
| 55 | + | ||
| 56 | + | // Underline | |
| 57 | + | { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' }, | |
| 58 | + | ||
| 59 | + | // Strikethrough | |
| 60 | + | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }, | |
| 61 | + | ||
| 62 | + | // Color | |
| 63 | + | { pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, replacement: '<span style="color: $1">$2</span>' }, | |
| 64 | + | ||
| 65 | + | // Size | |
| 66 | + | { pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, replacement: '<span style="font-size: $1px">$2</span>' }, | |
| 67 | + | ||
| 68 | + | // Font | |
| 69 | + | { pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, replacement: '<span style="font-family: $1">$2</span>' } | |
| 70 | + | ]; | |
| 71 | + | ||
| 72 | + | // Markdown-style formatting patterns | |
| 73 | + | const MARKDOWN_PATTERNS: Array<{ | |
| 74 | + | pattern: RegExp; | |
| 75 | + | replacement: string; | |
| 76 | + | }> = [ | |
| 77 | + | // Bold (double asterisk or double underscore) | |
| 78 | + | { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' }, | |
| 79 | + | { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' }, | |
| 80 | + | ||
| 81 | + | // Italic (single asterisk or single underscore) | |
| 82 | + | { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' }, | |
| 83 | + | { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' }, | |
| 84 | + | ||
| 85 | + | // Strikethrough | |
| 86 | + | { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' }, | |
| 87 | + | ||
| 88 | + | // Inline code | |
| 89 | + | { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' } | |
| 90 | + | ]; | |
| 91 | + | ||
| 92 | + | // Preprocess text to convert BBCode and Markdown to HTML | |
| 93 | + | function preprocessFormatting(text: string): string { | |
| 94 | + | let processedText = text; | |
| 95 | + | ||
| 96 | + | // Apply BBCode patterns first | |
| 97 | + | BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => { | |
| 98 | + | processedText = processedText.replace(pattern, replacement); | |
| 99 | + | }); | |
| 100 | + | ||
| 101 | + | // Apply Markdown patterns (but avoid conflicts with already processed BBCode) | |
| 102 | + | MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => { | |
| 103 | + | // Only apply if not already inside HTML tags | |
| 104 | + | processedText = processedText.replace(pattern, (match, content) => { | |
| 105 | + | // Simple check to avoid replacing inside existing HTML tags | |
| 106 | + | const beforeMatch = processedText.substring(0, processedText.indexOf(match)); | |
| 107 | + | const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length; | |
| 108 | + | const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length; | |
| 109 | + | ||
| 110 | + | // If we're inside an unclosed tag, don't apply markdown | |
| 111 | + | if (openTags > closeTags) { | |
| 112 | + | return match; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | return replacement.replace('$1', content); | |
| 116 | + | }); | |
| 117 | + | }); | |
| 118 | + | ||
| 119 | + | return processedText; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Main text formatting function | |
| 123 | + | export async function formatText( | |
| 124 | + | text: string, | |
| 125 | + | userStyle?: Partial<TextStyle>, | |
| 126 | + | allowRichFormatting: boolean = true | |
| 127 | + | ): Promise<string> { | |
| 128 | + | try { | |
| 129 | + | // If text is empty, return empty string | |
| 130 | + | if (!text) return ''; | |
| 131 | + | ||
| 132 | + | // Validate and apply user style | |
| 133 | + | const validatedStyle = validateTextStyle(userStyle || {}); | |
| 134 | + | ||
| 135 | + | // Preprocess the text for formatting if allowed | |
| 136 | + | let processedText = text; | |
| 137 | + | if (allowRichFormatting) { | |
| 138 | + | processedText = preprocessFormatting(text); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // If no rich formatting, just return escaped text | |
| 142 | + | if (!allowRichFormatting && !processedText.includes('<')) { | |
| 143 | + | return escapeHtml(text); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Create the unified processor | |
| 147 | + | const processor = unified() | |
| 148 | + | .use(remarkParse, { | |
| 149 | + | fragment: true // Parse as fragment, not full document | |
| 150 | + | }) | |
| 151 | + | .use(remarkRehype, { | |
| 152 | + | allowDangerousHtml: true // Allow preprocessed HTML to pass through | |
| 153 | + | }) | |
| 154 | + | .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML | |
| 155 | + | .use(rehypeStringify); | |
| 156 | + | ||
| 157 | + | // Process the text | |
| 158 | + | const result = await processor.process(processedText); | |
| 159 | + | let formattedHtml = String(result).trim(); | |
| 160 | + | ||
| 161 | + | // If we got an empty result, return the escaped text | |
| 162 | + | if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') { | |
| 163 | + | return escapeHtml(text); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Remove wrapping <p> tags if it's just a single paragraph | |
| 167 | + | if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) { | |
| 168 | + | const pCount = (formattedHtml.match(/<p>/g) || []).length; | |
| 169 | + | if (pCount === 1) { | |
| 170 | + | formattedHtml = formattedHtml.slice(3, -4).trim(); | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Final check - if we ended up with empty content, return escaped text | |
| 175 | + | if (!formattedHtml) { | |
| 176 | + | return escapeHtml(text); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | return formattedHtml; | |
| 180 | + | ||
| 181 | + | } catch (error) { | |
| 182 | + | console.error('Text formatting error:', error); | |
| 183 | + | // Return escaped plain text as fallback | |
| 184 | + | return escapeHtml(text); | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Utility function to escape HTML | |
| 189 | + | function escapeHtml(text: string): string { | |
| 190 | + | const div = document.createElement('div'); | |
| 191 | + | div.textContent = text; | |
| 192 | + | return div.innerHTML; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Generate CSS style string excluding font-family (which should be handled by classes) | |
| 196 | + | function generateCSSStyleWithoutFont(style: TextStyle): string { | |
| 197 | + | const styles: string[] = []; | |
| 198 | + | ||
| 199 | + | // Font size | |
| 200 | + | styles.push(`font-size: ${style.fontSize}px`); | |
| 201 | + | ||
| 202 | + | // Color (not for gradients) | |
| 203 | + | if (style.color && !style.gradient) { | |
| 204 | + | styles.push(`color: ${style.color}`); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Font weight | |
| 208 | + | if (style.bold) { | |
| 209 | + | styles.push(`font-weight: bold`); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Font style | |
| 213 | + | if (style.italic) { | |
| 214 | + | styles.push(`font-style: italic`); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Text decoration | |
| 218 | + | const decorations: string[] = []; | |
| 219 | + | if (style.underline) decorations.push('underline'); | |
| 220 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 221 | + | if (decorations.length > 0) { | |
| 222 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | return styles.join('; '); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // Create gradient text effect | |
| 229 | + | export function createGradientText( | |
| 230 | + | text: string, | |
| 231 | + | colors: string[], | |
| 232 | + | className: string = 'gradient-text', | |
| 233 | + | style?: TextStyle | |
| 234 | + | ): string { | |
| 235 | + | console.debug('createGradientText called with:', { text, colors, className, style }); | |
| 236 | + | ||
| 237 | + | if (colors.length < 2) { | |
| 238 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 239 | + | const inlineStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |
| 240 | + | const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`; | |
| 241 | + | console.debug('Single color result:', result); | |
| 242 | + | return result; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | const chars = Array.from(text); | |
| 246 | + | const totalChars = chars.length; | |
| 247 | + | ||
| 248 | + | if (totalChars === 0) return ''; | |
| 249 | + | ||
| 250 | + | // Create spans for each character with interpolated colors | |
| 251 | + | const gradientSpans = chars.map((char, index) => { | |
| 252 | + | if (char === ' ') { | |
| 253 | + | return ' '; // Use non-breaking space to preserve spacing | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Calculate color position (0 to 1) | |
| 257 | + | const position = totalChars === 1 ? 0 : index / (totalChars - 1); | |
| 258 | + | ||
| 259 | + | // Find the two colors to interpolate between | |
| 260 | + | const colorIndex = position * (colors.length - 1); | |
| 261 | + | const lowerIndex = Math.floor(colorIndex); | |
| 262 | + | const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); | |
| 263 | + | const t = colorIndex - lowerIndex; | |
| 264 | + | ||
| 265 | + | // Interpolate between colors | |
| 266 | + | const color1 = hexToRgb(colors[lowerIndex]); | |
| 267 | + | const color2 = hexToRgb(colors[upperIndex]); | |
| 268 | + | ||
| 269 | + | if (!color1 || !color2) { | |
| 270 | + | const fallbackStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |
| 271 | + | return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`; | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | const r = Math.round(color1.r + t * (color2.r - color1.r)); | |
| 275 | + | const g = Math.round(color1.g + t * (color2.g - color1.g)); | |
| 276 | + | const b = Math.round(color1.b + t * (color2.b - color1.b)); | |
| 277 | + | ||
| 278 | + | const interpolatedColor = `rgb(${r}, ${g}, ${b})`; | |
| 279 | + | ||
| 280 | + | // Combine interpolated color with other styles | |
| 281 | + | let inlineStyle = `color: ${interpolatedColor}`; | |
| 282 | + | if (style) { | |
| 283 | + | // Get styles without font-family and color | |
| 284 | + | if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; | |
| 285 | + | if (style.bold) inlineStyle += `; font-weight: bold`; | |
| 286 | + | if (style.italic) inlineStyle += `; font-style: italic`; | |
| 287 | + | const decorations: string[] = []; | |
| 288 | + | if (style.underline) decorations.push('underline'); | |
| 289 | + | if (style.strikethrough) decorations.push('line-through'); | |
| 290 | + | if (decorations.length > 0) { | |
| 291 | + | inlineStyle += `; text-decoration: ${decorations.join(' ')}`; | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | |
| 296 | + | }); | |
| 297 | + | ||
| 298 | + | // Wrap all spans in a container that uses flexbox to eliminate spacing | |
| 299 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |
| 300 | + | const containerStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined, color: undefined}) : ''; | |
| 301 | + | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | |
| 302 | + | console.debug('Final gradient HTML:', result); | |
| 303 | + | return result; | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // Helper function to convert hex to RGB | |
| 307 | + | function hexToRgb(hex: string): { r: number; g: number; b: number } | null { | |
| 308 | + | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| 309 | + | return result ? { | |
| 310 | + | r: parseInt(result[1], 16), | |
| 311 | + | g: parseInt(result[2], 16), | |
| 312 | + | b: parseInt(result[3], 16) | |
| 313 | + | } : null; | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Validate message content for security | |
| 317 | + | export function validateMessageContent(content: string): { | |
| 318 | + | isValid: boolean; | |
| 319 | + | sanitizedContent: string; | |
| 320 | + | errors: string[]; | |
| 321 | + | } { | |
| 322 | + | const errors: string[] = []; | |
| 323 | + | let sanitizedContent = content; | |
| 324 | + | ||
| 325 | + | // Check length | |
| 326 | + | if (content.length > 2000) { | |
| 327 | + | errors.push('Message too long (max 2000 characters)'); | |
| 328 | + | sanitizedContent = content.substring(0, 2000); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // Check for potential XSS patterns | |
| 332 | + | const dangerousPatterns = [ | |
| 333 | + | /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, | |
| 334 | + | /javascript:/gi, | |
| 335 | + | /vbscript:/gi, | |
| 336 | + | /onload\s*=/gi, | |
| 337 | + | /onerror\s*=/gi, | |
| 338 | + | /onclick\s*=/gi, | |
| 339 | + | /onmouseover\s*=/gi | |
| 340 | + | ]; | |
| 341 | + | ||
| 342 | + | dangerousPatterns.forEach(pattern => { | |
| 343 | + | if (pattern.test(content)) { | |
| 344 | + | errors.push('Potentially dangerous content detected'); | |
| 345 | + | sanitizedContent = sanitizedContent.replace(pattern, ''); | |
| 346 | + | } | |
| 347 | + | }); | |
| 348 | + | ||
| 349 | + | return { | |
| 350 | + | isValid: errors.length === 0, | |
| 351 | + | sanitizedContent, | |
| 352 | + | errors | |
| 353 | + | }; | |
| 354 | + | }⏎̸ | |
| @@ -0,0 +1,354 @@ | |||||
| 1 | + | import { unified } from 'unified'; | |||
| 2 | + | import remarkParse from 'remark-parse'; | |||
| 3 | + | import remarkRehype from 'remark-rehype'; | |||
| 4 | + | import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; | |||
| 5 | + | import rehypeStringify from 'rehype-stringify'; | |||
| 6 | + | import type { TextStyle } from '../types/text-formatting'; | |||
| 7 | + | import { generateCSSStyle, validateTextStyle } from '../types/text-formatting'; | |||
| 8 | + | ||||
| 9 | + | // Define allowed HTML tags and attributes for security | |||
| 10 | + | const ALLOWED_SCHEMA = { | |||
| 11 | + | ...defaultSchema, | |||
| 12 | + | tagNames: [ | |||
| 13 | + | ...(defaultSchema.tagNames || []), | |||
| 14 | + | 'span', | |||
| 15 | + | 'strong', | |||
| 16 | + | 'em', | |||
| 17 | + | 'u', | |||
| 18 | + | 's', | |||
| 19 | + | 'mark' | |||
| 20 | + | ], | |||
| 21 | + | attributes: { | |||
| 22 | + | ...defaultSchema.attributes, | |||
| 23 | + | span: ['style', 'class', 'data-gradient'], | |||
| 24 | + | strong: ['style', 'class'], | |||
| 25 | + | em: ['style', 'class'], | |||
| 26 | + | u: ['style', 'class'], | |||
| 27 | + | s: ['style', 'class'], | |||
| 28 | + | mark: ['style', 'class'] | |||
| 29 | + | }, | |||
| 30 | + | protocols: { | |||
| 31 | + | ...defaultSchema.protocols, | |||
| 32 | + | style: [] | |||
| 33 | + | } | |||
| 34 | + | }; | |||
| 35 | + | ||||
| 36 | + | // Custom rehype plugin to apply text styles | |||
| 37 | + | function rehypeApplyStyles(userStyle: TextStyle) { | |||
| 38 | + | return function(tree: any) { | |||
| 39 | + | // We don't need to apply styles here anymore | |||
| 40 | + | // The formatted-message component handles all styling | |||
| 41 | + | return tree; | |||
| 42 | + | }; | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // BBCode-style formatting patterns | |||
| 46 | + | const BB_CODE_PATTERNS: Array<{ | |||
| 47 | + | pattern: RegExp; | |||
| 48 | + | replacement: string; | |||
| 49 | + | }> = [ | |||
| 50 | + | // Bold | |||
| 51 | + | { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' }, | |||
| 52 | + | ||||
| 53 | + | // Italic | |||
| 54 | + | { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' }, | |||
| 55 | + | ||||
| 56 | + | // Underline | |||
| 57 | + | { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' }, | |||
| 58 | + | ||||
| 59 | + | // Strikethrough | |||
| 60 | + | { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }, | |||
| 61 | + | ||||
| 62 | + | // Color | |||
| 63 | + | { pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, replacement: '<span style="color: $1">$2</span>' }, | |||
| 64 | + | ||||
| 65 | + | // Size | |||
| 66 | + | { pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, replacement: '<span style="font-size: $1px">$2</span>' }, | |||
| 67 | + | ||||
| 68 | + | // Font | |||
| 69 | + | { pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, replacement: '<span style="font-family: $1">$2</span>' } | |||
| 70 | + | ]; | |||
| 71 | + | ||||
| 72 | + | // Markdown-style formatting patterns | |||
| 73 | + | const MARKDOWN_PATTERNS: Array<{ | |||
| 74 | + | pattern: RegExp; | |||
| 75 | + | replacement: string; | |||
| 76 | + | }> = [ | |||
| 77 | + | // Bold (double asterisk or double underscore) | |||
| 78 | + | { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' }, | |||
| 79 | + | { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' }, | |||
| 80 | + | ||||
| 81 | + | // Italic (single asterisk or single underscore) | |||
| 82 | + | { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' }, | |||
| 83 | + | { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' }, | |||
| 84 | + | ||||
| 85 | + | // Strikethrough | |||
| 86 | + | { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' }, | |||
| 87 | + | ||||
| 88 | + | // Inline code | |||
| 89 | + | { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' } | |||
| 90 | + | ]; | |||
| 91 | + | ||||
| 92 | + | // Preprocess text to convert BBCode and Markdown to HTML | |||
| 93 | + | function preprocessFormatting(text: string): string { | |||
| 94 | + | let processedText = text; | |||
| 95 | + | ||||
| 96 | + | // Apply BBCode patterns first | |||
| 97 | + | BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => { | |||
| 98 | + | processedText = processedText.replace(pattern, replacement); | |||
| 99 | + | }); | |||
| 100 | + | ||||
| 101 | + | // Apply Markdown patterns (but avoid conflicts with already processed BBCode) | |||
| 102 | + | MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => { | |||
| 103 | + | // Only apply if not already inside HTML tags | |||
| 104 | + | processedText = processedText.replace(pattern, (match, content) => { | |||
| 105 | + | // Simple check to avoid replacing inside existing HTML tags | |||
| 106 | + | const beforeMatch = processedText.substring(0, processedText.indexOf(match)); | |||
| 107 | + | const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length; | |||
| 108 | + | const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length; | |||
| 109 | + | ||||
| 110 | + | // If we're inside an unclosed tag, don't apply markdown | |||
| 111 | + | if (openTags > closeTags) { | |||
| 112 | + | return match; | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | return replacement.replace('$1', content); | |||
| 116 | + | }); | |||
| 117 | + | }); | |||
| 118 | + | ||||
| 119 | + | return processedText; | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Main text formatting function | |||
| 123 | + | export async function formatText( | |||
| 124 | + | text: string, | |||
| 125 | + | userStyle?: Partial<TextStyle>, | |||
| 126 | + | allowRichFormatting: boolean = true | |||
| 127 | + | ): Promise<string> { | |||
| 128 | + | try { | |||
| 129 | + | // If text is empty, return empty string | |||
| 130 | + | if (!text) return ''; | |||
| 131 | + | ||||
| 132 | + | // Validate and apply user style | |||
| 133 | + | const validatedStyle = validateTextStyle(userStyle || {}); | |||
| 134 | + | ||||
| 135 | + | // Preprocess the text for formatting if allowed | |||
| 136 | + | let processedText = text; | |||
| 137 | + | if (allowRichFormatting) { | |||
| 138 | + | processedText = preprocessFormatting(text); | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // If no rich formatting, just return escaped text | |||
| 142 | + | if (!allowRichFormatting && !processedText.includes('<')) { | |||
| 143 | + | return escapeHtml(text); | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Create the unified processor | |||
| 147 | + | const processor = unified() | |||
| 148 | + | .use(remarkParse, { | |||
| 149 | + | fragment: true // Parse as fragment, not full document | |||
| 150 | + | }) | |||
| 151 | + | .use(remarkRehype, { | |||
| 152 | + | allowDangerousHtml: true // Allow preprocessed HTML to pass through | |||
| 153 | + | }) | |||
| 154 | + | .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML | |||
| 155 | + | .use(rehypeStringify); | |||
| 156 | + | ||||
| 157 | + | // Process the text | |||
| 158 | + | const result = await processor.process(processedText); | |||
| 159 | + | let formattedHtml = String(result).trim(); | |||
| 160 | + | ||||
| 161 | + | // If we got an empty result, return the escaped text | |||
| 162 | + | if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') { | |||
| 163 | + | return escapeHtml(text); | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Remove wrapping <p> tags if it's just a single paragraph | |||
| 167 | + | if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) { | |||
| 168 | + | const pCount = (formattedHtml.match(/<p>/g) || []).length; | |||
| 169 | + | if (pCount === 1) { | |||
| 170 | + | formattedHtml = formattedHtml.slice(3, -4).trim(); | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // Final check - if we ended up with empty content, return escaped text | |||
| 175 | + | if (!formattedHtml) { | |||
| 176 | + | return escapeHtml(text); | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | return formattedHtml; | |||
| 180 | + | ||||
| 181 | + | } catch (error) { | |||
| 182 | + | console.error('Text formatting error:', error); | |||
| 183 | + | // Return escaped plain text as fallback | |||
| 184 | + | return escapeHtml(text); | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // Utility function to escape HTML | |||
| 189 | + | function escapeHtml(text: string): string { | |||
| 190 | + | const div = document.createElement('div'); | |||
| 191 | + | div.textContent = text; | |||
| 192 | + | return div.innerHTML; | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // Generate CSS style string excluding font-family (which should be handled by classes) | |||
| 196 | + | function generateCSSStyleWithoutFont(style: TextStyle): string { | |||
| 197 | + | const styles: string[] = []; | |||
| 198 | + | ||||
| 199 | + | // Font size | |||
| 200 | + | styles.push(`font-size: ${style.fontSize}px`); | |||
| 201 | + | ||||
| 202 | + | // Color (not for gradients) | |||
| 203 | + | if (style.color && !style.gradient) { | |||
| 204 | + | styles.push(`color: ${style.color}`); | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Font weight | |||
| 208 | + | if (style.bold) { | |||
| 209 | + | styles.push(`font-weight: bold`); | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // Font style | |||
| 213 | + | if (style.italic) { | |||
| 214 | + | styles.push(`font-style: italic`); | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // Text decoration | |||
| 218 | + | const decorations: string[] = []; | |||
| 219 | + | if (style.underline) decorations.push('underline'); | |||
| 220 | + | if (style.strikethrough) decorations.push('line-through'); | |||
| 221 | + | if (decorations.length > 0) { | |||
| 222 | + | styles.push(`text-decoration: ${decorations.join(' ')}`); | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | return styles.join('; '); | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // Create gradient text effect | |||
| 229 | + | export function createGradientText( | |||
| 230 | + | text: string, | |||
| 231 | + | colors: string[], | |||
| 232 | + | className: string = 'gradient-text', | |||
| 233 | + | style?: TextStyle | |||
| 234 | + | ): string { | |||
| 235 | + | console.debug('createGradientText called with:', { text, colors, className, style }); | |||
| 236 | + | ||||
| 237 | + | if (colors.length < 2) { | |||
| 238 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |||
| 239 | + | const inlineStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |||
| 240 | + | const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`; | |||
| 241 | + | console.debug('Single color result:', result); | |||
| 242 | + | return result; | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | const chars = Array.from(text); | |||
| 246 | + | const totalChars = chars.length; | |||
| 247 | + | ||||
| 248 | + | if (totalChars === 0) return ''; | |||
| 249 | + | ||||
| 250 | + | // Create spans for each character with interpolated colors | |||
| 251 | + | const gradientSpans = chars.map((char, index) => { | |||
| 252 | + | if (char === ' ') { | |||
| 253 | + | return ' '; // Use non-breaking space to preserve spacing | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // Calculate color position (0 to 1) | |||
| 257 | + | const position = totalChars === 1 ? 0 : index / (totalChars - 1); | |||
| 258 | + | ||||
| 259 | + | // Find the two colors to interpolate between | |||
| 260 | + | const colorIndex = position * (colors.length - 1); | |||
| 261 | + | const lowerIndex = Math.floor(colorIndex); | |||
| 262 | + | const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); | |||
| 263 | + | const t = colorIndex - lowerIndex; | |||
| 264 | + | ||||
| 265 | + | // Interpolate between colors | |||
| 266 | + | const color1 = hexToRgb(colors[lowerIndex]); | |||
| 267 | + | const color2 = hexToRgb(colors[upperIndex]); | |||
| 268 | + | ||||
| 269 | + | if (!color1 || !color2) { | |||
| 270 | + | const fallbackStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : ''; | |||
| 271 | + | return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`; | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | const r = Math.round(color1.r + t * (color2.r - color1.r)); | |||
| 275 | + | const g = Math.round(color1.g + t * (color2.g - color1.g)); | |||
| 276 | + | const b = Math.round(color1.b + t * (color2.b - color1.b)); | |||
| 277 | + | ||||
| 278 | + | const interpolatedColor = `rgb(${r}, ${g}, ${b})`; | |||
| 279 | + | ||||
| 280 | + | // Combine interpolated color with other styles | |||
| 281 | + | let inlineStyle = `color: ${interpolatedColor}`; | |||
| 282 | + | if (style) { | |||
| 283 | + | // Get styles without font-family and color | |||
| 284 | + | if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; | |||
| 285 | + | if (style.bold) inlineStyle += `; font-weight: bold`; | |||
| 286 | + | if (style.italic) inlineStyle += `; font-style: italic`; | |||
| 287 | + | const decorations: string[] = []; | |||
| 288 | + | if (style.underline) decorations.push('underline'); | |||
| 289 | + | if (style.strikethrough) decorations.push('line-through'); | |||
| 290 | + | if (decorations.length > 0) { | |||
| 291 | + | inlineStyle += `; text-decoration: ${decorations.join(' ')}`; | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`; | |||
| 296 | + | }); | |||
| 297 | + | ||||
| 298 | + | // Wrap all spans in a container that uses flexbox to eliminate spacing | |||
| 299 | + | const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; | |||
| 300 | + | const containerStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined, color: undefined}) : ''; | |||
| 301 | + | const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`; | |||
| 302 | + | console.debug('Final gradient HTML:', result); | |||
| 303 | + | return result; | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Helper function to convert hex to RGB | |||
| 307 | + | function hexToRgb(hex: string): { r: number; g: number; b: number } | null { | |||
| 308 | + | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |||
| 309 | + | return result ? { | |||
| 310 | + | r: parseInt(result[1], 16), | |||
| 311 | + | g: parseInt(result[2], 16), | |||
| 312 | + | b: parseInt(result[3], 16) | |||
| 313 | + | } : null; | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // Validate message content for security | |||
| 317 | + | export function validateMessageContent(content: string): { | |||
| 318 | + | isValid: boolean; | |||
| 319 | + | sanitizedContent: string; | |||
| 320 | + | errors: string[]; | |||
| 321 | + | } { | |||
| 322 | + | const errors: string[] = []; | |||
| 323 | + | let sanitizedContent = content; | |||
| 324 | + | ||||
| 325 | + | // Check length | |||
| 326 | + | if (content.length > 2000) { | |||
| 327 | + | errors.push('Message too long (max 2000 characters)'); | |||
| 328 | + | sanitizedContent = content.substring(0, 2000); | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // Check for potential XSS patterns | |||
| 332 | + | const dangerousPatterns = [ | |||
| 333 | + | /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, | |||
| 334 | + | /javascript:/gi, | |||
| 335 | + | /vbscript:/gi, | |||
| 336 | + | /onload\s*=/gi, | |||
| 337 | + | /onerror\s*=/gi, | |||
| 338 | + | /onclick\s*=/gi, | |||
| 339 | + | /onmouseover\s*=/gi | |||
| 340 | + | ]; | |||
| 341 | + | ||||
| 342 | + | dangerousPatterns.forEach(pattern => { | |||
| 343 | + | if (pattern.test(content)) { | |||
| 344 | + | errors.push('Potentially dangerous content detected'); | |||
| 345 | + | sanitizedContent = sanitizedContent.replace(pattern, ''); | |||
| 346 | + | } | |||
| 347 | + | }); | |||
| 348 | + | ||||
| 349 | + | return { | |||
| 350 | + | isValid: errors.length === 0, | |||
| 351 | + | sanitizedContent, | |||
| 352 | + | errors | |||
| 353 | + | }; | |||
| 354 | + | }⏎̸ | |||