1 Commits
Author SHA1 Message Date
fen c32566979f #260: .swapbtn grid for copy feedback (fix attempt 2)
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
Label and checkmark stack in one grid cell (inline-grid, grid-area 1/1,
checkmark visibility-hidden until .ok), so the button width is static by
construction across the click. Replaces the min-width pin from fix attempt
1, which enlarged buttons by up to 0.64px (ceil of fractional width) and
leaked the pin after restore.

Applies to paste-view Copy/Link (paste.html + paste.js copyFeedback) and
the /new result-box copy button (new.js). Verified with CDP bounding-box
probes: Copy width/x identical before/during/after at 1400x900 and
375x812 (including the previously failing 52.36px mobile case), Link
button static, checkmark visible during feedback, no minWidth pin set.
2026-09-17 17:18:15 -05:00
13 changed files with 136 additions and 295 deletions
+4 -5
View File
@@ -21,16 +21,16 @@ a web UI for sharing text and small files.
- Cookie based saved pastes and settings
- Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant
- Dark mode toggle in the topbar and settings, with a configurable default
- Polished code viewer: line-number gutter sized to the widest number and pinned during horizontal scroll, optional line wrap, jump-to-top/bottom buttons, and theme-aware scrollbars
## Screenshots
| | |
|---|---|
| ![Editor in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-editor-new.png) | ![Paste view in pastel-peach (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-pastel-peach-light.png) |
| ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) |
| ![Paste view in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-midnight-dark.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) |
| ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Editor at mobile width](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-editor-new.png) |
Mobile previews (375x812): [paste view](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png).
## Get Started
@@ -66,11 +66,10 @@ go build -o palette ./cmd/palette
| `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
| `PALETTE_TRUSTED_IP_HEADER` | unset | Name of a proxy-controlled client-IP header to key API rate limits on (e.g. `CF-Connecting-IP` when Cloudflare is the ingress; Cloudflare strips any client-supplied value). Unset: rate limits key on the peer address only, and all client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored. (#280) |
An `/admin` page exists for runtime settings, protected by a key set at
install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See
+1 -7
View File
@@ -32,16 +32,10 @@ services:
# Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can) or a
# paste file attachment.
# Max size in bytes of a single can item (file/text inside a can).
# Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400"
# Default dark mode for new visitors. Unset = dark on; set to "false",
# "0" or "off" to default to light mode. Visitors who toggle dark mode
# keep their choice in their browser.
# PALETTE_DEFAULT_DARK: "false"
# HMAC secret for password-unlock cookies. Default: random per start,
# which logs out every unlocked browser session on restart. Set a fixed
# secret (any random string) to keep unlock sessions across restarts,
+11
View File
@@ -91,6 +91,17 @@ func (l *limitReader) Read(p []byte) (int, error) {
return n, err
}
// isImageMime reports whether the sniffed mime is a raster image the viewer
// can render inline (#221). SVG is excluded: it is forced to text/plain on
// serving by the active-content rule and must never render as an image.
func isImageMime(mime string) bool {
switch mime {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return true
}
return false
}
// handleCreatePasteMultipart implements POST /api/pastes with
// multipart/form-data (#38). Fields mirror the JSON create path; a 'file'
// part makes the paste a file paste (1 file = 1 paste: if text content is
-61
View File
@@ -306,64 +306,3 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) {
t.Fatalf("paste should require password, got %d", rec2.Code)
}
}
// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes,
// not just raster images (the old isImageMime gate left non-image
// attachments serving an empty body from row.Content).
func TestRawStreamsNonImageAttachment(t *testing.T) {
s := testServer(t)
h := s.routes()
body := []byte("hello, this is a plain text attachment body")
rec, resp := multipartCreate(t, h, "notes.txt", body, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
if resp["attachment"] == nil {
t.Fatalf("no attachment in response: %v", resp)
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("nosniff = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), body) {
t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body))
}
}
// #281: active-content attachment types still get forced to text/plain on
// /raw, same rule as the /f/ serving path (#34).
func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) {
s := testServer(t)
h := s.routes()
html := []byte("<html><body><script>alert(1)</script></body></html>")
rec, resp := multipartCreate(t, h, "page.html", html, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), html) {
t.Fatal("raw bytes differ from upload")
}
}
-57
View File
@@ -1,57 +0,0 @@
// clientIP extracts the client IP for rate-limit keying.
//
// Trust boundary (issue #280): the bucket key MUST NOT come from any header a
// client can influence. The previous rightmost-X-Forwarded-For scheme (#85)
// assumed Traefik appends the real client IP, but the deployed ingress does
// not rewrite XFF, so a client rotating its own XFF value got a fresh bucket
// per request and the limit was unenforceable (pentest H1: 6x201 across 8
// rotating-XFF creates).
//
// Default: key on the actual peer address (RemoteAddr) only. Behind any
// reverse proxy this is the proxy's address, so all clients share one bucket
// per endpoint — coarse, but safe.
//
// Proxy-honoring mode: a deployment in front of a proxy that OVERWRITES (not
// appends to) a client-IP header can set PALETTE_TRUSTED_IP_HEADER (e.g.
// CF-Connecting-IP when Cloudflare is the ingress; Cloudflare strips any
// client-supplied value). The header is honored ONLY when explicitly
// configured at startup, and X-Forwarded-For / X-Real-Ip are never trusted.
package api
import (
"net"
"net/http"
"sync"
)
var (
trustedIPMu sync.RWMutex
trustedIPHeader string // empty = never trust any client-IP header
)
// SetTrustedIPHeader configures the single proxy-controlled header whose
// value may key rate-limit buckets. Called at startup; tests may reset it.
func SetTrustedIPHeader(name string) {
trustedIPMu.Lock()
defer trustedIPMu.Unlock()
trustedIPHeader = name
}
func getTrustedIPHeader() string {
trustedIPMu.RLock()
defer trustedIPMu.RUnlock()
return trustedIPHeader
}
func clientIP(r *http.Request) string {
if name := getTrustedIPHeader(); name != "" {
if v := r.Header.Get(name); v != "" {
return v
}
}
host := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
host = h
}
return host
}
+30
View File
@@ -3,6 +3,7 @@ package api
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
)
@@ -47,6 +48,35 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
return true
}
// clientIP extracts the client IP for rate-limit keying (#85).
//
// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
// proxy observed and is unspoofable by the client (a client-supplied fake
// entry only lands on the LEFT and is ignored). This matches chi's
// middleware.RealIP semantics for a single trusted proxy hop.
//
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
// reachable deployments must NOT expose the app to untrusted networks
// without a proxy in front, or attackers could forge the rightmost entry.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.LastIndex(xff, ","); i >= 0 {
return strings.TrimSpace(xff[i+1:])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
return strings.TrimSpace(xr)
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter()
// globalSettingsFn is set at startup; tests can point it at fixed settings.
+66 -38
View File
@@ -1,57 +1,85 @@
package api
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
// (appended by the trusted Traefik proxy), never the raw/leftmost header
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
// limit or rotate buckets.
import (
"fmt"
"bytes"
"net/http/httptest"
"testing"
)
func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
SetTrustedIPHeader("")
defer SetTrustedIPHeader("")
r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "203.0.113.7:4432"
func TestClientIPTakesRightmostXFF(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
if got := clientIP(r); got != "203.0.113.9" {
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
}
}
func TestClientIPXRealIPFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000"
r.Header.Set("X-Real-Ip", "203.0.113.10")
if got := clientIP(r); got != "203.0.113.7" {
t.Fatalf("clientIP = %q, want peer 203.0.113.7", got)
if got := clientIP(r); got != "203.0.113.10" {
t.Fatalf("clientIP = %q, want 203.0.113.10", got)
}
}
func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) {
SetTrustedIPHeader("")
defer SetTrustedIPHeader("")
r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "10.0.1.47:9999"
r.Header.Set("CF-Connecting-IP", "198.51.100.9")
if got := clientIP(r); got != "10.0.1.47" {
t.Fatalf("unconfigured: clientIP = %q, want peer 10.0.1.47", got)
}
SetTrustedIPHeader("CF-Connecting-IP")
if got := clientIP(r); got != "198.51.100.9" {
t.Fatalf("configured: clientIP = %q, want CF-Connecting-IP value", got)
func TestClientIPDirectFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "198.51.100.5:51000"
if got := clientIP(r); got != "198.51.100.5" {
t.Fatalf("clientIP = %q, want 198.51.100.5", got)
}
}
// Issue #280: rotating X-Forwarded-For must NOT reset the bucket. Pentest
// repro was 8 creates with rotating XFF -> 6x201.
func TestRotatingXFFDoesNotResetBucket(t *testing.T) {
globalLimiter = newLimiter()
defer SetTrustedIPHeader("")
SetTrustedIPHeader("")
s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway
var allowed, limited int
for i := 0; i < 8; i++ {
r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "198.51.100.1:5000"
r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i))
if rateLimitCreate(r, s) {
allowed++
} else {
limited++
// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
// leftmost XFF entry stays limited on their real (rightmost) IP.
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for i := 0; i < 5; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
// each request spoofs a DIFFERENT leftmost entry
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("req %d: want 201, got %d", i, rr.Code)
}
}
if float64(allowed) != s.RateLimitBurst || limited != 8-int(s.RateLimitBurst) {
t.Fatalf("rotating XFF: allowed=%d limited=%d, want allowed=%v (burst), limited=%d", allowed, limited, s.RateLimitBurst, 8-int(s.RateLimitBurst))
// 6th request, still the same real IP, new spoofed prefix: must 429
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
}
}
func spoofN(i int) string {
return "1.2.3." + string(rune('0'+i))
}
// Distinct real IPs must still get distinct buckets (no over-limiting).
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
}
}
}
+4 -12
View File
@@ -28,10 +28,6 @@ type Config struct {
DBPath string
MaxTextBytes int64
MaxItemBytes int64
// TrustedIPHeader optionally names a proxy-controlled client-IP header
// (e.g. CF-Connecting-IP behind Cloudflare) to key rate limits on. Empty
// (default) keys on the peer address only. See clientip.go (#280).
TrustedIPHeader string
}
type apiServer struct {
@@ -43,7 +39,6 @@ type apiServer struct {
}
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
SetTrustedIPHeader(cfg.TrustedIPHeader) // #280
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
}
@@ -501,18 +496,15 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", 404)
return
}
// #221: raw view of a paste backed by an attachment serves the stored
// blob bytes with the sniffed mime, not the (empty) text content — for
// ALL attachment mimes (#281); /raw/{id} is the raw fetch for the file
// too. serveContentType still forces active-content types (html, svg,
// xml) to text/plain per the #34 rule below.
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
// #221: raw view of an image paste serves the image bytes themselves as
// an image, not the (empty) text content.
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) {
blobs := a.store.Blobs()
if blobs != nil {
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
defer blob.Close()
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
w.Header().Set("Content-Type", serveContentType(att.Mime))
w.Header().Set("Content-Type", att.Mime)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
+10 -34
View File
@@ -319,10 +319,8 @@ html[data-wrap] .float { overflow-x: hidden; }
.codebody { padding: 0 18px; white-space: pre; overflow-x: auto; }
/* #167: each logical line is its own block so offsetTop identifies its first visual row */
.codeline { display: block; }
/* #167: gutter number spans must stack one per visual row (wrap on).
#274: the /new editor gutter uses the same .gutline blocks when its own
wrap toggle is on, so scope the rule to any gutter, not just .code. */
.code .gutter .gutline, .editor-wrap .gutter .gutline { display: block; }
/* #167 rev: gutter number spans must stack one per visual row (wrap on) */
.code .gutter .gutline { display: block; }
/* #194: codeline blocks are adjacent (no '\n' text between them), so an
empty block (blank source line) needs its own line box to stay one row */
.codeline:empty::before { content: "\200B"; }
@@ -582,26 +580,20 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
outline-offset: 1px;
}
/* swapbtn (#260): the label and the checkmark occupy the same grid cell, so
toggling feedback never changes the button's width — no pinning needed */
.swapbtn { display: inline-grid; }
.swapbtn > * { grid-area: 1 / 1; }
.swapbtn .btn-check { visibility: hidden; }
.swapbtn.ok .btn-check { visibility: visible; }
.swapbtn.ok .btn-label { visibility: hidden; }
/* in-place copy success feedback (#53) */
.iconbtn.ok, .btn.ok {
color: var(--ok);
border-color: var(--ok);
}
/* #260: static-width success feedback. Label and checkmark stack in one
grid cell, so the button is always as wide as the wider of the two and
never shifts on click. Feedback is a pure .ok class toggle. */
.swapbtn {
display: inline-grid;
}
.swapbtn > * {
grid-area: 1 / 1;
justify-self: center;
}
.swapbtn .swap-check { visibility: hidden; }
.swapbtn.ok .swap-check { visibility: visible; }
.swapbtn.ok .swap-label { visibility: hidden; }
/* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em;
@@ -923,19 +915,3 @@ button[type="submit"]:focus-visible,
}
.jumpnav.hidden { display: none; }
.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); }
/* #273: theme-aware scrollbars. Standard properties first (Firefox, and
Chromium >= 121 honors scrollbar-color), then ::-webkit rules for finer
Chromium styling. Colors come from CSS vars so they track the preset. */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover { background: var(--muted-fg); }
::-webkit-scrollbar-corner { background: transparent; }
-10
View File
@@ -38,14 +38,4 @@
window.addEventListener('resize', refresh);
if (scroller !== window && scroller) scroller.addEventListener('input', refresh);
refresh();
/* #282: the first evaluation can run before the layout settles (media
queries, web fonts, async highlighting) and under-measure the content,
leaving the nav hidden on long pages. Re-check once a real layout exists
and after load; the ResizeObserver also catches late content growth. */
requestAnimationFrame(function () { requestAnimationFrame(refresh); });
window.addEventListener('load', refresh);
window.setTimeout(refresh, 300);
if (window.ResizeObserver && scroller === window && document.body) {
new ResizeObserver(refresh).observe(document.body);
}
})();
+4 -64
View File
@@ -2,71 +2,13 @@
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
// #274: with wrap on, a logical line occupies several VISUAL rows in the
// textarea, so one number per logical line drifts off its text (same bug the
// paste view fixed in #167). A textarea can't be split into spans, so the
// wrapped row count per logical line is measured with a hidden mirror div
// that shares the editor's font, line metrics and wrapping rules, and the
// gutter renders one .gutline block per visual row with the number on the
// FIRST row of its logical line (fillers elsewhere).
let mirror = null;
function measureRows(lines) {
if (!mirror) {
mirror = document.createElement('div');
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.top = '0';
mirror.style.left = '-9999px';
document.body.appendChild(mirror);
}
const cs = getComputedStyle(content);
mirror.style.font = cs.font;
mirror.style.lineHeight = cs.lineHeight;
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.overflowWrap = 'anywhere';
mirror.style.wordBreak = 'break-all';
mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px';
const lh = parseFloat(cs.lineHeight) || 1;
const starts = [];
let total = 0;
const n = Math.max(lines.length, 1);
for (let i = 0; i < n; i++) {
// A trailing newline yields an empty last line: it still occupies one row.
mirror.textContent = lines[i] + '\n';
let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh));
starts.push(total);
total += rows;
}
return { starts, total };
}
function updateGutter() {
const lines = content.value.split('\n');
const n = Math.max(lines.length, 1);
if (!document.documentElement.hasAttribute('data-wrap')) {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= n; i++) s += i + '\n';
gutter.textContent = s.slice(0, -1);
return;
}
const { starts, total } = measureRows(lines);
gutter.textContent = '';
const frag = document.createDocumentFragment();
const spans = [];
for (let r = 0; r < total; r++) {
const c = document.createElement('span');
c.className = 'gutline';
c.textContent = '\u00a0';
spans.push(c);
frag.appendChild(c);
}
gutter.appendChild(frag);
for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1);
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
// #274: the wrap toggle and width changes re-wrap the textarea; re-measure.
new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
window.addEventListener('resize', updateGutter);
// #259: the editor scrolls itself; keep the gutter's numbers in step with it.
content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; });
updateGutter();
@@ -250,9 +192,7 @@ async function create() {
// button, password auto-unlock, then redirect to the paste.
function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id);
// #260 attempt 2: .swapbtn markup — label and checkmark share one grid
// cell, so the button width is static and feedback is a class toggle.
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon swapbtn" id="result-copy" title="Copy URL" type="button"><span class="swap-label">Copy</span><span class="swap-check">✓</span></button>', 'ok');
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon swapbtn" id="result-copy" title="Copy URL" type="button"><span class="btn-label">⧉</span><span class="btn-check">✓</span></button>', 'ok');
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
+2 -3
View File
@@ -18,9 +18,8 @@ function toggleStats() {
}
function copyFeedback(btn) {
if (!btn) return;
// #260 attempt 2: .swapbtn stacks the label and checkmark in the same grid
// cell, so the button width is always the wider of the two and never moves.
// Feedback is a pure class toggle; no width pinning, no textContent swap.
// #260: the .swapbtn grid stacks label + checkmark in one cell, so the
// width is static by construction — feedback is a pure class toggle.
btn.classList.add('ok');
clearTimeout(btn._okh);
btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000);
+2 -2
View File
@@ -8,8 +8,8 @@
<div class="spacer"></div>
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">Wrap</button>
<a class="iconbtn" href="/raw/{{.ID}}">Raw</a>
<a class="iconbtn swapbtn" href="#" id="copy-link-btn"><span class="swap-label">Link</span><span class="swap-check"></span></a>
<a class="iconbtn swapbtn" href="#" id="copy-btn"><span class="swap-label">Copy</span><span class="swap-check"></span></a>
<a class="iconbtn swapbtn" href="#" id="copy-link-btn"><span class="btn-label">Link</span><span class="btn-check"></span></a>
<a class="iconbtn swapbtn" href="#" id="copy-btn"><span class="btn-label">Copy</span><span class="btn-check"></span></a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">Delete</a>{{end}}
</div>
</div>