From 5bc4b9603ea5c442620c4aec1d4a5944c8e0bb29 Mon Sep 17 00:00:00 2001 From: Microck Date: Tue, 17 Feb 2026 02:29:15 +0000 Subject: [PATCH 1/3] Redesign timeline UI --- celstomp/celstomp-app.js | 101 ++++++ celstomp/css/components/island.css | 68 ++++ celstomp/css/components/layers.css | 24 ++ celstomp/css/components/overlays.css | 118 +++++++ celstomp/css/components/timeline.css | 163 ++++++++- celstomp/css/components/tools.css | 60 ++++ celstomp/js/editor/export-helper.js | 2 +- celstomp/js/input/pointer-events.js | 448 ++++++++++++++++++++++++ celstomp/js/tools/brush-helper.js | 6 +- celstomp/js/ui/interaction-shortcuts.js | 123 ++++++- celstomp/js/ui/island-helper.js | 62 +++- celstomp/js/ui/ui-components.js | 10 +- celstomp/parts/modals.js | 14 +- celstomp/parts/timeline.js | 103 +++++- package-lock.json | 1 + 15 files changed, 1256 insertions(+), 47 deletions(-) diff --git a/celstomp/celstomp-app.js b/celstomp/celstomp-app.js index 3d924b6..14f3f03 100644 --- a/celstomp/celstomp-app.js +++ b/celstomp/celstomp-app.js @@ -350,6 +350,8 @@ function renderBounds() { + fxctx.setTransform(1, 0, 0, 1, 0, 0); + fxctx.clearRect(0, 0, fxCanvas.width, fxCanvas.height); setTransform(bctx); setTransform(fxctx); bctx.fillStyle = "#2a2f38"; @@ -358,6 +360,10 @@ bctx.fillRect(0, 0, contentW, contentH); bctx.strokeRect(0, 0, contentW, contentH); drawRectSelectionOverlay(fxctx); + drawLineToolPreview(fxctx); + drawRectToolPreview(fxctx); + drawGrid(fxctx); + drawGuides(fxctx); } function onionCompositeOperation() { @@ -404,6 +410,12 @@ function clearFx() { fxctx.setTransform(1, 0, 0, 1, 0, 0); fxctx.clearRect(0, 0, fxCanvas.width, fxCanvas.height); + setTransform(fxctx); + drawRectSelectionOverlay(fxctx); + drawLineToolPreview(fxctx); + drawRectToolPreview(fxctx); + drawGrid(fxctx); + drawGuides(fxctx); } function wireBrushButtonRightClick() { @@ -901,6 +913,95 @@ safeSetValue(snapValue, v); updateHUD(); }); + const gridToggle = $("tlGridBtn"); + const gridSnapToggle = $("tlGridSnapBtn"); + const rulersToggle = $("tlRulersBtn"); + const guideSnapToggle = $("tlGuideSnapBtn"); + const addHGuideBtn = $("addHGuideBtn"); + const addVGuideBtn = $("addVGuideBtn"); + const clearGuidesBtn = $("clearGuidesBtn"); + const guideModeHint = $("guideModeHint"); + const gridSizeInput = $("tlGridSize"); + + let guidePlacementMode = null; + + if (gridToggle) { + gridToggle.addEventListener("click", e => { + gridEnabled = !gridEnabled; + gridToggle.classList.toggle("active", gridEnabled); + queueRenderAll(); + }); + } + if (gridSizeInput) { + gridSizeInput.addEventListener("change", e => { + const v = Math.max(8, Math.min(128, parseInt(e.target.value) || 32)); + gridSize = v; + e.target.value = v; + queueRenderAll(); + }); + } + if (gridSnapToggle) { + gridSnapToggle.addEventListener("click", e => { + gridSnap = !gridSnap; + gridSnapToggle.classList.toggle("active", gridSnap); + }); + } + if (rulersToggle) { + rulersToggle.addEventListener("click", e => { + rulersEnabled = !rulersEnabled; + rulersToggle.classList.toggle("active", rulersEnabled); + queueRenderAll(); + }); + } + if (guideSnapToggle) { + guideSnapToggle.addEventListener("click", e => { + guideSnap = !guideSnap; + guideSnapToggle.classList.toggle("active", guideSnap); + }); + } + function setGuidePlacementMode(mode) { + guidePlacementMode = mode; + if (addHGuideBtn) addHGuideBtn.classList.toggle("active", mode === "horizontal"); + if (addVGuideBtn) addVGuideBtn.classList.toggle("active", mode === "vertical"); + if (guideModeHint) { + guideModeHint.hidden = !mode; + guideModeHint.textContent = mode === "horizontal" ? "Click Canvas To Place H Guide" : mode === "vertical" ? "Click Canvas To Place V Guide" : ""; + } + if (!mode) { + document.body.classList.remove("guide-place-mode"); + document.body.classList.remove("guide-place-h"); + document.body.classList.remove("guide-place-v"); + } else { + document.body.classList.add("guide-place-mode"); + document.body.classList.toggle("guide-place-h", mode === "horizontal"); + document.body.classList.toggle("guide-place-v", mode === "vertical"); + } + } + if (addHGuideBtn) { + addHGuideBtn.addEventListener("click", e => { + if (guidePlacementMode === "horizontal") { + setGuidePlacementMode(null); + } else { + setGuidePlacementMode("horizontal"); + } + }); + } + if (addVGuideBtn) { + addVGuideBtn.addEventListener("click", e => { + if (guidePlacementMode === "vertical") { + setGuidePlacementMode(null); + } else { + setGuidePlacementMode("vertical"); + } + }); + } + if (clearGuidesBtn) { + clearGuidesBtn.addEventListener("click", () => { + guides = []; + queueRenderAll(); + }); + } + window.__celstompSetGuidePlacementMode = setGuidePlacementMode; function rebuildTimelineKeepFrame() { const cur = currentFrame; buildTimeline(); diff --git a/celstomp/css/components/island.css b/celstomp/css/components/island.css index b764d09..4eb3d70 100644 --- a/celstomp/css/components/island.css +++ b/celstomp/css/components/island.css @@ -54,6 +54,50 @@ cursor: grabbing; } +.islandDock.drag-lock-candidate { + outline: 2px solid rgba(92, 179, 255, 0.6); + outline-offset: -2px; +} + +.islandDock.right-locked { + left: auto !important; + right: var(--dock-gap); + top: calc(var(--header-h) + var(--dock-gap)); + bottom: calc(var(--timeline-h) + var(--dock-gap-bottom)); + height: auto !important; + width: min(320px, 36vw); +} + +.islandDock.right-locked .islandHeader { + background: rgba(92, 179, 255, 0.12); + border-bottom-color: rgba(92, 179, 255, 0.35); +} + +.islandLockHint { + position: fixed; + top: calc(var(--header-h) + 14px); + right: 10px; + width: 180px; + height: calc(100vh - var(--header-h) - var(--timeline-h) - 24px); + border: 2px dashed rgba(92, 179, 255, 0.32); + border-radius: 10px; + background: rgba(92, 179, 255, 0.08); + color: rgba(180, 225, 255, 0.9); + display: none; + align-items: center; + justify-content: center; + text-align: center; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + z-index: 39; + pointer-events: none; +} + +.islandLockHint.active { + display: flex; +} + .islandTitle{ font-size: 12px; letter-spacing: 0.08em; @@ -222,6 +266,30 @@ overflow-x: hidden; } +.islandSideBody { + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.24) rgba(0,0,0,0.22); +} + +.islandSideBody::-webkit-scrollbar { + width: 10px; +} + +.islandSideBody::-webkit-scrollbar-track { + background: rgba(0,0,0,0.24); + border-radius: 999px; +} + +.islandSideBody::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.24); + border-radius: 999px; + border: 2px solid rgba(0,0,0,0.22); +} + +.islandSideBody::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.34); +} + .islandSideGrid{ display: flex; flex-direction: column; diff --git a/celstomp/css/components/layers.css b/celstomp/css/components/layers.css index 7e95989..cffcb6a 100644 --- a/celstomp/css/components/layers.css +++ b/celstomp/css/components/layers.css @@ -136,6 +136,30 @@ body.swatch-reordering{ border-radius: 0px; } +#islandLayersSlot { + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.24) rgba(0,0,0,0.22); +} + +#islandLayersSlot::-webkit-scrollbar { + width: 10px; +} + +#islandLayersSlot::-webkit-scrollbar-track { + background: rgba(0,0,0,0.24); + border-radius: 999px; +} + +#islandLayersSlot::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.24); + border-radius: 999px; + border: 2px solid rgba(0,0,0,0.22); +} + +#islandLayersSlot::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.34); +} + #islandLayersSlot #layerSeg{ display: flex !important; flex-direction: column !important; diff --git a/celstomp/css/components/overlays.css b/celstomp/css/components/overlays.css index ed86422..e644f68 100644 --- a/celstomp/css/components/overlays.css +++ b/celstomp/css/components/overlays.css @@ -120,6 +120,124 @@ opacity: .95; } +.canvasTextEntry { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.32); + z-index: 9400; +} + +.canvasTextEntry.open { + display: flex; +} + +.canvasTextEntryCard { + width: min(420px, calc(100vw - 24px)); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(16, 20, 28, 0.96); + border-radius: 10px; + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.45); + padding: 12px; + display: grid; + gap: 10px; +} + +.canvasTextEntryLabel { + font-size: 12px; + color: #c7d0db; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.canvasTextEntryInput { + width: 100%; + min-height: 36px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(0, 0, 0, 0.28); + color: #e2e8f0; + border-radius: 8px; + padding: 8px 10px; +} + +.canvasTextEntryInput:focus { + outline: none; + border-color: rgba(92, 179, 255, 0.8); + box-shadow: 0 0 0 2px rgba(92, 179, 255, 0.2); +} + +.canvasTextEntryOptions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.canvasTextEntryOpt { + display: grid; + gap: 4px; +} + +.canvasTextEntryOpt > span { + font-size: 11px; + color: #b7c3d2; + letter-spacing: 0.03em; +} + +.canvasTextEntrySelect, +.canvasTextEntryNum { + width: 100%; + min-height: 32px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(0, 0, 0, 0.28); + color: #e2e8f0; + border-radius: 8px; + padding: 6px 8px; +} + +.canvasTextEntrySelect:focus, +.canvasTextEntryNum:focus { + outline: none; + border-color: rgba(92, 179, 255, 0.8); + box-shadow: 0 0 0 2px rgba(92, 179, 255, 0.2); +} + +.canvasTextEntryOptCheck { + align-items: center; + grid-template-columns: auto 1fr; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + padding: 6px 8px; +} + +.canvasTextEntryOptCheck input { + margin: 0; +} + +.canvasTextEntryActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.canvasTextEntryBtn { + min-height: 34px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: #d0d8e2; +} + +.canvasTextEntryBtnPrimary { + border-color: rgba(92, 179, 255, 0.6); + background: rgba(92, 179, 255, 0.18); + color: #9fd5ff; +} + .infoList{ margin: 0; padding-left: 18px; diff --git a/celstomp/css/components/timeline.css b/celstomp/css/components/timeline.css index 9256498..f760246 100644 --- a/celstomp/css/components/timeline.css +++ b/celstomp/css/components/timeline.css @@ -27,9 +27,9 @@ display:flex; align-items:center; justify-content:space-between; - padding:8px 12px; + padding:6px 12px; color:#c7d0db; - gap:10px; + gap:8px; flex-wrap:wrap; position: relative; } @@ -37,7 +37,7 @@ #timelineHeader .right{ display:flex; align-items:center; - gap:8px; + gap:6px; flex-wrap:wrap; } #timelineHeader .center{ @@ -45,10 +45,159 @@ display:flex; align-items:center; justify-content:center; - gap:8px; + gap:6px; flex-wrap:wrap; } +#timelineHeader button { + appearance: none; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.06); + color: #c7d0db; + border-radius: 8px; + min-height: 32px; + padding: 0 10px; + font-size: 12px; +} + +#timelineHeader button:hover { + background: rgba(255,255,255,0.11); + border-color: rgba(255,255,255,0.2); +} + +#timelineHeader input[type="number"] { + appearance: textfield; + -moz-appearance: textfield; +} + +#timelineHeader input[type="number"]::-webkit-outer-spin-button, +#timelineHeader input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.tl-group { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: rgba(0,0,0,0.25); + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.06); +} + +.tl-group-tools { + gap: 2px; +} + +.tl-group-guide { + gap: 2px; +} + +.tl-guide-hint { + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #ffb7a0; + background: rgba(255, 129, 92, 0.16); + border: 1px solid rgba(255, 129, 92, 0.42); + border-radius: 999px; + padding: 3px 8px; + white-space: nowrap; +} + +.tl-tool-btn { + width: 32px; + height: 32px; + padding: 6px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; + color: #9aa5b5; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.tl-tool-btn:hover { + background: rgba(255,255,255,0.12); + color: #c5cdd8; + border-color: rgba(255,255,255,0.15); +} + +.tl-tool-btn.active { + background: rgba(64, 156, 255, 0.18); + border-color: rgba(64, 156, 255, 0.5); + color: #5cb3ff; +} + +.tl-guide-btn.active { + background: rgba(255, 129, 92, 0.2); + border-color: rgba(255, 129, 92, 0.55); + color: #ff9f7b; +} + +.tl-tool-btn svg { + width: 18px; + height: 18px; +} + +.tl-play-btn { + width: 32px; + height: 32px; + padding: 6px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; + color: #9aa5b5; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.tl-play-btn:hover { + background: rgba(255,255,255,0.12); + color: #c5cdd8; + border-color: rgba(255,255,255,0.15); +} + +.tl-play-main { + width: 40px; + height: 40px; + background: rgba(64, 156, 255, 0.15); + border-color: rgba(64, 156, 255, 0.3); + color: #5cb3ff; +} + +.tl-play-main:hover { + background: rgba(64, 156, 255, 0.25); + border-color: rgba(64, 156, 255, 0.5); +} + +.tl-play-btn svg { + width: 100%; + height: 100%; +} + +.tl-num { + width: 45px; + padding: 4px 6px; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + color: #c7d0db; + font-size: 12px; +} + +.tl-num:focus { + outline: none; + border-color: #409cff; +} + #timelineHeader #playBtn, #timelineHeader #pauseBtn, @@ -252,6 +401,12 @@ body.dragging-cel{ cursor: grabbing; user-select:none; } .tlMobArrow{ display:none; } +.tlMobArrow { + border: 1px solid rgba(255,255,255,0.14) !important; + background: rgba(0,0,0,0.28) !important; + color: #9fb6cd !important; +} + @media (max-width: 900px){ #timelineHeader .center, #timelineHeader .right{ gap: 8px; } diff --git a/celstomp/css/components/tools.css b/celstomp/css/components/tools.css index f8c57f3..d0a8671 100644 --- a/celstomp/css/components/tools.css +++ b/celstomp/css/components/tools.css @@ -48,6 +48,35 @@ z-index: 8; } +#tools, +#toolsRight { + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.22) rgba(0,0,0,0.2); +} + +#tools::-webkit-scrollbar, +#toolsRight::-webkit-scrollbar { + width: 10px; +} + +#tools::-webkit-scrollbar-track, +#toolsRight::-webkit-scrollbar-track { + background: rgba(0,0,0,0.22); + border-radius: 999px; +} + +#tools::-webkit-scrollbar-thumb, +#toolsRight::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.22); + border-radius: 999px; + border: 2px solid rgba(0,0,0,0.2); +} + +#tools::-webkit-scrollbar-thumb:hover, +#toolsRight::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.3); +} + /* Collapsed states */ .app.tl-collapsed #tools, .app.tl-collapsed #toolsRight{ @@ -126,6 +155,37 @@ cursor: pointer; } +#islandToolsSlot #toolSeg > label svg { + width: 20px; + height: 20px; + color: rgba(255,255,255,0.85); + position: relative; + z-index: 1; +} + +#islandToolsSlot #toolSeg > label:has(svg) { + background-image: none; +} + +#islandToolsSlot #toolSeg > label:has(svg)::before { + display: none; +} + +body.guide-place-mode #stage, +body.guide-place-mode #drawCanvas { + cursor: crosshair !important; +} + +body.guide-place-h #stage, +body.guide-place-h #drawCanvas { + cursor: ns-resize !important; +} + +body.guide-place-v #stage, +body.guide-place-v #drawCanvas { + cursor: ew-resize !important; +} + #islandToolsSlot #toolSeg > label::before{ content: ""; width: 20px; diff --git a/celstomp/js/editor/export-helper.js b/celstomp/js/editor/export-helper.js index 1c7fd2f..4600051 100644 --- a/celstomp/js/editor/export-helper.js +++ b/celstomp/js/editor/export-helper.js @@ -8,7 +8,7 @@ const autosaveController = window.CelstompAutosave?.createController?.({ badgeEl: saveStateBadgeEl, buildSnapshot: async () => await buildProjectSnapshot(), pointerSelectors: [ "#drawCanvas", "#fillCurrent", "#fillAll", "#tlDupCel", "#toolSeg label", "#layerSeg .layerRow", "#timelineTable td" ], - valueSelectors: [ "#autofillToggle", "#brushSize", "#brushSizeRange", "#brushSizeNum", "#eraserSize", "#pressureSize", "#pressureOpacity", "#pressureTilt", "#tlSnap", "#tlSeconds", "#tlFps", "#tlOnion", "#tlTransparency", "#loopToggle", "#onionPrevColor", "#onionNextColor", "#onionAlpha", "#onionBlendMode" ], + valueSelectors: [ "#autofillToggle", "#brushSize", "#brushSizeRange", "#brushSizeNum", "#eraserSize", "#pressureSize", "#pressureOpacity", "#pressureTilt", "#tlSnap", "#tlSeconds", "#tlFps", "#tlOnion", "#tlTransparency", "#loopToggle", "#onionPrevColor", "#onionNextColor", "#onionAlpha", "#onionBlendMode", "#tlGridToggle", "#tlGridSnap", "#tlGridSize" ], onRestorePayload: (payload, source) => { const blob = new Blob([ JSON.stringify(payload.data) ], { type: "application/json" diff --git a/celstomp/js/input/pointer-events.js b/celstomp/js/input/pointer-events.js index b0e5534..c4059d3 100644 --- a/celstomp/js/input/pointer-events.js +++ b/celstomp/js/input/pointer-events.js @@ -12,6 +12,232 @@ let brushSize = 3; let autofill = false; let trailPoints = []; +let lineToolStart = null; +let lineToolPreview = null; +let rectToolStart = null; +let rectToolPreview = null; +let gridEnabled = false; +let gridSize = 32; +let gridSnap = false; +let rulersEnabled = false; +let guides = []; +let draggingGuide = null; +let guideSnap = false; + +let textEntryOverlay = null; +let textEntryInput = null; +let textEntryTarget = null; +let textEntryFamily = null; +let textEntrySize = null; +let textEntryWeight = null; +let textEntryItalic = null; +let textEntryAlign = null; +let textEntryStroke = null; +let textEntryStrokeWidth = null; +let textEntries = []; + +let textToolOptions = { + family: "system-ui, sans-serif", + size: 22, + weight: "400", + italic: false, + align: "left", + stroke: false, + strokeWidth: 2 +}; + +function normalizeTextOptions(opts) { + return { + family: opts?.family || "system-ui, sans-serif", + size: Math.max(8, Math.min(240, parseInt(opts?.size ?? 22, 10) || 22)), + weight: String(opts?.weight || "400"), + italic: !!opts?.italic, + align: opts?.align || "left", + stroke: !!opts?.stroke, + strokeWidth: Math.max(1, Math.min(20, parseInt(opts?.strokeWidth ?? 2, 10) || 2)) + }; +} + +function buildTextFont(opts) { + return `${opts.italic ? "italic " : ""}${opts.weight} ${opts.size}px ${opts.family}`; +} + +function applyTextStyle(ctx, opts, hex) { + ctx.fillStyle = hex; + ctx.textBaseline = "top"; + ctx.textAlign = opts.align; + ctx.font = buildTextFont(opts); +} + +function measureTextBounds(ctx, x, y, text, opts) { + const metrics = ctx.measureText(text || " "); + const textW = Math.max(1, Math.ceil(metrics.width || 1)); + const textH = Math.max(1, Math.ceil((metrics.actualBoundingBoxAscent || opts.size * 0.8) + (metrics.actualBoundingBoxDescent || opts.size * 0.25))); + let left = x; + if (opts.align === "center") left = x - textW / 2; + if (opts.align === "right") left = x - textW; + const pad = opts.stroke ? opts.strokeWidth + 2 : 2; + const x0 = Math.max(0, Math.floor(left - pad)); + const y0 = Math.max(0, Math.floor(y - pad)); + const x1 = Math.min(contentW, Math.ceil(left + textW + pad)); + const y1 = Math.min(contentH, Math.ceil(y + textH + pad)); + return { + x: x0, + y: y0, + w: Math.max(1, x1 - x0), + h: Math.max(1, y1 - y0) + }; +} + +function captureTextAreaSnapshot(ctx, bounds) { + try { + return ctx.getImageData(bounds.x, bounds.y, bounds.w, bounds.h); + } catch { + return null; + } +} + +function findTextEntryAtPoint(x, y, layer, frame) { + for (let i = textEntries.length - 1; i >= 0; i--) { + const entry = textEntries[i]; + if (entry.layer !== layer || entry.frame !== frame) continue; + const inX = x >= entry.bounds.x && x <= entry.bounds.x + entry.bounds.w; + const inY = y >= entry.bounds.y && y <= entry.bounds.y + entry.bounds.h; + if (inX && inY) return entry; + } + return null; +} + +function ensureTextEntryOverlay() { + if (textEntryOverlay && textEntryInput) return; + textEntryOverlay = document.createElement("div"); + textEntryOverlay.id = "canvasTextEntry"; + textEntryOverlay.className = "canvasTextEntry"; + textEntryOverlay.innerHTML = '
'; + document.body.appendChild(textEntryOverlay); + textEntryInput = textEntryOverlay.querySelector("#canvasTextEntryInput"); + textEntryFamily = textEntryOverlay.querySelector("#canvasTextEntryFamily"); + textEntrySize = textEntryOverlay.querySelector("#canvasTextEntrySize"); + textEntryWeight = textEntryOverlay.querySelector("#canvasTextEntryWeight"); + textEntryItalic = textEntryOverlay.querySelector("#canvasTextEntryItalic"); + textEntryAlign = textEntryOverlay.querySelector("#canvasTextEntryAlign"); + textEntryStroke = textEntryOverlay.querySelector("#canvasTextEntryStroke"); + textEntryStrokeWidth = textEntryOverlay.querySelector("#canvasTextEntryStrokeWidth"); + const cancelBtn = textEntryOverlay.querySelector("#canvasTextEntryCancel"); + const okBtn = textEntryOverlay.querySelector("#canvasTextEntryOk"); + cancelBtn?.addEventListener("click", () => closeTextEntryOverlay()); + okBtn?.addEventListener("click", () => commitTextEntryOverlay()); + textEntryInput?.addEventListener("keydown", e => { + if (e.key === "Enter") { + e.preventDefault(); + commitTextEntryOverlay(); + } + if (e.key === "Escape") { + e.preventDefault(); + closeTextEntryOverlay(); + } + }); +} + +function openTextEntryOverlay(target) { + ensureTextEntryOverlay(); + textEntryTarget = target; + textEntryOverlay.classList.add("open"); + document.body.classList.add("canvasTextEntryMode"); + const opts = normalizeTextOptions(target?.initialOptions || textToolOptions); + textEntryInput.value = target?.initialText || ""; + if (textEntryFamily) textEntryFamily.value = opts.family; + if (textEntrySize) textEntrySize.value = String(opts.size); + if (textEntryWeight) textEntryWeight.value = opts.weight; + if (textEntryItalic) textEntryItalic.checked = !!opts.italic; + if (textEntryAlign) textEntryAlign.value = opts.align; + if (textEntryStroke) textEntryStroke.checked = !!opts.stroke; + if (textEntryStrokeWidth) textEntryStrokeWidth.value = String(opts.strokeWidth); + queueMicrotask(() => textEntryInput?.focus()); +} + +function closeTextEntryOverlay() { + if (!textEntryOverlay) return; + textEntryOverlay.classList.remove("open"); + document.body.classList.remove("canvasTextEntryMode"); + textEntryTarget = null; +} + +function commitTextEntryOverlay() { + const text = (textEntryInput?.value || "").trim(); + if (!text || !textEntryTarget) { + closeTextEntryOverlay(); + return; + } + const t = textEntryTarget; + const off = getFrameCanvas(t.layer, t.frame, t.hex); + const ctx = off?.getContext("2d"); + if (!ctx) { + closeTextEntryOverlay(); + return; + } + const opts = normalizeTextOptions({ + family: textEntryFamily?.value || textToolOptions.family, + size: textEntrySize?.value, + weight: textEntryWeight?.value || textToolOptions.weight, + italic: !!textEntryItalic?.checked, + align: textEntryAlign?.value || textToolOptions.align, + stroke: !!textEntryStroke?.checked, + strokeWidth: textEntryStrokeWidth?.value + }); + + textToolOptions = { ...opts }; + + ctx.save(); + applyTextStyle(ctx, opts, t.hex); + const bounds = measureTextBounds(ctx, t.x, t.y, text, opts); + ctx.restore(); + + beginGlobalHistoryStep(t.layer, t.frame, t.hex); + if (t.editEntry && t.editEntry.snapshot) { + try { + ctx.putImageData(t.editEntry.snapshot, t.editEntry.bounds.x, t.editEntry.bounds.y); + } catch {} + } + + const snapshot = captureTextAreaSnapshot(ctx, bounds); + + ctx.save(); + applyTextStyle(ctx, opts, t.hex); + if (opts.stroke) { + ctx.lineWidth = opts.strokeWidth; + ctx.strokeStyle = "rgba(0,0,0,0.78)"; + ctx.strokeText(text, t.x, t.y); + } + ctx.fillText(text, t.x, t.y); + ctx.restore(); + + if (t.editEntry) { + t.editEntry.text = text; + t.editEntry.hex = t.hex; + t.editEntry.options = { ...opts }; + t.editEntry.bounds = bounds; + t.editEntry.snapshot = snapshot; + } else { + textEntries.push({ + layer: t.layer, + frame: t.frame, + text, + x: t.x, + y: t.y, + hex: t.hex, + options: { ...opts }, + bounds, + snapshot + }); + } + markGlobalHistoryDirty(); + commitGlobalHistoryStep(); + markFrameHasContent(t.layer, t.frame, t.hex); + queueRenderAll(); + updateTimelineHasContent(t.frame); + closeTextEntryOverlay(); +} function pressure(e) { const pid = Number.isFinite(e?.pointerId) ? e.pointerId : -1; @@ -47,6 +273,27 @@ function handlePointerDown(e) { return; } } + + // Handle guide placement mode + const guideMode = document.body.classList.contains("guide-place-mode"); + if (guideMode && e.button === 0) { + const pos = getCanvasPointer(e); + const pt = screenToContent(pos.x, pos.y); + if (pt.x >= 0 && pt.x <= contentW && pt.y >= 0 && pt.y <= contentH) { + if (document.body.classList.contains("guide-place-h")) { + guides.push({ horizontal: true, pos: pt.y }); + } else if (document.body.classList.contains("guide-place-v")) { + guides.push({ horizontal: false, pos: pt.x }); + } + queueRenderAll(); + if (window.__celstompSetGuidePlacementMode) { + window.__celstompSetGuidePlacementMode(null); + } + e.preventDefault(); + return; + } + } + try { drawCanvas.setPointerCapture(e.pointerId); } catch {} @@ -257,6 +504,16 @@ function startStroke(e) { const startPt = stabilizePoint(e, x, y); x = startPt.x; y = startPt.y; + if (gridSnap && gridEnabled) { + const snapped = snapToGrid(x, y); + x = snapped.x; + y = snapped.y; + } + if (guideSnap) { + const snapped = snapToGuides(x, y); + x = snapped.x; + y = snapped.y; + } if (x < 0 || y < 0 || x > contentW || y > contentH) return; if (e.button === 2) { startPan(e); @@ -338,6 +595,57 @@ function startStroke(e) { startPan(e); return; } + if (tool === "line") { + isDrawing = true; + const hex = colorToHex(currentColor); + strokeHex = activeLayer === LAYER.FILL ? fillWhite : hex; + activeSubColor[activeLayer] = strokeHex; + ensureSublayer(activeLayer, strokeHex); + renderLayerSwatches(activeLayer); + lineToolStart = { x, y }; + lineToolPreview = { x, y }; + return; + } + if (tool === "text") { + const found = findTextEntryAtPoint(x, y, activeLayer, currentFrame); + if (found) { + openTextEntryOverlay({ + x: found.x, + y: found.y, + layer: found.layer, + frame: found.frame, + hex: found.hex, + initialText: found.text, + initialOptions: found.options, + editEntry: found + }); + } else { + const hex = colorToHex(currentColor); + openTextEntryOverlay({ + x, + y, + layer: activeLayer, + frame: currentFrame, + hex, + initialOptions: { + ...textToolOptions, + size: Math.max(10, Math.round(brushSize * 4)) + } + }); + } + return; + } + if (tool === "rect") { + isDrawing = true; + const hex = colorToHex(currentColor); + strokeHex = activeLayer === LAYER.FILL ? fillWhite : hex; + activeSubColor[activeLayer] = strokeHex; + ensureSublayer(activeLayer, strokeHex); + renderLayerSwatches(activeLayer); + rectToolStart = { x, y }; + rectToolPreview = { x, y }; + return; + } if (activeLayer === PAPER_LAYER) { return; } @@ -392,6 +700,11 @@ function continueStroke(e) { const movePt = stabilizePoint(e, x, y); x = movePt.x; y = movePt.y; + if (gridSnap && gridEnabled && tool !== "line") { + const snapped = snapToGrid(x, y); + x = snapped.x; + y = snapped.y; + } if (!lastPt) lastPt = { x: x, y: y @@ -404,6 +717,16 @@ function continueStroke(e) { }; return; } + if (tool === "line") { + lineToolPreview = { x, y }; + queueRenderAll(); + return; + } + if (tool === "rect") { + rectToolPreview = { x, y }; + queueRenderAll(); + return; + } if (tool === "fill-eraser" || tool === "fill-brush") { fxTransform(); fxStamp1px(lastPt.x, lastPt.y, x, y); @@ -474,6 +797,43 @@ function endStroke() { stabilizedPt = null; return; } + if (tool === "line" && lineToolStart && lineToolPreview) { + const hex = strokeHex || activeSubColor?.[activeLayer] || colorToHex(currentColor); + const off = getFrameCanvas(activeLayer, currentFrame, hex); + const ctx = off.getContext("2d"); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.lineWidth = brushSize; + ctx.strokeStyle = hex; + ctx.beginPath(); + ctx.moveTo(lineToolStart.x, lineToolStart.y); + ctx.lineTo(lineToolPreview.x, lineToolPreview.y); + ctx.stroke(); + markFrameHasContent(activeLayer, currentFrame, hex); + lineToolStart = null; + lineToolPreview = null; + queueRenderAll(); + updateTimelineHasContent(currentFrame); + return; + } + if (tool === "rect" && rectToolStart && rectToolPreview) { + const hex = strokeHex || activeSubColor?.[activeLayer] || colorToHex(currentColor); + const off = getFrameCanvas(activeLayer, currentFrame, hex); + const ctx = off.getContext("2d"); + ctx.lineWidth = brushSize; + ctx.strokeStyle = hex; + const rx = Math.min(rectToolStart.x, rectToolPreview.x); + const ry = Math.min(rectToolStart.y, rectToolPreview.y); + const rw = Math.abs(rectToolPreview.x - rectToolStart.x); + const rh = Math.abs(rectToolPreview.y - rectToolStart.y); + ctx.strokeRect(rx, ry, rw, rh); + markFrameHasContent(activeLayer, currentFrame, hex); + rectToolStart = null; + rectToolPreview = null; + queueRenderAll(); + updateTimelineHasContent(currentFrame); + return; + } if (tool === "lasso-erase" && lassoActive) { lassoActive = false; applyLassoErase(); @@ -843,6 +1203,94 @@ function drawRectSelectionOverlay(ctx) { ctx.strokeRect(rectSelection.x, rectSelection.y, rectSelection.w, rectSelection.h); ctx.restore(); } +function drawLineToolPreview(ctx) { + if (!lineToolStart || !lineToolPreview) return; + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.lineWidth = brushSize; + ctx.strokeStyle = currentColor; + ctx.globalAlpha = 0.5; + ctx.beginPath(); + ctx.moveTo(lineToolStart.x, lineToolStart.y); + ctx.lineTo(lineToolPreview.x, lineToolPreview.y); + ctx.stroke(); + ctx.restore(); +} +function drawRectToolPreview(ctx) { + if (!rectToolStart || !rectToolPreview) return; + ctx.save(); + ctx.lineWidth = brushSize; + ctx.strokeStyle = currentColor; + ctx.globalAlpha = 0.5; + const rx = Math.min(rectToolStart.x, rectToolPreview.x); + const ry = Math.min(rectToolStart.y, rectToolPreview.y); + const rw = Math.abs(rectToolPreview.x - rectToolStart.x); + const rh = Math.abs(rectToolPreview.y - rectToolStart.y); + ctx.strokeRect(rx, ry, rw, rh); + ctx.restore(); +} +function drawGrid(ctx) { + if (!gridEnabled) return; + ctx.save(); + ctx.strokeStyle = "rgba(128, 128, 128, 0.5)"; + const z = getZoom(); + ctx.lineWidth = 1 / z; // Scale line width with zoom to remain visible + for (let x = 0; x <= contentW; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, contentH); + ctx.stroke(); + } + for (let y = 0; y <= contentH; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(contentW, y); + ctx.stroke(); + } + ctx.restore(); +} +function drawGuides(ctx) { + if (!guides.length) return; + ctx.save(); + ctx.strokeStyle = "#ff6b6b"; + const z = getZoom(); + ctx.lineWidth = 1 / z; // Scale with zoom + ctx.setLineDash([4 / z, 4 / z]); + for (const guide of guides) { + if (guide.horizontal) { + ctx.beginPath(); + ctx.moveTo(0, guide.pos); + ctx.lineTo(contentW, guide.pos); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.moveTo(guide.pos, 0); + ctx.lineTo(guide.pos, contentH); + ctx.stroke(); + } + } + ctx.restore(); +} +function snapToGuides(x, y) { + if (!guideSnap || !guides.length) return { x, y }; + const threshold = 8; + for (const guide of guides) { + if (guide.horizontal && Math.abs(y - guide.pos) < threshold) { + y = guide.pos; + } else if (!guide.horizontal && Math.abs(x - guide.pos) < threshold) { + x = guide.pos; + } + } + return { x, y }; +} +function snapToGrid(x, y) { + if (!gridSnap || !gridEnabled) return { x, y }; + return { + x: Math.round(x / gridSize) * gridSize, + y: Math.round(y / gridSize) * gridSize + }; +} function beginRectSelect(e) { if (activeLayer === PAPER_LAYER) return; const pos = getCanvasPointer(e); diff --git a/celstomp/js/tools/brush-helper.js b/celstomp/js/tools/brush-helper.js index c18d299..eccc1ff 100644 --- a/celstomp/js/tools/brush-helper.js +++ b/celstomp/js/tools/brush-helper.js @@ -315,9 +315,9 @@ function getBrushSizeForPreview(toolKind) { function updateBrushPreview() { if (!_brushPrevEl || !_brushPrevCanvas) return; const toolKind = getActiveToolKindForPreview(); - const isBrush = toolKind === "brush"; + const showForTools = toolKind === "brush" || toolKind === "eraser" || toolKind === "line" || toolKind === "rect" || toolKind === "text"; const isEraser = toolKind === "eraser"; - if (!isBrush && !isEraser) { + if (!showForTools) { _brushPrevEl.style.display = "none"; return; } @@ -514,4 +514,4 @@ function openBrushCtxMenu(ev, anchorEl) { function closeBrushCtxMenu() { if (_brushCtxMenu) _brushCtxMenu.hidden = true; _brushCtxState = null; -} \ No newline at end of file +} diff --git a/celstomp/js/ui/interaction-shortcuts.js b/celstomp/js/ui/interaction-shortcuts.js index 5359ed6..ba77387 100644 --- a/celstomp/js/ui/interaction-shortcuts.js +++ b/celstomp/js/ui/interaction-shortcuts.js @@ -580,6 +580,46 @@ function _wireExtraKeyboardShortcuts() { }, true); } } +function flipSelection(horizontal) { + if (!rectSelection.active) return; + const c = getFrameCanvas(rectSelection.L, rectSelection.F, rectSelection.key); + if (!c) return; + const ctx = c.getContext("2d", { willReadFrequently: true }); + if (!ctx) return; + + const selSnap = ctx.getImageData(rectSelection.x, rectSelection.y, rectSelection.w, rectSelection.h); + const fullSnap = ctx.getImageData(0, 0, contentW, contentH); + + beginGlobalHistoryStep(rectSelection.L, rectSelection.F, rectSelection.key); + + ctx.clearRect(rectSelection.x, rectSelection.y, rectSelection.w, rectSelection.h); + + const flipped = ctx.createImageData(rectSelection.w, rectSelection.h); + const src = selSnap.data; + const dst = flipped.data; + + for (let y = 0; y < rectSelection.h; y++) { + for (let x = 0; x < rectSelection.w; x++) { + const srcIdx = (y * rectSelection.w + x) * 4; + const dstY = horizontal ? y : (rectSelection.h - 1 - y); + const dstX = horizontal ? (rectSelection.w - 1 - x) : x; + const dstIdx = (dstY * rectSelection.w + dstX) * 4; + dst[dstIdx] = src[srcIdx]; + dst[dstIdx + 1] = src[srcIdx + 1]; + dst[dstIdx + 2] = src[srcIdx + 2]; + dst[dstIdx + 3] = src[srcIdx + 3]; + } + } + + ctx.putImageData(flipped, rectSelection.x, rectSelection.y); + + markGlobalHistoryDirty(); + commitGlobalHistoryStep(); + recomputeHasContent(rectSelection.L, rectSelection.F, rectSelection.key); + queueRenderAll(); + updateTimelineHasContent(rectSelection.F); +} + function wireKeyboardShortcuts() { if (document._celstompKeysWired) return; document._celstompKeysWired = true; @@ -600,12 +640,15 @@ function wireKeyboardShortcuts() { const toolByKey = { 1: "brush", 2: "eraser", - 3: "fill-brush", - 4: "fill-eraser", - 5: "lasso-fill", - 6: "lasso-erase", - 7: "rect-select", - 8: "eyedropper" + 3: "line", + 4: "rect", + 5: "text", + 6: "fill-brush", + 7: "fill-eraser", + 8: "lasso-fill", + 9: "lasso-erase", + 0: "rect-select", + "-": "eyedropper" }; document.addEventListener("keydown", e => { if (e.defaultPrevented) return; @@ -690,27 +733,48 @@ function onWindowKeyDown(e) { }); } if (isDigit(3)) { + e.preventDefault(); + pickTool({ + id: "tool-line", + value: "line" + }); + } + if (isDigit(4)) { + e.preventDefault(); + pickTool({ + id: "tool-rect", + value: "rect" + }); + } + if (isDigit(5)) { + e.preventDefault(); + pickTool({ + id: "tool-text", + value: "text" + }); + } + if (isDigit(6)) { e.preventDefault(); pickTool({ id: "tool-fillbrush", value: "fill-brush" }); } - if (isDigit(4)) { + if (isDigit(7)) { e.preventDefault(); pickTool({ id: "tool-filleraser", value: "fill-eraser" }); } - if (isDigit(5)) { + if (isDigit(8)) { e.preventDefault(); pickTool({ id: "tool-lassoFill", value: "lasso-fill" }); } - if (isDigit(6)) { + if (isDigit(9)) { e.preventDefault(); pickTool({ id: "tool-lassoErase", @@ -718,24 +782,22 @@ function onWindowKeyDown(e) { value: "lasso-erase" }); } - if (isDigit(7)) { + if (isDigit(0)) { e.preventDefault(); pickTool({ id: "tool-rectSelect", value: "rect-select" }); } - if (isDigit(8)) { - e.preventDefault(); - pickTool({ - id: "tool-eyedropper", - value: "eyedropper" - }); - } } } } if (e.key === "Escape") { + if (document.body.classList.contains("guide-place-mode") && window.__celstompSetGuidePlacementMode) { + e.preventDefault(); + window.__celstompSetGuidePlacementMode(null); + return; + } if (tool === "lasso-fill" && lassoActive) { e.preventDefault(); cancelLasso(); @@ -749,6 +811,25 @@ function onWindowKeyDown(e) { return; } } + if (rectSelection.active && (e.key.toLowerCase() === "h" || e.key.toLowerCase() === "v")) { + const tag = e.target && e.target.tagName ? e.target.tagName.toUpperCase() : ""; + if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT") { + e.preventDefault(); + flipSelection(e.key.toLowerCase() === "h"); + return; + } + } + if (e.key.toLowerCase() === "g") { + const tag = e.target && e.target.tagName ? e.target.tagName.toUpperCase() : ""; + if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT") { + e.preventDefault(); + gridEnabled = !gridEnabled; + const gridBtn = $("tlGridBtn"); + if (gridBtn) gridBtn.classList.toggle("active", gridEnabled); + queueRenderAll(); + return; + } + } if ((e.key === "Delete" || e.key === "Backspace") && rectSelection.active) { const tag = e.target && e.target.tagName ? e.target.tagName.toUpperCase() : ""; if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT") { @@ -788,6 +869,14 @@ function onWindowKeyDown(e) { } } } + const tag = e.target && e.target.tagName ? e.target.tagName.toUpperCase() : ""; + const typingNow = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || e.target && e.target.isContentEditable; + const textEditorOpen = document.body.classList.contains("canvasTextEntryMode"); + + if ((typingNow || textEditorOpen) && e.key === " ") { + return; + } + if (ctrl && e.key.toLowerCase() === "z" && !e.shiftKey) { e.preventDefault(); undo(); diff --git a/celstomp/js/ui/island-helper.js b/celstomp/js/ui/island-helper.js index ff6e029..2bacc9c 100644 --- a/celstomp/js/ui/island-helper.js +++ b/celstomp/js/ui/island-helper.js @@ -175,10 +175,50 @@ function wireFloatingIslandDrag() { if (!head) return; if (dock._dragWired) return; dock._dragWired = true; + const LOCK_KEY = "celstomp_island_right_locked"; + const LOCK_THRESHOLD = 170; + + let lockHint = $("islandLockHint"); + if (!lockHint) { + lockHint = document.createElement("div"); + lockHint.id = "islandLockHint"; + lockHint.className = "islandLockHint"; + lockHint.textContent = "Drop To Dock Right"; + document.body.appendChild(lockHint); + } + + function setRightLocked(v, opts = {}) { + const isLocked = !!v; + const persist = opts.persist !== false; + dock.classList.toggle("right-locked", isLocked); + if (isLocked) { + dock.style.left = ""; + dock.style.top = ""; + dock.style.right = "var(--dock-gap)"; + } else { + dock.style.right = ""; + } + if (persist) { + try { + localStorage.setItem(LOCK_KEY, isLocked ? "1" : "0"); + } catch {} + } + } + + try { + const saved = localStorage.getItem(LOCK_KEY); + if (saved === "1") { + setRightLocked(true, { + persist: false + }); + } + } catch {} + let dragging = false; let pid = null; let offX = 0; let offY = 0; + let lockCandidate = false; let cachedHeaderH = 48; let cachedVW = window.innerWidth; let cachedVH = window.innerHeight; @@ -202,6 +242,15 @@ function wireFloatingIslandDrag() { head.addEventListener("pointerdown", e => { if (e.pointerType === "mouse" && e.button !== 0) return; if (e.target.closest(".islandBtn, .islandBtns, .islandResizeHandle")) return; + + if (dock.classList.contains("right-locked")) { + const rLocked = dock.getBoundingClientRect(); + setRightLocked(false, { + persist: false + }); + dock.style.left = Math.round(rLocked.left) + "px"; + dock.style.top = Math.round(rLocked.top) + "px"; + } updateCache(); @@ -223,6 +272,9 @@ function wireFloatingIslandDrag() { const pos = clampPos(e.clientX - offX, e.clientY - offY); dock.style.left = pos.x + "px"; dock.style.top = pos.y + "px"; + lockCandidate = e.clientX >= window.innerWidth - LOCK_THRESHOLD; + dock.classList.toggle("drag-lock-candidate", lockCandidate); + lockHint.classList.toggle("active", lockCandidate); e.preventDefault(); }, { passive: false @@ -231,10 +283,18 @@ function wireFloatingIslandDrag() { if (!dragging || pid != null && e.pointerId !== pid) return; dragging = false; dock.classList.remove("dragging"); + dock.classList.remove("drag-lock-candidate"); + lockHint.classList.remove("active"); try { head.releasePointerCapture(pid); } catch {} pid = null; + if (lockCandidate) { + setRightLocked(true); + } else { + setRightLocked(false); + } + lockCandidate = false; }; window.addEventListener("pointerup", end, { @@ -405,4 +465,4 @@ function wireIslandResize() { }; handle.addEventListener("pointerup", end); handle.addEventListener("pointercancel", end); -} \ No newline at end of file +} diff --git a/celstomp/js/ui/ui-components.js b/celstomp/js/ui/ui-components.js index 3614e29..416557e 100644 --- a/celstomp/js/ui/ui-components.js +++ b/celstomp/js/ui/ui-components.js @@ -4,6 +4,9 @@ const tools = [ { id: 'tool-brush', val: 'brush', label: 'Brush', checked: true }, { id: 'tool-eraser', val: 'eraser', label: 'Eraser' }, + { id: 'tool-line', val: 'line', label: 'Line', icon: '' }, + { id: 'tool-rect', val: 'rect', label: 'Rect', icon: '' }, + { id: 'tool-text', val: 'text', label: 'Text', icon: '' }, { id: 'tool-fillbrush', val: 'fill-brush', label: 'Fill Brush' }, { id: 'tool-filleraser', val: 'fill-eraser', label: 'Eraser Fill' }, { id: 'tool-lassoFill', val: 'lasso-fill', label: 'Lasso Fill' }, @@ -27,7 +30,12 @@ const lbl = document.createElement('label'); lbl.htmlFor = t.id; lbl.dataset.tool = t.val; - lbl.textContent = t.label; + lbl.setAttribute('aria-label', t.label); + if (t.icon) { + lbl.innerHTML = t.icon; + } else { + lbl.textContent = t.label; + } if (t.val === 'brush') lbl.id = 'toolBrushLabel'; if (t.val === 'eraser') lbl.id = 'toolEraserLabel'; diff --git a/celstomp/parts/modals.js b/celstomp/parts/modals.js index 5351362..0c6a9ed 100644 --- a/celstomp/parts/modals.js +++ b/celstomp/parts/modals.js @@ -52,12 +52,14 @@ document.getElementById('part-modals').innerHTML = `

Tools

1Brush
2Eraser
-
3Fill Brush
-
4Fill Eraser
-
5Lasso Fill
-
6Lasso Erase
-
7Rect Select
-
8Eyedropper
+
3Line
+
4Rect
+
5Text
+
6Fill Brush
+
7Fill Eraser
+
8Lasso Fill
+
9Lasso Erase
+
0Rect Select

Navigation

diff --git a/celstomp/parts/timeline.js b/celstomp/parts/timeline.js index d98b5dc..4370965 100644 --- a/celstomp/parts/timeline.js +++ b/celstomp/parts/timeline.js @@ -22,27 +22,102 @@ document.getElementById('part-timeline').innerHTML = `
- - - - - + + + + +
- - +
+ + +
- - - +
+ + + + + +
- - +
+ + + + +
- - +
+ + + + + + + + +
Zoom diff --git a/package-lock.json b/package-lock.json index a7086ee..e3f3a2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -915,6 +915,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, From d61a3ec7290d9fa22dd54a3bce62319c98370d35 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 21 Feb 2026 08:43:08 +0000 Subject: [PATCH 2/3] feat: redesign timeline UI with icons --- celstomp/celstomp-app.js | 8 +- celstomp/css/components/timeline.css | 116 +++++++++++++++++++++++++++ celstomp/parts/timeline.js | 86 +++++++++++++------- 3 files changed, 181 insertions(+), 29 deletions(-) diff --git a/celstomp/celstomp-app.js b/celstomp/celstomp-app.js index 3d924b6..41afd42 100644 --- a/celstomp/celstomp-app.js +++ b/celstomp/celstomp-app.js @@ -884,7 +884,13 @@ safeSetChecked(onion, onionEnabled); safeSetChecked(psnap, playSnapped); toggle?.addEventListener("click", () => { - if (isPlaying) pausePlayback(); else startPlayback(); + if (isPlaying) { + pausePlayback(); + toggle.classList.remove("playing"); + } else { + startPlayback(); + toggle.classList.add("playing"); + } }); prevF?.addEventListener("click", () => gotoFrame(stepBySnap(-1))); nextF?.addEventListener("click", () => gotoFrame(stepBySnap(1))); diff --git a/celstomp/css/components/timeline.css b/celstomp/css/components/timeline.css index 9256498..711fac2 100644 --- a/celstomp/css/components/timeline.css +++ b/celstomp/css/components/timeline.css @@ -250,6 +250,122 @@ body.dragging-cel{ cursor: grabbing; user-select:none; } #timelineHeader input[type="number"], #timelineHeader .chip{ min-height:36px; } +.tl-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.tl-btn-group { + display: flex; + align-items: center; + gap: 2px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 2px; +} + +.tl-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #c7d0db; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.tl-icon-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.tl-icon-btn:active { + background: rgba(255, 255, 255, 0.15); +} + +.tl-icon-btn.danger:hover { + background: rgba(255, 82, 82, 0.2); + color: #ff5252; +} + +.tl-icon-btn svg { + pointer-events: none; +} + +.tl-play-btn { + width: 40px; + height: 40px; + background: rgba(0, 229, 255, 0.15); + color: #9fd5ff; +} + +.tl-play-btn:hover { + background: rgba(0, 229, 255, 0.25); +} + +.tl-play-btn.playing .play-icon { display: none; } +.tl-play-btn.playing .pause-icon { display: block !important; } + +.tl-num-input { + width: 60px; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + background: rgba(0, 0, 0, 0.28); + color: #e2e8f0; + font-size: 12px; + -moz-appearance: textfield; +} + +.tl-num-input::-webkit-outer-spin-button, +.tl-num-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.tl-num-input-sm { + width: 48px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 4px; + background: rgba(0, 0, 0, 0.28); + color: #e2e8f0; + font-size: 11px; + -moz-appearance: textfield; +} + +.tl-num-input-sm::-webkit-outer-spin-button, +.tl-num-input-sm::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.tl-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + font-size: 12px; + color: #c7d0db; + cursor: pointer; +} + +.tl-chip input[type="checkbox"] { + margin: 0; +} + +.tl-opts-group { + background: transparent; +} + .tlMobArrow{ display:none; } @media (max-width: 900px){ diff --git a/celstomp/parts/timeline.js b/celstomp/parts/timeline.js index d98b5dc..7280c54 100644 --- a/celstomp/parts/timeline.js +++ b/celstomp/parts/timeline.js @@ -8,47 +8,77 @@ document.getElementById('part-timeline').innerHTML = ` aria-expanded="false">▴ -
+
Timeline 0s+0f Loop - - - - +
+ + +
+ +
-
- - - - - +
+
+ + + + + +
-
+
+
+ + +
- - +
+ + +
- - - +
+ + +
- - +
+ + +
- - - - - Zoom - - - +
From 27fbe5c879c313efba56dd079f4ea42020c5dd14 Mon Sep 17 00:00:00 2001 From: Microck Date: Tue, 3 Mar 2026 20:48:48 +0000 Subject: [PATCH 3/3] Fix timeline controls and collapse behavior --- celstomp/celstomp-app.js | 21 +++++++++ celstomp/css/components/timeline.css | 3 +- celstomp/css/components/tools.css | 3 +- celstomp/js/ui/interaction-shortcuts.js | 58 +++++++++++++++++++------ celstomp/parts/timeline.js | 26 +++++++++-- 5 files changed, 91 insertions(+), 20 deletions(-) diff --git a/celstomp/celstomp-app.js b/celstomp/celstomp-app.js index 14f3f03..a6cfc84 100644 --- a/celstomp/celstomp-app.js +++ b/celstomp/celstomp-app.js @@ -1036,6 +1036,7 @@ const showLeft = $("showLeftEdge"); const showRight = $("showRightEdge"); const showTl = $("showTimelineEdge"); + const timelineEl = $("timeline"); const tLeft = $("toggleSidebarBtn"); const tRight = $("toggleRightbarBtn"); function applyLayoutChange() { @@ -1057,8 +1058,28 @@ } function setTimelineOpen(open) { app.classList.toggle("tl-collapsed", !open); + document.body?.classList.toggle("tl-collapsed", !open); + if (timelineEl) { + timelineEl.hidden = !open; + timelineEl.style.display = open ? "" : "none"; + timelineEl.setAttribute("aria-hidden", open ? "false" : "true"); + } + if (showTl) { + showTl.style.display = open ? "none" : "block"; + } applyLayoutChange(); } + if (!document._celstompPanelToggleDelegated) { + document._celstompPanelToggleDelegated = true; + document.addEventListener("click", e => { + if (e.target.closest("#hideLeftPanelBtn")) setLeftOpen(false); + if (e.target.closest("#hideRightPanelBtn")) setRightOpen(false); + if (e.target.closest("#hideTimelineBtn")) setTimelineOpen(false); + if (e.target.closest("#showLeftEdge")) setLeftOpen(true); + if (e.target.closest("#showRightEdge")) setRightOpen(true); + if (e.target.closest("#showTimelineEdge")) setTimelineOpen(true); + }); + } setLeftOpen(true); setRightOpen(true); setTimelineOpen(true); diff --git a/celstomp/css/components/timeline.css b/celstomp/css/components/timeline.css index f760246..cadc3de 100644 --- a/celstomp/css/components/timeline.css +++ b/celstomp/css/components/timeline.css @@ -21,7 +21,8 @@ min-height:0; } -.app.tl-collapsed #timeline{ display:none; } +.app.tl-collapsed #timeline, +body.tl-collapsed #timeline{ display:none; } #timelineHeader{ display:flex; diff --git a/celstomp/css/components/tools.css b/celstomp/css/components/tools.css index d0a8671..65e9097 100644 --- a/celstomp/css/components/tools.css +++ b/celstomp/css/components/tools.css @@ -100,7 +100,8 @@ .app.sidebar-collapsed #showLeftEdge{ display:block; } .app.rightbar-collapsed #showRightEdge{ display:block; } -.app.tl-collapsed #showTimelineEdge{ display:block; } +.app.tl-collapsed #showTimelineEdge, +body.tl-collapsed #showTimelineEdge{ display:block; } /* Island Tools Context */ diff --git a/celstomp/js/ui/interaction-shortcuts.js b/celstomp/js/ui/interaction-shortcuts.js index ba77387..6f03941 100644 --- a/celstomp/js/ui/interaction-shortcuts.js +++ b/celstomp/js/ui/interaction-shortcuts.js @@ -295,6 +295,8 @@ function _wireTimelineEnhancements() { const zoomIn = $("zoomTimelineIn"); const zoomOut = $("zoomTimelineOut"); + applyTimelineFrameWidth(readTimelineFrameWidth()); + if (insertBtn) { insertBtn.addEventListener("click", () => insertFrame(currentFrame)); } @@ -317,32 +319,59 @@ function _wireTimelineEnhancements() { } if (zoomIn) { zoomIn.addEventListener("click", () => { - timelineFrameWidth = Math.min(100, timelineFrameWidth + 5); - renderTimeline(); + applyTimelineFrameWidth(timelineFrameWidth + 5); }); } if (zoomOut) { zoomOut.addEventListener("click", () => { - timelineFrameWidth = Math.max(15, timelineFrameWidth - 5); - renderTimeline(); + applyTimelineFrameWidth(timelineFrameWidth - 5); }); } } + +function readTimelineFrameWidth() { + const cssValue = getComputedStyle(document.documentElement).getPropertyValue("--frame-w"); + const parsed = parseFloat(cssValue); + if (Number.isFinite(parsed)) return parsed; + return timelineFrameWidth; +} + +function applyTimelineFrameWidth(value) { + const next = Math.max(15, Math.min(100, Math.round(Number(value) || 24))); + timelineFrameWidth = next; + document.documentElement.style.setProperty("--frame-w", `${next}px`); + if (typeof updatePlayheadMarker === "function") updatePlayheadMarker(); + if (typeof updateClipMarkers === "function") updateClipMarkers(); +} + +function refreshTimelineView() { + if (typeof buildTimeline === "function") { + buildTimeline(); + if (typeof updatePlayheadMarker === "function") updatePlayheadMarker(); + if (typeof updateClipMarkers === "function") updateClipMarkers(); + return; + } + if (typeof renderTimeline === "function") { + renderTimeline(); + } +} + function insertFrame(frameIndex) { beginGlobalHistoryStep(); for (let i = totalFrames - 1; i >= frameIndex; i--) { const src = getFrameCanvas(LAYER.LINE, i, null); if (src) { - const ctx = getFrameCanvas(LAYER.LINE, i + 1, null); - if (ctx) { - ctx.clearRect(0, 0, contentW, contentH); - ctx.drawImage(src, 0, 0); + const dst = getFrameCanvas(LAYER.LINE, i + 1, null); + const dctx = dst?.getContext?.("2d"); + if (dctx) { + dctx.clearRect(0, 0, contentW, contentH); + dctx.drawImage(src, 0, 0); } } } totalFrames++; markProjectDirty(); - renderTimeline(); + refreshTimelineView(); commitGlobalHistoryStep(); } function deleteFrame(frameIndex) { @@ -354,10 +383,11 @@ function deleteFrame(frameIndex) { for (let i = frameIndex; i < totalFrames - 1; i++) { const src = getFrameCanvas(LAYER.LINE, i + 1, null); if (src) { - const ctx = getFrameCanvas(LAYER.LINE, i, null); - if (ctx) { - ctx.clearRect(0, 0, contentW, contentH); - ctx.drawImage(src, 0, 0); + const dst = getFrameCanvas(LAYER.LINE, i, null); + const dctx = dst?.getContext?.("2d"); + if (dctx) { + dctx.clearRect(0, 0, contentW, contentH); + dctx.drawImage(src, 0, 0); } } } @@ -369,7 +399,7 @@ function deleteFrame(frameIndex) { totalFrames--; if (currentFrame >= totalFrames) currentFrame = totalFrames - 1; markProjectDirty(); - renderTimeline(); + refreshTimelineView(); commitGlobalHistoryStep(); } function _wireLayerQoL() { diff --git a/celstomp/parts/timeline.js b/celstomp/parts/timeline.js index 4370965..90681de 100644 --- a/celstomp/parts/timeline.js +++ b/celstomp/parts/timeline.js @@ -119,11 +119,29 @@ document.getElementById('part-timeline').innerHTML = `
- + Zoom - + - +
@@ -136,5 +154,5 @@ document.getElementById('part-timeline').innerHTML = ` - + `;