diff --git a/react-common/components/controls/Button.tsx b/react-common/components/controls/Button.tsx index 19497d70c712..8ab0d2abfb87 100644 --- a/react-common/components/controls/Button.tsx +++ b/react-common/components/controls/Button.tsx @@ -28,6 +28,7 @@ export interface ButtonViewProps extends ContainerProps { export interface ButtonProps extends ButtonViewProps { onClick: () => void; + onRightClick?: () => void; onBlur?: () => void; onKeydown?: (e: React.KeyboardEvent) => void; } @@ -49,6 +50,7 @@ export const Button = (props: ButtonProps) => { ariaPressed, role, onClick, + onRightClick, onKeydown, onBlur, buttonRef, @@ -78,7 +80,8 @@ export const Button = (props: ButtonProps) => { ); let clickHandler = (ev: React.MouseEvent) => { - if (onClick) onClick(); + if (onRightClick && ev.button !== 0) onRightClick(); + else if (onClick) onClick(); if (href) window.open(href, target || "_blank", "noopener,noreferrer") ev.stopPropagation(); ev.preventDefault(); diff --git a/react-common/components/controls/CarouselNav.tsx b/react-common/components/controls/CarouselNav.tsx new file mode 100644 index 000000000000..4627d073ade6 --- /dev/null +++ b/react-common/components/controls/CarouselNav.tsx @@ -0,0 +1,62 @@ +import { classList } from "../util"; +import { Button } from "./Button"; + +export interface CarouselNavProps { + pages: number; + selected: number; + maxDisplayed?: number; + onPageSelected: (page: number) => void; +} + +export const CarouselNav = (props: CarouselNavProps) => { + const { pages, selected, maxDisplayed, onPageSelected } = props; + + const displayedPages: number[] = []; + let start = 0; + let end = pages; + + if (maxDisplayed) { + start = Math.min( + Math.max(0, selected - (maxDisplayed >> 1)), + Math.max(0, start + pages - maxDisplayed) + ); + end = Math.min(start + maxDisplayed, pages); + } + + for (let i = start; i < end; i++) { + displayedPages.push(i); + } + + return ( +
+
+ ) +} \ No newline at end of file diff --git a/react-common/components/controls/FocusTrap.tsx b/react-common/components/controls/FocusTrap.tsx deleted file mode 100644 index 39d2546c9073..000000000000 --- a/react-common/components/controls/FocusTrap.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as React from "react"; -import { classList, nodeListToArray, findNextFocusableElement, focusLastActive } from "../util"; - -export interface FocusTrapProps extends React.PropsWithChildren<{}> { - onEscape: () => void; - id?: string; - className?: string; - arrowKeyNavigation?: boolean; - dontStealFocus?: boolean; - includeOutsideTabOrder?: boolean; - dontRestoreFocus?: boolean; -} - -export const FocusTrap = (props: FocusTrapProps) => { - const { - children, - id, - className, - onEscape, - arrowKeyNavigation, - dontStealFocus, - includeOutsideTabOrder, - dontRestoreFocus - } = props; - - let container: HTMLDivElement; - const previouslyFocused = React.useRef(document.activeElement); - const [stoleFocus, setStoleFocus] = React.useState(false); - - React.useEffect(() => { - return () => { - if (!dontRestoreFocus && previouslyFocused.current) { - focusLastActive(previouslyFocused.current as HTMLElement) - } - } - }, []) - - const getElements = () => { - const all = nodeListToArray( - includeOutsideTabOrder ? container.querySelectorAll(`[tabindex]`) : - container.querySelectorAll(`[tabindex]:not([tabindex="-1"])`) - ); - - return all as HTMLElement[]; - } - - const handleRef = (ref: HTMLDivElement) => { - if (!ref) return; - container = ref; - - if (!dontStealFocus && !stoleFocus && !ref.contains(document.activeElement) && getElements().length) { - container.focus(); - - // Only steal focus once - setStoleFocus(true); - } - } - - const onKeyDown = (e: React.KeyboardEvent) => { - if (!container) return; - - const moveFocus = (forward: boolean, goToEnd: boolean) => { - const focusable = getElements(); - - if (!focusable.length) return; - - const index = focusable.indexOf(e.target as HTMLElement); - - if (forward) { - if (goToEnd) { - findNextFocusableElement(focusable, index, focusable.length - 1, forward).focus(); - } - else if (index === focusable.length - 1) { - findNextFocusableElement(focusable, index, 0, forward).focus(); - } - else { - findNextFocusableElement(focusable, index, index + 1, forward).focus(); - } - } - else { - if (goToEnd) { - findNextFocusableElement(focusable, index, 0, forward).focus(); - } - else if (index === 0) { - findNextFocusableElement(focusable, index, focusable.length - 1, forward).focus(); - } - else { - findNextFocusableElement(focusable, index, Math.max(index - 1, 0), forward).focus(); - } - } - - e.preventDefault(); - e.stopPropagation(); - } - - if (e.key === "Escape") { - onEscape(); - e.preventDefault(); - e.stopPropagation(); - } - else if (e.key === "Tab") { - if (e.shiftKey) moveFocus(false, false); - else moveFocus(true, false); - } - else if (arrowKeyNavigation) { - if (e.key === "ArrowDown") { - moveFocus(true, false); - } - else if (e.key === "ArrowUp") { - moveFocus(false, false); - } - else if (e.key === "Home") { - moveFocus(false, true); - } - else if (e.key === "End") { - moveFocus(true, true); - } - } - } - - return
- {children} -
-} \ No newline at end of file diff --git a/react-common/components/controls/FocusTrap/FocusTrap.tsx b/react-common/components/controls/FocusTrap/FocusTrap.tsx new file mode 100644 index 000000000000..79c433a5b11c --- /dev/null +++ b/react-common/components/controls/FocusTrap/FocusTrap.tsx @@ -0,0 +1,240 @@ +import * as React from "react"; +import { classList, nodeListToArray, findNextFocusableElement, focusLastActive } from "../../util"; +import { addRegion, FocusTrapProvider, removeRegion, useFocusTrapDispatch, useFocusTrapState } from "./context"; +import { useId } from "../../../hooks/useId"; + +export interface FocusTrapProps extends React.PropsWithChildren<{}> { + onEscape: () => void; + id?: string; + className?: string; + arrowKeyNavigation?: boolean; + dontStealFocus?: boolean; + includeOutsideTabOrder?: boolean; + dontRestoreFocus?: boolean; +} + +export const FocusTrap = (props: FocusTrapProps) => { + return ( + + + + ); +} + +const FocusTrapInner = (props: FocusTrapProps) => { + const { + children, + id, + className, + onEscape, + arrowKeyNavigation, + dontStealFocus, + includeOutsideTabOrder, + dontRestoreFocus + } = props; + + let container: HTMLDivElement; + const previouslyFocused = React.useRef(document.activeElement); + const [stoleFocus, setStoleFocus] = React.useState(false); + + const { regions } = useFocusTrapState(); + + React.useEffect(() => { + return () => { + if (!dontRestoreFocus && previouslyFocused.current) { + focusLastActive(previouslyFocused.current as HTMLElement) + } + } + }, []) + + const getElements = React.useCallback(() => { + let all = nodeListToArray( + includeOutsideTabOrder ? container.querySelectorAll(`[tabindex]`) : + container.querySelectorAll(`[tabindex]:not([tabindex="-1"])`) + ); + + if (regions.length) { + const regionElements: pxt.Map = {}; + + for (const region of regions) { + const el = container.querySelector(`[data-focus-trap-region="${region.id}"]`); + + if (el) { + regionElements[region.id] = el; + } + } + + for (const region of regions) { + const regionElement = regionElements[region.id]; + if (!region.enabled && regionElement) { + all = all.filter(el => !regionElement.contains(el)); + } + } + + const initialOrder = all.slice(); + all.sort((a, b) => { + const aRegion = regions.find(r => r.enabled && regionElements[r.id]?.contains(a)); + const bRegion = regions.find(r => r.enabled && regionElements[r.id]?.contains(b)); + + if (aRegion?.order === bRegion?.order) { + const aIndex = initialOrder.indexOf(a); + const bIndex = initialOrder.indexOf(b); + return aIndex - bIndex; + } + else if (!aRegion) { + return 1; + } + else if (!bRegion) { + return -1; + } + else { + return aRegion.order - bRegion.order; + } + }); + } + + return all as HTMLElement[]; + }, [regions, includeOutsideTabOrder]); + + const handleRef = React.useCallback((ref: HTMLDivElement) => { + if (!ref) return; + container = ref; + + if (!dontStealFocus && !stoleFocus && !ref.contains(document.activeElement) && getElements().length) { + container.focus(); + + // Only steal focus once + setStoleFocus(true); + } + }, [getElements, dontStealFocus, stoleFocus]); + + const onKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (!container) return; + + const moveFocus = (forward: boolean, goToEnd: boolean) => { + const focusable = getElements(); + + if (!focusable.length) return; + + const index = focusable.indexOf(e.target as HTMLElement); + + if (forward) { + if (goToEnd) { + findNextFocusableElement(focusable, index, focusable.length - 1, forward).focus(); + } + else if (index === focusable.length - 1) { + findNextFocusableElement(focusable, index, 0, forward).focus(); + } + else { + findNextFocusableElement(focusable, index, index + 1, forward).focus(); + } + } + else { + if (goToEnd) { + findNextFocusableElement(focusable, index, 0, forward).focus(); + } + else if (index === 0) { + findNextFocusableElement(focusable, index, focusable.length - 1, forward).focus(); + } + else { + findNextFocusableElement(focusable, index, Math.max(index - 1, 0), forward).focus(); + } + } + + e.preventDefault(); + e.stopPropagation(); + } + + if (e.key === "Escape") { + let foundHandler = false; + if (regions.length) { + for (const region of regions) { + if (!region.onEscape) continue; + const regionElement = container.querySelector(`[data-focus-trap-region="${region.id}"]`); + if (regionElement?.contains(document.activeElement)) { + foundHandler = true; + region.onEscape(); + break; + } + } + } + if (!foundHandler) { + onEscape(); + } + e.preventDefault(); + e.stopPropagation(); + } + else if (e.key === "Tab") { + if (e.shiftKey) moveFocus(false, false); + else moveFocus(true, false); + } + else if (arrowKeyNavigation) { + if (e.key === "ArrowDown") { + moveFocus(true, false); + } + else if (e.key === "ArrowUp") { + moveFocus(false, false); + } + else if (e.key === "Home") { + moveFocus(false, true); + } + else if (e.key === "End") { + moveFocus(true, true); + } + } + }, [getElements, onEscape, arrowKeyNavigation, regions]) + + return( +
+ {children} +
+ ); +} + + + +interface FocusTrapRegionProps extends React.PropsWithChildren<{}> { + enabled: boolean; + order?: number; + onEscape?: () => void; + id?: string; + className?: string; + divRef?: (ref: HTMLDivElement) => void; +} + +export const FocusTrapRegion = (props: FocusTrapRegionProps) => { + const { + className, + id, + onEscape, + order, + enabled, + children, + divRef + } = props; + + const regionId = useId(); + const dispatch = useFocusTrapDispatch(); + + React.useEffect(() => { + dispatch(addRegion(regionId, order, enabled, onEscape)); + + return () => dispatch(removeRegion(regionId)); + }, [regionId, enabled, order]) + + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/react-common/components/controls/FocusTrap/context.tsx b/react-common/components/controls/FocusTrap/context.tsx new file mode 100644 index 000000000000..5f3f47e70404 --- /dev/null +++ b/react-common/components/controls/FocusTrap/context.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; + +interface FocusTrapState { + regions: FocusTrapRegionState[]; +} + +interface FocusTrapRegionState { + id: string; + enabled: boolean; + order: number; + onEscape?: () => void; +} + +const FocustTrapStateContext = React.createContext(null); +const FocustTrapDispatchContext = React.createContext<(action: Action) => void>(null); + +export const FocusTrapProvider = ({ + children, +}: React.PropsWithChildren<{}>) => { + const [state, dispatch] = React.useReducer(focusTrapReducer, { + regions: [] + }); + + return ( + + + {children} + + + ); +} + +type AddRegion = { + type: "ADD_REGION"; + id: string; + order: number; + enabled: boolean; + onEscape?: () => void; +}; + +type RemoveRegion = { + type: "REMOVE_REGION"; + id: string; +}; + +type Action = AddRegion | RemoveRegion; + +export const addRegion = (id: string, order: number, enabled: boolean, onEscape?: () => void): AddRegion => ( + { + type: "ADD_REGION", + id, + order, + enabled, + onEscape + } +); + +export const removeRegion = (id: string): RemoveRegion => ( + { + type: "REMOVE_REGION", + id + } +); + +export function useFocusTrapState() { + return React.useContext(FocustTrapStateContext); +} + +export function useFocusTrapDispatch() { + return React.useContext(FocustTrapDispatchContext); +} + +function focusTrapReducer(state: FocusTrapState, action: Action): FocusTrapState { + let newRegions = state.regions.slice(); + + switch (action.type) { + case "ADD_REGION": + const newRegion = { + id: action.id, + enabled: action.enabled, + order: action.order, + onEscape: action.onEscape + }; + const existing = newRegions.findIndex(r => r.id === action.id); + if (existing !== -1) { + newRegions.splice(existing, 1, newRegion) + } + else { + newRegions.push(newRegion); + } + break; + case "REMOVE_REGION": + const toRemove = state.regions.findIndex(r => r.id === action.id); + if (toRemove !== -1) { + newRegions.splice(toRemove, 1) + } + break; + } + + return { + regions: newRegions + }; +} diff --git a/react-common/components/controls/FocusTrap/index.ts b/react-common/components/controls/FocusTrap/index.ts new file mode 100644 index 000000000000..a51d81ad6b6b --- /dev/null +++ b/react-common/components/controls/FocusTrap/index.ts @@ -0,0 +1 @@ +export * from "./FocusTrap"; \ No newline at end of file diff --git a/react-common/styles/controls/CarouselNav.less b/react-common/styles/controls/CarouselNav.less new file mode 100644 index 000000000000..0f720e04ee47 --- /dev/null +++ b/react-common/styles/controls/CarouselNav.less @@ -0,0 +1,77 @@ +.common-carousel-nav { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + .common-carousel-nav-arrow { + min-height: 1rem; + min-width: 1rem; + padding: 0; + background: none; + color: white; + width: 1.25rem; + height: 1.25rem; + + margin: 0; + + i.fas { + width: 1.25rem; + } + + &.disabled { + opacity: 0.5; + } + } + + ul { + list-style: none; + margin: 0; + margin-left: 0.125rem; + margin-right: 0.125rem; + margin-block: 0; + padding-inline: 0; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + height: 1.25rem; + } + + li { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + height: 1rem; + width: 1rem; + + .common-button { + width: 1rem; + height: 1rem; + padding: 0.3rem; + background: none; + + .common-carousel-nav-button-handle { + background-color: white; + border-radius: 100%; + height: 0.4rem; + width: 0.4rem; + display: block; + } + + &.selected { + padding: 0.125rem; + + .common-carousel-nav-button-handle { + height: 0.75rem; + width: 0.75rem; + } + } + } + } + + .common-button:focus-visible::after { + inset: -1px; + } +} diff --git a/react-common/styles/react-common.less b/react-common/styles/react-common.less index 87e9debe8bdb..bef6d8cabacc 100644 --- a/react-common/styles/react-common.less +++ b/react-common/styles/react-common.less @@ -25,6 +25,7 @@ @import "controls/VerticalResizeContainer.less"; @import "controls/VerticalSlider.less"; @import "controls/Accordion.less"; +@import "controls/CarouselNav.less"; @import "./react-common-variables.less"; @import "fontawesome-free/less/solid.less"; diff --git a/theme/image-editor/button.less b/theme/image-editor/button.less index 2a04a107c56f..c6061cd5c7ec 100644 --- a/theme/image-editor/button.less +++ b/theme/image-editor/button.less @@ -1,20 +1,26 @@ -.image-editor-button { - text-align: center; +.common-button.image-editor-button { line-height: 2rem; width: 1.75rem; height: 1.75rem; border-radius: 0.25rem; margin: 0.375rem; + padding: 0; + min-width: unset; + min-height: unset; transition: color 0.1s; color: var(--sidebar-icon-active-color); + background: none; - cursor: pointer; - user-select: none; + &:focus-visible::after { + inset: -1px; + outline-color: @inputBorderColorFocus; + } } -.image-editor-button.toggle, .image-editor-button.disabled { +.image-editor-button.toggle, .common-button.image-editor-button.disabled { color: var(--sidebar-icon-inactive-color); + background: none; } .image-editor-button.disabled { diff --git a/theme/image-editor/cursorSizes.less b/theme/image-editor/cursorSizes.less index c906c4050879..62c65491c0ce 100644 --- a/theme/image-editor/cursorSizes.less +++ b/theme/image-editor/cursorSizes.less @@ -3,13 +3,21 @@ display: flex; flex-direction: row; margin: auto; + text-align: center; } .cursor-button { border-radius: 1px; - background-color: var(--sidebar-icon-inactive-color); + background-color: var(--sidebar-icon-active-color); margin-right: 0.25rem; transition: background-color 0.1s; + vertical-align: middle; + display: inline-block; + margin-top: -0.25rem; +} + +.toggle .cursor-button { + background-color: var(--sidebar-icon-inactive-color); } .cursor-button-outer { @@ -17,24 +25,21 @@ width: 1.5rem; } -.cursor-button-outer:hover .cursor-button, .cursor-button-outer.selected .cursor-button { +.common-button:hover .cursor-button { background-color: var(--sidebar-icon-active-color); } .cursor-button.small { - margin-top: 0.75rem; width: 0.5rem; height: 0.5rem; } .cursor-button.medium { - margin-top: 0.625rem; width: 0.75rem; height: 0.75rem; } .cursor-button.large { - margin-top: 0.5rem; width: 1rem; height: 1rem; } \ No newline at end of file diff --git a/theme/image-editor/imageCanvas.less b/theme/image-editor/imageCanvas.less index c3dd8dfe49f5..a9d83e36adaa 100644 --- a/theme/image-editor/imageCanvas.less +++ b/theme/image-editor/imageCanvas.less @@ -48,7 +48,7 @@ -ms-interpolation-mode: nearest-neighbor; } -.checkerboard { +.checkerboard, .common-button.image-editor-button.checkerboard { background-color: #aeaeae; background-image: linear-gradient(45deg, #dedede 25%, transparent 25%), linear-gradient(-45deg, #dedede 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #dedede 75%), linear-gradient(-45deg, transparent 75%, #dedede 75%); background-size: 0.75rem 0.75rem; diff --git a/theme/image-editor/imageEditor.less b/theme/image-editor/imageEditor.less index becb3be9cd8e..f6be06ae8aa9 100644 --- a/theme/image-editor/imageEditor.less +++ b/theme/image-editor/imageEditor.less @@ -90,12 +90,19 @@ overflow: hidden; } +.image-editor-region { + width: 100%; + height: 100%; + position: relative; +} + .gallery-editor-header { height: 3rem; background-color: #4B7BEC; border: 2px solid #4067b3; border-bottom: none; display: flex; + flex-shrink: 0; } .image-editor-header-left, @@ -292,7 +299,7 @@ max-width: 120px; } -.image-editor-confirm { +.common-button.image-editor-confirm { display: flex; align-items: center; padding: 0 2rem; @@ -300,13 +307,15 @@ color: @white; cursor: pointer; user-select: none; + margin: 0; + border-radius: 0; } -.image-editor-confirm:hover { +.common-button.image-editor-confirm:hover { background-color: darken(@green, 5%); } -.image-editor-confirm:active { +.common-button.image-editor-confirm:active { background-color: darken(@green, 10%); } diff --git a/theme/image-editor/tilePalette.less b/theme/image-editor/tilePalette.less index b8c464696841..3e907842310c 100644 --- a/theme/image-editor/tilePalette.less +++ b/theme/image-editor/tilePalette.less @@ -105,74 +105,116 @@ .tile-canvas-controls { width: 100%; text-align: center; - margin-top: -0.5rem; } -.tile-palette-pages { - height: 1.75rem; - display: inline-block; +.tile-canvas { + width: 9.5rem; + height: 9.5rem; + position: relative; + + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: repeat(4, 1fr); + gap: 1px; + + margin: 0.25rem; + padding: 2px; + background-color: #3d3d3d; } -.tile-palette-page-arrow { - fill: var(--sidebar-icon-inactive-color); - stroke: var(--sidebar-icon-inactive-color); - stroke-linejoin: round; - cursor: pointer; +.tile-palette-controls { + display: flex; + flex-direction: row; + justify-content: space-evenly; + align-content: center; + height: 2rem; +} - transition: fill 0.1s, stroke 0.1s; +.tile-palette-controls .image-editor-button { + line-height: 1.75rem; } -.tile-palette-page-dot { - fill: var(--sidebar-icon-inactive-color); - cursor: pointer; - transition: fill 0.1s; +.tile-palette-controls-outer { + height: 2rem; } -.tile-palette-pages:not(.disabled) { - .tile-palette-page-arrow:hover { - fill: var(--sidebar-icon-active-color); - stroke: var(--sidebar-icon-active-color); +.tile-canvas-controls .common-carousel-nav { + .common-carousel-nav-arrow { + color: var(--sidebar-icon-inactive-color); + transition: color 0.1s; + + &:hover { + color: var(--sidebar-icon-active-color); + filter: none; + } } - .tile-palette-page-dot:hover { - fill: var(--sidebar-icon-active-color); + li .common-button { + .common-carousel-nav-button-handle { + background-color: var(--sidebar-icon-inactive-color); + transition: background-color 0.1s; + } + + &:hover { + filter: none; + + .common-carousel-nav-button-handle { + background-color: var(--sidebar-icon-active-color); + } + } } } -.tile-palette-pages.disabled { - .tile-palette-page-arrow, - .tile-palette-page-dot { - opacity: 0.5; +.tile-palette-dropdown.common-dropdown { + .common-dropdown-button { + background: none; + color: var(--sidebar-icon-active-color); + border: 1px solid var(--sidebar-icon-inactive-color); + min-width: unset; + width: calc(10rem - 0.5rem); + margin: 0 0.25rem; + transition: border-color 0.1s; + + &:hover { + border-color: var(--sidebar-icon-active-color); + filter: none; + } + } + + .common-menu-dropdown-pane { + max-height: 12rem; + overflow-y: auto; + overflow-x: hidden; } } -.tile-canvas { - width: 100%; - padding: 0.25rem; +.tile-button-outer { position: relative; } -.tile-canvas .paint-surface { + +.image-editor-button.common-button.tile-button { + margin: 0; width: 100%; - background-color: #3d3d3d; -} + height: 100%; + background-color: var(--editing-tools-bg-color); + border-radius: 0; -.tile-palette-controls { - display: flex; - flex-direction: row; - justify-content: space-evenly; - align-content: center; - height: 2rem; -} + canvas { + width: 100%; + height: 100%; -.tile-palette-controls .image-editor-button { - line-height: 1.75rem; + image-rendering: pixelated; + } } -.tile-palette-controls-outer { - height: 2rem; -} +.image-editor-button.common-button.add-tile-button { + margin: 0; + height: 100%; + width: 100%; + margin-top: 0.2rem; +} @media screen and (max-height: 720px) { .image-editor-tilemap-minimap { diff --git a/theme/image-editor/timeline.less b/theme/image-editor/timeline.less index fb51866f1ce8..f1f55f30522f 100644 --- a/theme/image-editor/timeline.less +++ b/theme/image-editor/timeline.less @@ -7,6 +7,13 @@ flex-direction: column; } +.image-editor-timeline-frames { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.4rem 0; +} + .image-editor-timeline-frame { border: 1px var(--sidebar-icon-inactive-color) solid; color: var(--sidebar-icon-inactive-color); @@ -14,7 +21,7 @@ border-radius: 0.25rem; width: 6rem; height: 6rem; - margin: 0.4rem; + margin: 0 0.4rem; transition: border 0.2s, color 0.2s, background-color 0.2s; overflow: hidden; @@ -126,6 +133,20 @@ background-color: #aeaeae; } +.common-button.image-editor-button.add-frame-button { + height: 2rem; + width: 6rem; + margin: 0 0.4rem; + + border: 1px solid var(--sidebar-icon-inactive-color); + transition: color 0.1s, border-color 0.1s; + + &:hover { + border-color: var(--sidebar-icon-active-color); + filter: none; + } +} + /* .timeline-frame-actions */ diff --git a/theme/image-editor/topBar.less b/theme/image-editor/topBar.less index 0ee9874f5611..5aebbe8e1afc 100644 --- a/theme/image-editor/topBar.less +++ b/theme/image-editor/topBar.less @@ -9,6 +9,7 @@ .image-editor-topbar > div .image-editor-button { margin-top: 0; margin-left: 0; + height: 2rem; } .image-editor-topbar > div { diff --git a/webapp/src/components/ImageEditor/Alert.tsx b/webapp/src/components/ImageEditor/Alert.tsx index d15bb9e858e6..cd69e5c7ae8b 100644 --- a/webapp/src/components/ImageEditor/Alert.tsx +++ b/webapp/src/components/ImageEditor/Alert.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { connect } from 'react-redux'; import { ImageEditorStore } from './store/imageReducer'; import { dispatchHideAlert } from './actions/dispatch'; -import { IconButton } from "./Button"; export interface AlertOption { label: string; diff --git a/webapp/src/components/ImageEditor/BottomBar.tsx b/webapp/src/components/ImageEditor/BottomBar.tsx index 8107117e47c1..cb2eb84c7859 100644 --- a/webapp/src/components/ImageEditor/BottomBar.tsx +++ b/webapp/src/components/ImageEditor/BottomBar.tsx @@ -3,11 +3,11 @@ import * as React from "react"; import { connect } from 'react-redux'; import { ImageEditorStore, AnimationState, TilemapState } from './store/imageReducer'; import { dispatchChangeImageDimensions, dispatchUndoImageEdit, dispatchRedoImageEdit, dispatchToggleAspectRatioLocked, dispatchChangeZoom, dispatchToggleOnionSkinEnabled, dispatchChangeAssetName } from './actions/dispatch'; -import { IconButton } from "./Button"; import { fireClickOnlyOnEnter } from "./util"; import { isNameTaken } from "../../assets"; import { obtainShortcutLock, releaseShortcutLock } from "./keyboardShortcuts"; import { classList } from "../../../../react-common/components/util"; +import { Button } from "../../../../react-common/components/controls/Button"; export interface BottomBarProps { dispatchChangeImageDimensions: (dimensions: [number, number]) => void; @@ -93,12 +93,11 @@ export class BottomBarImpl extends React.Component - } { !singleFrame &&
-
} { !resizeDisabled &&
} @@ -145,42 +144,44 @@ export class BottomBarImpl extends React.Component
- -
- -
- {!hideDoneButton &&
- {lf("Done")} -
} + {!hideDoneButton && +
); } diff --git a/webapp/src/components/ImageEditor/CursorSizes.tsx b/webapp/src/components/ImageEditor/CursorSizes.tsx index dfece1f1702f..87ad80f6ac6c 100644 --- a/webapp/src/components/ImageEditor/CursorSizes.tsx +++ b/webapp/src/components/ImageEditor/CursorSizes.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { ImageEditorStore, CursorSize } from './store/imageReducer'; import { dispatchChangeCursorSize } from './actions/dispatch'; import { connect } from 'react-redux'; +import { Button } from '../../../../react-common/components/controls/Button'; +import { classList } from '../../../../react-common/components/util'; interface CursorSizesProps { selected: CursorSize; @@ -14,17 +16,28 @@ class CursorSizesImpl extends React.Component { render() { const { selected } = this.props; - return
-
-
+ return ( +
+
-
-
-
-
-
-
-
+ ); } clickHandler(size: CursorSize) { diff --git a/webapp/src/components/ImageEditor/Dropdown.tsx b/webapp/src/components/ImageEditor/Dropdown.tsx deleted file mode 100644 index aa351b69be0e..000000000000 --- a/webapp/src/components/ImageEditor/Dropdown.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import * as React from 'react'; - -export interface DropdownOption { - text: string; - id: string; -} - -export interface DropdownProps { - options: DropdownOption[]; - selected: number; - onChange: (selected: DropdownOption, index: number) => void; -} - -export interface DropdownState { - open: boolean; -} - -export class Dropdown extends React.Component { - protected handlers: (() => void)[] = []; - - constructor(props: DropdownProps) { - super(props); - - this.state = { - open: false - }; - } - - componentDidUpdate() { - this.handlers = []; - } - - componentWillUnmount() { - this.handlers = null; - } - - render() { - const { options, selected } = this.props; - const { open } = this.state; - - const selectedOption = options[selected]; - - return
- -
    - { - options.map((option, index) => -
  • - {option.text} -
  • - ) - } -
-
- } - - protected clickHandler(index: number): () => void { - if (!this.handlers[index]) { - const { onChange, options } = this.props; - - this.handlers[index] = () => { - this.setState({ open: false }); - onChange(options[index], index); - }; - } - - return this.handlers[index]; - } - - protected handleDropdownClick = () => { - this.setState({ open: !this.state.open }); - } -} \ No newline at end of file diff --git a/webapp/src/components/ImageEditor/SideBar.tsx b/webapp/src/components/ImageEditor/SideBar.tsx index 9c3ab4a456a5..640308759cbb 100644 --- a/webapp/src/components/ImageEditor/SideBar.tsx +++ b/webapp/src/components/ImageEditor/SideBar.tsx @@ -2,12 +2,13 @@ import * as React from "react"; import { connect } from "react-redux"; import { tools } from "./toolDefinitions"; -import { IconButton } from "./Button"; import { ImageEditorTool, ImageEditorStore } from "./store/imageReducer"; import { dispatchChangeImageTool } from "./actions/dispatch"; import { Palette } from "./sprite/Palette"; import { TilePalette } from "./tilemap/TilePalette"; import { Minimap } from "./tilemap/Minimap"; +import { Button } from "../../../../react-common/components/controls/Button"; +import { classList } from "../../../../react-common/components/util"; interface SideBarProps { selectedTool: ImageEditorTool; @@ -30,12 +31,13 @@ export class SideBarImpl extends React.Component { }
{tools.filter(td => !td.hiddenTool).map(td => - + onClick={this.clickHandler(td.tool)} + /> )}
diff --git a/webapp/src/components/ImageEditor/Timeline.tsx b/webapp/src/components/ImageEditor/Timeline.tsx index 2d7462d48721..2928bdb81eac 100644 --- a/webapp/src/components/ImageEditor/Timeline.tsx +++ b/webapp/src/components/ImageEditor/Timeline.tsx @@ -6,6 +6,7 @@ import { dispatchChangeCurrentFrame, dispatchNewFrame, dispatchDuplicateFrame, d import { TimelineFrame } from "./TimelineFrame"; import { bindGestureEvents, ClientCoordinates } from "./util"; +import { Button } from "../../../../react-common/components/controls/Button"; interface TimelineProps { colors: string[]; @@ -81,9 +82,12 @@ export class TimelineImpl extends React.Component deleteFrame={this.deleteFrame} />
} -
- -
+
diff --git a/webapp/src/components/ImageEditor/TimelineFrame.tsx b/webapp/src/components/ImageEditor/TimelineFrame.tsx index 9d2107b35759..47859dd50c74 100644 --- a/webapp/src/components/ImageEditor/TimelineFrame.tsx +++ b/webapp/src/components/ImageEditor/TimelineFrame.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { IconButton } from "./Button"; +import { Button } from "../../../../react-common/components/controls/Button"; interface TimelineFrameProps { frames: pxt.sprite.ImageState[]; @@ -47,13 +47,15 @@ export class TimelineFrame extends React.Component {showActions &&
- - diff --git a/webapp/src/components/ImageEditor/TopBar.tsx b/webapp/src/components/ImageEditor/TopBar.tsx index 2f36a4d73369..984a9b28c531 100644 --- a/webapp/src/components/ImageEditor/TopBar.tsx +++ b/webapp/src/components/ImageEditor/TopBar.tsx @@ -3,10 +3,10 @@ import * as React from "react"; import { connect } from 'react-redux'; import { ImageEditorStore, AnimationState } from './store/imageReducer'; import { dispatchChangeInterval, dispatchChangePreviewAnimating, dispatchChangeOverlayEnabled } from './actions/dispatch'; -import { IconButton } from "./Button"; import { CursorSizes } from "./CursorSizes"; import { Toggle } from "./Toggle"; import { flip, rotate } from "./keyboardShortcuts"; +import { Button } from "../../../../react-common/components/controls/Button"; export interface TopBarProps { @@ -41,20 +41,40 @@ export class TopBarImpl extends React.Component {
- - - - +
{ !singleFrame &&
} { !singleFrame &&
-
diff --git a/webapp/src/components/ImageEditor/sprite/Palette.tsx b/webapp/src/components/ImageEditor/sprite/Palette.tsx index 43da36da74f7..606fa1c0a60c 100644 --- a/webapp/src/components/ImageEditor/sprite/Palette.tsx +++ b/webapp/src/components/ImageEditor/sprite/Palette.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { connect } from 'react-redux'; import { ImageEditorStore, AnimationState } from '../store/imageReducer'; import { dispatchChangeSelectedColor, dispatchChangeBackgroundColor, dispatchSwapBackgroundForeground } from '../actions/dispatch'; +import { Button } from '../../../../../react-common/components/controls/Button'; +import { classList } from '../../../../../react-common/components/util'; export interface PaletteProps { colors: string[]; @@ -13,8 +15,6 @@ export interface PaletteProps { } class PaletteImpl extends React.Component { - protected handlers: ((ev: React.MouseEvent) => void)[] = []; - render() { const { colors, selected, backgroundColor, dispatchSwapBackgroundForeground } = this.props; const SPACER = 1; @@ -57,33 +57,20 @@ class PaletteImpl extends React.Component {
- {this.props.colors.map((color, index) => { - return
+
; } - protected clickHandler(index: number) { - if (!this.handlers[index]) this.handlers[index] = (ev: React.MouseEvent) => { - if (ev.button === 0) { - this.props.dispatchChangeSelectedColor(index); - } - else { - this.props.dispatchChangeBackgroundColor(index); - ev.preventDefault(); - ev.stopPropagation(); - } - } - - return this.handlers[index]; - } - protected preventContextMenu = (ev: React.MouseEvent) => ev.preventDefault(); } diff --git a/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx b/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx index 8d1493f584ea..a8746c319dea 100644 --- a/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx +++ b/webapp/src/components/ImageEditor/tilemap/TilePalette.tsx @@ -6,12 +6,15 @@ import { dispatchChangeSelectedColor, dispatchChangeBackgroundColor, dispatchSwa dispatchCreateNewTile, dispatchSetGalleryOpen, dispatchOpenTileEditor, dispatchDeleteTile, dispatchShowAlert, dispatchHideAlert } from '../actions/dispatch'; import { TimelineFrame } from '../TimelineFrame'; -import { Dropdown, DropdownOption } from '../Dropdown'; import { Pivot, PivotOption } from '../Pivot'; -import { IconButton } from '../Button'; import { AlertOption } from '../Alert'; import { createTile } from '../../../assets'; +import { CarouselNav } from "../../../../../react-common/components/controls/CarouselNav"; +import { Dropdown, DropdownItem } from '../../../../../react-common/components/controls/Dropdown'; +import { Button } from '../../../../../react-common/components/controls/Button'; +import { classList } from '../../../../../react-common/components/util'; + export interface TilePaletteProps { colors: string[]; tileset: pxt.TileSet; @@ -49,7 +52,9 @@ const SCALE = pxt.BrowserUtils.isEdge() ? 25 : 1; const TILES_PER_PAGE = 16; -interface Category extends DropdownOption { +interface Category { + id: string; + text: string; tiles: GalleryTile[]; } @@ -93,7 +98,6 @@ interface UserTile { type RenderedTile = GalleryTile | UserTile class TilePaletteImpl extends React.Component { - protected canvas: HTMLCanvasElement; protected renderedTiles: RenderedTile[]; protected categoryTiles: RenderedTile[]; protected categories: Category[]; @@ -129,9 +133,7 @@ class TilePaletteImpl extends React.Component { } componentDidMount() { - this.canvas = this.refs["tile-canvas-surface"] as HTMLCanvasElement; this.updateGalleryTiles(); - this.redrawCanvas(); } UNSAFE_componentWillReceiveProps(nextProps: TilePaletteProps) { @@ -145,7 +147,6 @@ class TilePaletteImpl extends React.Component { componentDidUpdate() { this.updateGalleryTiles(); - this.redrawCanvas(); } render() { @@ -164,6 +165,19 @@ class TilePaletteImpl extends React.Component { const showCreateTile = !galleryOpen && (totalPages === 1 || page === totalPages - 1); const controlsDisabled = galleryOpen || !this.renderedTiles.some(t => !isGalleryTile(t) && t.index === selected); + const columns = 4; + const rows = 4; + const startIndex = page * columns * rows; + + const visibleTiles = this.categoryTiles.slice(startIndex, startIndex + columns * rows); + + const dropdownItems: DropdownItem[] = this.categories.filter(c => !!c.tiles.length) + .map(cat => ({ + id: cat.id, + title: cat.text, + label: cat.text, + })); + return
@@ -175,10 +189,12 @@ class TilePaletteImpl extends React.Component { - + onClick={this.foregroundBackgroundClickHandler} + />
{
- { galleryOpen && !!c.tiles.length)} selected={category} /> } + { galleryOpen && + + } { !galleryOpen &&
- - -
} @@ -222,20 +246,34 @@ class TilePaletteImpl extends React.Component {
- + { visibleTiles.map((tile, index) => + this.handleTileClick(index, false)} + onRightClick={() => this.handleTileClick(index, true)} + /> + )} { showCreateTile && -
- +
}
- { pageControls(totalPages, page, this.pageHandler) } +
; @@ -281,65 +319,8 @@ class TilePaletteImpl extends React.Component { } } - protected redrawCanvas() { - const columns = 4; - const rows = 4; - const margin = 1; - - const { tileset, page, selected } = this.props; - - const startIndex = page * columns * rows; - - const width = tileset.tileWidth + margin; - - this.canvas.width = (width * columns + margin) * SCALE; - this.canvas.height = (width * rows + margin) * SCALE; - - const context = this.canvas.getContext("2d"); - - for (let r = 0; r < rows; r++) { - for (let c = 0; c < columns; c++) { - const tile = this.categoryTiles[startIndex + r * columns + c]; - - if (tile) { - if (!isGalleryTile(tile) && tile.index === selected) { - context.fillStyle = "#ff0000"; - context.fillRect(c * width, r * width, width + 1, width + 1); - } - - context.fillStyle = "#333333"; - context.fillRect(c * width + 1, r * width + 1, width - 1, width - 1); - - this.drawBitmap(pxt.sprite.Bitmap.fromData(tile.bitmap), 1 + c * width, 1 + r * width) - } - } - } - - this.positionCreateTileButton(); - } - - protected drawBitmap(bitmap: pxt.sprite.Bitmap, x0 = 0, y0 = 0, transparent = true, cellWidth = SCALE, target = this.canvas) { - const { colors } = this.props; - - const context = target.getContext("2d"); - context.imageSmoothingEnabled = false; - for (let x = 0; x < bitmap.width; x++) { - for (let y = 0; y < bitmap.height; y++) { - const index = bitmap.get(x, y); - - if (index) { - context.fillStyle = colors[index]; - context.fillRect((x + x0) * cellWidth, (y + y0) * cellWidth, cellWidth, cellWidth); - } - else { - if (!transparent) context.clearRect((x + x0) * cellWidth, (y + y0) * cellWidth, cellWidth, cellWidth); - } - } - } - } - - protected dropdownHandler = (option: DropdownOption, index: number) => { - this.props.dispatchChangeTilePaletteCategory(index); + protected dropdownHandler = (id: string) => { + this.props.dispatchChangeTilePaletteCategory(this.categories.filter(c => !!c.tiles.length).findIndex(c => c.id === id)); } protected pivotHandler = (option: PivotOption, index: number) => { @@ -401,20 +382,9 @@ class TilePaletteImpl extends React.Component { } } - protected canvasClickHandler = (ev: React.MouseEvent) => { - this.handleCanvasClickCore(ev.clientX, ev.clientY, ev.button > 0); - } - - protected canvasTouchHandler = (ev: React.TouchEvent) => { - this.handleCanvasClickCore(ev.changedTouches[0].clientX, ev.changedTouches[0].clientY, false); - } - - protected handleCanvasClickCore(clientX: number, clientY: number, isRightClick: boolean) { - const bounds = this.canvas.getBoundingClientRect(); - const column = ((clientX - bounds.left) / (bounds.width / 4)) | 0; - const row = ((clientY - bounds.top) / (bounds.height / 4)) | 0; + protected handleTileClick(buttonIndex: number, isRightClick: boolean) { + const tile = this.renderedTiles[buttonIndex]; - const tile = this.renderedTiles[row * 4 + column]; if (tile) { let index: number; let qname: string; @@ -456,19 +426,6 @@ class TilePaletteImpl extends React.Component { } } - protected positionCreateTileButton() { - const button = this.refs["create-tile-ref"] as HTMLDivElement; - - if (button) { - const column = this.categoryTiles.length % 4; - const row = Math.floor(this.categoryTiles.length / 4) % 4; - - button.style.position = "absolute"; - button.style.left = "calc(" + (column / 4) + " * (100% - 0.5rem) + 0.25rem)"; - button.style.top = "calc(" + (row / 4) + " * (100% - 0.5rem) + 0.25rem)"; - } - } - protected foregroundBackgroundClickHandler = () => { if (this.props.drawingMode != TileDrawingMode.Default) { this.props.dispatchChangeDrawingMode(TileDrawingMode.Default); @@ -512,36 +469,56 @@ class TilePaletteImpl extends React.Component { } } +interface TileButtonProps { + tile: pxt.sprite.BitmapData; + title: string; + onClick: () => void; + onRightClick: () => void; + colors: string[]; +} + +const TileButton = (props: TileButtonProps) => { + const { tile, title, onClick, onRightClick, colors } = props; + + const canvasRef = React.useRef(); + + React.useEffect(() => { + const canvas = canvasRef.current; + + canvas.width = tile.width; + canvas.height = tile.height; + + const context = canvas.getContext("2d"); + + context.clearRect(0, 0, canvas.width, canvas.height); -function pageControls(pages: number, selected: number, onClick: (index: number) => void) { - const width = 16 + (pages - 1) * 5; - const pageMap: boolean[] = []; - for (let i = 0; i < pages; i++) pageMap[i] = i === selected; - - return - onClick(selected - 1) : undefined} /> - { - pageMap.map((isSelected, index) => - onClick(index) : undefined}/> - ) + const bitmap = pxt.sprite.Bitmap.fromData(tile); + + for (let x = 0; x < tile.width; x++) { + for (let y = 0; y < tile.height; y++) { + const index = bitmap.get(x, y); + + if (index) { + context.fillStyle = colors[index]; + context.fillRect(x, y, 1, 1); + } + } } - onClick(selected + 1) : undefined} /> - + }, [tile, colors]) + + return ( +
+
+ ) } - function mapStateToProps({ store: { present }, editor }: ImageEditorStore, ownProps: any) { let state = (present as TilemapState); if (!state) return {}; diff --git a/webapp/src/components/ImageFieldEditor.tsx b/webapp/src/components/ImageFieldEditor.tsx index e1e0adcaa500..74f1aeb23110 100644 --- a/webapp/src/components/ImageFieldEditor.tsx +++ b/webapp/src/components/ImageFieldEditor.tsx @@ -8,9 +8,10 @@ import { obtainShortcutLock, releaseShortcutLock } from "./ImageEditor/keyboardS import { GalleryTile, setTelemetryFunction } from './ImageEditor/store/imageReducer'; import { FilterPanel } from './FilterPanel'; import { fireClickOnEnter } from "../util"; -import { EditorToggle, EditorToggleItem, BasicEditorToggleItem } from "../../../react-common/components/controls/EditorToggle"; +import { EditorToggle } from "../../../react-common/components/controls/EditorToggle"; import { MusicFieldEditor } from "./MusicFieldEditor"; import { classList } from "../../../react-common/components/util"; +import { FocusTrap, FocusTrapRegion } from "../../../react-common/components/controls/FocusTrap"; export interface ImageFieldEditorProps { singleFrame: boolean; @@ -20,10 +21,6 @@ export interface ImageFieldEditorProps { includeSpecialTagsInFilter?: boolean; } -interface ToggleOption extends BasicEditorToggleItem { - view: string; -} - export interface ImageFieldEditorState { currentView: "editor" | "gallery" | "my-assets"; filterOpen: boolean; @@ -36,11 +33,6 @@ export interface ImageFieldEditorState { hideCloseButton?: boolean; } -interface ProjectGalleryItem extends pxt.sprite.GalleryItem { - assetType: pxt.AssetType; - id: string; -} - export interface AssetEditorCore { getAsset(): pxt.Asset; getPersistentData(): any; @@ -63,6 +55,7 @@ export class ImageFieldEditor extends React.Component extends React.Component - {showHeader &&
-
-
- i.view === currentView)} - /> -
-
-
-
- + return ( + + {showHeader &&
+
+
+ i.view === currentView)} + /> +
+
+
+
+ +
+
{lf("Filter")}
-
{lf("Filter")}
+ {!editingTile && !hideCloseButton &&
+ +
}
- {!editingTile && !hideCloseButton &&
- -
} -
-
} -
-
- {this.props.isMusicEditor ? - : - } +
+
+ + {this.props.isMusicEditor ? + : + + } + +
-
-
- +
+
+
+ +
-
-
+ + ); } componentDidMount() { @@ -621,23 +624,44 @@ export class ImageFieldEditor extends React.Component { + if (ref) this.imageEditorRegion = ref; + } + + protected onEscapeFromGallery = () => { + this.setState({ + currentView: "editor" + }, () => { + if (this.imageEditorRegion) { + this.imageEditorRegion.focus(); + } + }) + } } interface ImageEditorGalleryProps { items?: pxt.Asset[]; hidden: boolean; onAssetSelected: (item: pxt.Asset) => void; + onEscape: () => void; } class ImageEditorGallery extends React.Component { render() { - let { items, hidden } = this.props; - - return
- {!hidden && items && items.map((item, index) => - - )} -
+ let { items, hidden, onEscape } = this.props; + + return ( + + {!hidden && items && items.map((item, index) => + + )} + + ); } clickHandler = (asset: pxt.Asset) => { diff --git a/webapp/src/components/assetEditor/assetCard.tsx b/webapp/src/components/assetEditor/assetCard.tsx index 73a1c1c465c3..e0eb892cbd01 100644 --- a/webapp/src/components/assetEditor/assetCard.tsx +++ b/webapp/src/components/assetEditor/assetCard.tsx @@ -5,6 +5,7 @@ import { AssetEditorState, isGalleryAsset } from './store/assetEditorReducerStat import { dispatchChangeSelectedAsset } from './actions/dispatch'; import { AssetPreview } from "./assetPreview"; +import { fireClickOnEnter } from "../../util"; interface AssetCardProps { @@ -57,17 +58,25 @@ export class AssetCardView extends React.Component { const inGallery = isGalleryAsset(asset); const icon = this.getDisplayIconForAsset(asset.type); const showIcons = icon || !asset.meta?.displayName; - return
- - {showIcons &&
- {icon &&
- + return ( +
+ + {showIcons &&
+ {icon &&
+ +
} + {!asset.meta?.displayName && !inGallery &&
+ +
}
} - {!asset.meta?.displayName && !inGallery &&
- -
} -
} -
+
+ ); } }