diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index d3927b4d52..c3405f2db2 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -263,6 +263,11 @@ export const STICKY_NOTE_SHADOW_OPACITY = 0.16; export const STICKY_NOTE_EDGE_SHADOW_WIDTH = 0.5; export const STICKY_NOTE_EDGE_SHADOW_OPACITY = 0.08; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont; +/** number of slots in the font-picker top-picks strip — pick customization + * (replace / reorder) preserves it. Must equal `DEFAULT_FONTS.length` in + * `packages/excalidraw/components/FontPicker/FontPicker.tsx` (enforced by + * `fontTopPicks.test.ts`) */ +export const FONT_TOP_PICKS_SLOTS = 3; export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index c87d3ddfac..6ec1add522 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -112,6 +112,7 @@ export const actionClearCanvas = register({ gridModeEnabled: appState.gridModeEnabled, stats: appState.stats, colorTopPicks: appState.colorTopPicks, + fontTopPicks: appState.fontTopPicks, activeTool: appState.activeTool.type === "image" ? { diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index d3effa85bb..86d69482f5 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1143,7 +1143,10 @@ export const actionIncreaseFontSize = register({ type ChangeFontFamilyData = Partial< Pick< AppState, - "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily" + | "openPopup" + | "currentItemFontFamily" + | "currentHoveredFontFamily" + | "fontTopPicks" > > & { /** cache of selected & editing elements populated on opened popup */ @@ -1441,7 +1444,9 @@ export const actionChangeFontFamily = register<{ isOpened={appState.openPopup === "fontFamily"} selectedFontFamily={selectedFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily} + topPicks={appState.fontTopPicks} compactMode={stylesPanelMode !== "full"} + onTopPicksChange={(fontTopPicks) => updateData({ fontTopPicks })} onSelect={(fontFamily) => { withCaretPositionPreservation( () => { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 703a077282..7d9b0f3485 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -141,6 +141,7 @@ export const getDefaultAppState = (): Omit< stickyNoteStroke: null, stickyNoteBackground: null, }, + fontTopPicks: null, }; }; @@ -284,6 +285,7 @@ const APP_STATE_STORAGE_CONF = (< activeLockedId: { browser: false, export: false, server: false }, bindMode: { browser: true, export: false, server: false }, colorTopPicks: { browser: true, export: false, server: false }, + fontTopPicks: { browser: true, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss index e33f355c5d..30af2fe56e 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.scss +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss @@ -25,44 +25,6 @@ text-align: left; } - .color-picker__context-menu { - background: var(--popup-bg-color); - border-radius: var(--border-radius-lg); - box-shadow: var(--shadow-island); - padding: 0.25rem; - } - - .color-picker__context-menu-item { - font-size: 0.75rem; - padding: 0.375rem 0.625rem; - border-radius: var(--border-radius-md); - color: var(--text-primary-color); - cursor: pointer; - outline: none; - user-select: none; - - // :hover as well — radix only sets data-highlighted once the item gets - // focus via pointermove, which leaves dead frames (e.g. menu opening - // under a stationary cursor) - &[data-highlighted], - &:not([data-disabled]):hover { - background: var(--button-hover-bg); - } - - &[data-disabled] { - opacity: 0.5; - pointer-events: none; - } - } - - .color-picker__tip { - padding: 0 0.5rem; - font-size: 0.6875rem; - line-height: 1.4; - text-align: left; - color: var(--color-gray-60); - } - .color-picker-container { display: grid; grid-template-columns: 1fr 20px 1.625rem; @@ -442,157 +404,27 @@ } } - // top-picks drag & drop (pinning + reordering) + // top-picks drag & drop (pinning + reordering) — shared parts live in + // TopPicksDnD.scss // --------------------------------------------------------------------------- - .color-picker__top-picks { - // anchor for the animated drop-target outline - position: relative; - } - - .color-picker__top-picks .color-picker__button { - // the strip is tiny and never scrolls — allow touch-dragging picks - touch-action: none; - } - - .color-picker__top-picks.is-dnd-active { - .color-picker__button { - // no hover/tooltips while a drag is in flight; hit-testing is - // coordinate-based so this doesn't affect dropping - pointer-events: none; - transition: transform 0.14s ease, opacity 0.14s ease, - box-shadow 0.14s ease; - } - } - - // "marching ants" outline hinting that the strip accepts the drop — - // SVG because CSS dashed outlines/borders can't animate their dash offset - .color-picker__top-picks__dnd-outline { - position: absolute; - top: -5px; - left: -5px; - width: calc(100% + 10px); - height: calc(100% + 10px); - pointer-events: none; - overflow: visible; - - rect { - x: 0.5px; - y: 0.5px; - rx: 6px; - width: calc(100% - 1px); - height: calc(100% - 1px); - fill: none; - stroke: var(--color-primary); - stroke-width: 1; - stroke-dasharray: 4 4; - animation: excalidraw-color-dnd-march 0.8s linear infinite; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } - } - } - - @keyframes excalidraw-color-dnd-march { - to { - // one full dash+gap period per cycle → seamless loop - stroke-dashoffset: -8; - } - } - - // the pick being reordered — its ghost is following the pointer - .color-picker__button.is-dnd-source { - opacity: 0.3; - } - // slot a dragged swatch would replace .color-picker__button.is-dnd-target { transform: scale(1.2); box-shadow: 0 0 0 1px var(--color-primary-darkest); } - - // this color is already pinned — drop not allowed - .color-picker__button.is-dnd-duplicate { - animation: excalidraw-color-dnd-duplicate 0.45s ease-in-out infinite - alternate; - } - - @keyframes excalidraw-color-dnd-duplicate { - from { - box-shadow: 0 0 0 2px var(--color-danger, #e03131); - transform: scale(1); - } - to { - box-shadow: 0 0 0 3px var(--color-danger, #e03131); - transform: scale(1.12); - } - } } -// drag & drop ghost — appended to document.body (outside the .excalidraw -// scope) so it can follow the pointer without overflow clipping -.excalidraw-color-dnd-ghost { - position: fixed; - top: 0; - left: 0; - z-index: 10000; - pointer-events: none; - will-change: transform; - // NOTE transform is deliberately not transitioned here (tracks the pointer - // every frame); --dropping adds it for the fly-to-target animation - transition: width 0.15s ease, height 0.15s ease; +// drag & drop ghost content (see TopPicksDnD.scss) — outside the .excalidraw +// scope +.excalidraw-color-dnd-ghost-swatch { + --top-picks-dnd-ghost-ring: 0 0 0 1px rgba(0, 0, 0, 0.08); - &__swatch { - width: 100%; - height: 100%; - border-radius: 4px; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); - transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease; + border-radius: 4px; - &.is-transparent { - background-color: #fff; - background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg=="); - background-position: left center; - } - } - - &--lifted &__swatch { - transform: scale(1.2); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.08); - } - - &--over &__swatch { - transform: scale(1.05); - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.08); - } - - &--blocked &__swatch { - opacity: 0.4; - transform: scale(0.85); - } - - &--dropping { - transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1), width 0.18s ease, - height 0.18s ease; - } - - &--dropping &__swatch { - transform: scale(1); - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); - } - - &--fade &__swatch { - opacity: 0; - } -} - -body.excalidraw-color-dnd-active { - user-select: none !important; - -webkit-user-select: none !important; - - &, - * { - cursor: grabbing !important; + &.is-transparent { + background-color: #fff; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg=="); + background-position: left center; } } diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index fbafca026b..271cb2bbbb 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -38,8 +38,8 @@ import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { ColorPickerDnDContext, useColorPickerDnD, - useTopPicksDnD, -} from "./topPicksDnD"; + useColorTopPicksDnD, +} from "./colorTopPicksDnD"; import "./ColorPicker.scss"; @@ -80,6 +80,7 @@ const ColorPickerPopupContent = ({ getOpenPopup, appState, excludedColors, + onResetTopPicks, }: Pick< ColorPickerProps, | "type" @@ -93,6 +94,8 @@ const ColorPickerPopupContent = ({ | "excludedColors" > & { getOpenPopup: () => AppState["openPopup"]; + /** present only while the top picks are customized */ + onResetTopPicks?: () => void; }) => { const { container } = useExcalidrawContainer(); const app = useApp(); @@ -137,7 +140,7 @@ const ColorPickerPopupContent = ({ // `data-highlighted` styling and break its keyboard navigation) if ( target instanceof app.ownerWindow.HTMLElement && - target.closest(".color-picker__context-menu") + target.closest(".top-picks-dnd__context-menu") ) { event.preventDefault(); return; @@ -194,6 +197,7 @@ const ColorPickerPopupContent = ({ ref={colorPickerContentRef} palette={palette} excludedColors={excludedColors} + onResetTopPicks={onResetTopPicks} color={color} theme={appState.theme} onChange={(changedColor) => { @@ -320,7 +324,7 @@ const ColorPickerTrigger = ({ // the active-color swatch can be dragged onto the top-picks strip to // pin the current (possibly custom) color onPointerDown={ - dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined + dnd ? (event) => dnd.startSourceDrag(event, color) : undefined } >
{!color && slashIcon}
@@ -380,7 +384,19 @@ const ColorPickerComponent = ({ ? DEFAULT_ELEMENT_STROKE_PICKS : DEFAULT_ELEMENT_BACKGROUND_PICKS); - const dnd = useTopPicksDnD({ + const resetTopPicks = + isTopPicksCustomizable && customizableTopPicks + ? () => { + updateData({ + colorTopPicks: { + ...appState.colorTopPicks, + [customizableTopPicks]: null, + }, + }); + } + : undefined; + + const dnd = useColorTopPicksDnD({ enabled: isTopPicksCustomizable, picks: effectiveTopPicks, onPicksChange: (picks) => { @@ -428,18 +444,7 @@ const ColorPickerComponent = ({ type={type} topPicks={customTopPicks?.length ? customTopPicks : topPicks} isCustomized={!!customTopPicks?.length} - onReset={ - isTopPicksCustomizable && customizableTopPicks - ? () => { - updateData({ - colorTopPicks: { - ...appState.colorTopPicks, - [customizableTopPicks]: null, - }, - }); - } - : undefined - } + onReset={resetTopPicks} /> )} {!isCompactMode && } @@ -485,6 +490,9 @@ const ColorPickerComponent = ({ updateData={updateData} getOpenPopup={() => openRef.current} appState={appState} + onResetTopPicks={ + customTopPicks?.length ? resetTopPicks : undefined + } /> )} diff --git a/packages/excalidraw/components/ColorPicker/CustomColorList.tsx b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx index a5ccd869d6..d6f3c359cf 100644 --- a/packages/excalidraw/components/ColorPicker/CustomColorList.tsx +++ b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx @@ -9,7 +9,7 @@ import { useAtom } from "../../editor-jotai"; import HotkeyLabel from "./HotkeyLabel"; import { activeColorPickerSectionAtom } from "./colorPickerUtils"; -import { useColorPickerDnD } from "./topPicksDnD"; +import { useColorPickerDnD } from "./colorTopPicksDnD"; interface CustomColorListProps { theme: Theme; @@ -60,7 +60,7 @@ export const CustomColorList = ({ setActiveColorPickerSection("custom"); }} onPointerDown={ - dnd ? (event) => dnd.startSwatchDrag(event, c) : undefined + dnd ? (event) => dnd.startSourceDrag(event, c) : undefined } title={c} aria-label={label} diff --git a/packages/excalidraw/components/ColorPicker/Picker.tsx b/packages/excalidraw/components/ColorPicker/Picker.tsx index fa64b5aae8..4407394264 100644 --- a/packages/excalidraw/components/ColorPicker/Picker.tsx +++ b/packages/excalidraw/components/ColorPicker/Picker.tsx @@ -14,6 +14,7 @@ import type { ColorPaletteCustom } from "@excalidraw/common"; import { useAtom } from "../../editor-jotai"; import { t } from "../../i18n"; +import { TopPicksTip } from "../TopPicksDnD/TopPicksTip"; import { CustomColorList } from "./CustomColorList"; import PickerColorList from "./PickerColorList"; @@ -26,7 +27,7 @@ import { isCustomColor, } from "./colorPickerUtils"; import { colorPickerKeyNavHandler } from "./keyboardNavHandlers"; -import { useColorPickerDnD } from "./topPicksDnD"; +import { useColorPickerDnD } from "./colorTopPicksDnD"; import type { ColorPickerType } from "./colorPickerUtils"; @@ -44,6 +45,8 @@ interface PickerProps { onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; showHotKey?: boolean; excludedColors?: readonly string[]; + /** present only while the top picks are customized */ + onResetTopPicks?: () => void; } export const Picker = React.forwardRef( @@ -62,6 +65,7 @@ export const Picker = React.forwardRef( onEscape, showHotKey = true, excludedColors, + onResetTopPicks, }: PickerProps, ref, ) => { @@ -216,9 +220,11 @@ export const Picker = React.forwardRef( {children} {/* dnd context is only provided when top picks are customizable */} {dnd && ( -
- {t("colorPicker.topPicksTip")} -
+ )} diff --git a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx index 5b23c4d434..271742885a 100644 --- a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx +++ b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx @@ -19,7 +19,7 @@ import { colorPickerHotkeyBindings, getColorNameAndShadeFromColor, } from "./colorPickerUtils"; -import { useColorPickerDnD } from "./topPicksDnD"; +import { useColorPickerDnD } from "./colorTopPicksDnD"; import type { TranslationKeys } from "../../i18n"; @@ -108,7 +108,7 @@ const PickerColorList = ({ setActiveColorPickerSection("baseColors"); }} onPointerDown={ - dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined + dnd ? (event) => dnd.startSourceDrag(event, color) : undefined } title={`${label}${ color.startsWith("#") ? ` ${color}` : "" diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx index 9d4d05f506..334696aba9 100644 --- a/packages/excalidraw/components/ColorPicker/ShadeList.tsx +++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx @@ -17,7 +17,7 @@ import { activeColorPickerSectionAtom, getColorNameAndShadeFromColor, } from "./colorPickerUtils"; -import { useColorPickerDnD } from "./topPicksDnD"; +import { useColorPickerDnD } from "./colorTopPicksDnD"; interface ShadeListProps { theme: Theme; @@ -89,7 +89,7 @@ export const ShadeList = ({ setActiveColorPickerSection("shades"); }} onPointerDown={ - dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined + dnd ? (event) => dnd.startSourceDrag(event, color) : undefined } >
diff --git a/packages/excalidraw/components/ColorPicker/TopPicks.tsx b/packages/excalidraw/components/ColorPicker/TopPicks.tsx index e2b5d3ddb5..f2879b8fd8 100644 --- a/packages/excalidraw/components/ColorPicker/TopPicks.tsx +++ b/packages/excalidraw/components/ColorPicker/TopPicks.tsx @@ -1,5 +1,4 @@ import clsx from "clsx"; -import { ContextMenu } from "radix-ui"; import { applyDarkModeFilter, @@ -14,9 +13,13 @@ import { import type { Theme } from "@excalidraw/element/types"; import { t } from "../../i18n"; -import { useExcalidrawContainer } from "../App"; +import { TopPicksContextMenu } from "../TopPicksDnD/TopPicksContextMenu"; +import { + getTopPickReorderOffset, + TopPicksDnDOutline, +} from "../TopPicksDnD/topPicksDnD"; -import { useColorPickerDnD } from "./topPicksDnD"; +import { useColorPickerDnD } from "./colorTopPicksDnD"; import type { ColorPickerType } from "./colorPickerUtils"; @@ -44,7 +47,6 @@ export const TopPicks = ({ }: TopPicksProps) => { const dnd = useColorPickerDnD(); const dragState = dnd?.dragState ?? null; - const { container } = useExcalidrawContainer(); let colors; if (type === "elementStroke") { @@ -69,116 +71,62 @@ export const TopPicks = ({ return null; } - // live preview of the reorder result — every pick translates to the slot - // it would occupy if dropped right now - const getReorderOffset = (index: number) => { - if ( - !dragState || - dragState.origin.kind !== "pick" || - dragState.overIndex === null - ) { - return 0; - } - const from = dragState.origin.index; - const to = dragState.overIndex; - if (from === to) { - return 0; - } - let newIndex = index; - if (index === from) { - newIndex = to; - } else { - if (index > from) { - newIndex -= 1; - } - if (newIndex >= to) { - newIndex += 1; - } - } - return (newIndex - index) * dragState.slotSpan; - }; - - const strip = ( -
- {dragState && ( - - )} - {colors.map((color: string, index: number) => { - const reorderOffset = getReorderOffset(index); - const displayColor = applyDarkModeFilter(color, theme === THEME.DARK); - return ( - - ); - })} -
- ); - - if (!onReset) { - return strip; - } - return ( - - {strip} - - - - {t("colorPicker.resetTopPicks")} - - - - + +
+ {dragState && } + {colors.map((color: string, index: number) => { + const reorderOffset = getTopPickReorderOffset(dragState, index); + const displayColor = applyDarkModeFilter(color, theme === THEME.DARK); + return ( + + ); + })} +
+
); }; diff --git a/packages/excalidraw/components/ColorPicker/colorTopPicksDnD.ts b/packages/excalidraw/components/ColorPicker/colorTopPicksDnD.ts new file mode 100644 index 0000000000..f4055d0bd5 --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/colorTopPicksDnD.ts @@ -0,0 +1,64 @@ +import { createContext, useContext } from "react"; + +import { colorToHex, isTransparent } from "@excalidraw/common"; + +import { useTopPicksDnD } from "../TopPicksDnD/topPicksDnD"; + +import type { + CreateTopPicksGhost, + TopPicksDnD, +} from "../TopPicksDnD/topPicksDnD"; + +/** value-equality of colors — normalizes notation (`#fff` vs `#ffffff` vs + * `white`) so visually identical colors can't occupy multiple pick slots */ +const isSameColor = (a: string, b: string) => { + if (a.toLowerCase() === b.toLowerCase()) { + return true; + } + const aHex = colorToHex(a); + return aHex !== null && aHex === colorToHex(b); +}; + +const createColorGhost: CreateTopPicksGhost = ({ + value: color, + sourceEl, +}) => { + const swatch = document.createElement("div"); + swatch.className = "excalidraw-color-dnd-ghost-swatch"; + if (isTransparent(color)) { + swatch.classList.add("is-transparent"); + } else { + // swatches render the theme-adjusted color (dark mode remaps colors + // rather than CSS-filtering them) — sample the rendered color so the + // ghost matches what the user picked up + const rendered = getComputedStyle(sourceEl).backgroundColor; + swatch.style.backgroundColor = + rendered && rendered !== "rgba(0, 0, 0, 0)" ? rendered : color; + } + return { content: swatch, rect: sourceEl.getBoundingClientRect() }; +}; + +export type ColorPickerDnD = TopPicksDnD; + +export const ColorPickerDnDContext = createContext(null); + +export const useColorPickerDnD = () => useContext(ColorPickerDnDContext); + +/** pinning colors from the picker popup (palette/shades/custom) or the + * active-color trigger to the top-picks strip, and reordering the strip */ +export const useColorTopPicksDnD = ({ + enabled, + picks, + onPicksChange, +}: { + enabled: boolean; + picks: readonly string[]; + onPicksChange: (picks: string[]) => void; +}): ColorPickerDnD => + useTopPicksDnD({ + enabled, + picks, + onPicksChange, + isSamePick: isSameColor, + createGhost: createColorGhost, + }); diff --git a/packages/excalidraw/components/FontPicker/FontPicker.scss b/packages/excalidraw/components/FontPicker/FontPicker.scss index 19492772ab..1f182e0bc4 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.scss +++ b/packages/excalidraw/components/FontPicker/FontPicker.scss @@ -17,4 +17,34 @@ grid-template-columns: none; } } + + .FontPicker__top-picks-slots { + display: flex; + // follow the .buttonList spacing + gap: inherit; + } + + // slot a dragged font would replace + .FontPicker__top-picks .top-picks-dnd__pick.is-dnd-target { + transform: scale(1.1); + border-color: var(--color-primary); + } + + // glyph sample standing in for a clashing icon (see `needsGlyphSample`) + .FontPicker__top-pick-sample { + font-size: 1rem; + line-height: 1; + } + + .FontPicker__tip { + margin-top: 0.5rem; + } +} + +// drag & drop ghost content (see TopPicksDnD.scss) — outside the .excalidraw +// scope, so the tile's colors are sampled inline from a strip button +.excalidraw-font-dnd-ghost-tile { + display: flex; + align-items: center; + justify-content: center; } diff --git a/packages/excalidraw/components/FontPicker/FontPicker.test.tsx b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx index ab92464fd5..0801f1b8e5 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.test.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx @@ -1,19 +1,22 @@ -import { KEYS } from "@excalidraw/common"; +import { FONT_FAMILY, KEYS } from "@excalidraw/common"; import { Excalidraw } from "../.."; +import { API } from "../../tests/helpers/api"; import { Keyboard } from "../../tests/helpers/ui"; -import { act, render } from "../../tests/test-utils"; +import { act, fireEvent, render, waitFor } from "../../tests/test-utils"; + +const { h } = window; + +(global as any).ResizeObserver = + (global as any).ResizeObserver || + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; describe("FontPicker", () => { it("should be able to open font picker", async () => { - (global as any).ResizeObserver = - (global as any).ResizeObserver || - class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }; - const { queryByTestId } = await render( , ); @@ -28,4 +31,194 @@ describe("FontPicker", () => { fontPickerTrigger!.click(); }); }); + + describe("top picks drag & drop", () => { + const SLOT_SIZE = 32; + const SLOT_SPAN = 40; + + // jsdom has no layout — lay the strip slots out horizontally, and + // everything else (the list rows) well below the strip + beforeEach(() => { + vi.spyOn( + HTMLElement.prototype, + "getBoundingClientRect", + ).mockImplementation(function (this: HTMLElement) { + const index = this.dataset.topPickIndex; + if (index != null) { + return new DOMRect( + Number(index) * SLOT_SPAN, + 0, + SLOT_SIZE, + SLOT_SIZE, + ); + } + if (this.classList.contains("top-picks-dnd")) { + return new DOMRect(0, 0, SLOT_SPAN * 2 + SLOT_SIZE, SLOT_SIZE); + } + return new DOMRect(0, 300, SLOT_SIZE, SLOT_SIZE); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const slotCenter = (index: number) => ({ + clientX: index * SLOT_SPAN + SLOT_SIZE / 2, + clientY: SLOT_SIZE / 2, + }); + + const drag = async ( + source: HTMLElement, + from: { clientX: number; clientY: number }, + to: { clientX: number; clientY: number }, + ) => { + fireEvent.pointerDown(source, { pointerId: 1, button: 0, ...from }); + // wait out the sloppy-click grace period + await new Promise((resolve) => setTimeout(resolve, 120)); + fireEvent.pointerMove(window, { pointerId: 1, ...to }); + fireEvent.pointerUp(window, { pointerId: 1, ...to }); + // let the post-drop click suppression lapse + await new Promise((resolve) => setTimeout(resolve, 120)); + }; + + const getStripFamilies = (container: HTMLElement) => + Array.from( + container.querySelectorAll( + ".FontPicker__top-picks [data-top-pick-index]", + ), + ).map((button) => button.title); + + const openFontPicker = async () => { + const utils = await render(); + Keyboard.keyPress(KEYS.T); + act(() => { + utils.queryByTestId("font-family-show-fonts")!.click(); + }); + await waitFor(() => + expect( + document.querySelector('.dropdown-menu.fonts [title="Lilita One"]'), + ).not.toBeNull(), + ); + return utils; + }; + + it("pins a font dragged from the list, replacing the hovered slot", async () => { + const { container } = await openFontPicker(); + + const row = document.querySelector( + '.dropdown-menu.fonts [title="Lilita One"]', + )!; + await drag(row, { clientX: 16, clientY: 316 }, slotCenter(1)); + + await waitFor(() => + expect(h.state.fontTopPicks).toEqual([ + FONT_FAMILY.Excalifont, + FONT_FAMILY["Lilita One"], + FONT_FAMILY["Comic Shanns"], + ]), + ); + expect(getStripFamilies(container)).toEqual([ + "Hand-drawn", + "Lilita One", + "Code", + ]); + // pinning doesn't pick the font + expect(h.state.currentItemFontFamily).not.toBe(FONT_FAMILY["Lilita One"]); + }); + + it("refuses pinning an already pinned font", async () => { + await openFontPicker(); + + const row = document.querySelector( + '.dropdown-menu.fonts [title="Nunito"]', + )!; + await drag(row, { clientX: 16, clientY: 316 }, slotCenter(0)); + + expect(h.state.fontTopPicks).toBe(null); + }); + + it("reorders picks dragged within the strip", async () => { + const { container } = await openFontPicker(); + + const firstPick = container.querySelector( + '.FontPicker__top-picks [data-top-pick-index="0"]', + )!; + await drag(firstPick, slotCenter(0), slotCenter(2)); + + await waitFor(() => + expect(h.state.fontTopPicks).toEqual([ + FONT_FAMILY.Nunito, + FONT_FAMILY["Comic Shanns"], + FONT_FAMILY.Excalifont, + ]), + ); + expect(getStripFamilies(container)).toEqual([ + "Normal", + "Code", + "Hand-drawn", + ]); + }); + + it("pads a short customized list with unpicked defaults", async () => { + const { container } = await openFontPicker(); + + act(() => { + API.setAppState({ fontTopPicks: [FONT_FAMILY["Lilita One"]] }); + }); + await waitFor(() => + expect(getStripFamilies(container)).toEqual([ + "Lilita One", + "Hand-drawn", + "Normal", + ]), + ); + + // padded defaults count as pinned + const row = document.querySelector( + '.dropdown-menu.fonts [title="Nunito"]', + )!; + await drag(row, { clientX: 16, clientY: 316 }, slotCenter(0)); + expect(h.state.fontTopPicks).toEqual([FONT_FAMILY["Lilita One"]]); + + // dropping on a padded slot persists the full strip + const code = document.querySelector( + '.dropdown-menu.fonts [title="Comic Shanns"]', + )!; + await drag(code, { clientX: 16, clientY: 316 }, slotCenter(1)); + await waitFor(() => + expect(h.state.fontTopPicks).toEqual([ + FONT_FAMILY["Lilita One"], + FONT_FAMILY["Comic Shanns"], + FONT_FAMILY.Nunito, + ]), + ); + }); + + it("resets customized picks from the tip's reset link", async () => { + await openFontPicker(); + + const getResetLink = () => + document.querySelector( + ".FontPicker__tip .top-picks-dnd__tip-reset", + ); + // nothing to reset while the picks are the defaults + expect(getResetLink()).toBe(null); + + act(() => { + API.setAppState({ + fontTopPicks: [FONT_FAMILY["Lilita One"], FONT_FAMILY.Nunito], + }); + }); + await waitFor(() => expect(getResetLink()).not.toBe(null)); + + act(() => { + getResetLink()!.click(); + }); + + expect(h.state.fontTopPicks).toBe(null); + expect(h.state.openPopup).toBe("fontFamily"); + await waitFor(() => expect(getResetLink()).toBe(null)); + }); + }); }); diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 4325147e17..66a4a3726b 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -1,25 +1,44 @@ import { Popover } from "radix-ui"; import clsx from "clsx"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback } from "react"; -import { FONT_FAMILY } from "@excalidraw/common"; +import { + FONT_FAMILY, + FONT_TOP_PICKS_SLOTS, + getFontFamilyString, +} from "@excalidraw/common"; import type { FontFamilyValues } from "@excalidraw/element/types"; +import { Fonts } from "../../fonts"; import { t } from "../../i18n"; -import { RadioSelection } from "../RadioSelection"; import { ButtonSeparator } from "../ButtonSeparator"; import { FontFamilyCodeIcon, FontFamilyNormalIcon, FreedrawIcon, } from "../icons"; +import { TopPicksContextMenu } from "../TopPicksDnD/TopPicksContextMenu"; +import { + getTopPickReorderOffset, + TopPicksDnDOutline, +} from "../TopPicksDnD/topPicksDnD"; -import { FontPickerList } from "./FontPickerList"; +import { + FontPickerList, + getFontFamilyIcon, + getFontFamilyLabel, +} from "./FontPickerList"; import { FontPickerTrigger } from "./FontPickerTrigger"; +import { + FontPickerDnDContext, + useFontPickerDnD, + useFontTopPicksDnD, +} from "./fontTopPicksDnD"; import "./FontPicker.scss"; +// length must equal FONT_TOP_PICKS_SLOTS (enforced by fontTopPicks.test.ts) export const DEFAULT_FONTS = [ { value: FONT_FAMILY.Excalifont, @@ -41,7 +60,24 @@ export const DEFAULT_FONTS = [ }, ]; -const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value)); +const DEFAULT_FONT_TOP_PICKS = DEFAULT_FONTS.map((font) => font.value); + +const defaultFontFamilies = new Set(DEFAULT_FONT_TOP_PICKS); + +const getFontTopPicks = ( + topPicks: readonly FontFamilyValues[], +): readonly FontFamilyValues[] => { + const picks = [...new Set(topPicks)].slice(0, FONT_TOP_PICKS_SLOTS); + for (const fontFamily of DEFAULT_FONT_TOP_PICKS) { + if (picks.length >= FONT_TOP_PICKS_SLOTS) { + break; + } + if (!picks.includes(fontFamily)) { + picks.push(fontFamily); + } + } + return picks; +}; export const isDefaultFont = (fontFamily: number | null) => { if (!fontFamily) { @@ -51,11 +87,122 @@ export const isDefaultFont = (fontFamily: number | null) => { return defaultFontFamilies.has(fontFamily); }; +const getTopPickFont = (fontFamily: FontFamilyValues) => + DEFAULT_FONTS.find((font) => font.value === fontFamily) ?? { + value: fontFamily, + icon: getFontFamilyIcon(fontFamily), + text: getFontFamilyLabel( + fontFamily, + Fonts.registered.get(fontFamily)?.fontFaces ?? [], + ), + testId: `font-family-${fontFamily}`, + }; + +const needsGlyphSample = ( + fontFamily: FontFamilyValues, + picks: readonly FontFamilyValues[], +) => { + if (defaultFontFamilies.has(fontFamily)) { + return false; + } + const icon = getFontFamilyIcon(fontFamily); + return picks.some( + (pick) => pick !== fontFamily && getTopPickFont(pick).icon === icon, + ); +}; + +const FontTopPicks = ({ + picks, + selectedFontFamily, + onSelect, + onReset, + isCustomized, +}: { + picks: readonly FontFamilyValues[]; + selectedFontFamily: FontFamilyValues | null; + onSelect: (fontFamily: FontFamilyValues) => void; + /** present when the strip is user-customizable */ + onReset?: () => void; + isCustomized: boolean; +}) => { + const dnd = useFontPickerDnD(); + const dragState = dnd?.dragState ?? null; + + return ( + +
+ {/* the drop target (outline & hit area) — hugs the picks, unlike the + wrapper, which spans its grid column and carries padding */} +
+ {dragState && } + {picks.map((fontFamily, index) => { + const font = getTopPickFont(fontFamily); + const reorderOffset = getTopPickReorderOffset(dragState, index); + return ( + + ); + })} +
+
+
+ ); +}; + interface FontPickerProps { isOpened: boolean; selectedFontFamily: FontFamilyValues | null; hoveredFontFamily: FontFamilyValues | null; + /** user-customized top picks (`appState.fontTopPicks`) */ + topPicks: readonly FontFamilyValues[] | null; onSelect: (fontFamily: FontFamilyValues) => void; + onTopPicksChange: (fontTopPicks: FontFamilyValues[] | null) => void; onHover: (fontFamily: FontFamilyValues) => void; onLeave: () => void; onPopupChange: (open: boolean) => void; @@ -67,13 +214,14 @@ export const FontPicker = React.memo( isOpened, selectedFontFamily, hoveredFontFamily, + topPicks, onSelect, + onTopPicksChange, onHover, onLeave, onPopupChange, compactMode = false, }: FontPickerProps) => { - const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const onSelectCallback = useCallback( (value: number | false) => { if (value) { @@ -83,48 +231,73 @@ export const FontPicker = React.memo( [onSelect], ); + // the strip (and thus its customization) is hidden in compact mode + const isTopPicksCustomizable = !compactMode; + const isCustomized = !!topPicks?.length; + const picks = topPicks?.length + ? getFontTopPicks(topPicks) + : DEFAULT_FONT_TOP_PICKS; + + const resetTopPicks = () => onTopPicksChange(null); + + const dnd = useFontTopPicksDnD({ + enabled: isTopPicksCustomizable, + picks, + onPicksChange: onTopPicksChange, + }); + return ( -
- {!compactMode && ( -
- - type="button" - options={defaultFonts} - value={selectedFontFamily} - onClick={onSelectCallback} - /> -
- )} - {!compactMode && } - - - {isOpened && ( - + {!compactMode && ( + onPopupChange(true)} - onClose={() => onPopupChange(false)} + isCustomized={isCustomized} + onReset={isTopPicksCustomizable ? resetTopPicks : undefined} /> )} - -
+ {!compactMode && } + + + {isOpened && ( + onPopupChange(true)} + onClose={() => onPopupChange(false)} + onResetTopPicks={ + isTopPicksCustomizable && isCustomized + ? resetTopPicks + : undefined + } + /> + )} + +
+ ); }, (prev, next) => prev.isOpened === next.isOpened && prev.selectedFontFamily === next.selectedFontFamily && - prev.hoveredFontFamily === next.hoveredFontFamily, + prev.hoveredFontFamily === next.hoveredFontFamily && + prev.topPicks === next.topPicks && + prev.compactMode === next.compactMode, ); diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index 5d107cbeb1..f24eee02fd 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -29,6 +29,7 @@ import { import { PropertiesPopover } from "../PropertiesPopover"; import { QuickSearch } from "../QuickSearch"; import { ScrollableList } from "../ScrollableList"; +import { TopPicksTip } from "../TopPicksDnD/TopPicksTip"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; import { DropDownMenuItemBadgeType, @@ -43,6 +44,7 @@ import { FreedrawIcon, } from "../icons"; +import { useFontPickerDnD } from "./fontTopPicksDnD"; import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import type { JSX } from "react"; @@ -67,9 +69,13 @@ interface FontPickerListProps { onLeave: () => void; onOpen: () => void; onClose: () => void; + /** present only while the top picks are customized */ + onResetTopPicks?: () => void; } -const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => { +export const getFontFamilyIcon = ( + fontFamily: FontFamilyValues, +): JSX.Element => { switch (fontFamily) { case FONT_FAMILY.Excalifont: case FONT_FAMILY.Virgil: @@ -87,7 +93,7 @@ const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => { } }; -const getFontFamilyLabel = ( +export const getFontFamilyLabel = ( fontFamily: FontFamilyValues, fontFaces: ExcalidrawFontFace[], ) => @@ -105,12 +111,15 @@ export const FontPickerList = React.memo( onLeave, onOpen, onClose, + onResetTopPicks, }: FontPickerListProps) => { const { container } = useExcalidrawContainer(); const app = useApp(); const { fonts } = app; const { showDeprecatedFonts } = useAppProps(); const stylesPanelMode = useStylesPanelMode(); + // present only when the top picks are customizable + const dnd = useFontPickerDnD(); const [searchTerm, setSearchTerm] = useState(""); const inputRef = useRef(null); @@ -317,10 +326,14 @@ export const FontPickerList = React.memo( wrappedOnSelect(Number(e.currentTarget.value)); }} onMouseMove={() => { - if (hoveredFont?.value !== font.value) { + // don't live-preview fonts the dragged one merely passes over + if (!dnd?.dragState && hoveredFont?.value !== font.value) { onHover(font.value); } }} + onPointerDown={ + dnd ? (event) => dnd.startSourceDrag(event, font.value) : undefined + } > {groups.length ? groups : null} + {dnd && ( + + )} ); }, (prev, next) => prev.selectedFontFamily === next.selectedFontFamily && - prev.hoveredFontFamily === next.hoveredFontFamily, + prev.hoveredFontFamily === next.hoveredFontFamily && + !!prev.onResetTopPicks === !!next.onResetTopPicks, ); diff --git a/packages/excalidraw/components/FontPicker/fontTopPicksDnD.ts b/packages/excalidraw/components/FontPicker/fontTopPicksDnD.ts new file mode 100644 index 0000000000..61b9659807 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/fontTopPicksDnD.ts @@ -0,0 +1,106 @@ +import { createContext, useContext } from "react"; + +import type { FontFamilyValues } from "@excalidraw/element/types"; + +import { useTopPicksDnD } from "../TopPicksDnD/topPicksDnD"; + +import type { + CreateTopPicksGhost, + TopPicksDnD, +} from "../TopPicksDnD/topPicksDnD"; + +/** + * the ghost is just the font's icon on a strip-button tile (never the whole + * list row), so it previews how the pick lands in the strip. + * + * The ghost lives outside the editor's CSS scope — the tile look is sampled + * from a rendered strip button instead. + */ +const createFontGhost: CreateTopPicksGhost = ({ + sourceEl, + stripEl, +}) => { + const isPick = sourceEl.dataset.topPickIndex != null; + const template = + (isPick + ? sourceEl + : stripEl.querySelector( + "[data-top-pick-index]:not(.active)", + )) ?? stripEl.querySelector("[data-top-pick-index]")!; + const templateStyle = getComputedStyle(template); + const templateRect = template.getBoundingClientRect(); + + const tile = document.createElement("div"); + tile.className = "excalidraw-font-dnd-ghost-tile"; + tile.style.backgroundColor = templateStyle.backgroundColor; + tile.style.color = templateStyle.color; + tile.style.borderStyle = "solid"; + tile.style.borderWidth = templateStyle.borderTopWidth; + tile.style.borderColor = templateStyle.borderTopColor; + tile.style.borderRadius = templateStyle.borderTopLeftRadius; + + const icon = sourceEl.querySelector("svg"); + if (icon) { + const templateIconRect = template + .querySelector("svg") + ?.getBoundingClientRect(); + const iconClone = icon.cloneNode(true) as SVGSVGElement; + if (templateIconRect) { + iconClone.style.width = `${templateIconRect.width}px`; + iconClone.style.height = `${templateIconRect.height}px`; + } + tile.appendChild(iconClone); + } else { + // a pick rendering a glyph sample instead of an icon + const sample = sourceEl.querySelector( + ".FontPicker__top-pick-sample", + ); + if (sample) { + const sampleClone = sample.cloneNode(true) as HTMLElement; + // the ghost is outside the editor's CSS scope + sampleClone.style.fontSize = getComputedStyle(sample).fontSize; + sampleClone.style.lineHeight = "1"; + tile.appendChild(sampleClone); + } + } + + if (isPick) { + return { content: tile, rect: templateRect }; + } + + // list rows: spawn from (and fly back to) the row's icon + const anchorRect = (icon ?? sourceEl).getBoundingClientRect(); + return { + content: tile, + rect: new DOMRect( + anchorRect.left + anchorRect.width / 2 - templateRect.width / 2, + anchorRect.top + anchorRect.height / 2 - templateRect.height / 2, + templateRect.width, + templateRect.height, + ), + }; +}; + +export type FontPickerDnD = TopPicksDnD; + +export const FontPickerDnDContext = createContext(null); + +export const useFontPickerDnD = () => useContext(FontPickerDnDContext); + +/** pinning fonts from the font picker list to the top-picks strip, and + * reordering the strip */ +export const useFontTopPicksDnD = ({ + enabled, + picks, + onPicksChange, +}: { + enabled: boolean; + picks: readonly FontFamilyValues[]; + onPicksChange: (picks: FontFamilyValues[]) => void; +}): FontPickerDnD => + useTopPicksDnD({ + enabled, + picks, + onPicksChange, + createGhost: createFontGhost, + }); diff --git a/packages/excalidraw/components/TopPicksDnD/TopPicksContextMenu.tsx b/packages/excalidraw/components/TopPicksDnD/TopPicksContextMenu.tsx new file mode 100644 index 0000000000..8b07ad240a --- /dev/null +++ b/packages/excalidraw/components/TopPicksDnD/TopPicksContextMenu.tsx @@ -0,0 +1,50 @@ +import { ContextMenu } from "radix-ui"; + +import { useExcalidrawContainer } from "../App"; + +import type { ReactElement } from "react"; + +/** + * right-click context menu of a customizable top-picks strip, resetting it + * to its default picks. Renders the strip as-is when not customizable. + */ +export const TopPicksContextMenu = ({ + children, + onReset, + isCustomized, + resetLabel, +}: { + /** the strip (must accept a ref — used as the menu trigger) */ + children: ReactElement; + /** present when the strip is user-customizable */ + onReset?: () => void; + /** whether custom picks are currently applied (enables the reset item) */ + isCustomized: boolean; + resetLabel: string; +}) => { + const { container } = useExcalidrawContainer(); + + if (!onReset) { + return children; + } + + return ( + + {children} + + + + {resetLabel} + + + + + ); +}; diff --git a/packages/excalidraw/components/TopPicksDnD/TopPicksDnD.scss b/packages/excalidraw/components/TopPicksDnD/TopPicksDnD.scss new file mode 100644 index 0000000000..e2bb086fd8 --- /dev/null +++ b/packages/excalidraw/components/TopPicksDnD/TopPicksDnD.scss @@ -0,0 +1,199 @@ +// top-picks drag & drop (pinning + reordering) — shared by the color and +// font pickers. Strips opt in via `.top-picks-dnd` + `.top-picks-dnd__pick`. +// ----------------------------------------------------------------------------- + +.excalidraw { + .top-picks-dnd { + // anchor for the animated drop-target outline + position: relative; + } + + .top-picks-dnd__pick { + // the strip is tiny and never scrolls — allow touch-dragging picks + touch-action: none; + } + + .top-picks-dnd.is-dnd-active { + .top-picks-dnd__pick { + // no hover/tooltips while a drag is in flight; hit-testing is + // coordinate-based so this doesn't affect dropping + pointer-events: none; + transition: transform 0.14s ease, opacity 0.14s ease, + box-shadow 0.14s ease; + } + } + + .top-picks-dnd__outline { + position: absolute; + top: -5px; + left: -5px; + width: calc(100% + 10px); + height: calc(100% + 10px); + pointer-events: none; + overflow: visible; + + rect { + x: 0.5px; + y: 0.5px; + rx: 6px; + width: calc(100% - 1px); + height: calc(100% - 1px); + fill: none; + stroke: var(--color-primary); + stroke-width: 1; + stroke-dasharray: 4 4; + animation: excalidraw-top-picks-dnd-march 0.8s linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } + } + } + + @keyframes excalidraw-top-picks-dnd-march { + to { + // one full dash+gap period per cycle → seamless loop + stroke-dashoffset: -8; + } + } + + // the pick being reordered — its ghost is following the pointer + .top-picks-dnd__pick.is-dnd-source { + opacity: 0.3; + } + + // this value is already pinned — drop not allowed + .top-picks-dnd__pick.is-dnd-duplicate { + animation: excalidraw-top-picks-dnd-duplicate 0.45s ease-in-out infinite + alternate; + } + + @keyframes excalidraw-top-picks-dnd-duplicate { + from { + box-shadow: 0 0 0 2px var(--color-danger, #e03131); + transform: scale(1); + } + to { + box-shadow: 0 0 0 3px var(--color-danger, #e03131); + transform: scale(1.12); + } + } + + .top-picks-dnd__tip { + padding: 0 0.5rem; + font-size: 0.6875rem; + line-height: 1.4; + text-align: left; + color: var(--color-gray-60); + } + + // plain-text link + .top-picks-dnd__tip-reset { + padding: 0; + margin: 0; + border: 0; + background: none; + font: inherit; + color: var(--color-primary); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .top-picks-dnd__context-menu { + background: var(--popup-bg-color); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-island); + padding: 0.25rem; + } + + .top-picks-dnd__context-menu-item { + font-size: 0.75rem; + padding: 0.375rem 0.625rem; + border-radius: var(--border-radius-md); + color: var(--text-primary-color); + cursor: pointer; + outline: none; + user-select: none; + + // :hover as well — radix only sets data-highlighted once the item gets + // focus via pointermove, which leaves dead frames (e.g. menu opening + // under a stationary cursor) + &[data-highlighted], + &:not([data-disabled]):hover { + background: var(--button-hover-bg); + } + + &[data-disabled] { + opacity: 0.5; + pointer-events: none; + } + } +} + +// drag & drop ghost — appended to document.body (outside the .excalidraw +// scope) so it can follow the pointer without overflow clipping +.excalidraw-top-picks-dnd-ghost { + position: fixed; + top: 0; + left: 0; + z-index: 10000; + pointer-events: none; + will-change: transform; + // NOTE transform is deliberately not transitioned here (tracks the pointer + // every frame); --dropping adds it for the fly-to-target animation + transition: width 0.15s ease, height 0.15s ease; + + // content may set `--top-picks-dnd-ghost-ring` (a hairline box-shadow) + // to keep under the drop shadows below + &__content { + width: 100%; + height: 100%; + box-sizing: border-box; + box-shadow: var(--top-picks-dnd-ghost-ring, 0 0 transparent); + transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease; + } + + &--lifted &__content { + transform: scale(1.2); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25), + var(--top-picks-dnd-ghost-ring, 0 0 transparent); + } + + &--over &__content { + transform: scale(1.05); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2), + var(--top-picks-dnd-ghost-ring, 0 0 transparent); + } + + &--blocked &__content { + opacity: 0.4; + transform: scale(0.85); + } + + &--dropping { + transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1), width 0.18s ease, + height 0.18s ease; + } + + &--dropping &__content { + transform: scale(1); + box-shadow: var(--top-picks-dnd-ghost-ring, 0 0 transparent); + } + + &--fade &__content { + opacity: 0; + } +} + +body.excalidraw-top-picks-dnd-active { + user-select: none !important; + -webkit-user-select: none !important; + + &, + * { + cursor: grabbing !important; + } +} diff --git a/packages/excalidraw/components/TopPicksDnD/TopPicksTip.tsx b/packages/excalidraw/components/TopPicksDnD/TopPicksTip.tsx new file mode 100644 index 0000000000..8958ebeae5 --- /dev/null +++ b/packages/excalidraw/components/TopPicksDnD/TopPicksTip.tsx @@ -0,0 +1,60 @@ +import clsx from "clsx"; + +import { KEYS } from "@excalidraw/common"; + +import { t } from "../../i18n"; + +/** + * "drag to pin" hint shown in a picker popup whose top picks are + * customizable — with a reset link while the picks are customized + */ +export const TopPicksTip = ({ + className, + tip, + onReset, + resetTitle, +}: { + className?: string; + tip: string; + /** present only while the top picks are customized */ + onReset?: () => void; + resetTitle: string; +}) => ( +
+ {tip} + {onReset && ( + <> + {" · "} + + + )} +
+); diff --git a/packages/excalidraw/components/ColorPicker/topPicksDnD.tsx b/packages/excalidraw/components/TopPicksDnD/topPicksDnD.tsx similarity index 71% rename from packages/excalidraw/components/ColorPicker/topPicksDnD.tsx rename to packages/excalidraw/components/TopPicksDnD/topPicksDnD.tsx index f794fd07d6..cf1e361f8a 100644 --- a/packages/excalidraw/components/ColorPicker/topPicksDnD.tsx +++ b/packages/excalidraw/components/TopPicksDnD/topPicksDnD.tsx @@ -1,60 +1,65 @@ -import { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; -import { colorToHex, isTransparent } from "@excalidraw/common"; +import "./TopPicksDnD.scss"; /** - * Custom (non-native) drag & drop for pinning colors to the color-picker - * top-picks strip and reordering the strip itself. + * Custom (non-native) drag & drop for pinning values (colors, font families) + * to a picker's top-picks strip and reordering the strip itself. * - * Implemented with pointer events + a manually rendered "ghost" swatch so we + * Implemented with pointer events + a manually rendered "ghost" so we * control the visuals fully (native HTML5 dnd flickers and forces ugly * ghosting/cursors). + * + * The strip must be registered via `setStripEl`, and render its picks with + * `data-top-pick-index` (hit-testing and the drop animation measure these). */ -const GHOST_CLASS = "excalidraw-color-dnd-ghost"; -const BODY_CLASS = "excalidraw-color-dnd-active"; +const GHOST_CLASS = "excalidraw-top-picks-dnd-ghost"; +const BODY_CLASS = "excalidraw-top-picks-dnd-active"; const DRAG_THRESHOLD = 10; /** a fast sloppy click can travel many px — releases faster than this stay * clicks; the drag only starts once the pointer is held this long */ const DRAG_TIME_THRESHOLD_MS = 100; -/** value-equality of colors — normalizes notation (`#fff` vs `#ffffff` vs - * `white`) so visually identical colors can't occupy multiple pick slots */ -const isSameColor = (a: string, b: string) => { - if (a.toLowerCase() === b.toLowerCase()) { - return true; - } - const aHex = colorToHex(a); - return aHex !== null && aHex === colorToHex(b); -}; - type DragOrigin = - // a color dragged from the picker popup (palette/shades/custom) or the - // active-color trigger — dropping replaces the hovered pick - | { kind: "swatch" } + // a value dragged from outside the strip (picker popup, active-value + // trigger) — dropping replaces the hovered pick + | { kind: "source" } // a pick dragged from the top-picks strip itself — dropping reorders | { kind: "pick"; index: number }; -export type TopPicksDragState = { - color: string; +export type TopPicksDragState = { + value: T; origin: DragOrigin; - /** hovered strip slot — the slot to replace (swatch drags) or the final + /** hovered strip slot — the slot to replace (source drags) or the final * position (pick reorders). null while the pointer is outside the strip */ overIndex: number | null; - /** index of an already-pinned identical color that blocks the drop */ + /** index of an already-pinned identical value that blocks the drop */ duplicateIndex: number | null; /** signed distance between strip slot centers (for reorder preview) */ slotSpan: number; } | null; -type DragSession = { +export type TopPicksGhost = { + /** the ghost's visual. Rendered into `document.body` — outside the + * editor's CSS scope, so theme-dependent styling must be resolved from the + * rendered DOM (computed styles) rather than CSS variables */ + content: HTMLElement; + /** where the ghost spawns (its size) and flies back to on cancel */ + rect: DOMRect; +}; + +/** called on pointerdown (the source may not stay mounted until the drag + * activates), for every potential drag — keep it cheap */ +export type CreateTopPicksGhost = (args: { + value: T; + /** the element the drag started on */ + sourceEl: HTMLElement; + /** the registered strip */ + stripEl: HTMLElement; +}) => TopPicksGhost; + +type DragSession = { pointerId: number; startX: number; startY: number; @@ -64,10 +69,12 @@ type DragSession = { /** pending delayed activation (spatial threshold crossed before the * temporal one) */ activationTimer: number | null; - color: string; + value: T; origin: DragOrigin; - sourceEl: HTMLElement; - sourceRect: DOMRect; + /** built on pointerdown, shown on activation */ + ghostContent: HTMLElement; + /** see `TopPicksGhost.rect` */ + homeRect: DOMRect; activated: boolean; ghost: HTMLDivElement | null; ghostW: number; @@ -79,43 +86,94 @@ type DragSession = { duplicateIndex: number | null; }; -export type ColorPickerDnD = { - dragState: TopPicksDragState; - startSwatchDrag: (event: React.PointerEvent, color: string | null) => void; - startPickDrag: ( - event: React.PointerEvent, - index: number, - color: string, - ) => void; +export type TopPicksDnD = { + dragState: TopPicksDragState; + startSourceDrag: (event: React.PointerEvent, value: T | null) => void; + startPickDrag: (event: React.PointerEvent, index: number, value: T) => void; setStripEl: (el: HTMLDivElement | null) => void; }; -export const ColorPickerDnDContext = createContext(null); +/** + * live preview of the reorder result — the translation (px) moving the pick + * at `index` to the slot it would occupy if dropped right now + */ +export const getTopPickReorderOffset = ( + dragState: TopPicksDragState, + index: number, +) => { + if ( + !dragState || + dragState.origin.kind !== "pick" || + dragState.overIndex === null + ) { + return 0; + } + const from = dragState.origin.index; + const to = dragState.overIndex; + if (from === to) { + return 0; + } + let newIndex = index; + if (index === from) { + newIndex = to; + } else { + if (index > from) { + newIndex -= 1; + } + if (newIndex >= to) { + newIndex += 1; + } + } + return (newIndex - index) * dragState.slotSpan; +}; -export const useColorPickerDnD = () => useContext(ColorPickerDnDContext); +/** "marching ants" outline hinting that the strip accepts the drop — SVG + * because CSS dashed outlines/borders can't animate their dash offset */ +export const TopPicksDnDOutline = () => ( + +); -export const useTopPicksDnD = ({ +export const useTopPicksDnD = ({ enabled, picks, onPicksChange, + isSamePick = (a, b) => a === b, + createGhost, }: { enabled: boolean; - picks: readonly string[]; - onPicksChange: (picks: string[]) => void; -}): ColorPickerDnD => { - const [dragState, setDragState] = useState(null); + picks: readonly T[]; + onPicksChange: (picks: T[]) => void; + /** value-equality — pins identical to an existing pick are refused */ + isSamePick?: (a: T, b: T) => boolean; + createGhost: CreateTopPicksGhost; +}): TopPicksDnD => { + const [dragState, setDragState] = useState>(null); const stripRef = useRef(null); - const latestRef = useRef({ enabled, picks, onPicksChange }); - latestRef.current = { enabled, picks, onPicksChange }; + const latestRef = useRef({ + enabled, + picks, + onPicksChange, + isSamePick, + createGhost, + }); + latestRef.current = { + enabled, + picks, + onPicksChange, + isSamePick, + createGhost, + }; const controller = useMemo(() => { - let session: DragSession | null = null; + let session: DragSession | null = null; const publish = () => { if (session?.activated) { setDragState({ - color: session.color, + value: session.value, origin: session.origin, overIndex: session.overIndex, duplicateIndex: session.duplicateIndex, @@ -144,7 +202,7 @@ export const useTopPicksDnD = ({ button.getBoundingClientRect(), ); const [first, second] = session.slotRects; - // strip is evenly spaced (space-between); sign flips under RTL + // strip is evenly spaced; sign flips under RTL session.slotSpan = second ? second.left - first.left : first.width + 4; const stripRect = strip.getBoundingClientRect(); const padX = Math.max(Math.abs(session.slotSpan) / 2, 10); @@ -187,30 +245,19 @@ export const useTopPicksDnD = ({ if (!session) { return; } - const { sourceRect, sourceEl, color } = session; + const { ghostContent, homeRect: rect } = session; const ghost = document.createElement("div"); ghost.className = GHOST_CLASS; - const swatch = document.createElement("div"); - swatch.className = `${GHOST_CLASS}__swatch`; - if (isTransparent(color)) { - swatch.classList.add("is-transparent"); - } else { - // swatches render the theme-adjusted color (dark mode remaps colors - // rather than CSS-filtering them) — sample the rendered color so the - // ghost matches what the user picked up - const rendered = getComputedStyle(sourceEl).backgroundColor; - swatch.style.backgroundColor = - rendered && rendered !== "rgba(0, 0, 0, 0)" ? rendered : color; - } - ghost.appendChild(swatch); + ghostContent.classList.add(`${GHOST_CLASS}__content`); + ghost.appendChild(ghostContent); document.body.appendChild(ghost); session.ghost = ghost; - session.ghostW = sourceRect.width; - session.ghostH = sourceRect.height; - ghost.style.width = `${sourceRect.width}px`; - ghost.style.height = `${sourceRect.height}px`; + session.ghostW = rect.width; + session.ghostH = rect.height; + ghost.style.width = `${rect.width}px`; + ghost.style.height = `${rect.height}px`; positionGhost(x, y); // let the spawn frame paint at rest, then "lift" (scale-up transition) requestAnimationFrame(() => { @@ -249,9 +296,10 @@ export const useTopPicksDnD = ({ best = index; } }); - if (session.origin.kind === "swatch") { - const duplicate = latestRef.current.picks.findIndex((pick) => - isSameColor(pick, session!.color), + if (session.origin.kind === "source") { + const { picks, isSamePick } = latestRef.current; + const duplicate = picks.findIndex((pick) => + isSamePick(pick, session!.value), ); if (duplicate !== -1) { duplicateIndex = duplicate; @@ -286,7 +334,7 @@ export const useTopPicksDnD = ({ const rect = session.slotRects[overIndex]; setGhostSize(rect.width, rect.height, x, y); } else { - setGhostSize(session.sourceRect.width, session.sourceRect.height, x, y); + setGhostSize(session.homeRect.width, session.homeRect.height, x, y); } }; @@ -374,7 +422,7 @@ export const useTopPicksDnD = ({ settleStripInstantly(); if (session.ghost) { if (animate) { - releaseGhost({ rect: session.sourceRect }); + releaseGhost({ rect: session.homeRect }); } else { session.ghost.remove(); session.ghost = null; @@ -464,16 +512,16 @@ export const useTopPicksDnD = ({ event.stopPropagation(); suppressNextClick(); - const { overIndex, origin, color, slotRects } = session; - const { picks, onPicksChange } = latestRef.current; + const { overIndex, origin, value, slotRects } = session; + const { picks, onPicksChange, isSamePick } = latestRef.current; settleStripInstantly(); if (overIndex !== null) { - if (origin.kind === "swatch") { - if (!picks.some((pick) => isSameColor(pick, color))) { + if (origin.kind === "source") { + if (!picks.some((pick) => isSamePick(pick, value))) { const next = [...picks]; - next[overIndex] = color; + next[overIndex] = value; onPicksChange(next); } } else if (origin.index !== overIndex) { @@ -484,7 +532,7 @@ export const useTopPicksDnD = ({ } releaseGhost({ rect: slotRects[overIndex] }); } else { - releaseGhost({ rect: session.sourceRect }); + releaseGhost({ rect: session.homeRect }); } dispose(); }; @@ -505,17 +553,32 @@ export const useTopPicksDnD = ({ const begin = ( event: React.PointerEvent, - color: string | null, + value: T | null, origin: DragOrigin, ) => { if ( !latestRef.current.enabled || session || event.button !== 0 || - !color + value == null ) { return; } + const stripEl = stripRef.current; + if (!stripEl) { + return; + } + // build & measure the ghost now, while the source is guaranteed to be + // mounted — it may be re-rendered (detached) before the drag activates + // (e.g. the font list re-renders on hover), and a detached source + // measures as a zero rect, flying a cancelled ghost to the viewport's + // top-left + const { content: ghostContent, rect: homeRect } = + latestRef.current.createGhost({ + value, + sourceEl: event.currentTarget as HTMLElement, + stripEl, + }); session = { pointerId: event.pointerId, startX: event.clientX, @@ -524,12 +587,10 @@ export const useTopPicksDnD = ({ lastX: event.clientX, lastY: event.clientY, activationTimer: null, - color, + value, origin, - sourceEl: event.currentTarget as HTMLElement, - sourceRect: ( - event.currentTarget as HTMLElement - ).getBoundingClientRect(), + ghostContent, + homeRect, activated: false, ghost: null, ghostW: 0, @@ -559,10 +620,10 @@ export const useTopPicksDnD = ({ return useMemo( () => ({ dragState, - startSwatchDrag: (event, color) => - controller.begin(event, color, { kind: "swatch" }), - startPickDrag: (event, index, color) => - controller.begin(event, color, { kind: "pick", index }), + startSourceDrag: (event, value) => + controller.begin(event, value, { kind: "source" }), + startPickDrag: (event, index, value) => + controller.begin(event, value, { kind: "pick", index }), setStripEl: (el) => { stripRef.current = el; }, diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index 3080314250..46363507e9 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -118,6 +118,16 @@ display: inline-flex; } + // inset, so the focus/pressed rings are neither clipped by the scroll + // container nor covered by the adjacent item + .dropdown-menu-item:focus-visible { + box-shadow: inset 0 0 0 1px var(--color-brand-hover); + } + + .dropdown-menu-item:active { + box-shadow: inset 0 0 0 1px var(--color-brand-active); + } + .dropdown-menu-group:not(:first-child) { margin-top: 1rem; } diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 821ba20f52..10f7b40346 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -15,6 +15,8 @@ import { DEFAULT_TEXT_ALIGN, DEFAULT_VERTICAL_ALIGN, FONT_FAMILY, + FONT_METADATA, + FONT_TOP_PICKS_SLOTS, ROUNDNESS, DEFAULT_SIDEBAR, DEFAULT_ELEMENT_PROPS, @@ -1227,6 +1229,28 @@ const restoreColorTopPicksList = (value: unknown): readonly string[] | null => { return colors.size ? [...colors.values()] : null; }; +const restoreFontTopPicks = ( + value: unknown, +): readonly FontFamilyValues[] | null => { + if (!Array.isArray(value)) { + return null; + } + const fontFamilies = new Set(); + for (const fontFamily of value) { + const metadata = + typeof fontFamily === "number" ? FONT_METADATA[fontFamily] : undefined; + // only families the font picker lists (no internal or fallback fonts) + if (!metadata || metadata.private || metadata.fallback) { + continue; + } + fontFamilies.add(fontFamily as FontFamilyValues); + if (fontFamilies.size >= FONT_TOP_PICKS_SLOTS) { + break; + } + } + return fontFamilies.size ? [...fontFamilies] : null; +}; + export const restoreAppState = ( appState: ImportedDataState["appState"], localAppState: Partial | null | undefined, @@ -1291,6 +1315,7 @@ export const restoreAppState = ( nextAppState.colorTopPicks?.stickyNoteBackground, ), }; + nextAppState.fontTopPicks = restoreFontTopPicks(nextAppState.fontTopPicks); // legacy if ((appState as any).currentItemStrokeWidth !== undefined) { diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index f6d6d0ba50..a7aac0d94e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -249,6 +249,7 @@ "undo": "Undo", "redo": "Redo", "resetLibrary": "Reset library", + "reset": "Reset", "createNewRoom": "Create new room", "fullScreen": "Full screen", "darkMode": "Dark mode", @@ -722,7 +723,9 @@ }, "sceneFonts": "In this scene", "availableFonts": "Available fonts", - "empty": "No fonts found" + "empty": "No fonts found", + "topPicksTip": "Tip: drag any font onto your top picks to pin it", + "resetTopPicks": "Reset to default top picks" }, "userList": { "empty": "No users found", diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index d3d4174534..a0679a8ad1 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -928,6 +928,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -1140,6 +1141,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -1367,6 +1369,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -1713,6 +1716,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -2059,6 +2063,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -2286,6 +2291,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -2540,6 +2546,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -2853,6 +2860,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -3240,6 +3248,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -3775,6 +3784,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -4113,6 +4123,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -4451,6 +4462,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -5751,6 +5763,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -6983,6 +6996,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -7955,6 +7969,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -8966,6 +8981,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, @@ -9968,6 +9984,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "fontTopPicks": null, "frameRendering": { "clip": true, "enabled": true, diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index 68d2730b05..6c54ed7c9c 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -696,10 +696,10 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende role="dialog" >