Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 3 additions & 21 deletions frontend/src/components/floating/WhiteboardTools.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useWhiteboardUi } from '@/composables/useWhiteboardUi.js'
import { STICKY_COLORS, PEN_WIDTHS, HIGHLIGHTER_WIDTHS } from '@/diagram/whiteboardColors.js'
import { INK_ROW, nearestSwatch } from '@/diagram/espressoPalette.js'
import { ERASER_SIZES } from '@/diagram/eraser.js'
import { dotStyle } from '@/diagram/sizeDot.js'
import { visibleWhiteboardTools } from './whiteboardTools.js'
import ToolbarButton from '@/components/toolbar/ToolbarButton.vue'
import LineOptions from './LineOptions.vue'
Expand Down Expand Up @@ -160,29 +161,10 @@ function clearAll() {
confirmingClearAll.value = false
}

// The size preview dot, SCALED across the row's own range rather than clamped to
// it (#498).
//
// It used to be `Math.min(size, 18)`, which collapsed the highlighter's 18 and 26
// into the same 18px dot — two options drawn identically, told apart only by the
// selected background — and drew the pen's 2 as a 2px speck. The clamp existed for
// a real reason (a 26px dot does not fit a 28px cell), but capping the top instead
// of mapping the range is what made two sizes one control twice.
//
// The scale is per ROW, so the three options are as distinct as the cell allows.
// That means pen and highlighter draw the same three dots for different real
// The size preview dot: see diagram/sizeDot.js for the scaling rule. The scale is
// per ROW, so pen and highlighter draw the same three dots for different real
// widths — acceptable, because the toggle above the row already says which ink is
// in play, and this control's job is to separate ITS three sizes.
const DOT_MIN = 4
const DOT_MAX = 18

function dotStyle(size, sizes) {
const smallest = Math.min(...sizes)
const largest = Math.max(...sizes)
const position = largest === smallest ? 1 : (size - smallest) / (largest - smallest)
const dot = Math.round(DOT_MIN + position * (DOT_MAX - DOT_MIN))
return { width: `${dot}px`, height: `${dot}px` }
}

// New-line defaults live on ui.state; LineOptions emits a partial patch and this
// copies each present field onto the right default.
Expand Down
22 changes: 5 additions & 17 deletions frontend/src/components/floating/sizeDots.test.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
import { describe, it, expect } from 'vitest'
import { PEN_WIDTHS, HIGHLIGHTER_WIDTHS } from '@/diagram/whiteboardColors.js'
import { dotDiameter, DOT_MIN, DOT_MAX } from '@/diagram/sizeDot.js'

// #498: the middle and right highlighter options drew the SAME dot — not a
// perception problem, the same number of pixels. `Math.min(size, 18)` capped the
// top of the range instead of mapping it, so 18 and 26 both came out 18.
//
// dotStyle lives in a .vue file this browser-free env cannot import, so the rule is
// restated here and the component is checked to be using it. The rule is what the
// test is for: any two sizes in a row must draw two different dots.
const DOT_MIN = 4
const DOT_MAX = 18
function dotSize(size, sizes) {
const smallest = Math.min(...sizes)
const largest = Math.max(...sizes)
const position = largest === smallest ? 1 : (size - smallest) / (largest - smallest)
return Math.round(DOT_MIN + position * (DOT_MAX - DOT_MIN))
}

describe('the size preview dots (#498)', () => {
it.each([
['highlighter', HIGHLIGHTER_WIDTHS],
['pen', PEN_WIDTHS],
])('draws a distinct dot for every %s size', (_name, sizes) => {
const dots = sizes.map((size) => dotSize(size, sizes))
const dots = sizes.map((size) => dotDiameter(size, sizes))
expect(new Set(dots).size, `${dots.join(', ')} — two options drawn alike`).toBe(sizes.length)
})

Expand All @@ -34,15 +22,15 @@ describe('the size preview dots (#498)', () => {
it('never draws a dot too small to see, nor one too big for the cell', () => {
for (const sizes of [PEN_WIDTHS, HIGHLIGHTER_WIDTHS]) {
for (const size of sizes) {
expect(dotSize(size, sizes)).toBeGreaterThanOrEqual(DOT_MIN)
expect(dotDiameter(size, sizes)).toBeGreaterThanOrEqual(DOT_MIN)
// The cell is h-7 — 28px — so the dot has to stay inside it.
expect(dotSize(size, sizes)).toBeLessThanOrEqual(DOT_MAX)
expect(dotDiameter(size, sizes)).toBeLessThanOrEqual(DOT_MAX)
}
}
})

it('grows with the size it stands for', () => {
const dots = HIGHLIGHTER_WIDTHS.map((size) => dotSize(size, HIGHLIGHTER_WIDTHS))
const dots = HIGHLIGHTER_WIDTHS.map((size) => dotDiameter(size, HIGHLIGHTER_WIDTHS))
expect(dots).toEqual([...dots].sort((a, b) => a - b))
})
})
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/diagram/sizeDot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// The size-preview dot for a pen/highlighter/eraser width picker (#498, revised):
// SCALED across the row's own range rather than clamped to it.
//
// It used to be `Math.min(size, 18)`, which collapsed the highlighter's 18 and 26
// into the same 18px dot — two options drawn identically, told apart only by the
// selected background — and drew the pen's 2 as a 2px speck. The clamp existed for
// a real reason (a 26px dot does not fit a 28px cell), but capping the top instead
// of mapping the range is what made two sizes one control twice.
//
// Position is by INDEX in the row, not by the size's own value: the pen row's
// widths (2, 4, 8) are not evenly spaced, so an earlier pass that scaled by value
// put the middle dot only a third of the way up (barely bigger than small, a big
// jump to large) — three unevenly-stepped dots reading as two. Every row here has
// exactly three options, so three evenly-stepped diameters read as small/medium/
// large regardless of how the underlying widths happen to be spaced.
export const DOT_MIN = 4
export const DOT_MAX = 18

export function dotDiameter(size, sizes) {
const index = sizes.indexOf(size)
const position = sizes.length > 1 ? index / (sizes.length - 1) : 1
return Math.round(DOT_MIN + position * (DOT_MAX - DOT_MIN))
}

export function dotStyle(size, sizes) {
const dot = dotDiameter(size, sizes)
return { width: `${dot}px`, height: `${dot}px` }
}
29 changes: 29 additions & 0 deletions frontend/src/diagram/sizeDot.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { dotDiameter, DOT_MIN, DOT_MAX } from './sizeDot.js'

describe('dotDiameter', () => {
it('gives the smallest and largest option the row min/max diameter', () => {
expect(dotDiameter(2, [2, 4, 8])).toBe(DOT_MIN)
expect(dotDiameter(8, [2, 4, 8])).toBe(DOT_MAX)
})

// The bug this fixes: pen's widths (2, 4, 8) are not evenly spaced, so scaling
// by VALUE put the middle dot only a third of the way up — close to small, a big
// jump to large. Scaling by INDEX keeps every row's middle option evenly between
// the other two regardless of how far apart the real widths are.
it('evenly steps the middle option regardless of how the values are spaced', () => {
const evenlySpacedMiddle = (DOT_MIN + DOT_MAX) / 2
expect(dotDiameter(4, [2, 4, 8])).toBe(evenlySpacedMiddle)
expect(dotDiameter(18, [10, 18, 26])).toBe(evenlySpacedMiddle)
})

it('never draws two different options the same size (#498)', () => {
const sizes = [10, 18, 26]
const diameters = sizes.map((size) => dotDiameter(size, sizes))
expect(new Set(diameters).size).toBe(sizes.length)
})

it('falls back to the max diameter for a single-option row', () => {
expect(dotDiameter(5, [5])).toBe(DOT_MAX)
})
})
Loading