mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 05:47:39 +00:00
Feature/island sky color editor (#13)
* Add Island tab with sky color editor - Add parseVariables() to SaveGameParser to extract variable names, values, and offsets - Add updateVariable() to SaveGameSerializer for modifying variable values - Add colorUtils.js with LEGO Island's custom HSV-to-RGB algorithm - Add SkyColorEditor component with H/S/V sliders and color preview - Add Island tab to SaveEditorPage with Sky Color section - Include reset to default button when values differ from default (56/54/68) * Remove tooltip * Add light position editor to Island tab Allows users to select one of 6 sun positions using visual globe image selectors. Includes converted globe images (BMP to WebP).
This commit is contained in:
parent
64be72194e
commit
36a6e0fde9
BIN
public/globe1.webp
Normal file
BIN
public/globe1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/globe2.webp
Normal file
BIN
public/globe2.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe3.webp
Normal file
BIN
public/globe3.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe4.webp
Normal file
BIN
public/globe4.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe5.webp
Normal file
BIN
public/globe5.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe6.webp
Normal file
BIN
public/globe6.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@ -45,7 +45,9 @@ export class SaveGameParser {
|
||||
stateLocations: [],
|
||||
stateCountOffset: null,
|
||||
stateCount: 0,
|
||||
statesEndOffset: null // Where to insert new states (before previous_area)
|
||||
statesEndOffset: null, // Where to insert new states (before previous_area)
|
||||
variables: new Map(), // Map<string, { value, nameOffset, valueOffset, valueLength }>
|
||||
variablesEndOffset: null // Where END_OF_VARIABLES marker ends
|
||||
};
|
||||
}
|
||||
|
||||
@ -70,20 +72,30 @@ export class SaveGameParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip over the variables section
|
||||
* Parse the variables section, storing name/value pairs with their offsets
|
||||
* Must be called after parseHeader()
|
||||
*/
|
||||
skipVariables() {
|
||||
parseVariables() {
|
||||
while (true) {
|
||||
const nameOffset = this.reader.tell();
|
||||
const nameLength = this.reader.readU8();
|
||||
const name = this.reader.readString(nameLength);
|
||||
|
||||
if (name === 'END_OF_VARIABLES') {
|
||||
this.parsed.variablesEndOffset = this.reader.tell();
|
||||
break;
|
||||
}
|
||||
|
||||
const valueOffset = this.reader.tell();
|
||||
const valueLength = this.reader.readU8();
|
||||
this.reader.skip(valueLength);
|
||||
const value = this.reader.readString(valueLength);
|
||||
|
||||
this.parsed.variables.set(name, {
|
||||
value,
|
||||
nameOffset,
|
||||
valueOffset,
|
||||
valueLength
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -371,11 +383,11 @@ export class SaveGameParser {
|
||||
|
||||
/**
|
||||
* Full parse for the save editor (header + missions)
|
||||
* @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[] }}
|
||||
* @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[], variables: Map }}
|
||||
*/
|
||||
parse() {
|
||||
this.parseHeader();
|
||||
this.skipVariables();
|
||||
this.parseVariables();
|
||||
this.skipCharacters();
|
||||
this.skipPlants();
|
||||
this.skipBuildings();
|
||||
|
||||
@ -285,6 +285,58 @@ export class SaveGameSerializer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a variable value in the save file
|
||||
* @param {string} name - Variable name
|
||||
* @param {string} newValue - New value string
|
||||
* @param {ArrayBuffer} [buffer] - Optional buffer to use (for chaining operations)
|
||||
* @returns {ArrayBuffer|null} - Modified buffer or null on error
|
||||
*/
|
||||
updateVariable(name, newValue, buffer = null) {
|
||||
const varInfo = this.parsed.variables.get(name);
|
||||
if (!varInfo) {
|
||||
console.error(`Variable not found: ${name}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const workingBuffer = buffer || this.createCopy();
|
||||
const array = new Uint8Array(workingBuffer);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const oldLength = varInfo.valueLength;
|
||||
const newLength = newValue.length;
|
||||
|
||||
if (oldLength === newLength) {
|
||||
// In-place update - same length, just replace bytes
|
||||
const valueBytes = encoder.encode(newValue);
|
||||
array.set(valueBytes, varInfo.valueOffset + 1); // +1 for length byte
|
||||
return workingBuffer;
|
||||
} else {
|
||||
// Different length - need to rebuild buffer
|
||||
const oldArray = new Uint8Array(workingBuffer);
|
||||
const sizeDiff = newLength - oldLength;
|
||||
const newBuffer = new ArrayBuffer(workingBuffer.byteLength + sizeDiff);
|
||||
const newArray = new Uint8Array(newBuffer);
|
||||
|
||||
// Copy everything up to the value (including the length byte position)
|
||||
const valueDataStart = varInfo.valueOffset + 1; // After length byte
|
||||
newArray.set(oldArray.slice(0, varInfo.valueOffset));
|
||||
|
||||
// Write new length byte
|
||||
newArray[varInfo.valueOffset] = newLength;
|
||||
|
||||
// Write new value
|
||||
const valueBytes = encoder.encode(newValue);
|
||||
newArray.set(valueBytes, valueDataStart);
|
||||
|
||||
// Copy everything after the old value
|
||||
const afterOldValue = valueDataStart + oldLength;
|
||||
newArray.set(oldArray.slice(afterOldValue), valueDataStart + newLength);
|
||||
|
||||
return newBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the byte offset for a mission score
|
||||
* @param {string} missionType
|
||||
|
||||
119
src/core/savegame/colorUtils.js
Normal file
119
src/core/savegame/colorUtils.js
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Color utilities for LEGO Island's custom HSV-based background color
|
||||
*
|
||||
* IMPORTANT: LEGO Island uses a NON-STANDARD HSV algorithm (see legoutils.cpp)
|
||||
* - H (Hue): 0-100 maps to standard hue wheel
|
||||
* - S (Saturation): NOT standard saturation! S=100 produces WHITE
|
||||
* - V (Value): Brightness component
|
||||
*
|
||||
* For vivid colors, keep S in the 40-70 range. The default sky is "set 56 54 68".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a backgroundcolor variable value into HSV components
|
||||
* @param {string} value - Value in format "set H S V"
|
||||
* @returns {{ h: number, s: number, v: number }} HSV values (0-100)
|
||||
*/
|
||||
export function parseBackgroundColor(value) {
|
||||
const match = value.match(/^set\s+(\d+)\s+(\d+)\s+(\d+)$/);
|
||||
if (!match) {
|
||||
// Default sky color
|
||||
return { h: 56, s: 54, v: 68 };
|
||||
}
|
||||
return {
|
||||
h: parseInt(match[1], 10),
|
||||
s: parseInt(match[2], 10),
|
||||
v: parseInt(match[3], 10)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format HSV values into a backgroundcolor variable value
|
||||
* @param {number} h - Hue (0-100)
|
||||
* @param {number} s - Saturation (0-100)
|
||||
* @param {number} v - Value/Brightness (0-100)
|
||||
* @returns {string} Value in format "set H S V"
|
||||
*/
|
||||
export function formatBackgroundColor(h, s, v) {
|
||||
return `set ${Math.round(h)} ${Math.round(s)} ${Math.round(v)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LEGO Island's custom HSV (0-100 scale) to RGB (0-255)
|
||||
* Replicates the exact algorithm from legoutils.cpp ConvertHSVToRGB()
|
||||
*
|
||||
* WARNING: S=100 always produces white due to the algorithm!
|
||||
*
|
||||
* @param {number} h - Hue (0-100)
|
||||
* @param {number} s - Saturation (0-100)
|
||||
* @param {number} v - Value/Brightness (0-100)
|
||||
* @returns {{ r: number, g: number, b: number }} RGB values (0-255)
|
||||
*/
|
||||
export function hsvToRgb(h, s, v) {
|
||||
// Convert 0-100 scale to 0-1 (as the game does with * 0.01)
|
||||
const hNorm = h / 100;
|
||||
const sNorm = s / 100;
|
||||
const vNorm = v / 100;
|
||||
|
||||
// LEGO Island's custom algorithm (from legoutils.cpp ConvertHSVToRGB)
|
||||
let max;
|
||||
if (sNorm > 0.5) {
|
||||
max = (1.0 - vNorm) * sNorm + vNorm;
|
||||
} else {
|
||||
max = (vNorm + 1.0) * sNorm;
|
||||
}
|
||||
|
||||
if (max <= 0) {
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
const min = sNorm * 2.0 - max;
|
||||
const hueSegment = Math.floor(hNorm * 6);
|
||||
const hueFraction = hNorm * 6.0 - hueSegment;
|
||||
const delta = hueFraction * ((max - min) / max) * max;
|
||||
const ascending = min + delta; // Channel value rising from min
|
||||
const descending = max - delta; // Channel value falling from max
|
||||
|
||||
let r, g, b;
|
||||
switch (hueSegment) {
|
||||
case 0: // Red to Yellow
|
||||
r = max; g = ascending; b = min;
|
||||
break;
|
||||
case 1: // Yellow to Green
|
||||
r = descending; g = max; b = min;
|
||||
break;
|
||||
case 2: // Green to Cyan
|
||||
r = min; g = max; b = ascending;
|
||||
break;
|
||||
case 3: // Cyan to Blue
|
||||
r = min; g = descending; b = max;
|
||||
break;
|
||||
case 4: // Blue to Magenta
|
||||
r = ascending; g = min; b = max;
|
||||
break;
|
||||
case 5: // Magenta to Red
|
||||
case 6:
|
||||
r = max; g = min; b = descending;
|
||||
break;
|
||||
default:
|
||||
r = 0; g = 0; b = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSV (0-100 scale) to hex color string
|
||||
* @param {number} h - Hue (0-100)
|
||||
* @param {number} s - Saturation (0-100)
|
||||
* @param {number} v - Value/Brightness (0-100)
|
||||
* @returns {string} Hex color string (e.g., "#ffcc00")
|
||||
*/
|
||||
export function hsvToHex(h, s, v) {
|
||||
const { r, g, b } = hsvToRgb(h, s, v);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
@ -11,6 +11,7 @@ export { SaveGameSerializer, createSerializer } from '../formats/index.js';
|
||||
export { PlayersParser, parsePlayers } from '../formats/index.js';
|
||||
export { PlayersSerializer, createPlayersSerializer } from '../formats/index.js';
|
||||
export * from './constants.js';
|
||||
export * from './colorUtils.js';
|
||||
|
||||
// Import dependencies
|
||||
import { readBinaryFile, writeBinaryFile, fileExists, listFiles } from '../opfs.js';
|
||||
@ -24,6 +25,7 @@ import { getSaveFileName, PLAYERS_FILE, Actor, ActorNames } from './constants.js
|
||||
* @property {string} fileName - Save file name
|
||||
* @property {Object|null} header - Parsed header data
|
||||
* @property {Object|null} missions - Mission scores
|
||||
* @property {Map|null} variables - Parsed variables (name -> { value, nameOffset, valueOffset, valueLength })
|
||||
* @property {string|null} playerName - Player name from Players.gsi
|
||||
* @property {ArrayBuffer|null} buffer - Raw file buffer (for editing)
|
||||
*/
|
||||
@ -87,6 +89,7 @@ export async function listSaveSlots() {
|
||||
fileName,
|
||||
header: null,
|
||||
missions: null,
|
||||
variables: null,
|
||||
playerName: null,
|
||||
buffer: null
|
||||
};
|
||||
@ -98,6 +101,7 @@ export async function listSaveSlots() {
|
||||
const parsed = parseSaveGame(buffer);
|
||||
slot.header = parsed.header;
|
||||
slot.missions = parsed.missions;
|
||||
slot.variables = parsed.variables;
|
||||
slot.buffer = buffer;
|
||||
|
||||
// Try to get player name
|
||||
@ -160,6 +164,7 @@ export async function loadSaveSlot(slotNumber) {
|
||||
fileName,
|
||||
header: parsed.header,
|
||||
missions: parsed.missions,
|
||||
variables: parsed.variables,
|
||||
playerName,
|
||||
buffer
|
||||
};
|
||||
@ -219,6 +224,17 @@ export async function updateSaveSlot(slotNumber, updates) {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply variable update
|
||||
if (updates.variable) {
|
||||
const { name, value } = updates.variable;
|
||||
const varSerializer = createSerializer(newBuffer);
|
||||
const result = varSerializer.updateVariable(name, value);
|
||||
if (result) {
|
||||
newBuffer = result;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only save if something was actually modified
|
||||
if (!modified) {
|
||||
return slot;
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import BackButton from './BackButton.svelte';
|
||||
import Carousel from './Carousel.svelte';
|
||||
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
|
||||
import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
|
||||
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
|
||||
import { saveEditorState, currentPage } from '../stores.js';
|
||||
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
||||
import { Actor, ActorNames } from '../core/savegame/constants.js';
|
||||
@ -18,7 +20,8 @@
|
||||
|
||||
const saveTabs = [
|
||||
{ id: 'player', label: 'Player', firstSection: 'name' },
|
||||
{ id: 'scores', label: 'Scores', firstSection: null }
|
||||
{ id: 'scores', label: 'Scores', firstSection: null },
|
||||
{ id: 'island', label: 'Island', firstSection: 'skycolor' }
|
||||
];
|
||||
|
||||
// Reset state when navigating to this page
|
||||
@ -107,6 +110,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVariableUpdate(update) {
|
||||
if (selectedSlot === null) return;
|
||||
|
||||
try {
|
||||
const updated = await updateSaveSlot(selectedSlot, update);
|
||||
if (updated) {
|
||||
slots = slots.map(s =>
|
||||
s.slotNumber === selectedSlot
|
||||
? { ...s, variables: updated.variables }
|
||||
: s
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to update variable:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Tab/section functions
|
||||
function switchTab(tab) {
|
||||
activeTab = tab.id;
|
||||
@ -354,6 +374,27 @@
|
||||
onUpdate={handleMissionUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Island Tab -->
|
||||
<div class:hidden={activeTab !== 'island'}>
|
||||
<div class="config-section-card">
|
||||
<button type="button" class="config-card-header" onclick={() => toggleSection('skycolor')}>
|
||||
Sky Color
|
||||
</button>
|
||||
<div class="config-card-content" class:open={openSection === 'skycolor'}>
|
||||
<SkyColorEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section-card">
|
||||
<button type="button" class="config-card-header" onclick={() => toggleSection('lightposition')}>
|
||||
Light Position
|
||||
</button>
|
||||
<div class="config-card-content" class:open={openSection === 'lightposition'}>
|
||||
<LightPositionEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
110
src/lib/save-editor/LightPositionEditor.svelte
Normal file
110
src/lib/save-editor/LightPositionEditor.svelte
Normal file
@ -0,0 +1,110 @@
|
||||
<script>
|
||||
export let slot;
|
||||
export let onUpdate = () => {};
|
||||
|
||||
// Default light position (game default is "2")
|
||||
const DEFAULT_POSITION = 2;
|
||||
|
||||
// Get current position from slot's lightposition variable
|
||||
$: lightVar = slot?.variables?.get('lightposition');
|
||||
$: currentPosition = lightVar ? parseInt(lightVar.value, 10) : DEFAULT_POSITION;
|
||||
|
||||
// Globe images for each position (0-5 map to globe1-globe6)
|
||||
const positions = [0, 1, 2, 3, 4, 5];
|
||||
|
||||
function handleSelect(position) {
|
||||
onUpdate({
|
||||
variable: {
|
||||
name: 'lightposition',
|
||||
value: String(position)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
handleSelect(DEFAULT_POSITION);
|
||||
}
|
||||
|
||||
$: isDefault = currentPosition === DEFAULT_POSITION;
|
||||
</script>
|
||||
|
||||
<div class="light-position-editor">
|
||||
<div class="globe-grid">
|
||||
{#each positions as position}
|
||||
<button
|
||||
type="button"
|
||||
class="globe-btn"
|
||||
class:selected={currentPosition === position}
|
||||
onclick={() => handleSelect(position)}
|
||||
title="Position {position}"
|
||||
>
|
||||
<img
|
||||
src="globe{position + 1}.webp"
|
||||
alt="Light position {position}"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !isDefault}
|
||||
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.light-position-editor {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.globe-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.globe-btn {
|
||||
padding: 4px;
|
||||
background: var(--color-bg-input);
|
||||
border: 2px solid var(--color-border-medium);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.globe-btn:hover {
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.globe-btn.selected {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.globe-btn img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.globe-btn img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
228
src/lib/save-editor/SkyColorEditor.svelte
Normal file
228
src/lib/save-editor/SkyColorEditor.svelte
Normal file
@ -0,0 +1,228 @@
|
||||
<script>
|
||||
import { parseBackgroundColor, formatBackgroundColor, hsvToHex } from '../../core/savegame/colorUtils.js';
|
||||
|
||||
export let slot;
|
||||
export let onUpdate = () => {};
|
||||
|
||||
// Default sky color values
|
||||
const DEFAULT_HUE = 56;
|
||||
const DEFAULT_SATURATION = 54;
|
||||
const DEFAULT_BRIGHTNESS = 68;
|
||||
|
||||
// Get initial HSV from slot's backgroundcolor variable
|
||||
$: bgVar = slot?.variables?.get('backgroundcolor');
|
||||
$: initialHsv = bgVar ? parseBackgroundColor(bgVar.value) : { h: DEFAULT_HUE, s: DEFAULT_SATURATION, v: DEFAULT_BRIGHTNESS };
|
||||
|
||||
// Local state for sliders
|
||||
let hue = DEFAULT_HUE;
|
||||
let saturation = DEFAULT_SATURATION;
|
||||
let brightness = DEFAULT_BRIGHTNESS;
|
||||
let initialized = false;
|
||||
|
||||
// Initialize from slot data when it becomes available
|
||||
$: if (initialHsv && !initialized) {
|
||||
hue = initialHsv.h;
|
||||
saturation = initialHsv.s;
|
||||
brightness = initialHsv.v;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// Reset initialized flag when slot changes
|
||||
$: if (slot?.slotNumber !== undefined) {
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
// Computed preview color
|
||||
$: previewColor = hsvToHex(hue, saturation, brightness);
|
||||
|
||||
function handleChange() {
|
||||
onUpdate({
|
||||
variable: {
|
||||
name: 'backgroundcolor',
|
||||
value: formatBackgroundColor(hue, saturation, brightness)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
hue = DEFAULT_HUE;
|
||||
saturation = DEFAULT_SATURATION;
|
||||
brightness = DEFAULT_BRIGHTNESS;
|
||||
handleChange();
|
||||
}
|
||||
|
||||
$: isDefault = hue === DEFAULT_HUE && saturation === DEFAULT_SATURATION && brightness === DEFAULT_BRIGHTNESS;
|
||||
</script>
|
||||
|
||||
<div class="sky-color-editor">
|
||||
<div class="editor-content">
|
||||
<div class="color-preview" style="background-color: {previewColor}"></div>
|
||||
<div class="sliders">
|
||||
<label class="slider-row">
|
||||
<span class="slider-label">Hue</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={hue}
|
||||
onchange={handleChange}
|
||||
class="slider hue-slider"
|
||||
/>
|
||||
<span class="slider-value">{hue}</span>
|
||||
</label>
|
||||
<label class="slider-row">
|
||||
<span class="slider-label">Saturation</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={saturation}
|
||||
onchange={handleChange}
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{saturation}</span>
|
||||
</label>
|
||||
<label class="slider-row">
|
||||
<span class="slider-label">Brightness</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={brightness}
|
||||
onchange={handleChange}
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{brightness}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{#if !isDefault}
|
||||
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sky-color-editor {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--color-border-medium);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sliders {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
width: 70px;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--color-bg-input);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-bg-dark);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-bg-dark);
|
||||
}
|
||||
|
||||
.hue-slider {
|
||||
background: linear-gradient(to right,
|
||||
hsl(0, 70%, 50%),
|
||||
hsl(60, 70%, 50%),
|
||||
hsl(120, 70%, 50%),
|
||||
hsl(180, 70%, 50%),
|
||||
hsl(240, 70%, 50%),
|
||||
hsl(300, 70%, 50%),
|
||||
hsl(360, 70%, 50%)
|
||||
);
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
width: 28px;
|
||||
text-align: right;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-light);
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.editor-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
width: 60px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user