|
4 | 4 | import { |
5 | 5 | DEFAULT_FILTERS, |
6 | 6 | hasActiveCrop, |
| 7 | + buildPaletteLUT, |
7 | 8 | type Filters, |
8 | 9 | } from '$lib/utils/canvas-filters'; |
| 10 | + import {getPalette} from '$lib/stores/theme.svelte'; |
| 11 | + import {isLightColor} from '$lib/utils/color'; |
| 12 | +
|
| 13 | + const MAX_PALETTE_STOPS = 4; |
9 | 14 |
|
10 | 15 | type SliderDef = { |
11 | 16 | key: string; |
|
46 | 51 | // Section expanded states |
47 | 52 | let lightExpanded = $state(true); |
48 | 53 | let colorExpanded = $state(false); |
| 54 | + let paletteExpanded = $state(false); |
49 | 55 | let detailExpanded = $state(false); |
50 | 56 | let presetsExpanded = $state(false); |
51 | 57 |
|
| 58 | + // Current theme palette — the colors.toml turned into pickable LUT stops. |
| 59 | + // Filter out duplicates and empties so the picker only shows distinct colors. |
| 60 | + let paletteColors = $derived.by(() => { |
| 61 | + const seen = new Set<string>(); |
| 62 | + const out: string[] = []; |
| 63 | + for (const c of getPalette()) { |
| 64 | + if (!c || !c.startsWith('#') || c.length < 7) continue; |
| 65 | + const key = c.toLowerCase(); |
| 66 | + if (seen.has(key)) continue; |
| 67 | + seen.add(key); |
| 68 | + out.push(c); |
| 69 | + } |
| 70 | + return out; |
| 71 | + }); |
| 72 | +
|
| 73 | + function togglePaletteStop(hex: string) { |
| 74 | + const stops = filters.paletteStops; |
| 75 | + const idx = stops.findIndex(s => s.toLowerCase() === hex.toLowerCase()); |
| 76 | + let next: string[]; |
| 77 | + if (idx >= 0) { |
| 78 | + next = stops.filter((_, i) => i !== idx); |
| 79 | + } else if (stops.length >= MAX_PALETTE_STOPS) { |
| 80 | + return; // cap reached |
| 81 | + } else { |
| 82 | + next = [...stops, hex]; |
| 83 | + } |
| 84 | + filters = {...filters, paletteStops: next}; |
| 85 | + debouncedPreview(); |
| 86 | + } |
| 87 | +
|
| 88 | + function clearPaletteStops() { |
| 89 | + filters = { |
| 90 | + ...filters, |
| 91 | + paletteStops: [], |
| 92 | + paletteStrength: DEFAULT_FILTERS.paletteStrength, |
| 93 | + }; |
| 94 | + debouncedPreview(); |
| 95 | + } |
| 96 | +
|
| 97 | + function setPaletteStrength(v: number) { |
| 98 | + filters = {...filters, paletteStrength: v}; |
| 99 | + debouncedPreview(); |
| 100 | + } |
| 101 | +
|
| 102 | + function stopIndex(hex: string): number { |
| 103 | + return filters.paletteStops.findIndex( |
| 104 | + s => s.toLowerCase() === hex.toLowerCase() |
| 105 | + ); |
| 106 | + } |
| 107 | +
|
| 108 | + // Render the actual effective LUT as a data-URL strip, so the preview bar |
| 109 | + // shows exactly what will be applied to the image (HSL-space colorize with |
| 110 | + // luminance pinned to source) — not a naive straight-RGB gradient between |
| 111 | + // stops, which would misrepresent the grade. |
| 112 | + let rampPreviewUrl = $derived.by(() => { |
| 113 | + if (filters.paletteStops.length < 2) return ''; |
| 114 | + const lut = buildPaletteLUT(filters.paletteStops); |
| 115 | + if (!lut) return ''; |
| 116 | + const canvas = document.createElement('canvas'); |
| 117 | + canvas.width = 256; |
| 118 | + canvas.height = 1; |
| 119 | + const ctx = canvas.getContext('2d'); |
| 120 | + if (!ctx) return ''; |
| 121 | + const img = ctx.createImageData(256, 1); |
| 122 | + for (let i = 0; i < 256; i++) { |
| 123 | + img.data[i * 4] = lut[i * 3]; |
| 124 | + img.data[i * 4 + 1] = lut[i * 3 + 1]; |
| 125 | + img.data[i * 4 + 2] = lut[i * 3 + 2]; |
| 126 | + img.data[i * 4 + 3] = 255; |
| 127 | + } |
| 128 | + ctx.putImageData(img, 0, 0); |
| 129 | + return canvas.toDataURL(); |
| 130 | + }); |
| 131 | +
|
| 132 | + let paletteGradeActive = $derived( |
| 133 | + filters.paletteStops.length >= 2 && filters.paletteStrength > 0 |
| 134 | + ); |
| 135 | +
|
52 | 136 | // Slider definitions per section |
53 | 137 | const lightSliders: SliderDef[] = [ |
54 | 138 | {key: 'brightness', label: 'Brightness', min: 0, max: 200, step: 1}, |
|
370 | 454 | </ExpandableSection> |
371 | 455 | </section> |
372 | 456 |
|
| 457 | + <section class="border-b border-[rgba(255,255,255,0.06)] p-3"> |
| 458 | + <ExpandableSection |
| 459 | + title="Palette Grade" |
| 460 | + bind:expanded={paletteExpanded} |
| 461 | + suffix={paletteGradeActive ? ' \u2022' : ''} |
| 462 | + > |
| 463 | + <div class="space-y-3 pt-1"> |
| 464 | + <p class="text-fg-dimmed text-[10px] leading-relaxed"> |
| 465 | + Click up to {MAX_PALETTE_STOPS} theme colors to build a tone |
| 466 | + ramp (shadows → highlights). Preserves original brightness; |
| 467 | + only repaints hue and saturation. |
| 468 | + </p> |
| 469 | + |
| 470 | + <!-- Palette swatch grid --> |
| 471 | + <div class="grid grid-cols-8 gap-1"> |
| 472 | + {#each paletteColors as hex} |
| 473 | + {@const idx = stopIndex(hex)} |
| 474 | + {@const picked = idx >= 0} |
| 475 | + {@const capped = |
| 476 | + !picked && |
| 477 | + filters.paletteStops.length >= |
| 478 | + MAX_PALETTE_STOPS} |
| 479 | + <button |
| 480 | + type="button" |
| 481 | + class="relative aspect-square border transition-all duration-100 |
| 482 | + {picked |
| 483 | + ? 'border-accent ring-accent/40 ring-1' |
| 484 | + : 'border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.25)]'} |
| 485 | + {capped ? 'opacity-30' : ''}" |
| 486 | + style="background-color: {hex}" |
| 487 | + title={picked |
| 488 | + ? `Stop ${idx + 1} · ${hex} — click to remove` |
| 489 | + : capped |
| 490 | + ? `Max ${MAX_PALETTE_STOPS} stops` |
| 491 | + : `${hex} — click to add as stop`} |
| 492 | + disabled={capped} |
| 493 | + onclick={() => togglePaletteStop(hex)} |
| 494 | + > |
| 495 | + {#if picked} |
| 496 | + <span |
| 497 | + class="absolute inset-0 flex items-center justify-center text-[9px] font-bold tabular-nums" |
| 498 | + style="color: {isLightColor(hex) |
| 499 | + ? '#000' |
| 500 | + : '#fff'}">{idx + 1}</span |
| 501 | + > |
| 502 | + {/if} |
| 503 | + </button> |
| 504 | + {/each} |
| 505 | + </div> |
| 506 | + |
| 507 | + <!-- Ramp preview — shows the actual LUT (what each source |
| 508 | + luminance maps to), not a naive stop-to-stop RGB gradient. |
| 509 | + Auto-sorted by luminance, so shadows are on the left. --> |
| 510 | + <div class="space-y-1.5"> |
| 511 | + <div class="flex items-center justify-between"> |
| 512 | + <span class="text-fg-dimmed text-[10px]"> |
| 513 | + {filters.paletteStops.length === 0 |
| 514 | + ? 'No stops selected' |
| 515 | + : filters.paletteStops.length === 1 |
| 516 | + ? '1 stop — pick one more' |
| 517 | + : `${filters.paletteStops.length} stops · auto-sorted`} |
| 518 | + </span> |
| 519 | + {#if filters.paletteStops.length > 0} |
| 520 | + <button |
| 521 | + type="button" |
| 522 | + class="text-fg-dimmed hover:text-fg-secondary text-[10px] transition-colors" |
| 523 | + onclick={clearPaletteStops}>Clear</button |
| 524 | + > |
| 525 | + {/if} |
| 526 | + </div> |
| 527 | + {#if filters.paletteStops.length >= 2 && rampPreviewUrl} |
| 528 | + <img |
| 529 | + src={rampPreviewUrl} |
| 530 | + alt="Effective LUT" |
| 531 | + class="h-6 w-full border border-[rgba(255,255,255,0.08)]" |
| 532 | + style="image-rendering: pixelated; object-fit: fill;" |
| 533 | + /> |
| 534 | + {:else} |
| 535 | + <div |
| 536 | + class="h-6 border border-[rgba(255,255,255,0.08)]" |
| 537 | + style={filters.paletteStops.length === 1 |
| 538 | + ? `background: ${filters.paletteStops[0]}` |
| 539 | + : 'background: repeating-linear-gradient(45deg, rgba(255,255,255,0.04) 0 4px, transparent 4px 8px)'} |
| 540 | + ></div> |
| 541 | + {/if} |
| 542 | + <div |
| 543 | + class="text-fg-dimmed flex justify-between text-[9px]" |
| 544 | + > |
| 545 | + <span>Shadows</span> |
| 546 | + <span>Midtones</span> |
| 547 | + <span>Highlights</span> |
| 548 | + </div> |
| 549 | + </div> |
| 550 | + |
| 551 | + <!-- Strength slider (only matters once a ramp is valid) --> |
| 552 | + <div class="space-y-1.5"> |
| 553 | + <div class="flex items-center justify-between"> |
| 554 | + <span class="text-fg-secondary text-[11px]" |
| 555 | + >Strength</span |
| 556 | + > |
| 557 | + <!-- svelte-ignore a11y_no_static_element_interactions --> |
| 558 | + <span |
| 559 | + class="min-w-[36px] cursor-pointer text-right font-mono text-[11px] tabular-nums |
| 560 | + {filters.paletteStrength !== |
| 561 | + DEFAULT_FILTERS.paletteStrength |
| 562 | + ? 'text-fg-primary' |
| 563 | + : 'text-fg-dimmed'}" |
| 564 | + role="button" |
| 565 | + tabindex="-1" |
| 566 | + ondblclick={() => |
| 567 | + setPaletteStrength( |
| 568 | + DEFAULT_FILTERS.paletteStrength |
| 569 | + )} |
| 570 | + title="Double-click to reset" |
| 571 | + >{filters.paletteStrength}</span |
| 572 | + > |
| 573 | + </div> |
| 574 | + <input |
| 575 | + type="range" |
| 576 | + class="w-full cursor-pointer disabled:opacity-40" |
| 577 | + min="0" |
| 578 | + max="100" |
| 579 | + step="1" |
| 580 | + disabled={filters.paletteStops.length < 2} |
| 581 | + value={filters.paletteStrength} |
| 582 | + oninput={e => |
| 583 | + setPaletteStrength( |
| 584 | + parseFloat(e.currentTarget.value) |
| 585 | + )} |
| 586 | + ondblclick={() => |
| 587 | + setPaletteStrength( |
| 588 | + DEFAULT_FILTERS.paletteStrength |
| 589 | + )} |
| 590 | + /> |
| 591 | + <p class="text-fg-dimmed text-[9px] leading-snug"> |
| 592 | + ≤ 50% reads as a tint · ≥ 60% rebuilds the image in |
| 593 | + the palette. |
| 594 | + </p> |
| 595 | + </div> |
| 596 | + </div> |
| 597 | + </ExpandableSection> |
| 598 | + </section> |
| 599 | + |
373 | 600 | <section class="border-b border-[rgba(255,255,255,0.06)] p-3"> |
374 | 601 | <ExpandableSection |
375 | 602 | title="Detail" |
|
0 commit comments