From df97727380c46c54fcece3ab30d67149077171cc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Oct 2026 22:22:50 -0700 Subject: [PATCH] feat(ui): zoom and pan in the image preview (#56685) --- packages/ui/src/components/image-preview.css | 27 ++- .../src/components/image-preview.stories.tsx | 195 ++++++++++++++- packages/ui/src/components/image-preview.tsx | 228 +++++++++++++++++- packages/ui/src/components/image-zoom.test.ts | 81 +++++++ packages/ui/src/components/image-zoom.ts | 75 ++++++ packages/ui/src/i18n/en.ts | 3 + packages/ui/src/icons/catalog.ts | 4 + 7 files changed, 608 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/components/image-zoom.test.ts create mode 100644 packages/ui/src/components/image-zoom.ts diff --git a/packages/ui/src/components/image-preview.css b/packages/ui/src/components/image-preview.css index 3c47f7a2584..afea4e84696 100644 --- a/packages/ui/src/components/image-preview.css +++ b/packages/ui/src/components/image-preview.css @@ -37,19 +37,42 @@ [data-slot="image-preview-header"] { display: flex; + gap: 8px; padding: 8px 8px 0; justify-content: flex-end; align-items: center; align-self: stretch; } + [data-slot="image-preview-zoom"] { + display: flex; + align-items: center; + gap: 2px; + } + + [data-slot="image-preview-zoom-level"] { + min-width: 52px; + justify-content: center; + font-variant-numeric: tabular-nums; + } + [data-slot="image-preview-body"] { width: 100%; display: flex; align-items: center; justify-content: center; padding: 16px; - overflow: auto; + overflow: hidden; + user-select: none; + + &[data-zoomed] { + cursor: grab; + touch-action: pinch-zoom; + } + + &[data-dragging] { + cursor: grabbing; + } } [data-slot="image-preview-image"] { @@ -57,6 +80,8 @@ max-height: calc(90vh - 100px); object-fit: contain; border-radius: var(--radius-md); + transform-origin: center; + -webkit-user-drag: none; } } } diff --git a/packages/ui/src/components/image-preview.stories.tsx b/packages/ui/src/components/image-preview.stories.tsx index ea86c655cdd..60abe66ce49 100644 --- a/packages/ui/src/components/image-preview.stories.tsx +++ b/packages/ui/src/components/image-preview.stories.tsx @@ -14,10 +14,12 @@ Use for full-size image inspection; keep images optimized. - Optional: \`alt\` text. ### Variants and states -- Single layout with close action. +- Single layout with zoom controls and a close action. ### Behavior - Intended to be used via \`useDialog().show\`. +- Pinch or Cmd/Ctrl + wheel zooms at the cursor; wheel and drag pan while zoomed. +- Double-click toggles between fit and 100%; Cmd/Ctrl + =, - and 0 zoom in, out and to fit. ### Accessibility - Uses localized aria-label for close button. @@ -57,3 +59,194 @@ export const Basic = { ) }, } + +// A large, offline, screenshot-style bitmap with small text that is only legible once zoomed in. +export const Screenshot = { + render: () => { + const dialog = useDialog() + const src = screenshot() + + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) + }, +} + +function screenshot() { + const canvas = document.createElement("canvas") + canvas.width = 2880 + canvas.height = 1800 + const ctx = canvas.getContext("2d") + ctx.scale(2, 2) + + ctx.fillStyle = "#1d1e22" + ctx.fillRect(0, 0, 1440, 900) + ctx.fillStyle = "#2a2b31" + ctx.fillRect(0, 0, 1440, 38) + ;["#ff5f57", "#febc2e", "#28c840"].forEach((color, index) => { + ctx.fillStyle = color + ctx.beginPath() + ctx.arc(20 + index * 20, 19, 6, 0, Math.PI * 2) + ctx.fill() + }) + ctx.fillStyle = "#a9abb3" + ctx.font = "13px -apple-system, system-ui, sans-serif" + ctx.textAlign = "center" + ctx.fillText("orchard.ts — lumen-garden", 720, 24) + ctx.textAlign = "left" + + ctx.fillStyle = "#23242a" + ctx.fillRect(0, 38, 240, 836) + ctx.fillStyle = "#7d808a" + ctx.font = "600 10px -apple-system, system-ui, sans-serif" + ctx.fillText("EXPLORER", 16, 64) + ;[ + "src", + " orchard.ts", + " greenhouse.ts", + " sprinkler.ts", + " seeds", + " tomato.json", + " basil.json", + " marigold.json", + "test", + " orchard.test.ts", + "README.md", + "package.json", + ].forEach((name, index) => { + if (index === 1) { + ctx.fillStyle = "#33353d" + ctx.fillRect(0, 76 + index * 24, 240, 24) + } + + ctx.fillStyle = index === 1 ? "#e6e7eb" : "#a9abb3" + ctx.font = "12px -apple-system, system-ui, sans-serif" + ctx.fillText(name.trimStart(), 20 + (name.length - name.trimStart().length) * 6, 92 + index * 24) + }) + + const code = [ + [ + ["#c792ea", "import "], + ["#e6e7eb", "{ Sprinkler } "], + ["#c792ea", "from "], + ["#c3e88d", '"./sprinkler"'], + ], + [ + ["#c792ea", "import "], + ["#e6e7eb", "{ Greenhouse } "], + ["#c792ea", "from "], + ["#c3e88d", '"./greenhouse"'], + ], + [], + [["#5c6370", "// Waters each bed once the soil sensor drops below its threshold."]], + [ + ["#c792ea", "export function "], + ["#82aaff", "tend"], + ["#e6e7eb", "(garden: Greenhouse, now: Date) {"], + ], + [ + ["#c792ea", " const "], + ["#e6e7eb", "dry = garden.beds."], + ["#82aaff", "filter"], + ["#e6e7eb", "((bed) => bed.moisture < bed.threshold)"], + ], + [ + ["#c792ea", " if "], + ["#e6e7eb", "(dry.length === "], + ["#f78c6c", "0"], + ["#e6e7eb", ") "], + ["#c792ea", "return"], + ], + [], + [ + ["#c792ea", " for "], + ["#e6e7eb", "("], + ["#c792ea", "const "], + ["#e6e7eb", "bed "], + ["#c792ea", "of "], + ["#e6e7eb", "dry) {"], + ], + [ + ["#c792ea", " const "], + ["#e6e7eb", "minutes = Math."], + ["#82aaff", "ceil"], + ["#e6e7eb", "((bed.threshold - bed.moisture) * "], + ["#f78c6c", "4"], + ["#e6e7eb", ")"], + ], + [ + ["#e6e7eb", " Sprinkler."], + ["#82aaff", "open"], + ["#e6e7eb", "(bed.valve, { minutes, startedAt: now })"], + ], + [ + ["#e6e7eb", " garden.log."], + ["#82aaff", "push"], + ["#e6e7eb", "(`watered ${bed.name} for ${minutes}m`)"], + ], + [["#e6e7eb", " }"]], + [], + [ + ["#c792ea", " return "], + ["#e6e7eb", "dry."], + ["#82aaff", "map"], + ["#e6e7eb", "((bed) => bed.name)"], + ], + [["#e6e7eb", "}"]], + ] + + ctx.font = "13px Menlo, ui-monospace, monospace" + code.forEach((line, index) => { + const y = 76 + index * 22 + ctx.fillStyle = "#4b4e57" + ctx.textAlign = "right" + ctx.fillText(String(index + 1), 296, y) + ctx.textAlign = "left" + line.reduce((x, [color, text]) => { + ctx.fillStyle = color + ctx.fillText(text, x, y) + + return x + ctx.measureText(text).width + }, 320) + }) + + ctx.fillStyle = "#26272d" + ctx.fillRect(1080, 38, 360, 836) + ctx.fillStyle = "#e6e7eb" + ctx.font = "600 12px -apple-system, system-ui, sans-serif" + ctx.fillText("Soil moisture", 1100, 70) + ;[0.62, 0.31, 0.48, 0.27, 0.71, 0.55].forEach((value, index) => { + const y = 92 + index * 34 + ctx.fillStyle = "#a9abb3" + ctx.font = "11px -apple-system, system-ui, sans-serif" + ctx.fillText(`Bed ${String.fromCharCode(65 + index)}`, 1100, y + 12) + ctx.fillStyle = "#33353d" + ctx.fillRect(1160, y + 3, 220, 10) + ctx.fillStyle = value < 0.35 ? "#f78c6c" : "#7fd88f" + ctx.fillRect(1160, y + 3, 220 * value, 10) + ctx.fillStyle = "#7d808a" + ctx.font = "8px -apple-system, system-ui, sans-serif" + ctx.fillText(`${Math.round(value * 100)}% · threshold 35%`, 1386 - 64, y + 26) + }) + ctx.fillStyle = "#7d808a" + ctx.font = "7px -apple-system, system-ui, sans-serif" + ;[ + "Sensor readings refresh every 90 seconds.", + "Beds below their threshold are watered on the next tick.", + "Valve 4 was serviced on 12 March; calibration offset +0.02.", + ].forEach((text, index) => ctx.fillText(text, 1100, 330 + index * 11)) + + ctx.fillStyle = "#3b5bdb" + ctx.fillRect(0, 874, 1440, 26) + ctx.fillStyle = "#ffffff" + ctx.font = "11px -apple-system, system-ui, sans-serif" + ctx.fillText("main · 0 problems · Ln 6, Col 42 · UTF-8 · TypeScript", 12, 891) + + return canvas.toDataURL("image/png") +} diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 7eda4d0be9a..07cb3629788 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -1,22 +1,192 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { animate, type AnimationPlaybackControls } from "motion" +import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { useI18n } from "../context/i18n" +import { Button } from "@opencode/ui/button" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" +import { clampView, detailScale, FIT_VIEW, maxScale, panBy, wheelZoomFactor, zoomAt, type ZoomView } from "./image-zoom" export interface ImagePreviewProps { src: string alt?: string } +const STEP = Math.SQRT2 + +const CENTER = { x: 0, y: 0 } + export function ImagePreview(props: ImagePreviewProps) { const i18n = useI18n() + let viewport!: HTMLDivElement + let image!: HTMLImageElement + let close: HTMLButtonElement | undefined + const [view, setView] = createSignal(FIT_VIEW) + const [fitRatio, setFitRatio] = createSignal(1) + const [drag, setDrag] = createSignal<{ id: number; x: number; y: number }>() + // Where an eased zoom is heading, so repeated steps build on the destination rather than the frame in flight. + let goal = FIT_VIEW + let animation: AnimationPlaybackControls | undefined + + const percent = createMemo(() => new Intl.NumberFormat(i18n.locale(), { style: "percent", maximumFractionDigits: 0 })) + + const bounds = () => ({ + width: image.offsetWidth, + height: image.offsetHeight, + viewportWidth: viewport.clientWidth, + viewportHeight: viewport.clientHeight, + maxScale: maxScale(fitRatio()), + }) + + // Pinch, wheel and drag follow the hand directly and take over from an eased zoom mid-flight. + const follow = (next: ZoomView) => { + animation?.stop() + animation = undefined + goal = next + setView(next) + } + + const ease = (target: ZoomView) => { + animation?.stop() + goal = target + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return setView(target) + const from = view() + animation = animate(0, 1, { + type: "spring", + visualDuration: 0.25, + bounce: 0, + onUpdate: (progress) => + setView({ + scale: from.scale + (target.scale - from.scale) * progress, + x: from.x + (target.x - from.x) * progress, + y: from.y + (target.y - from.y) * progress, + }), + }) + } + + const step = (factor: number) => ease(zoomAt(goal, goal.scale * factor, CENTER, bounds())) + + const point = (event: MouseEvent) => { + const rect = viewport.getBoundingClientRect() + + return { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 } + } + + const measure = () => { + if (!image.naturalWidth) return + setFitRatio(image.offsetWidth / image.naturalWidth) + follow(clampView(view(), bounds())) + } + + createResizeObserver(() => [viewport, image], measure) + + createEffect( + on( + () => props.src, + () => follow(FIT_VIEW), + { defer: true }, + ), + ) + + onCleanup(() => animation?.stop()) + + // The preview owns zoom gestures while it is open: the page behind must neither scroll nor zoom the whole window. + makeEventListener( + window, + "wheel", + (event: WheelEvent) => { + const inside = event.target instanceof Node && viewport.contains(event.target) + const zoom = event.ctrlKey || event.metaKey + + if (!inside && !zoom) return + event.preventDefault() + event.stopPropagation() + + if (!inside) return + const current = view() + + if (zoom) { + follow(zoomAt(current, current.scale * wheelZoomFactor(pixels(event, event.deltaY)), point(event), bounds())) + + return + } + + const horizontal = event.shiftKey && event.deltaX === 0 + follow( + panBy( + current, + { + x: -pixels(event, horizontal ? event.deltaY : event.deltaX), + y: horizontal ? 0 : -pixels(event, event.deltaY), + }, + bounds(), + ), + ) + }, + { passive: false, capture: true }, + ) + + // Captured ahead of the desktop shell, which zooms the whole window on the same shortcuts. + makeEventListener( + window, + "keydown", + (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey) return + const action = shortcut(event.key) + + if (!action) return + event.preventDefault() + event.stopPropagation() + + if (action === "fit") return ease(FIT_VIEW) + step(action === "in" ? STEP : 1 / STEP) + }, + { capture: true }, + ) return (
- + { + // Open with focus on Close, as before, rather than on the zoom controls that now precede it. + event.preventDefault() + close?.focus() + }} + >
+
+ } + variant="ghost" + aria-label={i18n.t("ui.imagePreview.zoomOut")} + onClick={() => step(1 / STEP)} + /> + + } + variant="ghost" + aria-label={i18n.t("ui.imagePreview.zoomIn")} + onClick={() => step(STEP)} + /> +
} @@ -24,11 +194,63 @@ export function ImagePreview(props: ImagePreviewProps) { aria-label={i18n.t("ui.common.close")} />
-
- {props.alt +
1 ? "" : undefined} + data-dragging={drag() ? "" : undefined} + onDblClick={(event) => { + if (goal.scale > 1) return ease(FIT_VIEW) + ease(zoomAt(goal, detailScale(fitRatio()), point(event), bounds())) + }} + onPointerDown={(event) => { + if (event.button !== 0 || view().scale <= 1) return + event.preventDefault() + viewport.setPointerCapture(event.pointerId) + setDrag({ id: event.pointerId, x: event.clientX, y: event.clientY }) + }} + onPointerMove={(event) => { + const current = drag() + + if (current?.id !== event.pointerId) return + setDrag({ id: current.id, x: event.clientX, y: event.clientY }) + follow(panBy(view(), { x: event.clientX - current.x, y: event.clientY - current.y }, bounds())) + }} + onPointerUp={(event) => { + if (drag()?.id === event.pointerId) setDrag(undefined) + }} + onPointerCancel={(event) => { + if (drag()?.id === event.pointerId) setDrag(undefined) + }} + > + {props.alt
) } + +function shortcut(key: string) { + if (key === "=" || key === "+") return "in" + + if (key === "-" || key === "_") return "out" + + if (key === "0") return "fit" +} + +function pixels(event: WheelEvent, delta: number) { + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * 16 + + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * window.innerHeight + + return delta +} diff --git a/packages/ui/src/components/image-zoom.test.ts b/packages/ui/src/components/image-zoom.test.ts new file mode 100644 index 00000000000..f9bbcd488d4 --- /dev/null +++ b/packages/ui/src/components/image-zoom.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { clampView, detailScale, FIT_VIEW, maxScale, panBy, wheelZoomFactor, zoomAt } from "./image-zoom" + +// A 1000×500 image fitted into a 1000×600 viewport at half its natural size. +const bounds = { width: 1000, height: 500, viewportWidth: 1000, viewportHeight: 600, maxScale: maxScale(0.5) } + +describe("zoomAt", () => { + test("keeps the image point under the cursor in place", () => { + const anchor = { x: 300, y: -100 } + const view = zoomAt(FIT_VIEW, 2, anchor, bounds) + // The fitted point under the anchor sits at (anchor - offset) / scale in image space. + const before = { x: (anchor.x - FIT_VIEW.x) / FIT_VIEW.scale, y: (anchor.y - FIT_VIEW.y) / FIT_VIEW.scale } + + expect(view).toEqual({ scale: 2, x: -300, y: 100 }) + expect({ x: before.x * view.scale + view.x, y: before.y * view.scale + view.y }).toEqual(anchor) + }) + + test("zooms about the center without moving it", () => { + expect(zoomAt({ scale: 2, x: 120, y: -40 }, 4, { x: 0, y: 0 }, bounds)).toEqual({ scale: 4, x: 240, y: -80 }) + }) + + test("never shrinks below fit or grows past the limit", () => { + expect(zoomAt({ scale: 3, x: 400, y: 200 }, 0.2, { x: 100, y: 100 }, bounds)).toEqual(FIT_VIEW) + expect(zoomAt(FIT_VIEW, 100, { x: 0, y: 0 }, bounds).scale).toBe(16) + }) + + test("pulls an anchor near the edge back so no empty space shows", () => { + expect(zoomAt(FIT_VIEW, 2, { x: 500, y: 0 }, bounds)).toEqual({ scale: 2, x: -500, y: 0 }) + expect(zoomAt(FIT_VIEW, 1.1, { x: 500, y: 300 }, bounds)).toEqual({ scale: 1.1, x: -50, y: 0 }) + }) +}) + +describe("panBy", () => { + test("moves freely inside the overflow", () => { + expect(panBy({ scale: 2, x: 0, y: 0 }, { x: 120, y: -80 }, bounds)).toEqual({ scale: 2, x: 120, y: -80 }) + }) + + test("stops at each edge of the zoomed image", () => { + // At 2x the image is 2000×1000, so it can travel 500px horizontally and 200px vertically each way. + expect(panBy({ scale: 2, x: 450, y: 150 }, { x: 900, y: 900 }, bounds)).toEqual({ scale: 2, x: 500, y: 200 }) + expect(panBy({ scale: 2, x: 0, y: 0 }, { x: -900, y: -900 }, bounds)).toEqual({ scale: 2, x: -500, y: -200 }) + }) + + test("keeps an axis that still fits centered", () => { + expect(panBy(FIT_VIEW, { x: 40, y: 40 }, bounds)).toEqual(FIT_VIEW) + expect(panBy({ scale: 1.1, x: 0, y: 0 }, { x: 0, y: 40 }, bounds)).toEqual({ scale: 1.1, x: 0, y: 0 }) + }) +}) + +describe("clampView", () => { + test("re-centers a pan that a smaller zoom or larger viewport no longer allows", () => { + expect(clampView({ scale: 2, x: 500, y: 200 }, { ...bounds, viewportWidth: 1600, viewportHeight: 1200 })).toEqual({ + scale: 2, + x: 200, + y: 0, + }) + }) +}) + +describe("detailScale", () => { + test("opens a downscaled image at 100%", () => { + expect(detailScale(0.25)).toBe(4) + }) + + test("doubles an image already shown near its natural size", () => { + expect(detailScale(1)).toBe(2) + expect(detailScale(0.8)).toBe(2) + }) +}) + +describe("wheelZoomFactor", () => { + test("zooms in for upward deltas and out for downward ones by the same ratio", () => { + expect(wheelZoomFactor(-10)).toBeGreaterThan(1) + expect(wheelZoomFactor(-10) * wheelZoomFactor(10)).toBeCloseTo(1) + }) + + test("bounds a coarse mouse-wheel notch", () => { + expect(wheelZoomFactor(-400)).toBe(wheelZoomFactor(-100)) + expect(wheelZoomFactor(-400)).toBeLessThan(1.5) + }) +}) diff --git a/packages/ui/src/components/image-zoom.ts b/packages/ui/src/components/image-zoom.ts new file mode 100644 index 00000000000..165bfab8917 --- /dev/null +++ b/packages/ui/src/components/image-zoom.ts @@ -0,0 +1,75 @@ +/** + * Zoom and pan math for a fitted image inside a clipping viewport. + * + * `scale` is relative to the fitted size, so 1 is "fit". `x` and `y` offset the image center from the viewport + * center in screen pixels, and anchors use the same viewport-center coordinates. + */ +export type ZoomView = { scale: number; x: number; y: number } + +export type ZoomBounds = { + /** The image's fitted size, before any zoom. */ + width: number + height: number + viewportWidth: number + viewportHeight: number + maxScale: number +} + +export type ZoomPoint = { x: number; y: number } + +/** Upper zoom limit, relative to the image's natural pixel size. */ +export const MAX_NATURAL_ZOOM = 8 + +export const FIT_VIEW: ZoomView = { scale: 1, x: 0, y: 0 } + +/** The largest fitted-relative scale, given how far the fitted image is shrunk from its natural size. */ +export function maxScale(fitRatio: number) { + return Math.max(1, MAX_NATURAL_ZOOM / fitRatio) +} + +/** Scales the view while the image point under `anchor` stays where it is on screen. */ +export function zoomAt(view: ZoomView, scale: number, anchor: ZoomPoint, bounds: ZoomBounds): ZoomView { + const next = clamp(scale, 1, bounds.maxScale) + const ratio = next / view.scale + + return clampView( + { scale: next, x: anchor.x - (anchor.x - view.x) * ratio, y: anchor.y - (anchor.y - view.y) * ratio }, + bounds, + ) +} + +export function panBy(view: ZoomView, delta: ZoomPoint, bounds: ZoomBounds): ZoomView { + return clampView({ scale: view.scale, x: view.x + delta.x, y: view.y + delta.y }, bounds) +} + +/** Keeps the zoomed image covering the viewport on each axis it overflows, and centered on each axis it fits. */ +export function clampView(view: ZoomView, bounds: ZoomBounds): ZoomView { + return { + scale: view.scale, + x: clampAxis(view.x, bounds.width * view.scale, bounds.viewportWidth), + y: clampAxis(view.y, bounds.height * view.scale, bounds.viewportHeight), + } +} + +/** The double-click target: 100% for a downscaled image, or twice the fitted size when 100% is barely larger. */ +export function detailScale(fitRatio: number) { + const actual = 1 / fitRatio + + return actual >= 1.5 ? actual : 2 +} + +/** The zoom factor for one wheel event, bounded so a coarse mouse-wheel notch is a step rather than a jump. */ +export function wheelZoomFactor(deltaY: number) { + return Math.exp(-clamp(deltaY, -30, 30) / 100) +} + +function clampAxis(offset: number, size: number, viewport: number) { + const limit = Math.max(0, (size - viewport) / 2) + + // Adding zero turns -0 into 0. + return clamp(offset, -limit, limit) + 0 +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index ef90500ee82..d3087d9bbd4 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -178,6 +178,9 @@ const source = { "ui.textField.copied": "Copied", "ui.imagePreview.alt": "Image preview", + "ui.imagePreview.zoomIn": "Zoom in", + "ui.imagePreview.zoomOut": "Zoom out", + "ui.imagePreview.zoomToFit": "Zoom to fit", "ui.scrollView.ariaLabel": "scrollable content", "ui.tool.read": "Read", diff --git a/packages/ui/src/icons/catalog.ts b/packages/ui/src/icons/catalog.ts index f40c80b25e5..cac5d3c1bcf 100644 --- a/packages/ui/src/icons/catalog.ts +++ b/packages/ui/src/icons/catalog.ts @@ -78,6 +78,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + minus: { + viewBox: "0 0 16 16", + body: ``, + }, plus: { viewBox: "0 0 16 16", body: ``,