{showHeroBanner ?
: undefined}
-
+
- {targetTheme.scriptManager ?

{lf("My Projects")} - - + + {lf("View All")}

:

{lf("My Projects")}

}
@@ -202,15 +201,48 @@ export class Projects extends data.Component {
- {Object.keys(galleries).map(galleryName => -
-

{pxt.Util.rlf(galleryName)}

-
- + {Object.keys(galleries) + .filter(galleryName => { + // hide galleries that are part of an experiment and that experiment is + // not enabled + const galProps = galleries[galleryName] as pxt.GalleryProps | string + if (typeof galProps === "string") + return true + // filter categories by experiment + const exp = galProps.experimentName; + if (exp && !(pxt.appTarget.appTheme as any)[exp]) + return false; // experiment not enabled + const locales = galProps.locales; + if (locales && locales.indexOf(pxt.Util.userLanguage()) < 0) + return false; // locale not supported + // test if blocked + const testUrl = galProps.testUrl; + if (testUrl) { + const ping = this.getData(`ping:${testUrl.replace('@random@', Math.random().toString())}`); + if (ping !== true) // still loading or can't ping + return false; + } + // show the gallery + return true; + }) + .map(galleryName => { + const galProps = galleries[galleryName] as pxt.GalleryProps | string + const url = typeof galProps === "string" ? galProps : galProps.url + const shuffle: pxt.GalleryShuffle = typeof galProps === "string" ? undefined : galProps.shuffle; + return
+

{pxt.Util.rlf(galleryName)}

+
+ +
-
- )} + } + )} {targetTheme.organizationUrl || targetTheme.organizationUrl || targetTheme.privacyUrl || targetTheme.copyrightText ?
{targetTheme.organizationUrl && targetTheme.organization ? {targetTheme.organization} : undefined} {targetTheme.selectLanguage ? : undefined} @@ -223,6 +255,104 @@ export class Projects extends data.Component { } } +// This Component overrides shouldComponentUpdate, be sure to update that if the state is updated +export interface ProjectSettingsMenuProps extends ISettingsProps { + highContrast: boolean; +} +export interface ProjectSettingsMenuState { + highContrast?: boolean; +} + +export class ProjectSettingsMenu extends data.Component { + + constructor(props: ProjectSettingsMenuProps) { + super(props); + this.state = { + highContrast: props.highContrast + } + + this.showLanguagePicker = this.showLanguagePicker.bind(this); + this.toggleHighContrast = this.toggleHighContrast.bind(this); + this.showResetDialog = this.showResetDialog.bind(this); + this.showReportAbuse = this.showReportAbuse.bind(this); + this.showAboutDialog = this.showAboutDialog.bind(this); + this.signOutGithub = this.signOutGithub.bind(this); + } + + showLanguagePicker() { + pxt.tickEvent("home.langpicker", undefined, { interactiveConsent: true }); + this.props.parent.showLanguagePicker(); + } + + toggleHighContrast() { + pxt.tickEvent("home.togglecontrast", undefined, { interactiveConsent: true }); + this.props.parent.toggleHighContrast(); + this.setState({ highContrast: !this.state.highContrast }); + } + + toggleGreenScreen() { + pxt.tickEvent("home.togglegreenscreen", undefined, { interactiveConsent: true }); + this.props.parent.toggleGreenScreen(); + } + + toggleAccessibleBlocks() { + pxt.tickEvent("home.toggleaccessibleblocks", undefined, { interactiveConsent: true }); + this.props.parent.toggleAccessibleBlocks(); + } + + showResetDialog() { + pxt.tickEvent("home.reset", undefined, { interactiveConsent: true }); + this.props.parent.showResetDialog(); + } + + showReportAbuse() { + pxt.tickEvent("home.reportabuse", undefined, { interactiveConsent: true }); + this.props.parent.showReportAbuse(); + } + + + showAboutDialog() { + pxt.tickEvent("home.about"); + this.props.parent.showAboutDialog(); + } + + signOutGithub() { + pxt.tickEvent("home.github.signout"); + const githubProvider = cloudsync.githubProvider(); + if (githubProvider) { + githubProvider.logout(); + this.props.parent.forceUpdate(); + core.infoNotification(lf("Signed out from GitHub")) + } + } + + renderCore() { + const { highContrast } = this.state; + const targetTheme = pxt.appTarget.appTheme; + const githubUser = this.getData("github:user") as pxt.editor.UserInfo; + const reportAbuse = pxt.appTarget.cloud && pxt.appTarget.cloud.sharing && pxt.appTarget.cloud.importing; + const showDivider = targetTheme.selectLanguage || targetTheme.highContrast || githubUser; + + // tslint:disable react-a11y-anchors + return + {targetTheme.selectLanguage && } + {targetTheme.highContrast && } + {githubUser &&
} + {githubUser &&
+
+ {lf("User +
+ {lf("Sign out")} +
} + {showDivider &&
} + {reportAbuse ? : undefined} + + + {targetTheme.feedbackUrl ? {lf("Give Feedback")} : undefined} +
; + } +} + export class ProjectsMenu extends data.Component { constructor(props: ISettingsProps) { @@ -249,6 +379,9 @@ export class ProjectsMenu extends data.Component { renderCore() { const targetTheme = pxt.appTarget.appTheme; + // only show cloud head if a configuration is available + const showCloudHead = this.hasCloud(); + return ; } } @@ -276,9 +410,10 @@ interface ProjectsCarouselProps extends ISettingsProps { name: string; path?: string; cardWidth?: number; - onClick: (src: any) => void; + onClick: (src: any, action?: pxt.CodeCardAction) => void; selectedIndex?: number; setSelected?: (name: string, index: number) => void; + shuffle?: pxt.GalleryShuffle; } interface ProjectsCarouselState { @@ -319,6 +454,21 @@ export class ProjectsCarousel extends data.Component g.cards)); + const shuffle = this.props.shuffle + if (shuffle) { + // keep last one + const last = this.prevGalleries.pop(); + // shuffle array + const now = new Date(); + const seed = now.toDateString(); + this.prevGalleries.sort((l, r) => + ts.pxtc.Util.codalHash16(l.name + seed) + - ts.pxtc.Util.codalHash16(r.name + seed) + ); + // add last back + if (last) + this.prevGalleries.push(last); + } } } return this.prevGalleries || []; @@ -331,10 +481,11 @@ export class ProjectsCarousel extends data.Component { - this.props.parent.newProject({ name }); + if (pxt.appTarget.appTheme.nameProjectFirst || pxt.appTarget.appTheme.chooseLanguageRestrictionOnNewProject) { + this.props.parent.askForProjectCreationOptionsAsync() + .then(projectSettings => { + const { name, languageRestriction } = projectSettings + this.props.parent.newProject({ name, languageRestriction }); }) } else { this.props.parent.newProject({ name }); @@ -419,6 +570,7 @@ export class ProjectsCarousel extends data.Component
}
@@ -469,11 +624,12 @@ export class ProjectsCarousel extends data.Component { if (index === 1) this.latestProject = view }} cardType="file" - name={scr.name} + name={(ghid && ghid.project) || scr.name} time={scr.recentUse} url={scr.pubId && scr.pubCurrent ? "/" + scr.pubId : ""} scr={scr} index={index} @@ -531,11 +687,12 @@ export class ProjectsCodeCard extends sui.StatelessUIElement } @@ -546,18 +703,20 @@ export interface ProjectsDetailProps extends ISettingsProps { description?: string; imageUrl?: string; largeImageUrl?: string; + videoUrl?: string; youTubeId?: string; + buttonLabel?: string; url?: string; scr?: any; - onClick: (scr: any) => void; + onClick: (scr: any, action?: pxt.CodeCardAction) => void; cardType: pxt.CodeCardType; tags?: string[]; + otherActions?: pxt.CodeCardAction[]; } export interface ProjectsDetailState { } - export class ProjectsDetail extends data.Component { private linkRef: React.RefObject; @@ -597,9 +756,104 @@ export class ProjectsDetail extends data.Component + : + } + + protected getActionTitle(editor: pxt.CodeCardEditorType): string { + switch (editor) { + case "py": + return "Python"; + case "js": + return "JavaScript"; + case "blocks": + return lf("Blocks"); + default: + return null; + } + } + + protected getActionCard(text: string, type: string, onClick: any, autoFocus?: boolean, action?: pxt.CodeCardAction, key?: string): JSX.Element { + const editor = this.getActionEditor(type, action); + const title = this.getActionTitle(editor); + return
+ {this.getActionIcon(onClick, type, editor)} + {title &&
{title}
} + {this.isLink() && type != "example" ? // TODO (shakao) migrate forumurl to otherAction json in md + + : } +
} handleDetailClick() { @@ -607,9 +861,15 @@ export class ProjectsDetail extends data.Component onClick(scr, action); + } + handleOpenForumUrlInEditor() { + pxt.tickEvent('projects.actions.forum', undefined, { interactiveConsent: true }); const { url } = this.props; - discourse.extractSharedIdFromPostUrl(url) + pxt.discourse.extractSharedIdFromPostUrl(url) .then(projectId => { // if we have a projectid, load it if (projectId) @@ -621,6 +881,13 @@ export class ProjectsDetail extends data.Component = pxt.appTarget.appTheme.tagColors || {}; const descriptions = description && description.split("\n"); + const image = largeImageUrl || (youTubeId && `https://img.youtube.com/vi/${youTubeId}/0.jpg`); + const video = !pxt.BrowserUtils.isElectron() && videoUrl; + const showVideoOrImage = !pxt.appTarget.appTheme.hideHomeDetailsVideo; - let clickLabel = lf("Show Instructions"); - if (cardType == "tutorial") - clickLabel = lf("Start Tutorial"); - else if (cardType == "codeExample" || cardType == "example") - clickLabel = lf("Open Example"); - else if (cardType == "forumUrl") - clickLabel = lf("Open in Forum"); - else if (cardType == "template") - clickLabel = lf("New Project"); - else if (youTubeId) - clickLabel = lf("Play Video"); - - const action = this.isLink() ? - - : - + let clickLabel: string; + if (buttonLabel) + clickLabel = ts.pxtc.Util.rlf(buttonLabel); + else + clickLabel = this.getClickLabel(cardType); return
- {image &&
-
+ {showVideoOrImage && (video || image) &&
+ {video ?
: undefined} {action && !this.loanedSimulator ?

{disclaimer}

: undefined} - {this.state.sharingError ? -

{lf("Oops! There was an error. Please ensure you are connected to the Internet and try again.")}

- : undefined} + {tooBigErrorSuggestGitHub &&

{lf("Oops! Your project is too big. You can create a GitHub repository to share it.")} + +

} + {unknownError &&

{lf("Oops! There was an error. Please ensure you are connected to the Internet and try again.")}

} {url && ready ?

{lf("Your project is ready! Use the address below to share your projects.")}

@@ -480,20 +498,19 @@ export class ShareEditor extends data.Component : undefined}
: undefined}
: undefined} - {ready && !hideEmbed ?
+ {(ready && !hideEmbed) &&
- - {advancedMenu ? + {formats.map(f => )} - : undefined} - {advancedMenu ? + - : null} -
: undefined} + + +
}
) @@ -506,11 +523,12 @@ export class ShareEditor extends data.Component { + const { visible } = this.state; const targetTheme = pxt.appTarget.appTheme; const pressed = e.key.toLocaleLowerCase(); - // Don't fire events if they are typing in a name - if (document.activeElement && document.activeElement.tagName === "INPUT") return; + // Don't fire events if component is hidden or if they are typing in a name + if (!visible || (document.activeElement && document.activeElement.tagName === "INPUT")) return; if (targetTheme.simScreenshotKey && pressed === targetTheme.simScreenshotKey.toLocaleLowerCase()) { this.handleScreenshotClick(); @@ -607,4 +625,4 @@ class EmbedMenuItem extends sui.StatelessUIElement { const { label, mode, currentMode } = this.props; return } -} \ No newline at end of file +} diff --git a/webapp/src/simtoolbar.tsx b/webapp/src/simtoolbar.tsx index 91d06ca92723..b51d1ea07d14 100644 --- a/webapp/src/simtoolbar.tsx +++ b/webapp/src/simtoolbar.tsx @@ -107,6 +107,7 @@ export class SimulatorToolbar extends data.Component { const makeTooltip = lf("Open assembly instructions"); const restartTooltip = lf("Restart the simulator"); const debugTooltip = lf("Toggle debug mode"); + const keymapTooltip = lf("View simulator keyboard shortcuts"); const fullscreenTooltip = isFullscreen ? lf("Exit fullscreen mode") : lf("Launch in fullscreen"); const muteTooltip = isMuted ? lf("Unmute audio") : lf("Mute audio"); const screenshotTooltip = targetTheme.simScreenshotKey ? lf("Take Screenshot (shortcut {0})", targetTheme.simScreenshotKey) : lf("Take Screenshot"); @@ -121,6 +122,7 @@ export class SimulatorToolbar extends data.Component {
{!isHeadless &&
{audio && } + {simOpts.keymap && }
} {!isHeadless &&
{screenshot && } diff --git a/webapp/src/simulator.ts b/webapp/src/simulator.ts index 67645fefa140..fc8451c7a00a 100644 --- a/webapp/src/simulator.ts +++ b/webapp/src/simulator.ts @@ -11,6 +11,7 @@ interface SimulatorConfig { highlightStatement(stmt: pxtc.LocationInfo, brk?: pxsim.DebuggerBreakpointMessage): boolean; restartSimulator(): void; onStateChanged(state: pxsim.SimulatorState): void; + onSimulatorReady(): void; setState(key: string, value: any): void; editor: string; } @@ -43,6 +44,8 @@ export function init(root: HTMLElement, cfg: SimulatorConfig) { debuggerDiv.className = 'ui item landscape only'; root.appendChild(debuggerDiv); + const nestedEditorSim = /nestededitorsim=1/i.test(window.location.href); + let options: pxsim.SimulatorDriverOptions = { restart: () => cfg.restartSimulator(), revealElement: (el) => { @@ -136,7 +139,7 @@ export function init(root: HTMLElement, cfg: SimulatorConfig) { }, onTraceMessage: function (msg) { let brkInfo = lastCompileResult.breakpoints[msg.breakpointId] - if (config) config.highlightStatement(brkInfo) + if (config) config.highlightStatement(brkInfo, msg) }, onDebuggerWarning: function (wrn) { for (let id of wrn.breakpointIds) { @@ -161,6 +164,12 @@ export function init(root: HTMLElement, cfg: SimulatorConfig) { } cfg.onStateChanged(state); }, + onSimulatorReady: function () { + pxt.perf.recordMilestone("simulator ready") + if (!pxt.perf.perfReportLogged) { + pxt.perf.report() + } + }, onSimulatorCommand: (msg: pxsim.SimulatorCommandMessage): void => { switch (msg.command) { case "setstate": @@ -210,6 +219,7 @@ export function init(root: HTMLElement, cfg: SimulatorConfig) { }, stoppedClass: pxt.appTarget.simulator && pxt.appTarget.simulator.stoppedClass, invalidatedClass: pxt.appTarget.simulator && pxt.appTarget.simulator.invalidatedClass, + nestedEditorSim: nestedEditorSim }; driver = new pxsim.SimulatorDriver(document.getElementById('simulators'), options); config = cfg @@ -254,7 +264,7 @@ export interface RunOptions { } export function run(pkg: pxt.MainPackage, debug: boolean, - res: pxtc.CompileResult, options: RunOptions) { + res: pxtc.CompileResult, options: RunOptions, trace: boolean) { const js = res.outfiles[pxtc.BINARY_JS] const boardDefinition = pxt.appTarget.simulator.boardDefinition; const parts = pxtc.computeUsedParts(res, true); @@ -267,6 +277,7 @@ export function run(pkg: pxt.MainPackage, debug: boolean, mute, parts, debug, + trace, fnArgs, highContrast, light, diff --git a/webapp/src/simulatorserviceworker.ts b/webapp/src/simulatorserviceworker.ts new file mode 100644 index 000000000000..be5db1411fc4 --- /dev/null +++ b/webapp/src/simulatorserviceworker.ts @@ -0,0 +1,138 @@ +interface SimWorkerOptions { + urls?: string[]; +} + +interface SimWorkerContext { + setSimulatorWorkerOptions: (opts: SimWorkerOptions) => void; +} + +initSimulatorServiceWorker(); + +function initSimulatorServiceWorker() { + // Empty string for released, otherwise contains the ref or version path + const ref = `@relprefix@`.replace("---", "").replace(/^\//, ""); + + // We don't do offline for version paths, only named releases + const isNamedEndpoint = ref.indexOf("/") === -1; + + // pxtRelId is replaced with the commit hash for this release + const refCacheName = "makecode-sim;" + ref + ";@pxtRelId@"; + + const simUrls = [ + // This is the URL loaded in the simulator iframe (includes ref) + `@simUrl@`, + + `/cdn/bluebird.min.js`, + `/cdn/pxtsim.js`, + `/sim/sim.js`, + ]; + + const allFiles = simUrls.map(url => url.trim()) + .filter(url => !!url && url.indexOf("@") !== 0); + + // This function is called by workerConfig.js in the target to configure any + // extra URLs that need to be cached + (self as unknown as SimWorkerContext).setSimulatorWorkerOptions = opts => { + if (opts && Array.isArray(opts.urls)) { + allFiles.push(...resolveURLs(opts.urls)); + } + } + + let didInstall = false; + + self.addEventListener("install", (ev: ServiceWorkerEvent) => { + if (!isNamedEndpoint) { + console.log("Skipping service worker install for unnamed endpoint"); + return; + } + + didInstall = true; + + // Check to see if there are any extra sim URLs to be cached by the target + try { + importScripts(`@simworkerconfigUrl@`); + } + catch (e) { + // This file is optional in the target, so ignore 404 response + console.log("Failed to load target service worker config") + } + + console.log("Installing service worker...") + ev.waitUntil(caches.open(refCacheName) + .then(cache => { + console.log("Opened cache") + return cache.addAll(dedupe(allFiles)) + }) + .then(() => (self as any).skipWaiting())) + }); + + self.addEventListener("activate", (ev: ServiceWorkerEvent) => { + if (!isNamedEndpoint) { + console.log("Skipping service worker activate for unnamed endpoint"); + return; + } + + console.log("Activating service worker...") + ev.waitUntil(caches.keys() + .then(cacheNames => { + // Delete all caches that "belong" to this ref except for the current version + const toDelete = cacheNames.filter(c => { + const cacheRef = getRefFromCacheName(c); + return cacheRef === null || (cacheRef === ref && c !== refCacheName); + }) + + return Promise.all( + toDelete.map(name => caches.delete(name)) + ); + }) + .then(() => { + if (didInstall) { + // Only notify clients for the first activation + didInstall = false; + return notifyAllClientsAsync(); + } + return Promise.resolve(); + })) + }); + + self.addEventListener("fetch", (ev: ServiceWorkerEvent) => { + ev.respondWith(caches.match(ev.request) + .then(response => { + return response || fetch(ev.request) + })) + }); + + function dedupe(urls: string[]) { + const res: string[] = []; + + for (const url of urls) { + if (res.indexOf(url) === -1) res.push(url) + } + + return res; + } + + function resolveURLs(urls: string[]) { + return dedupe(urls.map(url => url.trim()).filter(url => !!url)); + } + + function getRefFromCacheName(name: string) { + const parts = name.split(";"); + + if (parts.length !== 3) return null; + + return parts[1]; + } + + function notifyAllClientsAsync() { + const scope = (self as unknown as ServiceWorkerScope); + + return scope.clients.claim().then(() => scope.clients.matchAll()).then(clients => { + clients.forEach(client => client.postMessage({ + type: "serviceworker", + state: "activated", + ref: ref + })) + }); + } +} diff --git a/webapp/src/snippetBuilder.tsx b/webapp/src/snippetBuilder.tsx index 14620cb7ada7..cb306a353cd0 100644 --- a/webapp/src/snippetBuilder.tsx +++ b/webapp/src/snippetBuilder.tsx @@ -269,7 +269,7 @@ export class SnippetBuilder extends data.Component compiler.decompileBlocksSnippetAsync(this.replaceTokens(tsOutput), blocksInfo)) .then(resp => { // get the root blocks (e.g. on_start) from the new code - const newXml = Blockly.Xml.textToDom(resp); + const newXml = Blockly.Xml.textToDom(resp.outfiles["main.blocks"]); const newBlocksDom = pxt.blocks.findRootBlocks(newXml) // get the existing root blocks diff --git a/webapp/src/snippetBuilderInputHandler.tsx b/webapp/src/snippetBuilderInputHandler.tsx index 0d812eadda6b..1d3c1c841e7e 100644 --- a/webapp/src/snippetBuilderInputHandler.tsx +++ b/webapp/src/snippetBuilderInputHandler.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import * as data from './data'; -import { SpriteEditor } from './snippetBuilderSpriteEditor'; +import { ImageEditor } from './components/ImageEditor/ImageEditor'; import * as sui from './sui'; import { PositionPicker } from './snippetBuilderPositionPicker'; import * as Snippet from './snippetBuilder' @@ -27,7 +27,8 @@ export class InputHandler extends data.Component this.props.onChange(v.replace(/[^a-zA-Z0-9_]/g, '_')); + textOnChange = (v: string) => this.props.onChange(v); + variableNameOnChange = (v: string) => this.props.onChange(ts.pxtc.escapeIdentifier(v)); renderInput() { const { value, input, onChange } = this.props; @@ -51,11 +52,10 @@ export class InputHandler extends data.Component ); } @@ -80,13 +80,14 @@ export class InputHandler extends data.Component diff --git a/webapp/src/snippetBuilderSpriteEditor.tsx b/webapp/src/snippetBuilderSpriteEditor.tsx deleted file mode 100644 index 55dc02bf5b11..000000000000 --- a/webapp/src/snippetBuilderSpriteEditor.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/// -import * as React from 'react'; -import * as sui from './sui'; -import * as data from './data'; -import * as compiler from './compiler'; - -const SPRITE_EDITOR_DEFAULT_HEIGHT = 492; -const SPRITE_EDITOR_DEFAULT_WIDTH = 503; - -interface ISpriteEditorProps { - input: pxt.SnippetQuestionInput; - onChange: (v: string) => void; - value: string; - fullscreen?: boolean; -} - -interface ISpriteEditorState { - firstRender: boolean; - spriteEditorActiveColor: number; - scale?: number; -} - -export class SpriteEditor extends data.Component { - private blocksInfo: pxtc.BlocksInfo; - private spriteEditor: pxtsprite.SpriteEditor; - - constructor(props: ISpriteEditorProps) { - super(props); - this.state = { - firstRender: true, - spriteEditorActiveColor: 3, - }; - - this.setScale = this.setScale.bind(this); - this.cleanupSpriteEditor = this.cleanupSpriteEditor.bind(this); - } - - componentWillUnmount() { - window.removeEventListener('resize', this.setScale); - this.updateSpriteState(); - } - - componentDidMount() { - // Run once to set initial scale - this.setScale(); - window.addEventListener('resize', this.setScale); - // Fetches blocksInfo for sprite editor - compiler - .getBlocksAsync() - .then((blocksInfo) => { - this.blocksInfo = blocksInfo; - this.renderSpriteEditor(); - }); - } - - protected setScale() { - let editorHost = document.getElementsByClassName("snippet-sprite-editor")[0] - // Sprite editor default height at scale 1 SPRITE_EDITOR_DEFAULT_WIDTH - full size value - const height = editorHost.clientHeight; - // Sprite editor default height at scale 1 SPRITE_EDITOR_DEFAULT_HEIGHT - full size value - const width = editorHost.clientWidth; - - let wScale = width / SPRITE_EDITOR_DEFAULT_WIDTH; - let hScale = height / SPRITE_EDITOR_DEFAULT_HEIGHT; - let scale = Math.min(wScale, hScale); - - // let scale = height > width ? width / SPRITE_EDITOR_DEFAULT_WIDTH : height / SPRITE_EDITOR_DEFAULT_HEIGHT; - - // Minimum resize threshold .81 - if (scale < .61) { - scale = .61; - } - // Maximum resize threshhold - else if (scale > 1) { - scale = 1; - } - - // Set new scale and reset sprite editor - this.setState({ scale }, () => { - // Ensure that sprite editor has mounted - if (this.spriteEditor) { - this.cleanupSpriteEditor(); - } - }); - } - - protected stripImageLiteralTags(imageLiteral: string) { - const imgTag = `img\``; - const endQuote = `\``; - if (imageLiteral.includes(imgTag)) { - return imageLiteral - .replace(imgTag, '') - .replace(endQuote, '') - } - - return imageLiteral; - } - - protected updateSpriteState() { - const newSpriteState = pxtsprite - .bitmapToImageLiteral(this.spriteEditor.bitmap().image, pxt.editor.FileType.Text); - - this.props.onChange(newSpriteState); - } - - protected renderSpriteEditor() { - const { spriteEditorActiveColor, scale } = this.state; - const { blocksInfo, props } = this; - const { value } = props; - - const stateSprite = value && this.stripImageLiteralTags(value); - const state = pxtsprite - .imageLiteralToBitmap('', stateSprite || DEFAULT_SPRITE_STATE); - - // Sprite editor container - const contentDiv = this.refs['spriteEditorContainer'] as HTMLDivElement; - - this.spriteEditor = new pxtsprite.SpriteEditor(state, blocksInfo, false, scale); - this.spriteEditor.render(contentDiv); - this.spriteEditor.rePaint(); - // this.spriteEditor.setActiveColor(spriteEditorActiveColor, true); - this.spriteEditor.color = spriteEditorActiveColor; - this.spriteEditor.setSidebarColor(spriteEditorActiveColor); - this.spriteEditor.setSizePresets([ - [8, 8], - [16, 16], - [32, 32], - [10, 8] - ]); - - contentDiv.style.height = (this.spriteEditor.outerHeight() + 3) + "px"; - contentDiv.style.width = (this.spriteEditor.outerWidth() + 3) + "px"; - contentDiv.style.overflow = "hidden"; - contentDiv.className = 'sprite-editor-dropdown-bg sprite-editor-dropdown'; - this.spriteEditor.addKeyListeners(); - this.spriteEditor.onClose(this.cleanupSpriteEditor); - } - - protected removeChildrenInNode(node: HTMLDivElement) { - while (node.hasChildNodes()) { - node.removeChild(node.lastChild); - } - } - - protected cleanupSpriteEditor = pxt.Util.debounce(() => { - // Sprite editor container - const contentDiv = this.refs['spriteEditorContainer'] as HTMLDivElement; - - this.updateSpriteState(); - this.spriteEditor.removeKeyListeners(); - - this.setState({ - firstRender: false, - spriteEditorActiveColor: this.spriteEditor.color, - }); - - this.removeChildrenInNode(contentDiv); - this.spriteEditor = undefined; - this.renderSpriteEditor(); - }, 500) - - public renderCore() { - const { scale } = this.state; - - return ( -
-
-
- ); - } -} - -const DEFAULT_SPRITE_STATE = ` -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -. . . . . . . . . . . . . . . . -`; \ No newline at end of file diff --git a/webapp/src/srceditor.tsx b/webapp/src/srceditor.tsx index ece48f44f575..89ece8bb9e06 100644 --- a/webapp/src/srceditor.tsx +++ b/webapp/src/srceditor.tsx @@ -85,7 +85,7 @@ export class Editor implements pxt.editor.IEditor { setScale(scale: number) { } closeFlyout() { } - + clearCaches() { } /******************************* loadFile *******************************/ diff --git a/webapp/src/sui.tsx b/webapp/src/sui.tsx index 85b741c6430c..e8c3460f9ee7 100644 --- a/webapp/src/sui.tsx +++ b/webapp/src/sui.tsx @@ -20,6 +20,7 @@ export interface UiProps { ariaLabel?: string; tabIndex?: number; rightIcon?: boolean; + inverted?: boolean; } export type SIZES = 'mini' | 'tiny' | 'small' | 'medium' | 'large' | 'big' | 'huge' | 'massive'; @@ -33,7 +34,7 @@ export function cx(classes: string[]): string { } function genericClassName(cls: string, props: UiProps, ignoreIcon: boolean = false): string { - return `${cls} ${ignoreIcon ? '' : props.icon && props.text ? 'icon icon-and-text' : props.icon ? 'icon' : ""} ${props.className || ""}`; + return `${cls} ${ignoreIcon ? '' : props.icon && props.text ? 'icon icon-and-text' : props.icon ? 'icon' : ""} ${props.inverted ? 'inverted' : ''} ${props.className || ""}`; } function genericContent(props: UiProps) { @@ -70,6 +71,12 @@ export interface DropdownProps extends UiProps { title?: string; id?: string; onChange?: (v: string) => void; + + avatarImage?: string; + avatarInitials?: string; + displayAbove?: boolean; + displayRight?: boolean; + dataTooltip?: string; } export interface DropdownState { @@ -304,7 +311,7 @@ export class DropdownMenu extends UIElement { } renderCore() { - const { disabled, title, role, icon, className, children } = this.props; + const { disabled, title, role, icon, className, avatarImage, avatarInitials, children, displayAbove, displayRight, dataTooltip } = this.props; const { open } = this.state; const aria = { @@ -324,15 +331,25 @@ export class DropdownMenu extends UIElement { 'dropdown', icon ? 'icon' : '', className || '', + displayAbove ? 'menuAbove' : '', + displayRight ? 'menuRight' : '' ]); const menuClasses = cx([ 'menu', open ? 'visible transition' : '' ]) + + const avatar = avatarImage || avatarInitials ? +
+ {avatarImage ? {title} : +
{avatarInitials}
} +
+ : undefined; return (
{ onBlur={this.handleBlur} tabIndex={0} > - {genericContent(this.props)} + {avatar ? avatar : genericContent(this.props)}
{children} @@ -349,6 +366,113 @@ export class DropdownMenu extends UIElement { } } +export interface ExpandableMenuProps { + title?: string; + onShow?: () => void; + onHide?: () => void; +} + +export interface ExpandableMenuState { + expanded?: boolean; +} + +export class ExpandableMenu extends UIElement { + hide = () => { + this.setState({ expanded: false }); + const { onHide } = this.props; + if (onHide) + onHide(); + } + + show = () => { + this.setState({ expanded: true }); + const { onShow } = this.props; + if (onShow) + onShow(); + } + + toggleExpanded = () => { + const { expanded } = this.state; + + if (expanded) { + this.hide(); + } else { + this.show(); + } + } + + render() { + const { title, children } = this.props; + const { expanded } = this.state + + return (
+ + {expanded &&
+ {children} +
} +
); + } +} + +export interface SelectProps { + options: SelectItem[]; + onChange?: (value: string) => void; + label?: string; +} + +export interface SelectState { + selected?: string; +} + +export interface SelectItem { + value: string | number; + display?: string; +} + +export class Select extends UIElement { + constructor(props: SelectProps) { + super(props); + const { options } = props; + this.state = { + selected: options[0] && (options[0].value + "") + }; + } + + handleOnChange = (ev: React.ChangeEvent) => { + const { onChange } = this.props; + this.setState({ + selected: ev.target.value + }); + + if (onChange) { + onChange(ev.target.value); + } + } + + render() { + const { options, label } = this.props; + const { selected } = this.state; + + return (
+ { label && `${label} ` } + +
); + } +} + /////////////////////////////////////////////////////////// //////////// Items ///////////// /////////////////////////////////////////////////////////// @@ -484,7 +608,9 @@ export interface LinkProps extends ButtonProps { export class Link extends StatelessUIElement { renderCore() { return ( - { } export function helpIconLink(url: string, title: string) { - return + return } /////////////////////////////////////////////////////////// @@ -545,6 +671,7 @@ export interface InputProps { placeholder?: string; disabled?: boolean; onChange?: (v: string) => void; + onEnter?: () => void; lines?: number; readOnly?: boolean; copy?: boolean; @@ -558,6 +685,7 @@ export interface InputProps { export interface InputState { value: string; + copied?: boolean; } export class Input extends data.Component { @@ -570,6 +698,7 @@ export class Input extends data.Component { this.copy = this.copy.bind(this); this.handleClick = this.handleClick.bind(this); this.handleChange = this.handleChange.bind(this); + this.handleEnterPressed = this.handleEnterPressed.bind(this); } componentDidMount() { @@ -589,6 +718,7 @@ export class Input extends data.Component { } copy() { + this.setState({ copied: false }); const p = this.props const el = ReactDOM.findDOMNode(this); @@ -605,7 +735,9 @@ export class Input extends data.Component { try { const success = document.execCommand("copy"); pxt.debug('copy: ' + success); + this.setState({ copied: !!success }); } catch (e) { + this.setState({ copied: false }); } } @@ -618,49 +750,62 @@ export class Input extends data.Component { handleChange(e: React.ChangeEvent) { const newValue = (e.target as any).value; if (!this.props.readOnly && (!this.state || this.state.value !== newValue)) { - this.setState({ value: newValue }) + this.setState({ value: newValue, copied: false }) } if (this.props.onChange) { this.props.onChange(newValue); } } + handleEnterPressed(e: React.KeyboardEvent) { + const charCode = core.keyCodeFromEvent(e); + if (charCode === core.ENTER_KEY) { + const { onEnter } = this.props; + if (onEnter) { + e.preventDefault(); + onEnter(); + } + } + } + renderCore() { - let p = this.props - let copyBtn = p.copy && document.queryCommandSupported('copy') - ?