mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(editor): customizable font picker top picks (#12160)
* feat(editor): customizable font picker top picks Font families can be dragged from the font picker list onto the top-picks strip to replace a pick, the strip can be reordered by dragging, and right-clicking it resets to the default picks — same as the color picker top picks. The drag ghost is only the font's icon (on a strip-button tile). The color picker's drag & drop is generalized into components/TopPicksDnD (value-agnostic hook, reorder preview, drop outline, reset context menu, shared styles), with thin per-picker adapters (colorTopPicksDnD / fontTopPicksDnD) supplying value equality and the ghost visual. Custom picks are kept in `appState.fontTopPicks` (browser-persisted only, kept on canvas clear); restore drops unknown/internal/fallback families, dedupes, and caps at the strip's 3 slots. Claude Opus 5.5 (1M context), reasoning effort: 40 * feat(editor): reset link in top picks tips The "drag to pin" tip in the font picker and color picker popups now ends with a plain-text "Reset" link while that picker's top picks are customized, restoring the defaults without leaving the popup. Shared as TopPicksTip (tip text styles merged into it). The link never takes focus on click and hands it back to the picker when activated by keyboard, since it unmounts on reset; Enter/Space are kept from the pickers' key handlers. Claude Opus 5.5 (1M context), reasoning effort: 15 * fix(editor): don't clip or overlap font list item focus/pressed rings The font list scrolls (clipping outer box-shadows at its edges) and each item paints over the previous one's bottom edge, so the focus-visible and :active rings are drawn inset there. Pressing and holding items became common with dragging fonts onto the top picks. Claude Opus 5.5 (1M context), reasoning effort: 15 * fix(editor): cancelled top picks drag flying to viewport top-left The ghost (and its home rect) was built on drag activation, by which time the source could have been re-rendered away — the font list re-renders on hover — and a detached element measures as a zero rect. Build and measure it on pointerdown instead, while the source is mounted. Claude Opus 5.5 (1M context), reasoning effort: 15 * fix: Always show 3 slots to reorder Signed-off-by: Mark Tolmacs <mark@lazycat.hu> * fix: Cleanup Inconsistent state updates. Font picks go through useExcalidrawSetAppState directly, while color picks go through the action's updateData. Duplicate icons in the strip. Virgil and Excalifont both use FreedrawIcon, so pinning both shows two identical tiles that can only be told apart by their tooltips. Signed-off-by: Mark Tolmacs <mark@lazycat.hu> * fix: remove useMemo Signed-off-by: Mark Tolmacs <mark@lazycat.hu> --------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
co-authored by
Mark Tolmacs
parent
dc2c16d9e2
commit
1118751f3e
@@ -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}";
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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 = <
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||
@@ -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 && <ButtonSeparator />}
|
||||
@@ -485,6 +490,9 @@ const ColorPickerComponent = ({
|
||||
updateData={updateData}
|
||||
getOpenPopup={() => openRef.current}
|
||||
appState={appState}
|
||||
onResetTopPicks={
|
||||
customTopPicks?.length ? resetTopPicks : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="color-picker__tip">
|
||||
{t("colorPicker.topPicksTip")}
|
||||
</div>
|
||||
<TopPicksTip
|
||||
tip={t("colorPicker.topPicksTip")}
|
||||
onReset={onResetTopPicks}
|
||||
resetTitle={t("colorPicker.resetTopPicks")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}` : ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
|
||||
@@ -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 = (
|
||||
<div
|
||||
className={clsx("color-picker__top-picks", {
|
||||
"is-dnd-active": !!dragState,
|
||||
})}
|
||||
ref={dnd?.setStripEl}
|
||||
>
|
||||
{dragState && (
|
||||
<svg
|
||||
className="color-picker__top-picks__dnd-outline"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect />
|
||||
</svg>
|
||||
)}
|
||||
{colors.map((color: string, index: number) => {
|
||||
const reorderOffset = getReorderOffset(index);
|
||||
const displayColor = applyDarkModeFilter(color, theme === THEME.DARK);
|
||||
return (
|
||||
<button
|
||||
className={clsx("color-picker__button", {
|
||||
active: color === activeColor,
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
"has-outline": !isColorDark(
|
||||
color,
|
||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||
),
|
||||
"is-dnd-source":
|
||||
dragState?.origin.kind === "pick" &&
|
||||
dragState.origin.index === index,
|
||||
"is-dnd-target":
|
||||
dragState?.origin.kind === "swatch" &&
|
||||
dragState.overIndex === index,
|
||||
"is-dnd-duplicate": dragState?.duplicateIndex === index,
|
||||
})}
|
||||
style={{
|
||||
"--swatch-color": displayColor,
|
||||
transform: reorderOffset
|
||||
? `translateX(${reorderOffset}px)`
|
||||
: undefined,
|
||||
}}
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
onPointerDown={
|
||||
dnd
|
||||
? (event) => dnd.startPickDrag(event, index, color)
|
||||
: undefined
|
||||
}
|
||||
data-testid={`color-top-pick-${color}`}
|
||||
data-top-pick-index={index}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!onReset) {
|
||||
return strip;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>{strip}</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal container={container}>
|
||||
<ContextMenu.Content
|
||||
className="color-picker__context-menu"
|
||||
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
|
||||
>
|
||||
<ContextMenu.Item
|
||||
className="color-picker__context-menu-item"
|
||||
disabled={!isCustomized}
|
||||
onSelect={onReset}
|
||||
>
|
||||
{t("colorPicker.resetTopPicks")}
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
<TopPicksContextMenu
|
||||
onReset={onReset}
|
||||
isCustomized={!!isCustomized}
|
||||
resetLabel={t("colorPicker.resetTopPicks")}
|
||||
>
|
||||
<div
|
||||
className={clsx("color-picker__top-picks top-picks-dnd", {
|
||||
"is-dnd-active": !!dragState,
|
||||
})}
|
||||
ref={dnd?.setStripEl}
|
||||
>
|
||||
{dragState && <TopPicksDnDOutline />}
|
||||
{colors.map((color: string, index: number) => {
|
||||
const reorderOffset = getTopPickReorderOffset(dragState, index);
|
||||
const displayColor = applyDarkModeFilter(color, theme === THEME.DARK);
|
||||
return (
|
||||
<button
|
||||
className={clsx("color-picker__button top-picks-dnd__pick", {
|
||||
active: color === activeColor,
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
"has-outline": !isColorDark(
|
||||
color,
|
||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||
),
|
||||
"is-dnd-source":
|
||||
dragState?.origin.kind === "pick" &&
|
||||
dragState.origin.index === index,
|
||||
"is-dnd-target":
|
||||
dragState?.origin.kind === "source" &&
|
||||
dragState.overIndex === index,
|
||||
"is-dnd-duplicate": dragState?.duplicateIndex === index,
|
||||
})}
|
||||
style={{
|
||||
"--swatch-color": displayColor,
|
||||
transform: reorderOffset
|
||||
? `translateX(${reorderOffset}px)`
|
||||
: undefined,
|
||||
}}
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
onPointerDown={
|
||||
dnd
|
||||
? (event) => dnd.startPickDrag(event, index, color)
|
||||
: undefined
|
||||
}
|
||||
data-testid={`color-top-pick-${color}`}
|
||||
data-top-pick-index={index}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TopPicksContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string> = ({
|
||||
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<string>;
|
||||
|
||||
export const ColorPickerDnDContext = createContext<ColorPickerDnD | null>(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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
@@ -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<HTMLElement>(
|
||||
".FontPicker__top-picks [data-top-pick-index]",
|
||||
),
|
||||
).map((button) => button.title);
|
||||
|
||||
const openFontPicker = async () => {
|
||||
const utils = await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
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<HTMLElement>(
|
||||
'.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<HTMLElement>(
|
||||
'.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<HTMLElement>(
|
||||
'.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<HTMLElement>(
|
||||
'.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<HTMLElement>(
|
||||
'.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<HTMLElement>(
|
||||
".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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<TopPicksContextMenu
|
||||
onReset={onReset}
|
||||
isCustomized={isCustomized}
|
||||
resetLabel={t("fontList.resetTopPicks")}
|
||||
>
|
||||
<div className="buttonList FontPicker__top-picks">
|
||||
{/* the drop target (outline & hit area) — hugs the picks, unlike the
|
||||
wrapper, which spans its grid column and carries padding */}
|
||||
<div
|
||||
className={clsx("FontPicker__top-picks-slots top-picks-dnd", {
|
||||
"is-dnd-active": !!dragState,
|
||||
})}
|
||||
ref={dnd?.setStripEl}
|
||||
>
|
||||
{dragState && <TopPicksDnDOutline />}
|
||||
{picks.map((fontFamily, index) => {
|
||||
const font = getTopPickFont(fontFamily);
|
||||
const reorderOffset = getTopPickReorderOffset(dragState, index);
|
||||
return (
|
||||
<button
|
||||
key={fontFamily}
|
||||
type="button"
|
||||
title={font.text}
|
||||
data-testid={font.testId}
|
||||
className={clsx("top-picks-dnd__pick", {
|
||||
active: fontFamily === selectedFontFamily,
|
||||
"is-dnd-source":
|
||||
dragState?.origin.kind === "pick" &&
|
||||
dragState.origin.index === index,
|
||||
"is-dnd-target":
|
||||
dragState?.origin.kind === "source" &&
|
||||
dragState.overIndex === index,
|
||||
"is-dnd-duplicate": dragState?.duplicateIndex === index,
|
||||
})}
|
||||
style={
|
||||
reorderOffset
|
||||
? { transform: `translateX(${reorderOffset}px)` }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onSelect(fontFamily)}
|
||||
onPointerDown={
|
||||
dnd
|
||||
? (event) => dnd.startPickDrag(event, index, fontFamily)
|
||||
: undefined
|
||||
}
|
||||
data-top-pick-index={index}
|
||||
>
|
||||
{needsGlyphSample(fontFamily, picks) ? (
|
||||
<span
|
||||
className="FontPicker__top-pick-sample"
|
||||
style={{ fontFamily: getFontFamilyString({ fontFamily }) }}
|
||||
>
|
||||
Aa
|
||||
</span>
|
||||
) : (
|
||||
font.icon
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TopPicksContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={clsx("FontPicker__container", {
|
||||
"FontPicker__container--compact": compactMode,
|
||||
})}
|
||||
<FontPickerDnDContext.Provider
|
||||
value={isTopPicksCustomizable ? dnd : null}
|
||||
>
|
||||
{!compactMode && (
|
||||
<div className="buttonList">
|
||||
<RadioSelection<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
isOpened={isOpened}
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={clsx("FontPicker__container", {
|
||||
"FontPicker__container--compact": compactMode,
|
||||
})}
|
||||
>
|
||||
{!compactMode && (
|
||||
<FontTopPicks
|
||||
picks={picks}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={hoveredFontFamily}
|
||||
onSelect={onSelectCallback}
|
||||
onHover={onHover}
|
||||
onLeave={onLeave}
|
||||
onOpen={() => onPopupChange(true)}
|
||||
onClose={() => onPopupChange(false)}
|
||||
isCustomized={isCustomized}
|
||||
onReset={isTopPicksCustomizable ? resetTopPicks : undefined}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
isOpened={isOpened}
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={hoveredFontFamily}
|
||||
onSelect={onSelectCallback}
|
||||
onHover={onHover}
|
||||
onLeave={onLeave}
|
||||
onOpen={() => onPopupChange(true)}
|
||||
onClose={() => onPopupChange(false)}
|
||||
onResetTopPicks={
|
||||
isTopPicksCustomizable && isCustomized
|
||||
? resetTopPicks
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</FontPickerDnDContext.Provider>
|
||||
);
|
||||
},
|
||||
(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,
|
||||
);
|
||||
|
||||
@@ -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<HTMLInputElement>(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
|
||||
}
|
||||
>
|
||||
<MenuItemContent
|
||||
icon={font.icon}
|
||||
@@ -404,10 +417,19 @@ export const FontPickerList = React.memo(
|
||||
>
|
||||
{groups.length ? groups : null}
|
||||
</ScrollableList>
|
||||
{dnd && (
|
||||
<TopPicksTip
|
||||
className="FontPicker__tip"
|
||||
tip={t("fontList.topPicksTip")}
|
||||
onReset={onResetTopPicks}
|
||||
resetTitle={t("fontList.resetTopPicks")}
|
||||
/>
|
||||
)}
|
||||
</PropertiesPopover>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||
prev.hoveredFontFamily === next.hoveredFontFamily &&
|
||||
!!prev.onResetTopPicks === !!next.onResetTopPicks,
|
||||
);
|
||||
|
||||
@@ -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<FontFamilyValues> = ({
|
||||
sourceEl,
|
||||
stripEl,
|
||||
}) => {
|
||||
const isPick = sourceEl.dataset.topPickIndex != null;
|
||||
const template =
|
||||
(isPick
|
||||
? sourceEl
|
||||
: stripEl.querySelector<HTMLElement>(
|
||||
"[data-top-pick-index]:not(.active)",
|
||||
)) ?? stripEl.querySelector<HTMLElement>("[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<HTMLElement>(
|
||||
".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<FontFamilyValues>;
|
||||
|
||||
export const FontPickerDnDContext = createContext<FontPickerDnD | null>(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,
|
||||
});
|
||||
@@ -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 (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal container={container}>
|
||||
<ContextMenu.Content
|
||||
className="top-picks-dnd__context-menu"
|
||||
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
|
||||
>
|
||||
<ContextMenu.Item
|
||||
className="top-picks-dnd__context-menu-item"
|
||||
disabled={!isCustomized}
|
||||
onSelect={onReset}
|
||||
>
|
||||
{resetLabel}
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}) => (
|
||||
<div className={clsx("top-picks-dnd__tip", className)}>
|
||||
{tip}
|
||||
{onReset && (
|
||||
<>
|
||||
{" · "}
|
||||
<button
|
||||
type="button"
|
||||
className="top-picks-dnd__tip-reset"
|
||||
title={resetTitle}
|
||||
// the link unmounts on reset — don't let it take the focus
|
||||
// (it'd drop to <body>, killing the picker's keyboard handling)
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
// ...and when it had it (keyboard), hand it back to the picker
|
||||
const focusTarget =
|
||||
event.currentTarget.ownerDocument.activeElement ===
|
||||
event.currentTarget
|
||||
? event.currentTarget.parentElement?.closest<HTMLElement>(
|
||||
"[tabindex]",
|
||||
)
|
||||
: null;
|
||||
onReset();
|
||||
focusTarget?.focus();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// the pickers' key handlers act on these (e.g. Enter picks the
|
||||
// hovered font) — keep them to the button
|
||||
if (event.key === KEYS.ENTER || event.key === KEYS.SPACE) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("buttons.reset")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
+156
-95
@@ -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<T> = {
|
||||
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<T> = (args: {
|
||||
value: T;
|
||||
/** the element the drag started on */
|
||||
sourceEl: HTMLElement;
|
||||
/** the registered strip */
|
||||
stripEl: HTMLElement;
|
||||
}) => TopPicksGhost;
|
||||
|
||||
type DragSession<T> = {
|
||||
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<T> = {
|
||||
dragState: TopPicksDragState<T>;
|
||||
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<ColorPickerDnD | null>(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<unknown>,
|
||||
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 = () => (
|
||||
<svg className="top-picks-dnd__outline" aria-hidden="true">
|
||||
<rect />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const useTopPicksDnD = ({
|
||||
export const useTopPicksDnD = <T,>({
|
||||
enabled,
|
||||
picks,
|
||||
onPicksChange,
|
||||
isSamePick = (a, b) => a === b,
|
||||
createGhost,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
picks: readonly string[];
|
||||
onPicksChange: (picks: string[]) => void;
|
||||
}): ColorPickerDnD => {
|
||||
const [dragState, setDragState] = useState<TopPicksDragState>(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<T>;
|
||||
}): TopPicksDnD<T> => {
|
||||
const [dragState, setDragState] = useState<TopPicksDragState<T>>(null);
|
||||
const stripRef = useRef<HTMLDivElement | null>(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<T> | 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;
|
||||
},
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<FontFamilyValues>();
|
||||
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<AppState> | null | undefined,
|
||||
@@ -1291,6 +1315,7 @@ export const restoreAppState = (
|
||||
nextAppState.colorTopPicks?.stickyNoteBackground,
|
||||
),
|
||||
};
|
||||
nextAppState.fontTopPicks = restoreFontTopPicks(nextAppState.fontTopPicks);
|
||||
|
||||
// legacy
|
||||
if ((appState as any).currentItemStrokeWidth !== undefined) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -696,10 +696,10 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="color-picker__top-picks"
|
||||
class="color-picker__top-picks top-picks-dnd"
|
||||
>
|
||||
<button
|
||||
class="color-picker__button active has-outline"
|
||||
class="color-picker__button top-picks-dnd__pick active has-outline"
|
||||
data-testid="color-top-pick-#ffffff"
|
||||
data-top-pick-index="0"
|
||||
style="--swatch-color: #ffffff;"
|
||||
@@ -711,7 +711,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
class="color-picker__button top-picks-dnd__pick has-outline"
|
||||
data-testid="color-top-pick-#f8f9fa"
|
||||
data-top-pick-index="1"
|
||||
style="--swatch-color: #f8f9fa;"
|
||||
@@ -723,7 +723,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
class="color-picker__button top-picks-dnd__pick has-outline"
|
||||
data-testid="color-top-pick-#f5faff"
|
||||
data-top-pick-index="2"
|
||||
style="--swatch-color: #f5faff;"
|
||||
@@ -735,7 +735,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
class="color-picker__button top-picks-dnd__pick has-outline"
|
||||
data-testid="color-top-pick-#fffce8"
|
||||
data-top-pick-index="3"
|
||||
style="--swatch-color: #fffce8;"
|
||||
@@ -747,7 +747,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
class="color-picker__button top-picks-dnd__pick has-outline"
|
||||
data-testid="color-top-pick-#fdf8f6"
|
||||
data-top-pick-index="4"
|
||||
style="--swatch-color: #fdf8f6;"
|
||||
|
||||
@@ -54,6 +54,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -705,6 +706,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -1283,6 +1285,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -1659,6 +1662,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -2036,6 +2040,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -2314,6 +2319,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -2784,6 +2790,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -3106,6 +3113,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -3446,6 +3454,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -3758,6 +3767,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -4060,6 +4070,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -4310,6 +4321,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -4583,6 +4595,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -4872,6 +4885,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5116,6 +5130,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5360,6 +5375,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5623,6 +5639,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5897,6 +5914,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -6169,6 +6187,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -6513,6 +6532,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -6957,6 +6977,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -7348,6 +7369,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -7675,6 +7697,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -7980,6 +8003,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8223,6 +8247,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8592,6 +8617,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8961,6 +8987,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -9383,6 +9410,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -9684,6 +9712,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -9962,6 +9991,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10241,6 +10271,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10486,6 +10517,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10798,6 +10830,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -11128,6 +11161,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -11377,6 +11411,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -12319,6 +12354,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -12592,6 +12628,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -12841,6 +12878,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -13092,6 +13130,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -13511,6 +13550,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -13734,6 +13774,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -13954,6 +13995,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -14270,6 +14312,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -14583,6 +14626,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -14842,6 +14886,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -15093,6 +15138,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -15344,6 +15390,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -15604,6 +15651,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -15950,6 +15998,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -16132,6 +16181,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -16431,6 +16481,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -16709,6 +16760,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -16874,6 +16926,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -17169,6 +17222,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -17342,6 +17396,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -18113,6 +18168,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -18782,6 +18838,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -19451,6 +19508,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -20223,6 +20281,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -21014,6 +21073,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -21511,6 +21571,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -22043,6 +22104,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -22519,6 +22581,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
|
||||
@@ -54,6 +54,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -494,6 +495,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -924,6 +926,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -1504,6 +1507,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -1723,6 +1727,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -2121,6 +2126,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -2379,6 +2385,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -2571,6 +2578,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -2910,6 +2918,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -3177,6 +3186,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -3431,6 +3441,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -3679,6 +3690,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -3950,6 +3962,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -4278,6 +4291,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -4727,6 +4741,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5024,6 +5039,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5313,6 +5329,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5534,6 +5551,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -5746,6 +5764,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -6153,6 +6172,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -6463,6 +6483,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -7274,6 +7295,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -7622,6 +7644,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -7914,6 +7937,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8162,6 +8186,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8415,6 +8440,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8607,6 +8633,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8799,6 +8826,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -8991,6 +9019,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -9236,6 +9265,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -9479,6 +9509,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -9687,6 +9718,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -9932,6 +9964,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10124,6 +10157,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10367,6 +10401,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10559,6 +10594,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10767,6 +10803,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -10959,6 +10996,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -11507,6 +11545,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -11800,6 +11839,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -11934,6 +11974,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -12146,6 +12187,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -12478,6 +12520,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -12921,6 +12964,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -13576,6 +13620,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -13713,6 +13758,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -14358,6 +14404,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -14712,6 +14759,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -14990,6 +15038,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -15124,6 +15173,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -15502,6 +15552,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
@@ -15636,6 +15687,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
FONT_FAMILY_FALLBACKS,
|
||||
FONT_TOP_PICKS_SLOTS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { DEFAULT_FONTS } from "../components/FontPicker/FontPicker";
|
||||
import { restoreAppState } from "../data/restore";
|
||||
|
||||
describe("font top picks slots", () => {
|
||||
it("matches the default font list length", () => {
|
||||
expect(DEFAULT_FONTS.length).toBe(FONT_TOP_PICKS_SLOTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore appState.fontTopPicks", () => {
|
||||
it("dedupes and drops unknown, internal and fallback font families", () => {
|
||||
const restored = restoreAppState(
|
||||
{
|
||||
fontTopPicks: [
|
||||
FONT_FAMILY["Lilita One"],
|
||||
"7",
|
||||
FONT_FAMILY["Lilita One"],
|
||||
4, // unused id
|
||||
FONT_FAMILY.Assistant, // private
|
||||
FONT_FAMILY_FALLBACKS.Xiaolai,
|
||||
FONT_FAMILY.Virgil, // deprecated, but still pickable
|
||||
],
|
||||
} as any,
|
||||
null,
|
||||
);
|
||||
expect(restored.fontTopPicks).toEqual([
|
||||
FONT_FAMILY["Lilita One"],
|
||||
FONT_FAMILY.Virgil,
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps at the strip slot count and nulls malformed input", () => {
|
||||
const many = [
|
||||
FONT_FAMILY.Nunito,
|
||||
FONT_FAMILY.Excalifont,
|
||||
FONT_FAMILY["Lilita One"],
|
||||
FONT_FAMILY["Comic Shanns"],
|
||||
FONT_FAMILY.Virgil,
|
||||
];
|
||||
expect(
|
||||
restoreAppState({ fontTopPicks: many } as any, null).fontTopPicks,
|
||||
).toEqual(many.slice(0, FONT_TOP_PICKS_SLOTS));
|
||||
expect(
|
||||
restoreAppState({ fontTopPicks: "junk" } as any, null).fontTopPicks,
|
||||
).toBe(null);
|
||||
expect(
|
||||
restoreAppState({ fontTopPicks: [4, "5"] } as any, null).fontTopPicks,
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -568,6 +568,9 @@ export interface AppState {
|
||||
stickyNoteStroke: readonly string[] | null;
|
||||
stickyNoteBackground: readonly string[] | null;
|
||||
};
|
||||
/** user-customized font-picker top picks (pinned via drag & drop from the
|
||||
* font picker popup). `null` means no customization (defaults are used) */
|
||||
fontTopPicks: readonly FontFamilyValues[] | null;
|
||||
}
|
||||
|
||||
export type SearchMatch = {
|
||||
|
||||
@@ -55,6 +55,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
|
||||
Reference in New Issue
Block a user