Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
471d927e64
commit
247c596c3a
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* `materialBottomSheet(standard, presets)`: the behaviour of `<x-bottom-sheet>`, spread into its
|
||||
* x-data alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
|
||||
* `materialBottomSheet(presets)`: the behaviour of `<x-bottom-sheet>`, spread into its x-data
|
||||
* alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
|
||||
*
|
||||
* Without preset heights the sheet is as tall as its content allows and a downward drag follows the
|
||||
* pointer: released past a quarter of the sheet's height or flicked down, it closes; otherwise it
|
||||
@@ -26,8 +26,7 @@ const TOP_MARGIN = 72
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
standard,
|
||||
window.materialBottomSheet = (presets = {}) => ({
|
||||
stops: presets.stops ?? [],
|
||||
labels: presets.labels ?? {},
|
||||
start: presets.start ?? 0,
|
||||
@@ -142,10 +141,10 @@ window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const end = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
window.removeEventListener('pointercancel', end)
|
||||
controller.abort()
|
||||
|
||||
this.dragging = false
|
||||
|
||||
@@ -158,9 +157,9 @@ window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
window.addEventListener('pointercancel', end)
|
||||
window.addEventListener('pointermove', move, { signal: controller.signal })
|
||||
window.addEventListener('pointerup', end, { signal: controller.signal })
|
||||
window.addEventListener('pointercancel', end, { signal: controller.signal })
|
||||
},
|
||||
|
||||
/** A preset-height drag let go: the nearest stop, or out past the smallest one. */
|
||||
|
||||
+16
-41
@@ -226,34 +226,18 @@ const isLastFocalItemAtEndOfContainer = (list, space) => {
|
||||
}
|
||||
|
||||
const firstIndexAfterFocalRangeWithSize = (list, size) => {
|
||||
for (let index = list.lastFocalIndex; index < list.keylines.length; index++) {
|
||||
if (list.keylines[index].size === size) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
const index = list.keylines.findIndex((keyline, i) => i >= list.lastFocalIndex && keyline.size === size)
|
||||
|
||||
return list.keylines.length - 1
|
||||
return index === -1 ? list.keylines.length - 1 : index
|
||||
}
|
||||
|
||||
const lastIndexBeforeFocalRangeWithSize = (list, size) => {
|
||||
for (let index = list.firstFocalIndex - 1; index >= 0; index--) {
|
||||
if (list.keylines[index].size === size) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
const index = list.keylines.findLastIndex((keyline, i) => i < list.firstFocalIndex && keyline.size === size)
|
||||
|
||||
return 0
|
||||
return index === -1 ? 0 : index
|
||||
}
|
||||
|
||||
const keylineBefore = (list, unadjustedOffset) => {
|
||||
for (let index = list.keylines.length - 1; index >= 0; index--) {
|
||||
if (list.keylines[index].unadjustedOffset < unadjustedOffset) {
|
||||
return list.keylines[index]
|
||||
}
|
||||
}
|
||||
|
||||
return list.keylines[0]
|
||||
}
|
||||
const keylineBefore = (list, unadjustedOffset) => list.keylines.findLast((keyline) => keyline.unadjustedOffset < unadjustedOffset) ?? list.keylines[0]
|
||||
|
||||
const keylineAfter = (list, unadjustedOffset) => list.keylines.find((keyline) => keyline.unadjustedOffset >= unadjustedOffset) ?? list.keylines.at(-1)
|
||||
|
||||
@@ -817,7 +801,7 @@ document.addEventListener('alpine:init', () => {
|
||||
target: null,
|
||||
targetAt: 0,
|
||||
settle: null,
|
||||
listeners: [],
|
||||
controller: null,
|
||||
mutations: null,
|
||||
resizes: null,
|
||||
reducedMotion: null,
|
||||
@@ -828,17 +812,20 @@ document.addEventListener('alpine:init', () => {
|
||||
const scroller = this.$refs.scroller
|
||||
|
||||
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
state.controller = new AbortController()
|
||||
|
||||
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
|
||||
const signal = state.controller.signal
|
||||
|
||||
scroller.addEventListener('scroll', () => this.scrolled(), { passive: true, signal })
|
||||
|
||||
// A scroll the person makes themselves is theirs to end wherever it ends.
|
||||
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
|
||||
this.listen(scroller, type, () => (state.target = null), { passive: true })
|
||||
scroller.addEventListener(type, () => (state.target = null), { passive: true, signal })
|
||||
}
|
||||
this.listen(scroller, 'keydown', (event) => this.navigate(event))
|
||||
this.listen(scroller, 'focusin', (event) => this.reveal(event))
|
||||
this.listen(scroller, 'click', (event) => this.open(event))
|
||||
this.listen(state.reducedMotion, 'change', () => this.schedule())
|
||||
scroller.addEventListener('keydown', (event) => this.navigate(event), { signal })
|
||||
scroller.addEventListener('focusin', (event) => this.reveal(event), { signal })
|
||||
scroller.addEventListener('click', (event) => this.open(event), { signal })
|
||||
state.reducedMotion.addEventListener('change', () => this.schedule(), { signal })
|
||||
|
||||
state.resizes = new ResizeObserver(() => this.refresh())
|
||||
state.resizes.observe(scroller)
|
||||
@@ -859,11 +846,6 @@ document.addEventListener('alpine:init', () => {
|
||||
return element === this.$root || state.items.some((item) => item.element === element || item.surface === element)
|
||||
},
|
||||
|
||||
listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options)
|
||||
state.listeners.push(() => target.removeEventListener(type, handler, options))
|
||||
},
|
||||
|
||||
/** Measures the container and items, and builds the strategy for this width. */
|
||||
refresh() {
|
||||
const root = this.$root
|
||||
@@ -1098,13 +1080,6 @@ document.addEventListener('alpine:init', () => {
|
||||
})
|
||||
},
|
||||
|
||||
/** The item nearest the current scroll position. */
|
||||
current() {
|
||||
const scroll = this.scrollOffset()
|
||||
|
||||
return state.snaps.reduce((best, snap, index) => (Math.abs(snap - scroll) < Math.abs(state.snaps[best] - scroll) ? index : best), 0)
|
||||
},
|
||||
|
||||
/** Where the carousel is headed: an unfinished scroll's target, or where it is. */
|
||||
heading() {
|
||||
const moving = state.target !== null && performance.now() - state.targetAt < TARGET_MS
|
||||
@@ -1235,7 +1210,7 @@ document.addEventListener('alpine:init', () => {
|
||||
destroy() {
|
||||
cancelAnimationFrame(state.frame)
|
||||
clearTimeout(state.settle)
|
||||
state.listeners.forEach((remove) => remove())
|
||||
state.controller?.abort()
|
||||
state.resizes?.disconnect()
|
||||
state.mutations?.disconnect()
|
||||
},
|
||||
|
||||
+5
-14
@@ -19,6 +19,8 @@
|
||||
* and Home and End go to its ends. The roving mark is re-applied whenever the set changes, because
|
||||
* a Livewire morph rewrites the chips underneath it.
|
||||
*/
|
||||
import { ringIndex } from './util.js'
|
||||
|
||||
const CONTROLS = 'button:not(:disabled), a[href]:not([tabindex="-1"]), input:not(:disabled):not([type="hidden"])'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
@@ -216,20 +218,9 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const forwards = getComputedStyle(this.row).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
|
||||
const backwards = forwards === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'
|
||||
|
||||
let next = null
|
||||
|
||||
if (event.key === forwards || event.key === 'ArrowDown') {
|
||||
next = (index + 1) % controls.length
|
||||
} else if (event.key === backwards || event.key === 'ArrowUp') {
|
||||
next = (index - 1 + controls.length) % controls.length
|
||||
} else if (event.key === 'Home') {
|
||||
next = 0
|
||||
} else if (event.key === 'End') {
|
||||
next = controls.length - 1
|
||||
}
|
||||
const rtl = getComputedStyle(this.row).direction === 'rtl'
|
||||
// Up and Down move the row too, unmirrored, beside whichever of Left and Right does.
|
||||
const next = ringIndex(event.key, index, controls.length, { rtl }) ?? ringIndex(event.key, index, controls.length, { vertical: true })
|
||||
|
||||
if (next === null) {
|
||||
return
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
* opens or closes it at once, as the browser does, and this follows along. Under reduced motion the
|
||||
* duration token is zero and every route is the browser's own, instant.
|
||||
*/
|
||||
import { ms } from './util.js'
|
||||
|
||||
const COLLAPSE = 'details[data-md-collapse]'
|
||||
const CLOSING = 'data-md-collapse-closing'
|
||||
|
||||
@@ -33,8 +35,6 @@ const targets = new WeakMap()
|
||||
const animations = new WeakMap()
|
||||
const lifted = new WeakMap()
|
||||
|
||||
const milliseconds = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0
|
||||
|
||||
/** The height the section is drawn at, closed: its summary, and its own padding and border. */
|
||||
const closedHeight = (details) => {
|
||||
const style = getComputedStyle(details)
|
||||
@@ -59,7 +59,7 @@ const restoreName = (details) => {
|
||||
/** Moves the height from where it is drawn now to `to`, then `done`. Returns false under reduced motion. */
|
||||
const animate = (details, from, to, done) => {
|
||||
const style = getComputedStyle(details)
|
||||
const duration = milliseconds(style.getPropertyValue('--md-sys-motion-spatial-fast-duration'))
|
||||
const duration = ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0
|
||||
|
||||
if (duration === 0) {
|
||||
return false
|
||||
@@ -185,7 +185,7 @@ document.addEventListener('click', (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (milliseconds(getComputedStyle(details).getPropertyValue('--md-sys-motion-spatial-fast-duration')) === 0) {
|
||||
if ((ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0) === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+13
-23
@@ -23,29 +23,28 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialFab', () => ({
|
||||
collapsed: false,
|
||||
lastY: 0,
|
||||
ticking: false,
|
||||
listeners: [],
|
||||
frame: null,
|
||||
// Set in init(). Declared here, or Alpine writes them to the outermost x-data scope,
|
||||
// where a second FAB in the same page scope would take the first one's listener.
|
||||
schedule: null,
|
||||
|
||||
init() {
|
||||
this.lastY = Math.max(window.scrollY, 0)
|
||||
this.listen(window, 'scroll', () => this.queue(), { passive: true })
|
||||
},
|
||||
|
||||
/** One reading a frame: `scroll` fires far more often than anything can be drawn. */
|
||||
queue() {
|
||||
if (this.ticking) {
|
||||
return
|
||||
this.schedule = () => {
|
||||
this.frame ??= requestAnimationFrame(() => this.measure())
|
||||
}
|
||||
|
||||
this.ticking = true
|
||||
window.addEventListener('scroll', this.schedule, { passive: true })
|
||||
},
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.ticking = false
|
||||
this.measure()
|
||||
})
|
||||
destroy() {
|
||||
window.removeEventListener('scroll', this.schedule)
|
||||
cancelAnimationFrame(this.frame)
|
||||
},
|
||||
|
||||
measure() {
|
||||
this.frame = null
|
||||
|
||||
const y = Math.max(window.scrollY, 0)
|
||||
const moved = y - this.lastY
|
||||
|
||||
@@ -61,14 +60,5 @@ document.addEventListener('alpine:init', () => {
|
||||
this.lastY = y
|
||||
}
|
||||
},
|
||||
|
||||
listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options)
|
||||
this.listeners.push(() => target.removeEventListener(type, handler, options))
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
+11
-9
@@ -23,14 +23,21 @@ function grow(textarea) {
|
||||
textarea.style.height = `${textarea.scrollHeight + textarea.offsetHeight - textarea.clientHeight}px`
|
||||
}
|
||||
|
||||
/** `root` itself, if it matches `selector`, together with its matching descendants. */
|
||||
function matching(root, selector) {
|
||||
const descendants = [...root.querySelectorAll(selector)]
|
||||
|
||||
return root instanceof Element && root.matches(selector) ? [root, ...descendants] : descendants
|
||||
}
|
||||
|
||||
function growAll(root) {
|
||||
if (! growsByItself) {
|
||||
root.querySelectorAll(GROWING).forEach(grow)
|
||||
matching(root, GROWING).forEach(grow)
|
||||
}
|
||||
}
|
||||
|
||||
function markAll(root) {
|
||||
root.querySelectorAll(MIXED).forEach((input) => (input.indeterminate = true))
|
||||
matching(root, MIXED).forEach((input) => (input.indeterminate = true))
|
||||
}
|
||||
|
||||
if (! growsByItself) {
|
||||
@@ -63,13 +70,8 @@ new MutationObserver((records) => {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const input of [node, ...node.querySelectorAll(MIXED)].filter((element) => element.matches(MIXED))) {
|
||||
input.indeterminate = true
|
||||
}
|
||||
|
||||
if (! growsByItself) {
|
||||
;[node, ...node.querySelectorAll(GROWING)].filter((element) => element.matches(GROWING)).forEach(grow)
|
||||
}
|
||||
markAll(node)
|
||||
growAll(node)
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, {
|
||||
|
||||
+3
-10
@@ -17,6 +17,8 @@
|
||||
* mid-count. The observer ignores the directive's own writes by comparing against the last
|
||||
* thing it wrote.
|
||||
*/
|
||||
import { ms } from './util.js'
|
||||
|
||||
const FIGURE = /[-−]?\d{1,3}(?:[,'’]\d{3})+(?:\.\d+)?|[-−]?\d+(?:\.\d+)?/g
|
||||
|
||||
const readFigure = (text) => {
|
||||
@@ -54,15 +56,6 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.directive('figure', (el, _, { cleanup }) => {
|
||||
const text = () => (el.childNodes.length === 1 && el.firstChild.nodeType === Node.TEXT_NODE ? el.firstChild : null)
|
||||
|
||||
// In milliseconds, whichever unit arrives: a minifier turns `600ms` into `.6s`, and a
|
||||
// bare parseFloat would read that as 0.6ms — an instant count.
|
||||
const duration = () => {
|
||||
const token = getComputedStyle(document.documentElement).getPropertyValue('--md-sys-motion-spatial-slow-duration').trim()
|
||||
const value = parseFloat(token) || 0
|
||||
|
||||
return token.endsWith('ms') ? value : value * 1000
|
||||
}
|
||||
|
||||
let written = text()?.data ?? null
|
||||
let frame = null
|
||||
|
||||
@@ -70,7 +63,7 @@ document.addEventListener('alpine:init', () => {
|
||||
cancelAnimationFrame(frame)
|
||||
|
||||
const node = text()
|
||||
const length = duration()
|
||||
const length = ms(document.documentElement, '--md-sys-motion-spatial-slow-duration') ?? 0
|
||||
|
||||
if (!node || !target || length === 0 || from === target.value) {
|
||||
return
|
||||
|
||||
+13
-10
@@ -85,6 +85,17 @@ document.addEventListener('keydown', (event) => {
|
||||
}
|
||||
}, true)
|
||||
|
||||
/** The nearest layer matching `selector` around `target` that is open, walking out past shut ones. */
|
||||
const nearestOpen = (target, selector) => {
|
||||
let layer = target instanceof Element ? target.closest(selector) : null
|
||||
|
||||
while (layer !== null && !isOpen(layer)) {
|
||||
layer = layer.parentElement?.closest(selector) ?? null
|
||||
}
|
||||
|
||||
return layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `focusin` is an open modal layer moving the focus into itself (see the header): the
|
||||
* focus arrives from outside the nearest open layer around its target, or from nowhere, and no Tab
|
||||
@@ -96,11 +107,7 @@ export const openingFocus = (event) => {
|
||||
return false
|
||||
}
|
||||
|
||||
let layer = event.target.closest(MODAL_LAYERS)
|
||||
|
||||
while (layer !== null && !isOpen(layer)) {
|
||||
layer = layer.parentElement?.closest(MODAL_LAYERS) ?? null
|
||||
}
|
||||
const layer = nearestOpen(event.target, MODAL_LAYERS)
|
||||
|
||||
return layer !== null && !(event.relatedTarget instanceof Node && layer.contains(event.relatedTarget))
|
||||
}
|
||||
@@ -111,11 +118,7 @@ const owns = (panel, event) => {
|
||||
return false
|
||||
}
|
||||
|
||||
let layer = event.target instanceof Element ? event.target.closest(LAYERS) : null
|
||||
|
||||
while (layer !== null && !isOpen(layer)) {
|
||||
layer = layer.parentElement?.closest(LAYERS) ?? null
|
||||
}
|
||||
const layer = nearestOpen(event.target, LAYERS)
|
||||
|
||||
return layer === null ? panels.at(-1) === panel : layer === panel
|
||||
}
|
||||
|
||||
+512
-535
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
* `materialNavigationBar` is `<x-navigation-bar hide-on-scroll>` — see the same file's sibling.
|
||||
*/
|
||||
import { from, upTo } from './breakpoints.js'
|
||||
import { remember } from './util.js'
|
||||
|
||||
/** The root of every interactive rail on the page once its view is initialised: what `toggle()` asks. */
|
||||
const rails = new Set()
|
||||
@@ -102,11 +103,7 @@ document.addEventListener('alpine:init', () => {
|
||||
root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
|
||||
root.removeAttribute('data-rail-auto')
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
|
||||
} catch {
|
||||
// Blocked storage: the rail still toggles, it just will not remember.
|
||||
}
|
||||
remember(root.dataset.railKey || 'material-rail', root.dataset.rail)
|
||||
},
|
||||
|
||||
show() {
|
||||
@@ -125,8 +122,7 @@ document.addEventListener('alpine:init', () => {
|
||||
wide: mode === 'adaptive' ? from('expanded').matches : false,
|
||||
roomy: mode === 'adaptive' ? from('large').matches : false,
|
||||
tight: mode === 'collapsible' ? upTo('medium').matches : false,
|
||||
queries: [],
|
||||
listeners: [],
|
||||
controller: null,
|
||||
|
||||
/**
|
||||
* `data-md-closing` while a rail that was open over the page leaves it: `sheet` when the
|
||||
@@ -144,6 +140,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
init() {
|
||||
rails.add(this.$root)
|
||||
this.controller = new AbortController()
|
||||
|
||||
if (mode !== 'adaptive') {
|
||||
// Below `medium` a collapsible rail is held at its collapsed width whatever the
|
||||
@@ -181,16 +178,12 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
watch(query, onChange) {
|
||||
const listener = (event) => onChange(event.matches)
|
||||
|
||||
query.addEventListener('change', listener)
|
||||
this.queries.push(query)
|
||||
this.listeners.push(listener)
|
||||
query.addEventListener('change', (event) => onChange(event.matches), { signal: this.controller.signal })
|
||||
},
|
||||
|
||||
destroy() {
|
||||
rails.delete(this.$root)
|
||||
this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
|
||||
this.controller.abort()
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,9 +36,6 @@ const longestTransition = (element) => {
|
||||
return Math.max(0, ...durations.map((duration, index) => duration + (delays[index % delays.length] || 0)))
|
||||
}
|
||||
|
||||
/** Out of the DOM, which takes it off the top layer without a `beforetoggle` or `toggle` of its own. */
|
||||
const remove = (ghost) => ghost.remove()
|
||||
|
||||
/**
|
||||
* A still copy of the popover, pinned where it is drawn. Colours come from custom properties an
|
||||
* ancestor may set (a vibrant menu's `--material-menu-surface`), which the copy loses at the end of
|
||||
@@ -97,7 +94,8 @@ const exit = ({ ghost, scrollTop }, popover) => {
|
||||
|
||||
ghosts.set(popover, ghost)
|
||||
setTimeout(() => {
|
||||
remove(ghost)
|
||||
// Out of the DOM, which takes it off the top layer without a `beforetoggle` or `toggle` of its own.
|
||||
ghost.remove()
|
||||
|
||||
if (ghosts.get(popover) === ghost) {
|
||||
ghosts.delete(popover)
|
||||
@@ -118,7 +116,7 @@ document.addEventListener(
|
||||
|
||||
if (previous) {
|
||||
ghosts.delete(popover)
|
||||
remove(previous)
|
||||
previous.remove()
|
||||
}
|
||||
|
||||
if (event.newState !== 'closed') {
|
||||
|
||||
+10
-636
@@ -33,9 +33,8 @@
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/*ProgressIndicatorTokens.kt
|
||||
* compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt (keyframes)
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/*.kt (via bin/shapes.mjs
|
||||
* and bin/loading-indicator.mjs: RoundedPolygon, CornerRounding, Cubic, Morph, FeatureMapping,
|
||||
* PolygonMeasure)
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/*.kt (via shapes.js:
|
||||
* RoundedPolygon, CornerRounding, Cubic, Morph, FeatureMapping, PolygonMeasure)
|
||||
*
|
||||
* Copyright 2022-2025 The Android Open Source Project
|
||||
*
|
||||
@@ -53,6 +52,9 @@
|
||||
* ---------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { asCubics, circlePolygon, match, point, rounding, star as starPolygon } from './shapes.js'
|
||||
import { ms } from './util.js'
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg'
|
||||
const WATCHED = ['data-md-value', 'data-md-max', 'data-md-circular', 'data-md-wavy', 'data-md-thick']
|
||||
|
||||
@@ -289,624 +291,6 @@ function waveSegment(from, to, halfWavelength, height, shift, middle, amplitude)
|
||||
return d
|
||||
}
|
||||
|
||||
// Shapes: bin/shapes.mjs (RoundedPolygon) --------------------------------------------------
|
||||
|
||||
const DISTANCE_EPSILON = 1e-4
|
||||
const ANGLE_EPSILON = 1e-6
|
||||
|
||||
const point = (x, y) => ({ x, y })
|
||||
const plus = (a, b) => point(a.x + b.x, a.y + b.y)
|
||||
const minus = (a, b) => point(a.x - b.x, a.y - b.y)
|
||||
const times = (a, k) => point(a.x * k, a.y * k)
|
||||
const div = (a, k) => point(a.x / k, a.y / k)
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y
|
||||
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y)
|
||||
const rotate90 = (a) => point(-a.y, a.x)
|
||||
const lerp = (a, b, f) => (1 - f) * a + f * b
|
||||
const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f))
|
||||
const direction = (a) => div(a, length(a))
|
||||
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
const convex = (previous, current, next) => {
|
||||
const [a, b] = [minus(current, previous), minus(next, current)]
|
||||
|
||||
return a.x * b.y - a.y * b.x > 0
|
||||
}
|
||||
|
||||
/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */
|
||||
const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y]
|
||||
|
||||
const straightLine = (x0, y0, x1, y1) => [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1]
|
||||
|
||||
function circularArc(centerX, centerY, x0, y0, x1, y1) {
|
||||
const p0d = direction(point(x0 - centerX, y0 - centerY))
|
||||
const p1d = direction(point(x1 - centerX, y1 - centerY))
|
||||
const rotatedP0 = rotate90(p0d)
|
||||
const rotatedP1 = rotate90(p1d)
|
||||
const clockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0
|
||||
const cosa = dot(p0d, p1d)
|
||||
|
||||
if (cosa > 0.999) {
|
||||
return straightLine(x0, y0, x1, y1)
|
||||
}
|
||||
|
||||
const k =
|
||||
(((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) * (Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) /
|
||||
(1 - cosa) *
|
||||
(clockwise ? 1 : -1)
|
||||
|
||||
return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1]
|
||||
}
|
||||
|
||||
function pointOnCurve(c, t) {
|
||||
const u = 1 - t
|
||||
|
||||
return point(
|
||||
c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t),
|
||||
c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t),
|
||||
)
|
||||
}
|
||||
|
||||
function split(c, t) {
|
||||
const u = 1 - t
|
||||
const p = pointOnCurve(c, t)
|
||||
|
||||
return [
|
||||
[c[0], c[1], c[0] * u + c[2] * t, c[1] * u + c[3] * t, c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t), c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t), p.x, p.y],
|
||||
[p.x, p.y, c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t), c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t), c[4] * u + c[6] * t, c[5] * u + c[7] * t, c[6], c[7]],
|
||||
]
|
||||
}
|
||||
|
||||
const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]]
|
||||
const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON
|
||||
|
||||
const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing })
|
||||
|
||||
class RoundedCorner {
|
||||
constructor(p0, p1, p2, cornerRounding) {
|
||||
this.p0 = p0
|
||||
this.p1 = p1
|
||||
this.p2 = p2
|
||||
|
||||
const v01 = minus(p0, p1)
|
||||
const v21 = minus(p2, p1)
|
||||
const d01 = length(v01)
|
||||
const d21 = length(v21)
|
||||
|
||||
if (d01 > 0 && d21 > 0) {
|
||||
this.d1 = div(v01, d01)
|
||||
this.d2 = div(v21, d21)
|
||||
this.cornerRadius = cornerRounding?.radius ?? 0
|
||||
this.smoothing = cornerRounding?.smoothing ?? 0
|
||||
this.cosAngle = dot(this.d1, this.d2)
|
||||
this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle)
|
||||
this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0
|
||||
} else {
|
||||
this.d1 = point(0, 0)
|
||||
this.d2 = point(0, 0)
|
||||
this.cornerRadius = 0
|
||||
this.smoothing = 0
|
||||
this.cosAngle = 0
|
||||
this.sinAngle = 0
|
||||
this.expectedRoundCut = 0
|
||||
}
|
||||
}
|
||||
|
||||
get expectedCut() {
|
||||
return (1 + this.smoothing) * this.expectedRoundCut
|
||||
}
|
||||
|
||||
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
|
||||
const allowedCut = Math.min(allowedCut0, allowedCut1)
|
||||
|
||||
if (this.expectedRoundCut < DISTANCE_EPSILON || allowedCut < DISTANCE_EPSILON || this.cornerRadius < DISTANCE_EPSILON) {
|
||||
return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]
|
||||
}
|
||||
|
||||
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut)
|
||||
const actualSmoothing0 = this.actualSmoothing(allowedCut0)
|
||||
const actualSmoothing1 = this.actualSmoothing(allowedCut1)
|
||||
const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut
|
||||
const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut)
|
||||
const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance))
|
||||
const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut))
|
||||
const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut))
|
||||
const flanking0 = this.flankingCurve(actualRoundCut, actualSmoothing0, this.p1, this.p0, circleIntersection0, circleIntersection2, center, actualR)
|
||||
const flanking2 = reverse(this.flankingCurve(actualRoundCut, actualSmoothing1, this.p1, this.p2, circleIntersection2, circleIntersection0, center, actualR))
|
||||
|
||||
return [flanking0, circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]), flanking2]
|
||||
}
|
||||
|
||||
actualSmoothing(allowedCut) {
|
||||
if (allowedCut > this.expectedCut) {
|
||||
return this.smoothing
|
||||
}
|
||||
|
||||
if (allowedCut > this.expectedRoundCut) {
|
||||
return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) {
|
||||
const sideDirection = direction(minus(sideStart, corner))
|
||||
const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing)))
|
||||
const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing)
|
||||
const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR))
|
||||
const circleTangent = rotate90(minus(curveEnd, circleCenter))
|
||||
const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection
|
||||
const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3)
|
||||
|
||||
return cubic(curveStart, anchorStart, anchorEnd, curveEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function lineIntersection(p0, d0, p1, d1) {
|
||||
const rotatedD1 = rotate90(d1)
|
||||
const den = dot(d0, rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON) {
|
||||
return null
|
||||
}
|
||||
|
||||
const num = dot(minus(p1, p0), rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return plus(p0, times(d0, num / den))
|
||||
}
|
||||
|
||||
class RoundedPolygon {
|
||||
constructor(features, center) {
|
||||
this.features = features
|
||||
this.center = center
|
||||
this.cubics = flatten(features, center)
|
||||
}
|
||||
|
||||
transformed(f) {
|
||||
const move = (c) => {
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < 8; i += 2) {
|
||||
const p = f(c[i], c[i + 1])
|
||||
out.push(p.x, p.y)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
return new RoundedPolygon(
|
||||
this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })),
|
||||
f(this.center.x, this.center.y),
|
||||
)
|
||||
}
|
||||
|
||||
/** RoundedPolygon.normalized: into the unit square, by the approximate (control point) bounds. */
|
||||
normalized() {
|
||||
let [left, top, right, bottom] = [Infinity, Infinity, -Infinity, -Infinity]
|
||||
|
||||
for (const c of this.cubics) {
|
||||
const count = zeroLength(c) ? 2 : 8
|
||||
|
||||
for (let i = 0; i < count; i += 2) {
|
||||
left = Math.min(left, c[i])
|
||||
right = Math.max(right, c[i])
|
||||
top = Math.min(top, c[i + 1])
|
||||
bottom = Math.max(bottom, c[i + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const [width, height] = [right - left, bottom - top]
|
||||
const side = Math.max(width, height)
|
||||
const offsetX = (side - width) / 2 - left
|
||||
const offsetY = (side - height) / 2 - top
|
||||
|
||||
return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side))
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(features, center) {
|
||||
const out = []
|
||||
let firstCubic = null
|
||||
let lastCubic = null
|
||||
let firstFeatureSplitStart = null
|
||||
let firstFeatureSplitEnd = null
|
||||
|
||||
if (features.length > 0 && features[0].cubics.length === 3) {
|
||||
const [start, end] = split(features[0].cubics[1], 0.5)
|
||||
firstFeatureSplitStart = [features[0].cubics[0], start]
|
||||
firstFeatureSplitEnd = [end, features[0].cubics[2]]
|
||||
}
|
||||
|
||||
for (let i = 0; i <= features.length; i++) {
|
||||
let featureCubics
|
||||
|
||||
if (i === 0 && firstFeatureSplitEnd !== null) {
|
||||
featureCubics = firstFeatureSplitEnd
|
||||
} else if (i === features.length) {
|
||||
if (firstFeatureSplitStart === null) {
|
||||
break
|
||||
}
|
||||
|
||||
featureCubics = firstFeatureSplitStart
|
||||
} else {
|
||||
featureCubics = features[i].cubics
|
||||
}
|
||||
|
||||
for (const c of featureCubics) {
|
||||
if (!zeroLength(c)) {
|
||||
if (lastCubic !== null) {
|
||||
out.push(lastCubic)
|
||||
}
|
||||
|
||||
lastCubic = c
|
||||
firstCubic ??= c
|
||||
} else if (lastCubic !== null) {
|
||||
lastCubic = [...lastCubic]
|
||||
lastCubic[6] = c[0]
|
||||
lastCubic[7] = c[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCubic !== null && firstCubic !== null) {
|
||||
out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]])
|
||||
} else {
|
||||
out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY). */
|
||||
function polygonFromVertices(vertices, perVertexRounding, center) {
|
||||
const n = vertices.length
|
||||
const roundedCorners = vertices.map((vertex, i) => new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding[i]))
|
||||
|
||||
const cutAdjusts = vertices.map((vertex, i) => {
|
||||
const next = (i + 1) % n
|
||||
const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut
|
||||
const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut
|
||||
const sideSize = length(minus(vertex, vertices[next]))
|
||||
|
||||
if (expectedRoundCut > sideSize) {
|
||||
return [sideSize / expectedRoundCut, 0]
|
||||
}
|
||||
|
||||
if (expectedCut > sideSize) {
|
||||
return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)]
|
||||
}
|
||||
|
||||
return [1, 1]
|
||||
})
|
||||
|
||||
const corners = roundedCorners.map((corner, i) => {
|
||||
const allowedCuts = [0, 1].map((delta) => {
|
||||
const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n]
|
||||
|
||||
return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio
|
||||
})
|
||||
|
||||
return corner.getCubics(allowedCuts[0], allowedCuts[1])
|
||||
})
|
||||
|
||||
const features = []
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const end = corners[i].at(-1)
|
||||
const start = corners[(i + 1) % n][0]
|
||||
|
||||
features.push({ type: 'corner', convex: convex(vertices[(i + n - 1) % n], vertices[i], vertices[(i + 1) % n]), cubics: corners[i] })
|
||||
features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] })
|
||||
}
|
||||
|
||||
return new RoundedPolygon(features, center)
|
||||
}
|
||||
|
||||
/** RoundedPolygon.circle(numVertices): a regular polygon rounded all the way round. */
|
||||
function circlePolygon(vertexCount) {
|
||||
const radius = 1 / Math.cos(Math.PI / vertexCount)
|
||||
const vertices = Array.from({ length: vertexCount }, (_, i) => radialToCartesian(radius, ((Math.PI / vertexCount) * 2 * i)))
|
||||
|
||||
return polygonFromVertices(vertices, vertices.map(() => rounding(1)), point(0, 0))
|
||||
}
|
||||
|
||||
/** RoundedPolygon.star(numVerticesPerRadius, innerRadius, rounding, innerRounding). */
|
||||
function starPolygon(vertexCount, innerRadius, outerRounding, innerRounding) {
|
||||
const vertices = []
|
||||
const roundings = []
|
||||
|
||||
for (let i = 0; i < vertexCount; i++) {
|
||||
vertices.push(radialToCartesian(1, (Math.PI / vertexCount) * 2 * i), radialToCartesian(innerRadius, (Math.PI / vertexCount) * (2 * i + 1)))
|
||||
roundings.push(outerRounding, innerRounding)
|
||||
}
|
||||
|
||||
return polygonFromVertices(vertices, roundings, point(0, 0))
|
||||
}
|
||||
|
||||
// Morph: bin/loading-indicator.mjs (FloatMapping, PolygonMeasure, FeatureMapping, Morph) ---
|
||||
|
||||
const positiveModulo = (num, mod) => ((num % mod) + mod) % mod
|
||||
const progressInRange = (progress, from, to) => (to >= from ? progress >= from && progress <= to : progress >= from || progress <= to)
|
||||
const progressDistance = (a, b) => Math.min(Math.abs(a - b), 1 - Math.abs(a - b))
|
||||
|
||||
function linearMap(xValues, yValues, x) {
|
||||
const n = xValues.length
|
||||
const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n]))
|
||||
const end = (start + 1) % n
|
||||
const sizeX = positiveModulo(xValues[end] - xValues[start], 1)
|
||||
const sizeY = positiveModulo(yValues[end] - yValues[start], 1)
|
||||
const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX
|
||||
|
||||
return positiveModulo(yValues[start] + sizeY * position, 1)
|
||||
}
|
||||
|
||||
const MEASURE_SEGMENTS = 3
|
||||
|
||||
function closestProgressTo(c, threshold) {
|
||||
let total = 0
|
||||
let remainder = threshold
|
||||
let previous = point(c[0], c[1])
|
||||
|
||||
for (let i = 1; i <= MEASURE_SEGMENTS; i++) {
|
||||
const progress = i / MEASURE_SEGMENTS
|
||||
const p = pointOnCurve(c, progress)
|
||||
const segment = Math.hypot(p.x - previous.x, p.y - previous.y)
|
||||
|
||||
if (segment >= remainder) {
|
||||
return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold]
|
||||
}
|
||||
|
||||
remainder -= segment
|
||||
total += segment
|
||||
previous = p
|
||||
}
|
||||
|
||||
return [1, total]
|
||||
}
|
||||
|
||||
class MeasuredCubic {
|
||||
constructor(c, startOutlineProgress, endOutlineProgress) {
|
||||
this.cubic = c
|
||||
this.startOutlineProgress = startOutlineProgress
|
||||
this.endOutlineProgress = endOutlineProgress
|
||||
this.measuredSize = closestProgressTo(c, Infinity)[1]
|
||||
}
|
||||
|
||||
cutAtProgress(cutOutlineProgress) {
|
||||
const bounded = clamp(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress)
|
||||
const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress)
|
||||
const t = closestProgressTo(this.cubic, relativeProgress * this.measuredSize)[0]
|
||||
const [c1, c2] = split(this.cubic, t)
|
||||
|
||||
return [new MeasuredCubic(c1, this.startOutlineProgress, bounded), new MeasuredCubic(c2, bounded, this.endOutlineProgress)]
|
||||
}
|
||||
}
|
||||
|
||||
class MeasuredPolygon {
|
||||
constructor(features, cubics, outlineProgress) {
|
||||
this.features = features
|
||||
this.cubics = []
|
||||
|
||||
let startOutlineProgress = 0
|
||||
|
||||
for (let i = 0; i < cubics.length; i++) {
|
||||
if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) {
|
||||
this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1]))
|
||||
startOutlineProgress = outlineProgress[i + 1]
|
||||
}
|
||||
}
|
||||
|
||||
this.cubics.at(-1).endOutlineProgress = 1
|
||||
}
|
||||
|
||||
static measure(polygon) {
|
||||
const cubics = []
|
||||
const featureToCubic = []
|
||||
|
||||
for (const feature of polygon.features) {
|
||||
feature.cubics.forEach((c, i) => {
|
||||
if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) {
|
||||
featureToCubic.push([feature, cubics.length])
|
||||
}
|
||||
|
||||
cubics.push(c)
|
||||
})
|
||||
}
|
||||
|
||||
const measures = [0]
|
||||
|
||||
for (const c of cubics) {
|
||||
measures.push(measures.at(-1) + closestProgressTo(c, Infinity)[1])
|
||||
}
|
||||
|
||||
const outlineProgress = measures.map((measure) => measure / measures.at(-1))
|
||||
const features = featureToCubic.map(([feature, ix]) => ({
|
||||
progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1),
|
||||
feature,
|
||||
}))
|
||||
|
||||
return new MeasuredPolygon(features, cubics, outlineProgress)
|
||||
}
|
||||
|
||||
cutAndShift(cuttingPoint) {
|
||||
if (cuttingPoint < DISTANCE_EPSILON) {
|
||||
return this
|
||||
}
|
||||
|
||||
const n = this.cubics.length
|
||||
const targetIndex = this.cubics.findIndex((c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress)
|
||||
const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint)
|
||||
const cubics = [b2.cubic]
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
cubics.push(this.cubics[(i + targetIndex) % n].cubic)
|
||||
}
|
||||
|
||||
cubics.push(b1.cubic)
|
||||
|
||||
const outlineProgress = Array.from({ length: n + 2 }, (_, index) => {
|
||||
if (index === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (index === n + 1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1)
|
||||
})
|
||||
|
||||
const features = this.features.map(({ progress, feature }) => ({ progress: positiveModulo(progress - cuttingPoint, 1), feature }))
|
||||
|
||||
return new MeasuredPolygon(features, cubics, outlineProgress)
|
||||
}
|
||||
}
|
||||
|
||||
function featureDistSquared(f1, f2) {
|
||||
if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) {
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const representative = (feature) => {
|
||||
const [first, last] = [feature.cubics[0], feature.cubics.at(-1)]
|
||||
|
||||
return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2)
|
||||
}
|
||||
|
||||
const [p1, p2] = [representative(f1), representative(f2)]
|
||||
|
||||
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2
|
||||
}
|
||||
|
||||
function doMapping(features1, features2) {
|
||||
const distanceVertexList = []
|
||||
|
||||
for (const f1 of features1) {
|
||||
for (const f2 of features2) {
|
||||
const distance = featureDistSquared(f1.feature, f2.feature)
|
||||
|
||||
if (distance !== Infinity) {
|
||||
distanceVertexList.push({ distance, f1, f2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distanceVertexList.sort((a, b) => a.distance - b.distance)
|
||||
|
||||
if (distanceVertexList.length === 0) {
|
||||
return [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
]
|
||||
}
|
||||
|
||||
if (distanceVertexList.length === 1) {
|
||||
const { f1, f2 } = distanceVertexList[0]
|
||||
|
||||
return [
|
||||
[f1.progress, f2.progress],
|
||||
[(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1],
|
||||
]
|
||||
}
|
||||
|
||||
const mapping = []
|
||||
const usedF1 = new Set()
|
||||
const usedF2 = new Set()
|
||||
|
||||
for (const { f1, f2 } of distanceVertexList) {
|
||||
if (usedF1.has(f1) || usedF2.has(f2)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress)
|
||||
const index = insertionIndex === -1 ? mapping.length : insertionIndex
|
||||
const n = mapping.length
|
||||
|
||||
if (n >= 1) {
|
||||
const [before1, before2] = mapping[(index + n - 1) % n]
|
||||
const [after1, after2] = mapping[index % n]
|
||||
|
||||
if (
|
||||
progressDistance(f1.progress, before1) < DISTANCE_EPSILON ||
|
||||
progressDistance(f1.progress, after1) < DISTANCE_EPSILON ||
|
||||
progressDistance(f2.progress, before2) < DISTANCE_EPSILON ||
|
||||
progressDistance(f2.progress, after2) < DISTANCE_EPSILON
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (n > 1 && !progressInRange(f2.progress, before2, after2)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mapping.splice(index, 0, [f1.progress, f2.progress])
|
||||
usedF1.add(f1)
|
||||
usedF2.add(f2)
|
||||
}
|
||||
|
||||
return mapping
|
||||
}
|
||||
|
||||
/** Morph.match: both shapes cut into pairs of matching cubics. */
|
||||
function match(p1, p2) {
|
||||
const measuredPolygon1 = MeasuredPolygon.measure(p1)
|
||||
const measuredPolygon2 = MeasuredPolygon.measure(p2)
|
||||
const corners = (features) => features.filter(({ feature }) => feature.type === 'corner')
|
||||
const mappings = doMapping(corners(measuredPolygon1.features), corners(measuredPolygon2.features))
|
||||
const [sources, targets] = [mappings.map((m) => m[0]), mappings.map((m) => m[1])]
|
||||
const map = (x) => linearMap(sources, targets, x)
|
||||
const mapBack = (x) => linearMap(targets, sources, x)
|
||||
const polygon2CutPoint = map(0)
|
||||
const bs1 = measuredPolygon1.cubics
|
||||
const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics
|
||||
const pairs = []
|
||||
|
||||
let i1 = 0
|
||||
let i2 = 0
|
||||
let b1 = bs1[i1++]
|
||||
let b2 = bs2[i2++]
|
||||
|
||||
while (b1 !== undefined && b2 !== undefined) {
|
||||
const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress
|
||||
const b2a = i2 === bs2.length ? 1 : mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1))
|
||||
const minb = Math.min(b1a, b2a)
|
||||
let seg1
|
||||
let seg2
|
||||
|
||||
if (b1a > minb + ANGLE_EPSILON) {
|
||||
;[seg1, b1] = b1.cutAtProgress(minb)
|
||||
} else {
|
||||
seg1 = b1
|
||||
b1 = bs1[i1++]
|
||||
}
|
||||
|
||||
if (b2a > minb + ANGLE_EPSILON) {
|
||||
;[seg2, b2] = b2.cutAtProgress(positiveModulo(map(minb) - polygon2CutPoint, 1))
|
||||
} else {
|
||||
seg2 = b2
|
||||
b2 = bs2[i2++]
|
||||
}
|
||||
|
||||
pairs.push([seg1.cubic, seg2.cubic])
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** Morph.asCubics: every matched pair interpolated at `progress`, closed on its first anchor. */
|
||||
function asCubics(pairs, progress) {
|
||||
const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress))
|
||||
|
||||
cubics.at(-1)[6] = cubics[0][0]
|
||||
cubics.at(-1)[7] = cubics[0][1]
|
||||
|
||||
return cubics
|
||||
}
|
||||
|
||||
// The circular wave ---------------------------------------------------------------------
|
||||
|
||||
const circularShapes = new Map()
|
||||
@@ -915,7 +299,7 @@ const circularShapes = new Map()
|
||||
function shapesFor(vertexCount) {
|
||||
if (!circularShapes.has(vertexCount)) {
|
||||
const circle = circlePolygon(vertexCount).normalized()
|
||||
const star = starPolygon(vertexCount, 0.75, rounding(0.35, 0.4), rounding(0.5)).normalized()
|
||||
const star = starPolygon(vertexCount, { innerRadius: 0.75, cornerRounding: rounding(0.35, 0.4), innerCornerRounding: rounding(0.5) }).normalized()
|
||||
let pairs = null
|
||||
|
||||
circularShapes.set(vertexCount, {
|
||||
@@ -935,6 +319,8 @@ const GAUSS_LEGENDRE = [
|
||||
[0.9602898564975363, 0.1012285362903763],
|
||||
]
|
||||
|
||||
// Pure arithmetic rather than getTotalLength(): WebKit lays the whole page out on that call, even
|
||||
// for a detached path, and this runs every frame while the amplitude animates.
|
||||
function cubicLength(c) {
|
||||
let sum = 0
|
||||
|
||||
@@ -988,18 +374,6 @@ function circularPath(cubics, pivot, repeat, scale, cx, cy) {
|
||||
|
||||
// The component -------------------------------------------------------------------------
|
||||
|
||||
/** A duration token in milliseconds, whichever unit a minifier left it in; null when unset. */
|
||||
function tokenDuration(element, token) {
|
||||
const value = getComputedStyle(element).getPropertyValue(token).trim()
|
||||
const number = parseFloat(value)
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value.endsWith('ms') ? number : number * 1000
|
||||
}
|
||||
|
||||
function svgElement(name, attributes) {
|
||||
const element = document.createElementNS(SVG, name)
|
||||
|
||||
@@ -1077,7 +451,7 @@ class Indicator {
|
||||
// Compose draws determinate and indeterminate indicators as two different composables.
|
||||
this.reset(target)
|
||||
} else if (target !== null && target !== this.target) {
|
||||
const duration = tokenDuration(element, '--md-sys-motion-effects-slow-duration') ?? VALUE_SETTLE
|
||||
const duration = ms(element, '--md-sys-motion-effects-slow-duration') ?? VALUE_SETTLE
|
||||
this.timeScale = duration > 0 && !this.motion.matches ? VALUE_SETTLE / duration : 0
|
||||
}
|
||||
|
||||
@@ -1202,7 +576,7 @@ class Indicator {
|
||||
const goal = this.target === null ? 1 : amplitudeFor(clamp(this.progress, 0, 1))
|
||||
|
||||
if (!this.amplitudeAnimation && goal !== this.amplitudeGoal) {
|
||||
const duration = reduced ? 0 : (tokenDuration(this.element, '--md-sys-motion-duration-long') ?? AMPLITUDE_DURATION)
|
||||
const duration = reduced ? 0 : (ms(this.element, '--md-sys-motion-duration-long') ?? AMPLITUDE_DURATION)
|
||||
|
||||
this.amplitudeGoal = goal
|
||||
this.morphed = true
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* that can hover, at once on keyboard focus (not the focus a sheet or dialog moves to it as it
|
||||
* opens, layers.js), and standing for 1.5s once the pointer or the focus
|
||||
* leaves, which M3 gives plain and rich tooltips alike (docs/reference/m3/
|
||||
* components-actions-communication-containment.md § Tooltips, ACT-25). The bubble is inside the
|
||||
* components-actions-communication-containment.md § Tooltips). The bubble is inside the
|
||||
* wrapper, so moving onto it to reach its actions, by pointer or by Tab, is no leave at all; Escape
|
||||
* hides it at once. Persistent: a press on the trigger opens it as a light-dismiss popover.
|
||||
*
|
||||
@@ -16,20 +16,12 @@
|
||||
* A Livewire morph strips attributes the server did not render and gives the bubble a new id, so
|
||||
* they are written again whenever the trigger is reached.
|
||||
*/
|
||||
import { openingFocus } from './layers.js'
|
||||
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_GRACE_MS = 1500
|
||||
|
||||
// A persistent bubble is a `popover="auto"`, and its trigger is outside it: the press on the
|
||||
// trigger light-dismisses the open bubble, and the click that follows would open it again, so a
|
||||
// second press never closed it. A close the browser made this recently is taken as that press, as
|
||||
// menu.js's REOPEN_GUARD_MS does; `beforetoggle` times it, because `toggle` is queued past the click.
|
||||
const REOPEN_GUARD_MS = 250
|
||||
import { hoverPopover, LEAVE_DELAY_MS } from './tooltip.js'
|
||||
import { reopenGuard } from './util.js'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialRichTooltip', (persistent = false) => ({
|
||||
timer: null,
|
||||
stop: null,
|
||||
|
||||
init() {
|
||||
const bubble = this.$refs.bubble
|
||||
@@ -59,16 +51,12 @@ document.addEventListener('alpine:init', () => {
|
||||
wrapper.addEventListener('pointerenter', describe)
|
||||
|
||||
if (persistent) {
|
||||
let dismissedAt = -Infinity
|
||||
let closingExplicitly = false
|
||||
// A persistent bubble is a `popover="auto"`, and its trigger is outside it: the
|
||||
// press on the trigger light-dismisses the open bubble, and the click that
|
||||
// follows would open it again, so a second press never closed it.
|
||||
const guard = reopenGuard()
|
||||
|
||||
bubble.addEventListener('beforetoggle', (event) => {
|
||||
if (event.newState === 'closed' && !closingExplicitly) {
|
||||
dismissedAt = performance.now()
|
||||
}
|
||||
|
||||
closingExplicitly = false
|
||||
})
|
||||
bubble.addEventListener('beforetoggle', (event) => guard.beforeToggle(event))
|
||||
|
||||
wrapper.addEventListener('click', (event) => {
|
||||
if (bubble.contains(event.target)) {
|
||||
@@ -76,9 +64,9 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
|
||||
if (open()) {
|
||||
closingExplicitly = true
|
||||
guard.closing()
|
||||
bubble.hidePopover()
|
||||
} else if (performance.now() - dismissedAt > REOPEN_GUARD_MS) {
|
||||
} else if (guard.canOpen()) {
|
||||
bubble.showPopover()
|
||||
}
|
||||
})
|
||||
@@ -86,25 +74,20 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const show = (delay) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => !open() && bubble.showPopover(), delay)
|
||||
}
|
||||
const popover = hoverPopover(wrapper, {
|
||||
open,
|
||||
show: () => bubble.showPopover(),
|
||||
hide: () => bubble.hidePopover(),
|
||||
focusable: (event) => event.target.matches(':focus-visible'),
|
||||
})
|
||||
|
||||
const hide = (delay = 0) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => open() && bubble.hidePopover(), delay)
|
||||
}
|
||||
wrapper.addEventListener('focusout', (event) => !wrapper.contains(event.relatedTarget) && popover.hide(LEAVE_DELAY_MS), { signal: popover.signal })
|
||||
|
||||
wrapper.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||
wrapper.addEventListener('pointerleave', () => hide(LEAVE_GRACE_MS))
|
||||
wrapper.addEventListener('focusin', (event) => !openingFocus(event) && event.target.matches(':focus-visible') && show(0))
|
||||
wrapper.addEventListener('focusout', (event) => !wrapper.contains(event.relatedTarget) && hide(LEAVE_GRACE_MS))
|
||||
document.addEventListener('keydown', (event) => event.key === 'Escape' && hide())
|
||||
this.stop = popover.destroy
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.timer)
|
||||
this.stop?.()
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
+6
-15
@@ -41,15 +41,6 @@ const RETURN_GUARD_MS = 250
|
||||
// counting, so the live region speaks once.
|
||||
const SETTLE_MS = 120
|
||||
|
||||
/** The longest `transition-duration` + `transition-delay` on an element, in ms: zero under reduced motion. */
|
||||
const longestTransition = (element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000)
|
||||
const delays = style.transitionDelay.split(',').map(ms)
|
||||
|
||||
return Math.max(0, ...style.transitionDuration.split(',').map((duration, index) => ms(duration) + (delays[index % delays.length] || 0)))
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({
|
||||
open: false,
|
||||
@@ -162,9 +153,9 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
/**
|
||||
* Keeps the full-screen layout for the length of the view's exit. The closed state's
|
||||
* durations are read a frame on, once they are the ones computed; reopening, which counts
|
||||
* `leavings` up, lets a pending end go by.
|
||||
* Keeps the full-screen layout for the length of the view's exit: until the animations the
|
||||
* closed state starts, a frame on, have finished (at once when none run); reopening, which
|
||||
* counts `leavings` up, lets a pending end go by.
|
||||
*/
|
||||
hold() {
|
||||
const leaving = ++this.leavings
|
||||
@@ -172,13 +163,13 @@ document.addEventListener('alpine:init', () => {
|
||||
this.leaving = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const duration = this.$refs.view ? longestTransition(this.$refs.view) : 0
|
||||
const animations = this.$refs.view?.getAnimations() ?? []
|
||||
|
||||
setTimeout(() => {
|
||||
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
|
||||
if (leaving === this.leavings) {
|
||||
this.leaving = false
|
||||
}
|
||||
}, duration)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* Shared, DOM-free geometry: androidx's graphics-shapes (RoundedPolygon — a shape built from
|
||||
* vertices with per-corner rounding, turned into cubic Béziers) and its Morph (matching two
|
||||
* shapes' cubics so they can be interpolated). Used by bin/shapes.mjs and bin/loading-indicator.mjs
|
||||
* to build the shape and loading-indicator SVGs at commit time, and by progress.js to morph the
|
||||
* circular wavy progress indicator's star and circle at runtime.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Ported from androidx (https://github.com/androidx/androidx), commit
|
||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
|
||||
*
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/CornerRounding.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Cubic.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FeatureMapping.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FloatMapping.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Morph.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Point.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/PolygonMeasure.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Shapes.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Utils.kt
|
||||
*
|
||||
* Copyright 2022-2025 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* ---------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const DISTANCE_EPSILON = 1e-4
|
||||
const ANGLE_EPSILON = 1e-6
|
||||
|
||||
// Point.kt / Utils.kt ---------------------------------------------------------------------
|
||||
|
||||
const point = (x, y) => ({ x, y })
|
||||
const plus = (a, b) => point(a.x + b.x, a.y + b.y)
|
||||
const minus = (a, b) => point(a.x - b.x, a.y - b.y)
|
||||
const times = (a, k) => point(a.x * k, a.y * k)
|
||||
const div = (a, k) => point(a.x / k, a.y / k)
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y
|
||||
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y)
|
||||
const rotate90 = (a) => point(-a.y, a.x)
|
||||
const lerp = (a, b, f) => (1 - f) * a + f * b
|
||||
const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f))
|
||||
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
const convex = (previous, current, next) => {
|
||||
const [a, b] = [minus(current, previous), minus(next, current)]
|
||||
|
||||
return a.x * b.y - a.y * b.x > 0
|
||||
}
|
||||
|
||||
function direction(a) {
|
||||
const d = length(a)
|
||||
|
||||
if (!(d > 0)) {
|
||||
throw new Error("Can't get the direction of a 0-length vector")
|
||||
}
|
||||
|
||||
return div(a, d)
|
||||
}
|
||||
|
||||
// Cubic.kt ----------------------------------------------------------------------------------
|
||||
|
||||
/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */
|
||||
const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y]
|
||||
|
||||
const straightLine = (x0, y0, x1, y1) => [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1]
|
||||
|
||||
function circularArc(centerX, centerY, x0, y0, x1, y1) {
|
||||
const p0d = direction(point(x0 - centerX, y0 - centerY))
|
||||
const p1d = direction(point(x1 - centerX, y1 - centerY))
|
||||
const rotatedP0 = rotate90(p0d)
|
||||
const rotatedP1 = rotate90(p1d)
|
||||
const clockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0
|
||||
const cosa = dot(p0d, p1d)
|
||||
|
||||
if (cosa > 0.999) {
|
||||
return straightLine(x0, y0, x1, y1)
|
||||
}
|
||||
|
||||
const k =
|
||||
(((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) * (Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) /
|
||||
(1 - cosa) *
|
||||
(clockwise ? 1 : -1)
|
||||
|
||||
return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1]
|
||||
}
|
||||
|
||||
function pointOnCurve(c, t) {
|
||||
const u = 1 - t
|
||||
|
||||
return point(
|
||||
c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t),
|
||||
c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t),
|
||||
)
|
||||
}
|
||||
|
||||
function split(c, t) {
|
||||
const u = 1 - t
|
||||
const p = pointOnCurve(c, t)
|
||||
|
||||
return [
|
||||
[c[0], c[1], c[0] * u + c[2] * t, c[1] * u + c[3] * t, c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t), c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t), p.x, p.y],
|
||||
[p.x, p.y, c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t), c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t), c[4] * u + c[6] * t, c[5] * u + c[7] * t, c[6], c[7]],
|
||||
]
|
||||
}
|
||||
|
||||
const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]]
|
||||
const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
/** The parameters in (0, 1) where one axis of a cubic turns: the roots of its derivative. */
|
||||
function turningPoints(c, axis) {
|
||||
const [p0, p1, p2, p3] = [c[axis], c[axis + 2], c[axis + 4], c[axis + 6]]
|
||||
const a = -p0 + 3 * p1 - 3 * p2 + p3
|
||||
const b = 2 * (p0 - 2 * p1 + p2)
|
||||
const k = p1 - p0
|
||||
const roots = []
|
||||
|
||||
if (Math.abs(a) < 1e-9) {
|
||||
if (Math.abs(b) > 1e-9) {
|
||||
roots.push(-k / b)
|
||||
}
|
||||
} else if (b * b - 4 * a * k >= 0) {
|
||||
const root = Math.sqrt(b * b - 4 * a * k)
|
||||
roots.push((-b + root) / (2 * a), (-b - root) / (2 * a))
|
||||
}
|
||||
|
||||
return roots.filter((t) => t > 1e-6 && t < 1 - 1e-6)
|
||||
}
|
||||
|
||||
/** Axis-aligned bounds of one cubic: of all four points when approximate, else of the curve itself. */
|
||||
function cubicBounds(c, approximate) {
|
||||
const xs = [c[0], c[6]]
|
||||
const ys = [c[1], c[7]]
|
||||
|
||||
if (approximate) {
|
||||
xs.push(c[2], c[4])
|
||||
ys.push(c[3], c[5])
|
||||
} else {
|
||||
turningPoints(c, 0).forEach((t) => xs.push(pointOnCurve(c, t).x))
|
||||
turningPoints(c, 1).forEach((t) => ys.push(pointOnCurve(c, t).y))
|
||||
}
|
||||
|
||||
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
|
||||
}
|
||||
|
||||
// CornerRounding.kt ---------------------------------------------------------------------------
|
||||
|
||||
const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing })
|
||||
const UNROUNDED = rounding()
|
||||
|
||||
// RoundedPolygon.kt ---------------------------------------------------------------------------
|
||||
|
||||
class RoundedCorner {
|
||||
constructor(p0, p1, p2, cornerRounding) {
|
||||
this.p0 = p0
|
||||
this.p1 = p1
|
||||
this.p2 = p2
|
||||
|
||||
const v01 = minus(p0, p1)
|
||||
const v21 = minus(p2, p1)
|
||||
const d01 = length(v01)
|
||||
const d21 = length(v21)
|
||||
|
||||
if (d01 > 0 && d21 > 0) {
|
||||
this.d1 = div(v01, d01)
|
||||
this.d2 = div(v21, d21)
|
||||
this.cornerRadius = cornerRounding?.radius ?? 0
|
||||
this.smoothing = cornerRounding?.smoothing ?? 0
|
||||
this.cosAngle = dot(this.d1, this.d2)
|
||||
this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle)
|
||||
this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0
|
||||
} else {
|
||||
this.d1 = point(0, 0)
|
||||
this.d2 = point(0, 0)
|
||||
this.cornerRadius = 0
|
||||
this.smoothing = 0
|
||||
this.cosAngle = 0
|
||||
this.sinAngle = 0
|
||||
this.expectedRoundCut = 0
|
||||
}
|
||||
}
|
||||
|
||||
get expectedCut() {
|
||||
return (1 + this.smoothing) * this.expectedRoundCut
|
||||
}
|
||||
|
||||
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
|
||||
const allowedCut = Math.min(allowedCut0, allowedCut1)
|
||||
|
||||
if (this.expectedRoundCut < DISTANCE_EPSILON || allowedCut < DISTANCE_EPSILON || this.cornerRadius < DISTANCE_EPSILON) {
|
||||
return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]
|
||||
}
|
||||
|
||||
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut)
|
||||
const actualSmoothing0 = this.actualSmoothing(allowedCut0)
|
||||
const actualSmoothing1 = this.actualSmoothing(allowedCut1)
|
||||
const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut
|
||||
const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut)
|
||||
const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance))
|
||||
const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut))
|
||||
const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut))
|
||||
const flanking0 = this.flankingCurve(actualRoundCut, actualSmoothing0, this.p1, this.p0, circleIntersection0, circleIntersection2, center, actualR)
|
||||
const flanking2 = reverse(this.flankingCurve(actualRoundCut, actualSmoothing1, this.p1, this.p2, circleIntersection2, circleIntersection0, center, actualR))
|
||||
|
||||
return [flanking0, circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]), flanking2]
|
||||
}
|
||||
|
||||
actualSmoothing(allowedCut) {
|
||||
if (allowedCut > this.expectedCut) {
|
||||
return this.smoothing
|
||||
}
|
||||
|
||||
if (allowedCut > this.expectedRoundCut) {
|
||||
return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) {
|
||||
const sideDirection = direction(minus(sideStart, corner))
|
||||
const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing)))
|
||||
const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing)
|
||||
const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR))
|
||||
const circleTangent = rotate90(minus(curveEnd, circleCenter))
|
||||
const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection
|
||||
const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3)
|
||||
|
||||
return cubic(curveStart, anchorStart, anchorEnd, curveEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function lineIntersection(p0, d0, p1, d1) {
|
||||
const rotatedD1 = rotate90(d1)
|
||||
const den = dot(d0, rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON) {
|
||||
return null
|
||||
}
|
||||
|
||||
const num = dot(minus(p1, p0), rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return plus(p0, times(d0, num / den))
|
||||
}
|
||||
|
||||
/**
|
||||
* A polygon as androidx keeps it: its features (corners and the edges between them, each a
|
||||
* list of cubics) and a centre. `cubics` flattens the features exactly as RoundedPolygon does.
|
||||
*/
|
||||
class RoundedPolygon {
|
||||
constructor(features, center) {
|
||||
this.features = features
|
||||
this.center = center
|
||||
this.cubics = flatten(features, center)
|
||||
}
|
||||
|
||||
transformed(f) {
|
||||
const move = (c) => {
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < 8; i += 2) {
|
||||
const p = f(c[i], c[i + 1])
|
||||
out.push(p.x, p.y)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
return new RoundedPolygon(
|
||||
this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })),
|
||||
f(this.center.x, this.center.y),
|
||||
)
|
||||
}
|
||||
|
||||
bounds(approximate) {
|
||||
const all = this.cubics.map((c) => (zeroLength(c) ? [c[0], c[1], c[0], c[1]] : cubicBounds(c, approximate)))
|
||||
|
||||
return [Math.min(...all.map((b) => b[0])), Math.min(...all.map((b) => b[1])), Math.max(...all.map((b) => b[2])), Math.max(...all.map((b) => b[3]))]
|
||||
}
|
||||
|
||||
/** RoundedPolygon.normalized: into the unit square, by the approximate (control point) bounds. */
|
||||
normalized() {
|
||||
const [left, top, right, bottom] = this.bounds(true)
|
||||
const width = right - left
|
||||
const height = bottom - top
|
||||
const side = Math.max(width, height)
|
||||
const offsetX = (side - width) / 2 - left
|
||||
const offsetY = (side - height) / 2 - top
|
||||
|
||||
return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side))
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(features, center) {
|
||||
const out = []
|
||||
let firstCubic = null
|
||||
let lastCubic = null
|
||||
let firstFeatureSplitStart = null
|
||||
let firstFeatureSplitEnd = null
|
||||
|
||||
if (features.length > 0 && features[0].cubics.length === 3) {
|
||||
const [start, end] = split(features[0].cubics[1], 0.5)
|
||||
firstFeatureSplitStart = [features[0].cubics[0], start]
|
||||
firstFeatureSplitEnd = [end, features[0].cubics[2]]
|
||||
}
|
||||
|
||||
for (let i = 0; i <= features.length; i++) {
|
||||
let featureCubics
|
||||
|
||||
if (i === 0 && firstFeatureSplitEnd !== null) {
|
||||
featureCubics = firstFeatureSplitEnd
|
||||
} else if (i === features.length) {
|
||||
if (firstFeatureSplitStart === null) {
|
||||
break
|
||||
}
|
||||
|
||||
featureCubics = firstFeatureSplitStart
|
||||
} else {
|
||||
featureCubics = features[i].cubics
|
||||
}
|
||||
|
||||
for (const c of featureCubics) {
|
||||
if (!zeroLength(c)) {
|
||||
if (lastCubic !== null) {
|
||||
out.push(lastCubic)
|
||||
}
|
||||
|
||||
lastCubic = c
|
||||
firstCubic ??= c
|
||||
} else if (lastCubic !== null) {
|
||||
lastCubic = [...lastCubic]
|
||||
lastCubic[6] = c[0]
|
||||
lastCubic[7] = c[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCubic !== null && firstCubic !== null) {
|
||||
out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]])
|
||||
} else {
|
||||
out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y])
|
||||
}
|
||||
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const previous = out[(i + out.length - 1) % out.length]
|
||||
|
||||
if (Math.abs(out[i][0] - previous[6]) > DISTANCE_EPSILON || Math.abs(out[i][1] - previous[7]) > DISTANCE_EPSILON) {
|
||||
throw new Error('RoundedPolygon must be contiguous')
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY). */
|
||||
function polygonFromVertices(vertices, { cornerRounding = UNROUNDED, perVertexRounding = null, center = null } = {}) {
|
||||
const n = vertices.length
|
||||
|
||||
if (n < 3) {
|
||||
throw new Error('Polygons must have at least 3 vertices')
|
||||
}
|
||||
|
||||
if (perVertexRounding !== null && perVertexRounding.length !== n) {
|
||||
throw new Error('perVertexRounding list should be either null or the same size as the number of vertices')
|
||||
}
|
||||
|
||||
const roundedCorners = vertices.map((vertex, i) => new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding?.[i] ?? cornerRounding))
|
||||
|
||||
const cutAdjusts = vertices.map((vertex, i) => {
|
||||
const next = (i + 1) % n
|
||||
const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut
|
||||
const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut
|
||||
const sideSize = length(minus(vertex, vertices[next]))
|
||||
|
||||
if (expectedRoundCut > sideSize) {
|
||||
return [sideSize / expectedRoundCut, 0]
|
||||
}
|
||||
|
||||
if (expectedCut > sideSize) {
|
||||
return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)]
|
||||
}
|
||||
|
||||
return [1, 1]
|
||||
})
|
||||
|
||||
const corners = roundedCorners.map((corner, i) => {
|
||||
const allowedCuts = [0, 1].map((delta) => {
|
||||
const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n]
|
||||
|
||||
return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio
|
||||
})
|
||||
|
||||
return corner.getCubics(allowedCuts[0], allowedCuts[1])
|
||||
})
|
||||
|
||||
const features = []
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const previous = vertices[(i + n - 1) % n]
|
||||
const next = vertices[(i + 1) % n]
|
||||
const end = corners[i].at(-1)
|
||||
const start = corners[(i + 1) % n][0]
|
||||
|
||||
features.push({ type: 'corner', convex: convex(previous, vertices[i], next), cubics: corners[i] })
|
||||
features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] })
|
||||
}
|
||||
|
||||
if (center === null) {
|
||||
center = point(vertices.reduce((sum, v) => sum + v.x, 0) / n, vertices.reduce((sum, v) => sum + v.y, 0) / n)
|
||||
}
|
||||
|
||||
return new RoundedPolygon(features, center)
|
||||
}
|
||||
|
||||
// Shapes.kt -----------------------------------------------------------------------------------
|
||||
|
||||
/** RoundedPolygon(numVertices, radius, centerX, centerY, rounding, perVertexRounding). */
|
||||
function regularPolygon(numVertices, { radius = 1, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
|
||||
const vertices = Array.from({ length: numVertices }, (_, i) => radialToCartesian(radius, (Math.PI / numVertices) * 2 * i))
|
||||
|
||||
return polygonFromVertices(vertices, { cornerRounding, perVertexRounding, center: point(0, 0) })
|
||||
}
|
||||
|
||||
/** RoundedPolygon.circle(numVertices, radius): a regular polygon rounded all the way round. */
|
||||
function circlePolygon(numVertices = 8, radius = 1) {
|
||||
const polygonRadius = radius / Math.cos(Math.PI / numVertices)
|
||||
|
||||
return regularPolygon(numVertices, { radius: polygonRadius, cornerRounding: rounding(radius) })
|
||||
}
|
||||
|
||||
/** RoundedPolygon.star(numVerticesPerRadius, radius, innerRadius, rounding, innerRounding). */
|
||||
function star(numVerticesPerRadius, { radius = 1, innerRadius = 0.5, cornerRounding = UNROUNDED, innerCornerRounding = cornerRounding } = {}) {
|
||||
const vertices = []
|
||||
const roundings = []
|
||||
|
||||
for (let i = 0; i < numVerticesPerRadius; i++) {
|
||||
vertices.push(radialToCartesian(radius, (Math.PI / numVerticesPerRadius) * 2 * i), radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius) * (2 * i + 1)))
|
||||
roundings.push(cornerRounding, innerCornerRounding)
|
||||
}
|
||||
|
||||
return polygonFromVertices(vertices, { perVertexRounding: roundings, center: point(0, 0) })
|
||||
}
|
||||
|
||||
// Morph: FloatMapping.kt, PolygonMeasure.kt, FeatureMapping.kt, Morph.kt ----------------------
|
||||
|
||||
const positiveModulo = (num, mod) => ((num % mod) + mod) % mod
|
||||
const progressInRange = (progress, from, to) => (to >= from ? progress >= from && progress <= to : progress >= from || progress <= to)
|
||||
const progressDistance = (a, b) => Math.min(Math.abs(a - b), 1 - Math.abs(a - b))
|
||||
|
||||
function linearMap(xValues, yValues, x) {
|
||||
const n = xValues.length
|
||||
const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n]))
|
||||
const end = (start + 1) % n
|
||||
const sizeX = positiveModulo(xValues[end] - xValues[start], 1)
|
||||
const sizeY = positiveModulo(yValues[end] - yValues[start], 1)
|
||||
const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX
|
||||
|
||||
return positiveModulo(yValues[start] + sizeY * position, 1)
|
||||
}
|
||||
|
||||
const MEASURE_SEGMENTS = 3
|
||||
|
||||
/** LengthMeasurer.closestProgressTo: [the parameter at which `threshold` length is reached, the length]. */
|
||||
function closestProgressTo(c, threshold) {
|
||||
let total = 0
|
||||
let remainder = threshold
|
||||
let previous = point(c[0], c[1])
|
||||
|
||||
for (let i = 1; i <= MEASURE_SEGMENTS; i++) {
|
||||
const progress = i / MEASURE_SEGMENTS
|
||||
const p = pointOnCurve(c, progress)
|
||||
const segment = Math.hypot(p.x - previous.x, p.y - previous.y)
|
||||
|
||||
if (segment >= remainder) {
|
||||
return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold]
|
||||
}
|
||||
|
||||
remainder -= segment
|
||||
total += segment
|
||||
previous = p
|
||||
}
|
||||
|
||||
return [1, total]
|
||||
}
|
||||
|
||||
class MeasuredCubic {
|
||||
constructor(c, startOutlineProgress, endOutlineProgress) {
|
||||
if (endOutlineProgress < startOutlineProgress) {
|
||||
throw new Error('endOutlineProgress is expected to be equal or greater than startOutlineProgress')
|
||||
}
|
||||
|
||||
this.cubic = c
|
||||
this.startOutlineProgress = startOutlineProgress
|
||||
this.endOutlineProgress = endOutlineProgress
|
||||
this.measuredSize = closestProgressTo(c, Infinity)[1]
|
||||
}
|
||||
|
||||
cutAtProgress(cutOutlineProgress) {
|
||||
const bounded = clamp(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress)
|
||||
const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress)
|
||||
const t = closestProgressTo(this.cubic, relativeProgress * this.measuredSize)[0]
|
||||
const [c1, c2] = split(this.cubic, t)
|
||||
|
||||
return [new MeasuredCubic(c1, this.startOutlineProgress, bounded), new MeasuredCubic(c2, bounded, this.endOutlineProgress)]
|
||||
}
|
||||
}
|
||||
|
||||
class MeasuredPolygon {
|
||||
constructor(features, cubics, outlineProgress) {
|
||||
this.features = features
|
||||
this.cubics = []
|
||||
|
||||
let startOutlineProgress = 0
|
||||
|
||||
for (let i = 0; i < cubics.length; i++) {
|
||||
if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) {
|
||||
this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1]))
|
||||
startOutlineProgress = outlineProgress[i + 1]
|
||||
}
|
||||
}
|
||||
|
||||
this.cubics.at(-1).endOutlineProgress = 1
|
||||
}
|
||||
|
||||
static measure(polygon) {
|
||||
const cubics = []
|
||||
const featureToCubic = []
|
||||
|
||||
for (const feature of polygon.features) {
|
||||
feature.cubics.forEach((c, i) => {
|
||||
if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) {
|
||||
featureToCubic.push([feature, cubics.length])
|
||||
}
|
||||
|
||||
cubics.push(c)
|
||||
})
|
||||
}
|
||||
|
||||
const measures = [0]
|
||||
|
||||
for (const c of cubics) {
|
||||
measures.push(measures.at(-1) + closestProgressTo(c, Infinity)[1])
|
||||
}
|
||||
|
||||
const outlineProgress = measures.map((measure) => measure / measures.at(-1))
|
||||
const features = featureToCubic.map(([feature, ix]) => ({
|
||||
progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1),
|
||||
feature,
|
||||
}))
|
||||
|
||||
return new MeasuredPolygon(features, cubics, outlineProgress)
|
||||
}
|
||||
|
||||
cutAndShift(cuttingPoint) {
|
||||
if (cuttingPoint < DISTANCE_EPSILON) {
|
||||
return this
|
||||
}
|
||||
|
||||
const n = this.cubics.length
|
||||
const targetIndex = this.cubics.findIndex((c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress)
|
||||
const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint)
|
||||
const cubics = [b2.cubic]
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
cubics.push(this.cubics[(i + targetIndex) % n].cubic)
|
||||
}
|
||||
|
||||
cubics.push(b1.cubic)
|
||||
|
||||
const outlineProgress = Array.from({ length: n + 2 }, (_, index) => {
|
||||
if (index === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (index === n + 1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1)
|
||||
})
|
||||
|
||||
const features = this.features.map(({ progress, feature }) => ({ progress: positiveModulo(progress - cuttingPoint, 1), feature }))
|
||||
|
||||
return new MeasuredPolygon(features, cubics, outlineProgress)
|
||||
}
|
||||
}
|
||||
|
||||
function featureDistSquared(f1, f2) {
|
||||
if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) {
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const representative = (feature) => {
|
||||
const [first, last] = [feature.cubics[0], feature.cubics.at(-1)]
|
||||
|
||||
return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2)
|
||||
}
|
||||
|
||||
const [p1, p2] = [representative(f1), representative(f2)]
|
||||
|
||||
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2
|
||||
}
|
||||
|
||||
function doMapping(features1, features2) {
|
||||
const distanceVertexList = []
|
||||
|
||||
for (const f1 of features1) {
|
||||
for (const f2 of features2) {
|
||||
const distance = featureDistSquared(f1.feature, f2.feature)
|
||||
|
||||
if (distance !== Infinity) {
|
||||
distanceVertexList.push({ distance, f1, f2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distanceVertexList.sort((a, b) => a.distance - b.distance)
|
||||
|
||||
if (distanceVertexList.length === 0) {
|
||||
return [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
]
|
||||
}
|
||||
|
||||
if (distanceVertexList.length === 1) {
|
||||
const { f1, f2 } = distanceVertexList[0]
|
||||
|
||||
return [
|
||||
[f1.progress, f2.progress],
|
||||
[(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1],
|
||||
]
|
||||
}
|
||||
|
||||
const mapping = []
|
||||
const usedF1 = new Set()
|
||||
const usedF2 = new Set()
|
||||
|
||||
for (const { f1, f2 } of distanceVertexList) {
|
||||
if (usedF1.has(f1) || usedF2.has(f2)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress)
|
||||
const index = insertionIndex === -1 ? mapping.length : insertionIndex
|
||||
|
||||
if (index < mapping.length && mapping[index][0] === f1.progress) {
|
||||
throw new Error("There can't be two features with the same progress")
|
||||
}
|
||||
|
||||
const n = mapping.length
|
||||
|
||||
if (n >= 1) {
|
||||
const [before1, before2] = mapping[(index + n - 1) % n]
|
||||
const [after1, after2] = mapping[index % n]
|
||||
|
||||
if (
|
||||
progressDistance(f1.progress, before1) < DISTANCE_EPSILON ||
|
||||
progressDistance(f1.progress, after1) < DISTANCE_EPSILON ||
|
||||
progressDistance(f2.progress, before2) < DISTANCE_EPSILON ||
|
||||
progressDistance(f2.progress, after2) < DISTANCE_EPSILON
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (n > 1 && !progressInRange(f2.progress, before2, after2)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mapping.splice(index, 0, [f1.progress, f2.progress])
|
||||
usedF1.add(f1)
|
||||
usedF2.add(f2)
|
||||
}
|
||||
|
||||
return mapping
|
||||
}
|
||||
|
||||
/** Morph.match: both shapes cut into pairs of matching cubics. */
|
||||
function match(p1, p2) {
|
||||
const measuredPolygon1 = MeasuredPolygon.measure(p1)
|
||||
const measuredPolygon2 = MeasuredPolygon.measure(p2)
|
||||
const corners = (features) => features.filter(({ feature }) => feature.type === 'corner')
|
||||
const mappings = doMapping(corners(measuredPolygon1.features), corners(measuredPolygon2.features))
|
||||
const [sources, targets] = [mappings.map((m) => m[0]), mappings.map((m) => m[1])]
|
||||
const map = (x) => linearMap(sources, targets, x)
|
||||
const mapBack = (x) => linearMap(targets, sources, x)
|
||||
const polygon2CutPoint = map(0)
|
||||
const bs1 = measuredPolygon1.cubics
|
||||
const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics
|
||||
const pairs = []
|
||||
|
||||
let i1 = 0
|
||||
let i2 = 0
|
||||
let b1 = bs1[i1++]
|
||||
let b2 = bs2[i2++]
|
||||
|
||||
while (b1 !== undefined && b2 !== undefined) {
|
||||
const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress
|
||||
const b2a = i2 === bs2.length ? 1 : mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1))
|
||||
const minb = Math.min(b1a, b2a)
|
||||
let seg1
|
||||
let seg2
|
||||
|
||||
if (b1a > minb + ANGLE_EPSILON) {
|
||||
;[seg1, b1] = b1.cutAtProgress(minb)
|
||||
} else {
|
||||
seg1 = b1
|
||||
b1 = bs1[i1++]
|
||||
}
|
||||
|
||||
if (b2a > minb + ANGLE_EPSILON) {
|
||||
;[seg2, b2] = b2.cutAtProgress(positiveModulo(map(minb) - polygon2CutPoint, 1))
|
||||
} else {
|
||||
seg2 = b2
|
||||
b2 = bs2[i2++]
|
||||
}
|
||||
|
||||
pairs.push([seg1.cubic, seg2.cubic])
|
||||
}
|
||||
|
||||
if (b1 !== undefined || b2 !== undefined) {
|
||||
throw new Error("Expected both Polygon's Cubic to be fully matched")
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** Morph.asCubics: every matched pair interpolated at `progress`, closed on its first anchor. */
|
||||
function asCubics(pairs, progress) {
|
||||
const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress))
|
||||
|
||||
cubics.at(-1)[6] = cubics[0][0]
|
||||
cubics.at(-1)[7] = cubics[0][1]
|
||||
|
||||
return cubics
|
||||
}
|
||||
|
||||
export {
|
||||
point,
|
||||
plus,
|
||||
minus,
|
||||
times,
|
||||
length,
|
||||
pointOnCurve,
|
||||
split,
|
||||
turningPoints,
|
||||
cubicBounds,
|
||||
rounding,
|
||||
UNROUNDED,
|
||||
polygonFromVertices,
|
||||
regularPolygon,
|
||||
circlePolygon,
|
||||
star,
|
||||
match,
|
||||
asCubics,
|
||||
}
|
||||
+21
-26
@@ -83,7 +83,6 @@ const TICK_MIN_SPACING = 8
|
||||
const TOUCH_SLOP = 8
|
||||
|
||||
const VALUE = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')
|
||||
const VALUE_AS_NUMBER = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'valueAsNumber')
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-6
|
||||
@@ -206,6 +205,7 @@ function slider(root) {
|
||||
const drawing = root.querySelector('[data-md-slider-drawing]')
|
||||
const range = inputs.length > 1
|
||||
const cleanups = []
|
||||
const controller = new AbortController()
|
||||
let queued = false
|
||||
let release = null
|
||||
// Focus this script gave an input on a press, which draws no ring until a key is pressed.
|
||||
@@ -214,8 +214,7 @@ function slider(root) {
|
||||
let spaceHeld = false
|
||||
|
||||
const listen = (target, type, handler, capture = false) => {
|
||||
target.addEventListener(type, handler, capture)
|
||||
cleanups.push(() => target.removeEventListener(type, handler, capture))
|
||||
target.addEventListener(type, handler, { capture, signal: controller.signal })
|
||||
}
|
||||
|
||||
/** The inputs' min, max and step, as a native range reads them; `step` is null for `any`. */
|
||||
@@ -379,25 +378,20 @@ function slider(root) {
|
||||
const pressed = (index) => drawing?.querySelectorAll('[data-md-slider-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-md-pressed', i === index))
|
||||
|
||||
inputs.forEach((input) => {
|
||||
// A value written by script fires no event: catch it on the input's own setters.
|
||||
for (const [property, descriptor] of [['value', VALUE], ['valueAsNumber', VALUE_AS_NUMBER]]) {
|
||||
Object.defineProperty(input, property, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return descriptor.get.call(this)
|
||||
},
|
||||
set(value) {
|
||||
descriptor.set.call(this, value)
|
||||
schedule()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cleanups.push(() => {
|
||||
delete input.value
|
||||
delete input.valueAsNumber
|
||||
// A value written by script fires no event: catch it on the input's own setter.
|
||||
Object.defineProperty(input, 'value', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return VALUE.get.call(this)
|
||||
},
|
||||
set(value) {
|
||||
VALUE.set.call(this, value)
|
||||
schedule()
|
||||
},
|
||||
})
|
||||
|
||||
cleanups.push(() => delete input.value)
|
||||
|
||||
listen(input, 'focus', () => showFocus(input, focusedByPointer !== input && input.matches(':focus-visible')))
|
||||
listen(input, 'blur', () => {
|
||||
focusedByPointer = null
|
||||
@@ -510,6 +504,7 @@ function slider(root) {
|
||||
return {
|
||||
destroy() {
|
||||
release?.()
|
||||
controller.abort()
|
||||
cleanups.forEach((cleanup) => cleanup())
|
||||
},
|
||||
|
||||
@@ -613,17 +608,17 @@ function slider(root) {
|
||||
})
|
||||
}
|
||||
|
||||
const dragController = new AbortController()
|
||||
|
||||
release = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
window.removeEventListener('pointercancel', end)
|
||||
dragController.abort()
|
||||
pressed(null)
|
||||
release = null
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
window.addEventListener('pointercancel', end)
|
||||
window.addEventListener('pointermove', move, { signal: dragController.signal })
|
||||
window.addEventListener('pointerup', end, { signal: dragController.signal })
|
||||
window.addEventListener('pointercancel', end, { signal: dragController.signal })
|
||||
|
||||
if (dragging) {
|
||||
follow(start)
|
||||
|
||||
+4
-10
@@ -17,6 +17,8 @@
|
||||
* `previewScheme(name)` shows another one on this page without storing anything — the
|
||||
* application saves a choice itself, and the next full load draws what it saved.
|
||||
*/
|
||||
import { remember } from './util.js'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
@@ -53,11 +55,7 @@ document.addEventListener('alpine:init', () => {
|
||||
root.dataset.themeChoice = choice
|
||||
root.dataset.theme = this.resolved
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.themeKey || 'material-theme', choice)
|
||||
} catch {
|
||||
// Blocked storage: the page still switches, it just will not remember.
|
||||
}
|
||||
remember(root.dataset.themeKey || 'material-theme', choice)
|
||||
},
|
||||
|
||||
toggle() {
|
||||
@@ -80,11 +78,7 @@ document.addEventListener('alpine:init', () => {
|
||||
root.dataset.contrast = this.resolvedContrast
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.contrastKey || 'material-contrast', choice)
|
||||
} catch {
|
||||
// Blocked storage: the page still switches, it just will not remember.
|
||||
}
|
||||
remember(root.dataset.contrastKey || 'material-contrast', choice)
|
||||
},
|
||||
|
||||
previewScheme(name) {
|
||||
|
||||
+14
-25
@@ -208,13 +208,16 @@ document.addEventListener('alpine:init', () => {
|
||||
return false
|
||||
},
|
||||
|
||||
/** The allowed time nearest to one that is not, searching outwards a minute at a time. */
|
||||
nearest(time) {
|
||||
/**
|
||||
* The allowed time nearest to one that is not, searching outwards a minute at a time; `pm`
|
||||
* limits the search to that half of the day, for a period switch that lands outside it.
|
||||
*/
|
||||
nearest(time, pm = null) {
|
||||
for (let distance = 0; distance <= 720; distance++) {
|
||||
for (const candidate of [time - distance, time + distance]) {
|
||||
const wrapped = (candidate + 1440) % 1440
|
||||
|
||||
if (this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
|
||||
if ((pm === null || (wrapped >= 720) === pm) && this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
@@ -370,7 +373,8 @@ document.addEventListener('alpine:init', () => {
|
||||
if (this.hourAllowed(hour)) {
|
||||
this.setHour(hour)
|
||||
} else {
|
||||
const time = this.nearestInPeriod(pm, hour * 60 + this.minute)
|
||||
const raw = hour * 60 + this.minute
|
||||
const time = this.nearest(raw, pm) ?? raw
|
||||
|
||||
this.hour = Math.floor(time / 60)
|
||||
this.minute = time % 60
|
||||
@@ -395,19 +399,6 @@ document.addEventListener('alpine:init', () => {
|
||||
event.currentTarget.querySelector(`[data-md-timepicker-period-option="${pm ? 'pm' : 'am'}"]`)?.focus()
|
||||
},
|
||||
|
||||
/** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */
|
||||
nearestInPeriod(pm, time) {
|
||||
let best = null
|
||||
|
||||
for (let candidate = pm ? 720 : 0; candidate < (pm ? 1440 : 720); candidate++) {
|
||||
if (this.allowed(Math.floor(candidate / 60), candidate % 60) && (best === null || Math.abs(candidate - time) < Math.abs(best - time))) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? time
|
||||
},
|
||||
|
||||
// ---- The dial --------------------------------------------------------------------------
|
||||
|
||||
angleFor() {
|
||||
@@ -450,10 +441,10 @@ document.addEventListener('alpine:init', () => {
|
||||
this.pick(moveEvent, false)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const release = (upEvent, cancelled = false) => {
|
||||
dial.removeEventListener('pointermove', move)
|
||||
dial.removeEventListener('pointerup', release)
|
||||
dial.removeEventListener('pointercancel', abandon)
|
||||
controller.abort()
|
||||
this.dragging = false
|
||||
|
||||
if (cancelled) {
|
||||
@@ -473,11 +464,9 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const abandon = (cancelEvent) => release(cancelEvent, true)
|
||||
|
||||
dial.addEventListener('pointermove', move)
|
||||
dial.addEventListener('pointerup', release)
|
||||
dial.addEventListener('pointercancel', abandon)
|
||||
dial.addEventListener('pointermove', move, { signal: controller.signal })
|
||||
dial.addEventListener('pointerup', release, { signal: controller.signal })
|
||||
dial.addEventListener('pointercancel', (cancelEvent) => release(cancelEvent, true), { signal: controller.signal })
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+6
-15
@@ -4,12 +4,13 @@
|
||||
* on a right-to-left page; Home and End to its ends). Every control stays in the Tab order, so a
|
||||
* control added by a Livewire render is reachable without any bookkeeping.
|
||||
*/
|
||||
import { ringIndex } from './util.js'
|
||||
|
||||
const CONTROLS = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialToolbar', (vertical = false) => ({
|
||||
move(event) {
|
||||
const keys = vertical ? { ArrowUp: -1, ArrowDown: 1 } : { ArrowLeft: -1, ArrowRight: 1 }
|
||||
const controls = [...this.$root.querySelectorAll(CONTROLS)].filter((control) => control.getClientRects().length > 0)
|
||||
const at = controls.indexOf(document.activeElement)
|
||||
|
||||
@@ -17,22 +18,12 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
let next = null
|
||||
const rtl = !vertical && getComputedStyle(this.$root).direction === 'rtl'
|
||||
const next = ringIndex(event.key, at, controls.length, { rtl, vertical })
|
||||
|
||||
if (event.key === 'Home') {
|
||||
next = controls[0]
|
||||
} else if (event.key === 'End') {
|
||||
next = controls.at(-1)
|
||||
} else if (event.key in keys) {
|
||||
const rtl = !vertical && getComputedStyle(this.$root).direction === 'rtl'
|
||||
const by = keys[event.key] * (rtl ? -1 : 1)
|
||||
|
||||
next = controls[(at + by + controls.length) % controls.length]
|
||||
}
|
||||
|
||||
if (next) {
|
||||
if (next !== null) {
|
||||
event.preventDefault()
|
||||
next.focus()
|
||||
controls[next].focus()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
+51
-34
@@ -13,25 +13,58 @@
|
||||
import { openingFocus } from './layers.js'
|
||||
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_DELAY_MS = 1500
|
||||
export const LEAVE_DELAY_MS = 1500
|
||||
|
||||
let visible = null
|
||||
|
||||
/**
|
||||
* The hover/focus/Escape timing a tooltip and a transient rich tooltip (rich-tooltip.js) share:
|
||||
* shows after HOVER_DELAY_MS on a pointer that can hover, at once on a focus `focusable` accepts
|
||||
* (never the focus layers.js moves as a sheet or dialog opens), hides after LEAVE_DELAY_MS once
|
||||
* the pointer leaves, and at once on Escape. `open`, `show` and `hide` do the actual popover call;
|
||||
* a caller adds its own extra listeners (a press, a blur) on the returned `signal`, and calls
|
||||
* `hide` from them.
|
||||
*/
|
||||
export function hoverPopover(el, { open, show, hide, focusable }) {
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
let timer = null
|
||||
|
||||
const delayed = (action, delay) => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(action, delay)
|
||||
}
|
||||
|
||||
const showLater = (delay) => delayed(() => !open() && show(), delay)
|
||||
const hideLater = (delay = 0) => delayed(() => open() && hide(), delay)
|
||||
|
||||
el.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && showLater(HOVER_DELAY_MS), { signal })
|
||||
el.addEventListener('pointerleave', () => hideLater(LEAVE_DELAY_MS), { signal })
|
||||
el.addEventListener('focusin', (event) => !openingFocus(event) && focusable(event) && showLater(0), { signal })
|
||||
document.addEventListener('keydown', (event) => event.key === 'Escape' && hideLater(), { signal })
|
||||
|
||||
return {
|
||||
hide: hideLater,
|
||||
signal,
|
||||
destroy() {
|
||||
clearTimeout(timer)
|
||||
controller.abort()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialTooltip', () => ({
|
||||
timer: null,
|
||||
listeners: [],
|
||||
popover: null,
|
||||
|
||||
init() {
|
||||
const tip = this.$el
|
||||
const trigger = tip.parentElement
|
||||
|
||||
const open = () => tip.matches(':popover-open')
|
||||
|
||||
const show = (delay) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (!tip.isConnected || open()) {
|
||||
const popover = (this.popover = hoverPopover(trigger, {
|
||||
open: () => tip.matches(':popover-open'),
|
||||
show: () => {
|
||||
if (!tip.isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,43 +74,27 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
visible = tip
|
||||
tip.showPopover()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = (delay = 0) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (open()) {
|
||||
tip.hidePopover()
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
tip.hidePopover()
|
||||
|
||||
if (visible === tip) {
|
||||
visible = null
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
},
|
||||
focusable: () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible'),
|
||||
}))
|
||||
|
||||
this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||
this.listen(trigger, 'pointerleave', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(trigger, 'pointerdown', () => hide())
|
||||
this.listen(trigger, 'focusin', (event) => !openingFocus(event) && trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0))
|
||||
this.listen(trigger, 'focusout', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide())
|
||||
},
|
||||
|
||||
listen(target, type, handler) {
|
||||
target.addEventListener(type, handler)
|
||||
this.listeners.push(() => target.removeEventListener(type, handler))
|
||||
trigger.addEventListener('pointerdown', () => popover.hide(), { signal: popover.signal })
|
||||
trigger.addEventListener('focusout', () => popover.hide(LEAVE_DELAY_MS), { signal: popover.signal })
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.timer)
|
||||
this.popover.destroy()
|
||||
|
||||
if (visible === this.$el) {
|
||||
visible = null
|
||||
}
|
||||
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Small pieces shared by more than one component, in the pattern of breakpoints.js: a plain
|
||||
* function or two, not a framework.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The index an Arrow/Home/End key moves to from `index` (which may be -1, nothing chosen yet),
|
||||
* wrapping around the ends; null when the key means nothing here. `vertical` picks Up/Down over
|
||||
* Left/Right; `rtl` mirrors Left/Right (Up/Down never mirror). A caller that answers to both axes
|
||||
* at once — a chip set's row, which also takes Up/Down — calls this twice and takes whichever
|
||||
* answers.
|
||||
*/
|
||||
export function ringIndex(key, index, length, { rtl = false, vertical = false } = {}) {
|
||||
if (key === 'Home') {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (key === 'End') {
|
||||
return length - 1
|
||||
}
|
||||
|
||||
const forward = vertical ? 'ArrowDown' : rtl ? 'ArrowLeft' : 'ArrowRight'
|
||||
const backward = vertical ? 'ArrowUp' : rtl ? 'ArrowRight' : 'ArrowLeft'
|
||||
|
||||
if (key === forward) {
|
||||
return (index + 1 + length) % length
|
||||
}
|
||||
|
||||
if (key === backward) {
|
||||
return index < 0 ? length - 1 : (index - 1 + length) % length
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** A CSS custom property's time value in milliseconds, whichever unit it is written in; null when unset. */
|
||||
export function ms(element, token) {
|
||||
const value = getComputedStyle(element).getPropertyValue(token).trim()
|
||||
const number = parseFloat(value)
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value.endsWith('ms') ? number : number * 1000
|
||||
}
|
||||
|
||||
/** Writes to localStorage, quietly: a blocked or full store still lets the page work, it just won't remember. */
|
||||
export function remember(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Blocked storage: the page still works, it just will not remember.
|
||||
}
|
||||
}
|
||||
|
||||
const REOPEN_GUARD_MS = 250
|
||||
|
||||
/**
|
||||
* A `popover="auto"` closes on the press that lands on its trigger, and the click that follows
|
||||
* would open it again. A close this recent is taken as that press. It is timed from
|
||||
* `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
|
||||
* click. Only a close the browser made on its own (light dismiss) counts — `closing()` marks one
|
||||
* this script makes as explicit, so a keyboard command that shuts it is never mistaken for that
|
||||
* race, only the browser's own light dismiss racing the click it precedes is.
|
||||
*/
|
||||
export function reopenGuard() {
|
||||
let closedAt = -Infinity
|
||||
let closingExplicitly = false
|
||||
|
||||
return {
|
||||
beforeToggle(event) {
|
||||
if (event.newState === 'closed' && !closingExplicitly) {
|
||||
closedAt = performance.now()
|
||||
}
|
||||
|
||||
closingExplicitly = false
|
||||
},
|
||||
|
||||
closing() {
|
||||
closingExplicitly = true
|
||||
},
|
||||
|
||||
canOpen() {
|
||||
return performance.now() - closedAt > REOPEN_GUARD_MS
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user