diff --git a/react-common/components/controls/FocusList.tsx b/react-common/components/controls/FocusList.tsx index b6ecceeb1101..102dbd399d98 100644 --- a/react-common/components/controls/FocusList.tsx +++ b/react-common/components/controls/FocusList.tsx @@ -57,6 +57,42 @@ export const FocusList = (props: FocusListProps) => { } } + const isFocusable = (e: HTMLElement) => { + return e.getAttribute("data-isfocusable") === "true" + && getComputedStyle(e).display !== "none"; + } + + const firstFocusableElement = () => { + return focusableElements.find(e => isFocusable(e)) + } + + const lastFocusableElement = () => { + for (let i = 0; i < focusableElements.length; i++) { + if (isFocusable(focusableElements[focusableElements.length - 1 - i])) { + return focusableElements[focusableElements.length - 1 - i]; + } + } + + return focusableElements[0]; + } + + const nextFocusableElement = (index: number, forwards: boolean) => { + let current: HTMLElement + for (let i = 1; i < focusableElements.length; i++) { + if (forwards) { + current = focusableElements[(index + i) % focusableElements.length]; + } + else { + current = focusableElements[(index + focusableElements.length - i) % focusableElements.length]; + } + + if (isFocusable(current)) { + return current; + } + } + return focusableElements[0]; + } + const onKeyDown = (e: React.KeyboardEvent) => { if (!focusableElements?.length) return; @@ -84,31 +120,31 @@ export const FocusList = (props: FocusListProps) => { } else if (e.key === (useUpAndDownArrowKeys ? "ArrowDown" : "ArrowRight")) { if (index === focusableElements.length - 1 || target === focusList) { - focus(focusableElements[0]); + focus(firstFocusableElement()); } else { - focus(focusableElements[index + 1]); + focus(nextFocusableElement(index, true)); } e.preventDefault(); e.stopPropagation(); } else if (e.key === (useUpAndDownArrowKeys ? "ArrowUp" : "ArrowLeft")) { if (index === 0 || target === focusList) { - focus(focusableElements[focusableElements.length - 1]); + focus(lastFocusableElement()); } else { - focus(focusableElements[Math.max(index - 1, 0)]); + focus(nextFocusableElement(index, false)); } e.preventDefault(); e.stopPropagation(); } else if (e.key === "Home") { - focus(focusableElements[0]); + focus(firstFocusableElement()); e.preventDefault(); e.stopPropagation(); } else if (e.key === "End") { - focus(focusableElements[focusableElements.length - 1]); + focus(lastFocusableElement()); e.preventDefault(); e.stopPropagation(); } diff --git a/react-common/components/controls/Tree.tsx b/react-common/components/controls/Tree.tsx new file mode 100644 index 000000000000..cc75b5f778aa --- /dev/null +++ b/react-common/components/controls/Tree.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; + +import { ContainerProps, classList } from "../util"; +import { FocusList } from "./FocusList"; + +export interface TreeProps extends ContainerProps { + role?: "tree" | "group"; +} + +export interface TreeItemProps extends ContainerProps { + role?: "treeitem"; + onClick?: () => void; + initiallyExpanded?: boolean; +} + +export interface TreeItemBodyProps extends ContainerProps { +} + +export const Tree = (props: TreeProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + } = props; + + if (!role || role === "tree") { + return ( + + {children} + + ) + } + + return ( +
+ {children} +
+ ) +}; + +export const TreeItem = (props: TreeItemProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + initiallyExpanded, + onClick + } = props; + + const [expanded, setExpanded] = React.useState(initiallyExpanded); + const mappedChildren = React.Children.toArray(children); + const hasSubtree = mappedChildren.length > 1; + + const subtreeContainer = React.useRef() + + React.useEffect(() => { + if (!hasSubtree) return; + + if (expanded) { + const focusable = subtreeContainer.current.querySelectorAll(`[tabindex]:not([tabindex="-1"]),[data-isfocusable]`); + focusable.forEach(f => f.setAttribute("data-isfocusable", "true")); + } + else { + const focusable = subtreeContainer.current.querySelectorAll(`[tabindex]:not([tabindex="-1"]),[data-isfocusable]`); + focusable.forEach(f => f.setAttribute("data-isfocusable", "false")); + } + }, [expanded, hasSubtree]); + + const onTreeItemClick = React.useCallback(() => { + if (hasSubtree) { + setExpanded(!expanded); + } + if (onClick) { + onClick(); + } + }, [hasSubtree, expanded]) + + return ( +
+
+ {hasSubtree && + + } + {mappedChildren[0]} +
+
+ {hasSubtree ? mappedChildren[1] : undefined} +
+
+ ); +} + +export const TreeItemBody = (props: TreeItemBodyProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + } = props; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/react-common/styles/controls/Tree.less b/react-common/styles/controls/Tree.less new file mode 100644 index 000000000000..9848836ad26a --- /dev/null +++ b/react-common/styles/controls/Tree.less @@ -0,0 +1,33 @@ +.common-tree { + display: flex; + flex-direction: column; +} + +.common-tree.subtree .common-treeitem { + // The width of the chevron is 1.81rem + 0.25rem margin + padding-left: 2.06rem; +} + +.common-treeitem { + display: flex; + flex-direction: row; + align-items: center; + + height: 3rem; + cursor: pointer; + + background-color: @treeitemBackgroundColor; + + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.common-treeitem:hover { + filter: grayscale(.15) brightness(.85) contrast(1.3); +} + +.common-treeitem-container { + display: flex; + flex-direction: column; +} \ No newline at end of file diff --git a/react-common/styles/react-common-variables.less b/react-common/styles/react-common-variables.less index 60dc69bb3263..4e4d49f62dfe 100644 --- a/react-common/styles/react-common-variables.less +++ b/react-common/styles/react-common-variables.less @@ -141,4 +141,11 @@ @progressBarBorder: #7D7D7D; @progressBarUnfilledColor: #EEE; -@progressBarFilledColor: #5874e1; \ No newline at end of file +@progressBarFilledColor: #5874e1; + + +/**************************************************** + * Tree View * + ****************************************************/ + + @treeitemBackgroundColor: #ffffff; \ No newline at end of file diff --git a/react-common/styles/react-common.less b/react-common/styles/react-common.less index 12894f3bcd0c..a48b66630f7d 100644 --- a/react-common/styles/react-common.less +++ b/react-common/styles/react-common.less @@ -21,6 +21,7 @@ @import "controls/RadioButtonGroup.less"; @import "controls/Spinner.less"; @import "controls/Textarea.less"; +@import "controls/Tree.less"; @import "controls/VerticalResizeContainer.less"; @import "controls/VerticalSlider.less"; @import "./react-common-variables.less"; diff --git a/theme/timeMachine.less b/theme/timeMachine.less index 95deb2c1b679..19611d2bfe05 100644 --- a/theme/timeMachine.less +++ b/theme/timeMachine.less @@ -1,39 +1,67 @@ -.ui.fullscreen.modal.time-machine-dialog > .content { +.time-machine { + display: flex; + flex-direction: column; + top: 0; + left: 0; + bottom: 0; + right: 0; + position: absolute; + z-index: 999999999999; background-color: white; } -.time-machine { - display: flex; - flex-direction: row; +.time-machine-header { + height: @mainMenuHeight; width: 100%; - height: 100%; - position: relative; + display: grid; + grid-template-columns: 1fr 2fr 1fr; + background-color: @teal; + flex-shrink: 0; - .time-machine-timeline { - width: 10rem; - height: 100%; - padding: 1rem; - position: relative; - - display: flex; - flex-direction: column; - text-align: center; + .common-button { + min-width: 9rem; + } - .time-machine-label { - margin: 1rem; + .common-button.menu-button { + .common-button-label { + font-size: 16px; } - .time-machine-timeline-slider { - flex-grow: 1; + i.fas { + font-size: 1em; } } } +.time-machine-actions-container { + display: flex; + align-items: center; + justify-content: center; +} + +.time-machine-actions { + display: flex; + flex-direction: row; + align-items: center; + + .time-machine-label { + color: white; + margin-right: 1rem; + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } +} + +.time-machine-content { + display: flex; + flex-direction: row; + flex-grow: 1; +} + .time-machine-preview { position: relative; flex-grow: 1; - border: #dedede solid 1px; - border-radius: 0.5rem; overflow: hidden; & > iframe { @@ -44,6 +72,7 @@ z-index: 1; } + // Loading div that appears while projects are importing & > div { position: absolute; top: 0; @@ -59,4 +88,64 @@ height: 5rem; } } +} + +.time-machine-timeline { + width: 20rem; + height: 100%; + position: relative; + + border-left: solid 2px darken(desaturate(@editorToolsBackground, 60%), 10%); + + display: flex; + flex-direction: column; + overflow: hidden; + + .time-machine-timeline-slider { + flex-grow: 1; + } + + .common-treeitem { + border-left: solid 2px @treeitemBackgroundColor; + } + + .common-treeitem.selected { + border-left: solid 2px @primaryColor; + filter: grayscale(.15) brightness(.85) contrast(1.3); + } + + .time-machine-tree-container { + flex-grow: 1; + overflow-y: auto; + padding-right: 1rem; + padding-left: 1rem; + padding-bottom: 1rem; + } + + h3 { + padding-right: 1rem; + padding-left: 1rem; + padding-top: 1rem; + } +} + +@media @tabletAndBelow { + .time-machine-header { + display: flex; + flex-direction: row; + + .time-machine-back-button { + flex-grow: 1; + } + } + + .time-machine-timeline { + width: 15rem; + } +} + +@media @mobileAndBelow { + .time-machine-header { + height: @mobileMenuHeight; + } } \ No newline at end of file diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 06bde7d739fc..fdea903bd677 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -5017,7 +5017,7 @@ export class ProjectView const hideTutorialIteration = inTutorial && tutorialOptions.metadata?.hideIteration; const flyoutOnly = this.state.editorState?.hasCategories === false || (inTutorial && tutorialOptions.metadata?.flyoutOnly); const { hideEditorToolbar, transparentEditorToolbar } = targetTheme; - const hideMenuBar = targetTheme.hideMenuBar || hideTutorialIteration || (isTabTutorial && pxt.appTarget.appTheme.embeddedTutorial); + const hideMenuBar = targetTheme.hideMenuBar || hideTutorialIteration || (isTabTutorial && pxt.appTarget.appTheme.embeddedTutorial) || pxt.shell.isTimeMachineEmbed(); const isHeadless = simOpts && simOpts.headless; const selectLanguage = targetTheme.selectLanguage; const showEditorToolbar = inEditor && !hideEditorToolbar && this.editor.hasEditorToolbar(); diff --git a/webapp/src/container.tsx b/webapp/src/container.tsx index c84bec327597..e383e047738f 100644 --- a/webapp/src/container.tsx +++ b/webapp/src/container.tsx @@ -324,6 +324,7 @@ export class SettingsMenu extends data.Component : undefined} {showSave ? : undefined} {!isController ? : undefined} + {targetTheme.timeMachine ? : undefined} {showSimCollapse ? : undefined}
{targetTheme.selectLanguage ? : undefined} @@ -341,7 +342,6 @@ export class SettingsMenu extends data.Component} {reportAbuse ? : undefined} {!isController ? : undefined} - {targetTheme.timeMachine ? : undefined} { // we always need a way to clear local storage, regardless if signed in or not diff --git a/webapp/src/dialogs.tsx b/webapp/src/dialogs.tsx index 1444d84dd7fc..ed4087db6388 100644 --- a/webapp/src/dialogs.tsx +++ b/webapp/src/dialogs.tsx @@ -13,6 +13,7 @@ import Util = pxt.Util; import { TimeMachine } from "./timeMachine"; import { fireClickOnEnter } from "./util"; import { pairAsync } from "./cmds"; +import { invalidate } from "./data"; let dontShowDownloadFlag = false; @@ -859,40 +860,58 @@ export function isDontShowDownloadDialogFlagSet() { } export async function showTurnBackTimeDialogAsync(header: pxt.workspace.Header, reloadHeader: () => void) { - const history = (await workspace.getScriptHistoryAsync(header)).entries; const text = await workspace.getTextAsync(header.id); + let history: pxt.workspace.HistoryEntry[] = []; - const onTimestampSelect = async (timestamp: number) => { + if (text?.[pxt.HISTORY_FILE]) { + history = JSON.parse(text[pxt.HISTORY_FILE]).entries; + } + + const loadProject = async (text: pxt.workspace.ScriptText, editorVersion: string) => { core.hideDialog(); - let currentText = text; - - for (let i = 0; i < history.length; i++) { - const index = history.length - 1 - i; - const entry = history[index]; - currentText = workspace.applyDiff(currentText, entry); - if (entry.timestamp === timestamp) { - const version = index > 0 ? history[index - 1].editorVersion : entry.editorVersion; - - // Attempt to update the version in pxt.json - try { - const config = JSON.parse(currentText[pxt.CONFIG_NAME]) as pxt.PackageConfig; - if (config.targetVersions) { - config.targetVersions.target = version; - } - currentText[pxt.CONFIG_NAME] = JSON.stringify(config, null, 4); - } - catch (e) { - } + header.targetVersion = editorVersion; + + await workspace.saveAsync(header, text); + reloadHeader(); + } - // Also set version in the header; this is what the compiler actually checks when applying upgrades - header.targetVersion = version; - break; + const copyProject = async (text: pxt.workspace.ScriptText, editorVersion: string, timestamp?: number) => { + core.hideDialog(); + + const newHistory: pxt.workspace.HistoryFile = { + entries: timestamp == undefined ? history : history.slice(0, history.findIndex(e => e.timestamp === timestamp)) + } + + if (text[pxt.HISTORY_FILE]) { + text[pxt.HISTORY_FILE] = JSON.stringify(newHistory); + } + const date = new Date(timestamp); + + const dateString = date.toLocaleDateString( + pxt.U.userLanguage(), + { + year: "numeric", + month: "numeric", + day: "numeric" } + ); + + const timeString = date.toLocaleTimeString( + pxt.U.userLanguage(), + { + timeStyle: "short" + } as any + ); + + const newHeader: pxt.workspace.Header = { + ...header, + targetVersion: editorVersion } - await workspace.saveAsync(header, currentText); - reloadHeader(); + await workspace.duplicateAsync(newHeader, `${newHeader.name} ${dateString} ${timeString}`, text); + + invalidate("headers:"); } await core.dialogAsync({ @@ -900,6 +919,13 @@ export async function showTurnBackTimeDialogAsync(header: pxt.workspace.Header, className: "time-machine-dialog", size: "fullscreen", hasCloseIcon: true, - jsx: + jsx: ( + + ) }) } \ No newline at end of file diff --git a/webapp/src/timeMachine.tsx b/webapp/src/timeMachine.tsx index d3cc5cea0d87..0f444ae5a084 100644 --- a/webapp/src/timeMachine.tsx +++ b/webapp/src/timeMachine.tsx @@ -1,35 +1,41 @@ import * as React from "react"; import * as workspace from "./workspace"; +import { Tree, TreeItem, TreeItemBody } from "../../react-common/components/controls/Tree"; +import { createPortal } from "react-dom"; import { Button } from "../../react-common/components/controls/Button"; -import { VerticalSlider } from "../../react-common/components/controls/VerticalSlider"; +import { hideDialog } from "./core"; +import { FocusTrap } from "../../react-common/components/controls/FocusTrap"; interface TimeMachineProps { - onTimestampSelect: (timestamp: number) => void; + onProjectLoad: (text: pxt.workspace.ScriptText, editorVersion: string, timestamp?: number) => void; + onProjectCopy: (text: pxt.workspace.ScriptText, editorVersion: string, timestamp?: number) => void; text: pxt.workspace.ScriptText; history: pxt.workspace.HistoryEntry[]; } -interface FunctionWrapper { - impl: U; -} - interface PendingMessage { original: pxt.editor.EditorMessageRequest; handler: (response: any) => void; } +interface TimeEntry { + label: string; + timestamp: number; +} + type FrameState = "loading" | "loaded" | "loading-project" | "loaded-project"; export const TimeMachine = (props: TimeMachineProps) => { - const { onTimestampSelect, text, history } = props; - - const [selected, setSelected] = React.useState(history.length - 1); - const [importProject, setImportProject] = React.useState Promise>>(); + const { text, history, onProjectLoad, onProjectCopy } = props; + // -1 here is a standin for "now" + const [selected, setSelected] = React.useState(-1); const [loading, setLoading] = React.useState("loading") const iframeRef = React.useRef(); + const importProject = React.useRef<(text: pxt.workspace.ScriptText) => Promise>(); + React.useEffect(() => { const iframe = iframeRef.current; let nextId = 1; @@ -125,7 +131,7 @@ export const TimeMachine = (props: TimeMachineProps) => { }; - setImportProject({ impl: loadProject }); + importProject.current = loadProject; window.addEventListener("message", onMessageReceived); return () => { @@ -134,104 +140,271 @@ export const TimeMachine = (props: TimeMachineProps) => { }, []); React.useEffect(() => { - if (loading === "loaded" && importProject) { - const previewFiles = applyUntilTimestamp(text, history, history[history.length - 1].timestamp); - importProject.impl(previewFiles) + if (loading === "loaded" && importProject.current) { + importProject.current(text) } - }, [loading, importProject, history, text]); - - const onSliderValueChanged = React.useCallback((newValue: number) => { - setSelected(newValue); - const previewFiles = applyUntilTimestamp(text, history, history[newValue].timestamp); - importProject.impl(previewFiles) - - }, [text, history, importProject]); + }, [loading, importProject.current, text]); - const valueText = React.useCallback((value: number) => { - const timestamp = history[value].timestamp; + const onTimeSelected = (newValue: number) => { + if (importProject.current) { + setSelected(newValue); - return formatTime(timestamp); - }, [history]); + if (newValue === -1) { + importProject.current(text); + } + else { + const previewFiles = applyUntilTimestamp(text, history, newValue); + importProject.current(previewFiles) + } + } + }; const onGoPressed = React.useCallback(() => { - onTimestampSelect(history[selected].timestamp); - }, [selected, onTimestampSelect, history]); + if (selected === -1) { + hideDialog(); + } + else { + const previewFiles = applyUntilTimestamp(text, history, selected); + onProjectLoad(previewFiles, history.find(e => e.timestamp === selected).editorVersion, selected); + } + }, [selected, onProjectLoad]); + + const onSaveCopySelect = React.useCallback(() => { + if (selected === -1) { + onProjectCopy(text, pxt.appTarget.versions.target); + } + else { + const previewFiles = applyUntilTimestamp(text, history, selected); + onProjectCopy(previewFiles, history.find(e => e.timestamp === selected).editorVersion, selected) + } + }, [selected, onProjectCopy]); const url = `${window.location.origin + window.location.pathname}?timeMachine=1&controller=1&skillsMap=1&noproject=1&nocookiebanner=1`; - return ( -
-
-
- {pxt.U.lf("Past")} + const buckets: {[index: string]: TimeEntry[]} = {}; + + for (const entry of history) { + const date = new Date(entry.timestamp); + const key = new Date(date.toLocaleDateString( + pxt.U.userLanguage(), + { + year: "numeric", + month: "numeric", + day: "numeric" + } + )).getTime(); + + if (!buckets[key]) { + buckets[key] = []; + } + + buckets[key].push({ + label: formatTime(entry.timestamp), + timestamp: entry.timestamp + }); + } + + const nowEntry = { + label: lf("Now"), + timestamp: -1 + }; + + const sortedBuckets = Object.keys(buckets).sort((a, b) => parseInt(b) - parseInt(a)); + for (const bucket of sortedBuckets) { + buckets[bucket].sort((a, b) => b.timestamp - a.timestamp); + } + + if (!sortedBuckets.length || !isToday(parseInt(sortedBuckets[0]))) { + buckets[Date.now()] = [nowEntry] + } + else { + buckets[sortedBuckets[0]].unshift(nowEntry) + } + + return createPortal( + +
+
+
- -
- {pxt.U.lf("Present")} +
+
+
+ {formatFullDate(selected)} +
+
-
-
-
-
+
+
+
+
+
+ {/* eslint-disable @microsoft/sdl/react-iframe-missing-sandbox */} +