diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 3f23ccb0ae14..59c5248c9f43 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -4836,13 +4836,39 @@ export class ProjectView async hasBlocksFromExtensionAsync(dependencyName: string): Promise { const blocksInfo = await compiler.getBlocksAsync(); const workspaceBlocks = this.blocksEditor?.editor?.getAllBlocks(false) || []; + const removedPackageIds = this.getPackageIdsRemovedWithDependency(dependencyName); return workspaceBlocks.some(block => { const symbol = blocksInfo.blocksById[block.type] || pxtblockly.blockSymbol(block.type); - return symbol?.pkg === dependencyName; + return removedPackageIds.has(symbol?.pkg); }); } + private getPackageIdsRemovedWithDependency(dependencyName: string): Set { + const removedPackageIds = new Set([dependencyName]); + const dependency = pkg.mainPkg?.resolveDep(dependencyName); + if (!dependency) return removedPackageIds; + + const dependencyPackages = new Set(); + const retainedPackages = new Set(); + const visit = (pack: pxt.Package, visited: Set) => { + if (!pack || visited.has(pack)) return; + visited.add(pack); + pack.resolvedDependencies().forEach(child => visit(child, visited)); + }; + + visit(dependency, dependencyPackages); + Object.keys(pkg.mainPkg.dependencies()) + .filter(name => name !== dependencyName) + .map(name => pkg.mainPkg.resolveDep(name)) + .forEach(pack => visit(pack, retainedPackages)); + + dependencyPackages.forEach(pack => { + if (!retainedPackages.has(pack)) removedPackageIds.add(pack.id); + }); + return removedPackageIds; + } + showBoardDialogAsync(features?: string[], closeIcon?: boolean): Promise { return this.scriptSearch.showBoardsAsync(features, closeIcon); } diff --git a/webapp/src/compiler.ts b/webapp/src/compiler.ts index e82f4f33c077..bb6ca6b217b0 100644 --- a/webapp/src/compiler.ts +++ b/webapp/src/compiler.ts @@ -635,7 +635,7 @@ interface BundledPackage { } interface UsedPackageInfo { - dirname: string, + packageId: string, info: pxt.PackageApiInfo } @@ -678,7 +678,7 @@ async function getCachedApiInfoAsync(project: pkg.EditorPackage, bundled: pxt.Ma const usedPackages = project.pkgAndDeps(); const externalPackages: pkg.EditorPackage[] = []; const usedPackageInfo: UsedPackageInfo[] = [{ - dirname: corePkgName, + packageId: pxt.appTarget.corepkg, info: corePkg }]; @@ -689,7 +689,7 @@ async function getCachedApiInfoAsync(project: pkg.EditorPackage, bundled: pxt.Ma for (const bundle of bundledPackages) { if (bundle.config.name === getPackageKey(dep)) { usedPackageInfo.push({ - dirname: bundle.dirname, + packageId: dep.getPkgId(), info: bundled[bundle.dirname] }); foundIt = true; @@ -721,7 +721,7 @@ async function getCachedApiInfoAsync(project: pkg.EditorPackage, bundled: pxt.Ma else { pxt.debug(`Fetched cached API info for ${getPackageKey(dep)}`); usedPackageInfo.push({ - dirname: dep.getPkgId(), + packageId: dep.getPkgId(), info: entry }); } @@ -734,13 +734,13 @@ async function getCachedApiInfoAsync(project: pkg.EditorPackage, bundled: pxt.Ma for (const used of usedPackageInfo) { if (!used) continue; - let { info, dirname } = used; + let { info, packageId } = used; const byQName = U.cloneApis(info.apis.byQName); - // reinclude the pkg the api originates from, which is trimmed during compression + // Restore the runtime package ID, which is trimmed during compression. for (const api of Object.keys(byQName)) { - byQName[api].pkg = dirname; + byQName[api].pkg = packageId; // We had a bug where we were caching the translated language code and it broke translations. // make sure we clear it on any old cached entries from before the bug was fixed diff --git a/webapp/src/components/tutorial/TutorialCallout.tsx b/webapp/src/components/tutorial/TutorialCallout.tsx index 4e501d2b26d0..d7787825c2bd 100644 --- a/webapp/src/components/tutorial/TutorialCallout.tsx +++ b/webapp/src/components/tutorial/TutorialCallout.tsx @@ -10,41 +10,88 @@ interface TutorialCalloutProps extends React.PropsWithChildren<{}> { onClick?: (visible: boolean) => void; } +interface HorizontalPosition { + left?: string; + width?: string; +} + export function TutorialCallout(props: TutorialCalloutProps) { const { children, className, buttonIcon, buttonLabel, onClick } = props; const [ visible, setVisible ] = React.useState(false); const [ maxHeight, setMaxHeight ] = React.useState("unset"); const [ top, setTop ] = React.useState("unset"); const [ bottom, setBottom ] = React.useState("unset"); + const [ horizontalPosition, setHorizontalPosition ] = React.useState({}); const popupRef = React.useRef(null); const contentRef = React.useRef(null); - React.useEffect(() => { + React.useLayoutEffect(() => { if (!visible) return undefined; - function checkSize() { + function updatePosition() { const lowerBuffer = (document.getElementById("editortools")?.clientHeight ?? 0) + 30; - if (contentRef.current?.getBoundingClientRect().bottom >= window.innerHeight - lowerBuffer) { + const upperBuffer = 16; + const trigger = popupRef.current?.querySelector(".tutorial-callout-button"); + const content = contentRef.current; + + if (!trigger || !content) return; + + const triggerBottom = trigger.getBoundingClientRect().bottom; + const contentStyle = getComputedStyle(content); + const transform = contentStyle.transform; + const verticalOffset = transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42; + const borderHeight = parseFloat(contentStyle.borderTopWidth) + parseFloat(contentStyle.borderBottomWidth); + const verticalChrome = parseFloat(contentStyle.paddingTop) + parseFloat(contentStyle.paddingBottom) + borderHeight; + const popupHeight = content.scrollHeight + borderHeight; + const availableBottom = window.innerHeight - lowerBuffer; + const availableHeight = Math.max(availableBottom - upperBuffer, 0); + const inlineLeft = content.style.left; + const inlineRight = content.style.right; + const inlineWidth = content.style.width; + content.style.left = ""; + content.style.right = ""; + content.style.width = ""; + const contentRect = content.getBoundingClientRect(); + content.style.left = inlineLeft; + content.style.right = inlineRight; + content.style.width = inlineWidth; + const clampedLeft = Math.min( + Math.max(contentRect.left, upperBuffer), + Math.max(window.innerWidth - upperBuffer - contentRect.width, upperBuffer) + ); + const isHorizontallyClamped = Math.abs(clampedLeft - contentRect.left) > 0.5; + const nextHorizontalPosition = isHorizontallyClamped + ? { left: `${clampedLeft}px`, width: `${contentRect.width}px` } + : {}; + setHorizontalPosition(current => + current.left === nextHorizontalPosition.left && current.width === nextHorizontalPosition.width + ? current + : nextHorizontalPosition + ); + + if (triggerBottom + verticalOffset + popupHeight > availableBottom) { setTop("unset"); - setBottom(`${lowerBuffer}px`); - setMaxHeight("90vh"); + setBottom(`${lowerBuffer + verticalOffset}px`); + setMaxHeight(popupHeight > availableHeight + ? `${contentStyle.boxSizing === "border-box" ? availableHeight : Math.max(availableHeight - verticalChrome, 0)}px` + : "unset"); } else { setBottom("unset"); - - // Set the top of the hint to the bottom of wrapper div (which aligns to the hint button). - const popupBottom = popupRef.current?.getBoundingClientRect().bottom; - setTop(popupBottom ? `${popupBottom}px` : "unset"); + setTop(`${triggerBottom}px`); + setMaxHeight("unset"); } } + let animationFrame: number; const observer = new ResizeObserver(() => { - window.requestAnimationFrame(checkSize); + window.cancelAnimationFrame(animationFrame); + animationFrame = window.requestAnimationFrame(updatePosition); }); observer.observe(document.body); if (contentRef.current) observer.observe(contentRef.current); - checkSize(); + updatePosition(); const closeOnOutsideClick = (e: PointerEvent) => { if (!popupRef?.current?.contains(e.target as Node)) { @@ -52,9 +99,13 @@ export function TutorialCallout(props: TutorialCalloutProps) { } }; - document.addEventListener("click", closeOnOutsideClick); + const outsideClickTimeout = window.setTimeout(() => { + document.addEventListener("click", closeOnOutsideClick); + }, 0); return () => { + window.cancelAnimationFrame(animationFrame); + window.clearTimeout(outsideClickTimeout); observer.disconnect(); document.removeEventListener("click", closeOnOutsideClick); } @@ -90,7 +141,7 @@ export function TutorialCallout(props: TutorialCalloutProps) { ariaLabel={buttonTitle} disabled={!children} onClick={children ? handleButtonClick : undefined} /> - {visible &&
+ {visible &&
}