From 4cabc26d11d35e628e1629188440e1dbf8e3f7f8 Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Sun, 8 Mar 2026 22:24:07 -0700 Subject: [PATCH] Use "spacer" tiles to get correct inter-word spacing for claimed words Previously, we had the code computing an expected gap between tiles, (and setting it as the "column-gap" CSS attribute). But this proved fragile. In practice, it's hard to predict exactly what a browser will do, (particular with things like sub-pixel rendering, etc.), so it's easy for a calculation like this to be off. And we saw that in practice. Tiles in one row would be a pixel or two off (hrozintonally) from tiles in the next row. Being so close, but not quite exactly the same was distracting (especially for someone who looks closely and pays attention to details like this). What we do instead, in this commit, is place an invisible "spacer" tile between each word. This way, the browser is doing the exact same computation for the "spacer" tiles and the "real" tiles, so they will match. That much is simple. The real trick comes when one word ends precisely at the right edge of the view and then the "spacer" tile wraps on to the next row. This results in the first word in that row being offset to the right by the spacer tile, but we don't want that. To account for this, we use a resizeObserver to notice when any resizing happens, and in that case we walk through the tiles and notice whenever the "offsetTop" of a spacer tile is larger than the "offsetTop" of the previous tile. This indicates that that spacer tile got wrapped. And in this case we style that spacer to have zero width. Phew! That's a lot of text to explain a fair amount of code, all just to correct a rendering error where tiles were just off by about a single pixel. --- anagrams/anagrams.css | 13 +++++- anagrams/anagrams.jsx | 95 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/anagrams/anagrams.css b/anagrams/anagrams.css index d525582..55bba1b 100644 --- a/anagrams/anagrams.css +++ b/anagrams/anagrams.css @@ -262,8 +262,17 @@ .player-words-row { display: flex; flex-wrap: wrap; - row-gap: 2px; - column-gap: calc(var(--word-tile-size) + var(--word-tile-gap)); + gap: var(--word-tile-gap); +} + +.word-spacer { + width: var(--word-tile-size); + height: var(--word-tile-size); + /* Invisible but takes up exactly one tile's space. */ +} + +.word-spacer.wrapped { + width: 0; } .word-display { diff --git a/anagrams/anagrams.jsx b/anagrams/anagrams.jsx index dd97423..9ff1c4b 100644 --- a/anagrams/anagrams.jsx +++ b/anagrams/anagrams.jsx @@ -56,6 +56,72 @@ function WordDisplay(props) { ); } +class PlayerWordsRow extends React.Component { + constructor(props) { + super(props); + this._ref = React.createRef(); + this._observer = null; + } + + componentDidMount() { + this._updateSpacers(); + this._observer = new ResizeObserver(() => this._updateSpacers()); + if (this._ref.current) { + this._observer.observe(this._ref.current); + } + } + + componentDidUpdate() { + this._updateSpacers(); + } + + componentWillUnmount() { + if (this._observer) this._observer.disconnect(); + } + + _updateSpacers() { + const el = this._ref.current; + if (!el) return; + const spacers = el.querySelectorAll(".word-spacer"); + for (const spacer of spacers) { + /* Temporarily unwrap to measure natural position. */ + spacer.classList.remove("wrapped"); + } + let prevTop = null; + for (const child of el.children) { + const top = child.offsetTop; + if (child.classList.contains("word-spacer")) { + if (prevTop !== null && top > prevTop) { + child.classList.add("wrapped"); + } + } + prevTop = top; + } + } + + render() { + const { words, stealable, onSteal } = this.props; + const items = []; + words.forEach((w, i) => { + if (i > 0) { + items.push(
); + } + items.push( + onSteal(w) : null} + /> + ); + }); + + return ( +
+ {items} +
+ ); + } +} + function VoteModal(props) { const { word, player_name, my_session, submitter_session, votes_cast, voters_total, onVote } = props; @@ -1452,16 +1518,11 @@ class Game extends React.Component { {pw.words.length === 0 ? ( No words yet ) : ( -
- {pw.words.map(w => - this.steal_word(sid, w) : null} - /> - )} -
+ this.steal_word(sid, w)} + /> )}
); @@ -1495,15 +1556,11 @@ class Game extends React.Component { {sorted.map(entry => (

{entry.name}

-
- {entry.words.map((w, i) => - - {w.split("").map((ch, j) => ( - - ))} - - )} -
+ ({ id: i, word: w }))} + stealable={false} + onSteal={null} + />
))} -- 2.45.2