From 41c9f17ed443a42338ba0eb5ff9b68e0015b310f Mon Sep 17 00:00:00 2001 From: nickhu <314484063@qq.com> Date: Fri, 28 Aug 2026 19:38:10 +0800 Subject: [PATCH] feat: configure host-specific plugin services --- .gitignore | 1 + README.md | 2 + docs/host-plugin-services.md | 116 ++++++++++++ package.json | 3 +- src/l10n/en.json | 4 +- src/lib/plugin-services.ts | 170 ++++++++++++++++++ src/main.ts | 7 + src/plugins-controller.ts | 29 ++- src/plugins/block-sharing/components/Home.tsx | 12 +- src/plugins/block-sharing/index.tsx | 91 ++++++---- src/plugins/code-find/index.tsx | 4 - src/plugins/code-find/styles.less | 8 - src/plugins/costume-piskel/index.tsx | 10 +- src/plugins/custom-css/index.ts | 4 +- .../index.tsx | 24 ++- .../manifest.ts | 2 +- .../marketplace.ts | 26 +-- src/plugins/inspiro/componet/Entrance.tsx | 22 +-- src/plugins/inspiro/index.tsx | 6 +- .../VoiceFloatingNew/VoiceFloatingNew.tsx | 151 ---------------- src/plugins/voice-cooperation/config.ts | 3 - src/plugins/voice-cooperation/index.tsx | 28 +-- src/plugins/voice-cooperation/lib/livekit.ts | 6 +- src/types/interface.d.ts | 5 + test/plugin-services.test.js | 128 +++++++++++++ 25 files changed, 592 insertions(+), 270 deletions(-) create mode 100644 docs/host-plugin-services.md create mode 100644 src/lib/plugin-services.ts delete mode 100644 src/plugins/voice-cooperation/config.ts create mode 100644 test/plugin-services.test.js diff --git a/.gitignore b/.gitignore index b00d86dd..bfd6334e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # production /dist /lib +/.test-dist # log *.log diff --git a/README.md b/README.md index 1cd3f629..c9cb58b1 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ ## Project Structure +Remote content and service URLs are supplied by the host. See [Host-provided plugin services](./docs/host-plugin-services.md) for the runtime contract, capability reporting, and CCW/Cocrea profiles. + ```console ├──plugin-template // Plugin template directory │ ├──plugin-index-js.hbs // Handlebars template for JavaScript plugin index diff --git a/docs/host-plugin-services.md b/docs/host-plugin-services.md new file mode 100644 index 00000000..ccf5b85a --- /dev/null +++ b/docs/host-plugin-services.md @@ -0,0 +1,116 @@ +# Host-provided plugin services + +`gandi-plugins` does not infer a site from `window.location`. A host supplies the remote services it owns or has verified through the `pluginServices` option passed to `PluginsController`. + +Missing configuration is a capability decision, not a request to guess a sibling domain. The controller automatically adds fully remote plugins with missing service configuration to `unavailablePlugins`. `block-sharing` remains available because local SVG export, copy, paste, and file-drop import do not require a remote service. + +## Contract + +```ts +interface PluginServices { + blockSharing?: { + contentBaseUrl?: string; + importAssetBaseUrl?: string; + }; + costumePiskel?: { editorUrl: string }; + customExtensionVersionManager?: { + apiBaseUrl: string; + webBaseUrl: string; + }; + customCss?: { defaultStylesheetUrl?: string }; + inspiro?: { + apiBaseUrl: string; + assetBaseUrl: string; + }; + voiceCooperation?: { + livekitUrl: string; + connectedAudioUrl?: string; + memberJoinedAudioUrl?: string; + }; +} +``` + +HTTP content must use an absolute `http:` or `https:` URL. LiveKit must use an absolute `ws:` or `wss:` URL. Invalid configured URLs fail when the controller is created; absent services are reported as unavailable. + +`customExtensionVersionManager` requires both marketplace URLs and `inspiro` requires both API and asset URLs. Their capabilities stay false if either half is absent. `voiceCooperation` additionally depends on the host's existing `server.hosts.GANDI_MAIN/rtc/join` token endpoint; configuring LiveKit does not create or replace that backend. + +The bundle exports `normalizePluginServices`, `getPluginCapabilities`, and `getServiceUnavailablePlugins`. A controller instance also exposes its derived `capabilities` property. + +Capabilities report validated configuration availability, not continuous service health. The host can call `getPluginCapabilities(pluginServices)` before controller construction and use the result for UI or telemetry. `blockSharingLocalSvg` is always true; remote content and remote deep-link import are separate flags. + +## CCW profile + +These are the existing CCW routes, moved out of plugin implementations and spot-checked on 2026-08-28. The host must still own environment selection, monitoring, and rollout: + +```ts +const pluginServices = { + blockSharing: { + contentBaseUrl: "https://learn.ccw.site", + importAssetBaseUrl: "https://m.ccw.site/creator-college/images", + }, + costumePiskel: { + editorUrl: "https://ai-static.ccw.site/test/piskel-1e80317/index.html", + }, + customExtensionVersionManager: { + apiBaseUrl: "https://bfs-web.ccw.site", + webBaseUrl: "https://assets.ccw.site", + }, + customCss: { + defaultStylesheetUrl: "https://m.ccw.site/gandi/default.css", + }, + inspiro: { + apiBaseUrl: "https://gandi-main.ccw.site", + assetBaseUrl: "https://m.ccw.site", + }, + voiceCooperation: { + livekitUrl: "wss://voice.ccw.site", + }, +}; +``` + +The custom CSS URL is only the initial value shown in the URL input; it is not loaded until the user changes that setting. Hosts may omit it. + +## Cocrea profile + +Only the existing Inspiro routes had current evidence in the Cocrea stack on 2026-08-28: + +```ts +const pluginServices = { + inspiro: { + apiBaseUrl: "https://gandi-main.cocrea.world", + assetBaseUrl: "https://d3sh2pl0ajjotb.cloudfront.net", + }, +}; +``` + +The image and music endpoints exist, return the expected authentication error while signed out, allow credentialed requests from `https://cocrea.world`, and the configured avatar assets are PNG files. A signed-in generation smoke test is still required before enabling Inspiro in production. + +Do not add inferred `learn.cocrea.world`, `ai-static.cocrea.world`, `bfs-web.cocrea.world`, `assets.cocrea.world` marketplace paths, or `voice.cocrea.world` routes. No matching deployed business service was verified for them. In this profile: + +- `block-sharing` keeps local SVG export, copy, paste, and file-drop import; its remote browser and deep-link fetch are unavailable. +- `costume-piskel` and `custom-extension-version-manager` are unavailable. +- `custom-css` has an empty URL example but keeps all local theme and user-entered URL behavior. +- `code-find` uses no site-owned empty-state image. +- `voice-cooperation` is explicitly unavailable because no Cocrea LiveKit service is configured. + +## Consumer wiring + +`gandi-gui` needs a `pluginServices` prop. On the current `origin/main` integration path: + +1. `src/components/gui/gui.jsx` passes the prop to `src/containers/plugins.jsx`. +2. `src/containers/plugins.jsx` passes it to `usePluginsContext` and declares its PropType. +3. `src/hooks/usePluginsContext.js` includes it in the initial context and updates it when the prop changes. + +That context is already passed directly to `new PluginsClass(context)`. Keep `unavailablePlugins` as an additional product-policy override; it is unioned with service-derived unavailability inside `gandi-plugins`. + +`hub-international` should define its Cocrea profile from reviewed public environment configuration and pass it through: + +```tsx + +``` + +After a signed-in Inspiro smoke test, `hub-international` may remove `inspiro` from its explicit `UNAVAILABLE_PLUGINS`. It should keep no voice service entry; the plugin bundle will report and enforce `voiceCooperation: false`. diff --git a/package.json b/package.json index fce51d2b..762f64c2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "build": "webpack --config webpack.config.js --progress", "buildSinglePlugin": "node scripts/build-single-plugin.js", "lint": "eslint src --ext .js,.ts", + "test:plugin-services": "rimraf .test-dist && tsc --module commonjs --target ES2020 --outDir .test-dist --rootDir src --declaration false --sourceMap false --esModuleInterop true --skipLibCheck true src/lib/plugin-services.ts src/plugins/custom-extension-version-manager/marketplace.ts src/plugins/custom-extension-version-manager/types.ts && node --test test/plugin-services.test.js", "prepublishOnly": "pnpm install && pnpm build", "clean": "rimraf lib && mkdirp lib && rimraf ./dist && mkdirp dist" }, @@ -104,4 +105,4 @@ "last 2 versions", "> 1%" ] -} \ No newline at end of file +} diff --git a/src/l10n/en.json b/src/l10n/en.json index 43471942..c9200e72 100644 --- a/src/l10n/en.json +++ b/src/l10n/en.json @@ -208,7 +208,7 @@ "plugins.extensionManager.title": "Extension Manager", "plugins.extensionManager.intro": "Manage your extensions.", "plugins.customExtensionVersionManager.title": "Custom Extension Versions", - "plugins.customExtensionVersionManager.description": "Load available versions of installed custom extensions from the CCW asset marketplace and switch safely.", + "plugins.customExtensionVersionManager.description": "Load available versions of installed custom extensions from the configured asset marketplace and switch safely.", "plugins.customExtensionVersionManager.extension": "Custom extension", "plugins.customExtensionVersionManager.noExtensions": "No loaded custom extensions", "plugins.customExtensionVersionManager.refreshExtensions": "Refresh extensions", @@ -420,4 +420,4 @@ "plugins.debuggerAddon.terminalLoadMode.auto": "Load automatically with project", "plugins.debuggerAddon.terminalLoadMode.onClick": "Load on icon click", "plugins.debuggerAddon.terminalLoadMode.manual": "Do not load automatically" -} \ No newline at end of file +} diff --git a/src/lib/plugin-services.ts b/src/lib/plugin-services.ts new file mode 100644 index 00000000..273f0955 --- /dev/null +++ b/src/lib/plugin-services.ts @@ -0,0 +1,170 @@ +export interface PluginServices { + blockSharing?: { + contentBaseUrl?: string; + importAssetBaseUrl?: string; + }; + costumePiskel?: { + editorUrl: string; + }; + customExtensionVersionManager?: { + apiBaseUrl: string; + webBaseUrl: string; + }; + customCss?: { + defaultStylesheetUrl?: string; + }; + inspiro?: { + apiBaseUrl: string; + assetBaseUrl: string; + }; + voiceCooperation?: { + livekitUrl: string; + connectedAudioUrl?: string; + memberJoinedAudioUrl?: string; + }; +} + +export interface PluginCapabilities { + blockSharingLocalSvg: true; + blockSharingRemoteContent: boolean; + blockSharingRemoteImport: boolean; + costumePiskel: boolean; + customExtensionVersionManager: boolean; + customCssDefaultStylesheet: boolean; + inspiro: boolean; + voiceCooperation: boolean; +} + +type UrlProtocol = "http:" | "https:" | "ws:" | "wss:"; + +const normalizeUrl = (value: unknown, field: string, protocols: UrlProtocol[]): string | undefined => { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") { + throw new TypeError(`${field} must be an absolute URL.`); + } + + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw new TypeError(`${field} must be an absolute URL.`); + } + + if (!protocols.includes(url.protocol as UrlProtocol)) { + throw new TypeError(`${field} must use one of these protocols: ${protocols.join(", ")}.`); + } + return url.toString(); +}; + +const normalizeHttpUrl = (value: unknown, field: string) => normalizeUrl(value, field, ["http:", "https:"]); + +export const normalizePluginServices = (services: PluginServices = {}): PluginServices => { + const normalized: PluginServices = {}; + + const blockSharingContent = normalizeHttpUrl( + services.blockSharing?.contentBaseUrl, + "pluginServices.blockSharing.contentBaseUrl", + ); + const blockSharingImport = normalizeHttpUrl( + services.blockSharing?.importAssetBaseUrl, + "pluginServices.blockSharing.importAssetBaseUrl", + ); + if (blockSharingContent || blockSharingImport) { + normalized.blockSharing = { + ...(blockSharingContent ? { contentBaseUrl: blockSharingContent } : {}), + ...(blockSharingImport ? { importAssetBaseUrl: blockSharingImport } : {}), + }; + } + + const piskelEditor = normalizeHttpUrl(services.costumePiskel?.editorUrl, "pluginServices.costumePiskel.editorUrl"); + if (piskelEditor) normalized.costumePiskel = { editorUrl: piskelEditor }; + + const marketplaceApi = normalizeHttpUrl( + services.customExtensionVersionManager?.apiBaseUrl, + "pluginServices.customExtensionVersionManager.apiBaseUrl", + ); + const marketplaceWeb = normalizeHttpUrl( + services.customExtensionVersionManager?.webBaseUrl, + "pluginServices.customExtensionVersionManager.webBaseUrl", + ); + if (marketplaceApi && marketplaceWeb) { + normalized.customExtensionVersionManager = { + apiBaseUrl: marketplaceApi, + webBaseUrl: marketplaceWeb, + }; + } + + const defaultStylesheet = normalizeHttpUrl( + services.customCss?.defaultStylesheetUrl, + "pluginServices.customCss.defaultStylesheetUrl", + ); + if (defaultStylesheet) normalized.customCss = { defaultStylesheetUrl: defaultStylesheet }; + + const inspiroApi = normalizeHttpUrl(services.inspiro?.apiBaseUrl, "pluginServices.inspiro.apiBaseUrl"); + const inspiroAssets = normalizeHttpUrl(services.inspiro?.assetBaseUrl, "pluginServices.inspiro.assetBaseUrl"); + if (inspiroApi && inspiroAssets) { + normalized.inspiro = { + apiBaseUrl: inspiroApi, + assetBaseUrl: inspiroAssets, + }; + } + + const voiceLivekit = normalizeUrl( + services.voiceCooperation?.livekitUrl, + "pluginServices.voiceCooperation.livekitUrl", + ["ws:", "wss:"], + ); + if (voiceLivekit) { + const connectedAudio = normalizeHttpUrl( + services.voiceCooperation?.connectedAudioUrl, + "pluginServices.voiceCooperation.connectedAudioUrl", + ); + const memberJoinedAudio = normalizeHttpUrl( + services.voiceCooperation?.memberJoinedAudioUrl, + "pluginServices.voiceCooperation.memberJoinedAudioUrl", + ); + normalized.voiceCooperation = { + livekitUrl: voiceLivekit, + ...(connectedAudio ? { connectedAudioUrl: connectedAudio } : {}), + ...(memberJoinedAudio ? { memberJoinedAudioUrl: memberJoinedAudio } : {}), + }; + } + + return normalized; +}; + +export const getPluginCapabilities = (services: PluginServices = {}): PluginCapabilities => { + const normalized = normalizePluginServices(services); + return { + blockSharingLocalSvg: true, + blockSharingRemoteContent: Boolean(normalized.blockSharing?.contentBaseUrl), + blockSharingRemoteImport: Boolean(normalized.blockSharing?.importAssetBaseUrl), + costumePiskel: Boolean(normalized.costumePiskel), + customExtensionVersionManager: Boolean(normalized.customExtensionVersionManager), + customCssDefaultStylesheet: Boolean(normalized.customCss?.defaultStylesheetUrl), + inspiro: Boolean(normalized.inspiro), + voiceCooperation: Boolean(normalized.voiceCooperation), + }; +}; + +export const getServiceUnavailablePlugins = (capabilities: PluginCapabilities): string[] => { + const unavailablePlugins: string[] = []; + if (!capabilities.costumePiskel) unavailablePlugins.push("costume-piskel"); + if (!capabilities.customExtensionVersionManager) unavailablePlugins.push("custom-extension-version-manager"); + if (!capabilities.inspiro) unavailablePlugins.push("inspiro"); + if (!capabilities.voiceCooperation) unavailablePlugins.push("voice-cooperation"); + return unavailablePlugins; +}; + +export const buildPluginServiceUrl = ( + baseUrl: string, + relativePath: string, + searchParams: Record = {}, +): string => { + const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + const url = new URL(relativePath.replace(/^\/+/, ""), normalizedBase); + Object.entries(searchParams).forEach(([key, value]) => { + if (value !== undefined) url.searchParams.set(key, value); + }); + return url.toString(); +}; diff --git a/src/main.ts b/src/main.ts index 585374da..58de687d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,4 +2,11 @@ import PluginsController from "./plugins-controller"; import Plugins from "./plugins-entry"; export const PluginNames = Object.keys(Plugins); +export { + buildPluginServiceUrl, + getPluginCapabilities, + getServiceUnavailablePlugins, + normalizePluginServices, +} from "./lib/plugin-services"; +export type { PluginCapabilities, PluginServices } from "./lib/plugin-services"; export { PluginsController as default }; diff --git a/src/plugins-controller.ts b/src/plugins-controller.ts index 63786ac8..7a230266 100644 --- a/src/plugins-controller.ts +++ b/src/plugins-controller.ts @@ -8,6 +8,12 @@ import pluginsL10n from "./plugins-l10n"; import { createIntl, createIntlCache } from "@formatjs/intl"; import { IntlShape } from "react-intl"; import { camelToKebab } from "utils/name-helper"; +import { + getPluginCapabilities, + getServiceUnavailablePlugins, + normalizePluginServices, + PluginCapabilities, +} from "./lib/plugin-services"; export const LoadingPlugins = {}; @@ -21,13 +27,16 @@ export interface PluginFunction { }; } -export interface PluginsControllerOptions extends Omit { +export interface PluginsControllerOptions + extends Omit { + pluginServices?: PluginContext["pluginServices"]; disabledPlugins?: Array; unavailablePlugins?: string[]; locale: string; } class PluginsController { + capabilities: PluginCapabilities; context: PluginContext; plugins: Record void>; options: PluginsControllerOptions; @@ -42,11 +51,23 @@ class PluginsController { constructor(options: PluginsControllerOptions) { this.plugins = {}; const intl = this.createIntl(options.locale); - this.context = Object.assign(options, { + const pluginServices = normalizePluginServices(options.pluginServices); + this.capabilities = getPluginCapabilities(pluginServices); + const unavailablePlugins = Array.from( + new Set([...(options.unavailablePlugins || []), ...getServiceUnavailablePlugins(this.capabilities)]), + ); + this.options = { + ...options, + pluginServices, + unavailablePlugins, + }; + this.context = { + ...options, + pluginServices, + pluginCapabilities: this.capabilities, intl, msg: (id: string) => intl.formatMessage({ id }), - }); - this.options = options; + }; this.wrapperElement = this.initWrapper(); this.initPluginsManager(); } diff --git a/src/plugins/block-sharing/components/Home.tsx b/src/plugins/block-sharing/components/Home.tsx index 7d28d616..0304cd76 100644 --- a/src/plugins/block-sharing/components/Home.tsx +++ b/src/plugins/block-sharing/components/Home.tsx @@ -4,11 +4,11 @@ import { Spinner } from "@gandi-ide/gandi-ui"; import hack from "../hack"; interface ArticleProps { - name: string; - Jump: string; + src: string; + loadBlock: boolean; } -const Article: React.FC = ({ name, Jump }) => { +const Article: React.FC = ({ src, loadBlock }) => { const iframeRef = useRef(null); const [loading, setLoading] = React.useState(true); @@ -22,7 +22,7 @@ const Article: React.FC = ({ name, Jump }) => { // 在这里执行你的加载完成后的逻辑 setTimeout(() => { setTimeout(() => { - if (window.location.search.indexOf("Block") !== -1) { + if (loadBlock) { iframeRef.current.contentWindow.postMessage(["loadBlock"], "*"); } }, 2000); @@ -30,7 +30,7 @@ const Article: React.FC = ({ name, Jump }) => { }, 100); }; } - }, []); + }, [loadBlock]); return (
@@ -44,7 +44,7 @@ const Article: React.FC = ({ name, Jump }) => { id="block-sharing-article-iframe" ref={iframeRef} className={styles.inner} - src={name + "?hideHeader" + (Jump ? "&JumpLink=" + Jump : "")} + src={src} title="教程文章" width="100%" height="500px" diff --git a/src/plugins/block-sharing/index.tsx b/src/plugins/block-sharing/index.tsx index 0a506a85..70330444 100644 --- a/src/plugins/block-sharing/index.tsx +++ b/src/plugins/block-sharing/index.tsx @@ -15,6 +15,7 @@ import DemoList from "./components/DemoList"; import toast from "react-hot-toast"; import { Box } from "@gandi-ide/gandi-ui"; import HomeIcon from "./icons/home.svg"; +import { buildPluginServiceUrl } from "../../lib/plugin-services"; const DEFAULT_CONTAINER_INFO = { width: 300, @@ -22,11 +23,18 @@ const DEFAULT_CONTAINER_INFO = { translateX: 72, translateY: 60, }; -const LINK = "https://learn.ccw.site"; -// const LINK = "http://localhost:3000"; -// const LINK = "http://learn-qa.xiguacity.cn"; - -const blockSharing: React.FC = ({ msg, vm, workspace, blockly, utils }) => { +const blockSharing: React.FC = ({ + msg, + vm, + workspace, + blockly, + utils, + pluginServices, + pluginCapabilities, +}) => { + const contentBaseUrl = pluginServices?.blockSharing?.contentBaseUrl; + const importAssetBaseUrl = pluginServices?.blockSharing?.importAssetBaseUrl; + const remoteContentAvailable = pluginCapabilities?.blockSharingRemoteContent ?? Boolean(contentBaseUrl); const [visible, setVisible] = React.useState(false); const [currentIndex, setCurrentIndex] = React.useState(0); const [loading, setLoading] = React.useState("load"); @@ -121,20 +129,31 @@ const blockSharing: React.FC = ({ msg, vm, workspace, blockly, ut hack.startHack(workspace, blockly, vm, utils, setLoading); React.useEffect(() => { - if (window.location.search.includes("Block") && window.location.search.includes("JumpLink")) { - setBlockValue(window.location.search.split("Block=")[1].split("&")[0]); - setJumpLink(window.location.search.split("JumpLink=")[1].split("&")[0]); - setAutoURL(Boolean(true)); - handleClick(undefined); - if (window.location.search.split("JumpLink=")[1].split("&")[0]) { - console.log("e"); + const searchParams = new URLSearchParams(window.location.search); + const requestedBlock = searchParams.get("Block"); + const requestedAsset = searchParams.get("JumpLink"); + let handledDeepLink = false; + + if (requestedBlock && requestedAsset) { + if (contentBaseUrl) { + setBlockValue(requestedBlock); + setJumpLink(requestedAsset); + setAutoURL(true); + handleClick(undefined); + handledDeepLink = true; + } + if (importAssetBaseUrl) { hack.postBlocks( - `https://m.ccw.site/creator-college/images/${window.location.search.split("JumpLink=")[1].split("&")[0]}`, + buildPluginServiceUrl(importAssetBaseUrl, encodeURIComponent(requestedAsset)), workspace, utils, vm, ); + handledDeepLink = true; } + } + + if (handledDeepLink) { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete("Block"); currentUrl.searchParams.delete("JumpLink"); @@ -145,27 +164,38 @@ const blockSharing: React.FC = ({ msg, vm, workspace, blockly, ut return () => { window.postMessage(["dispose"], "*"); }; - }, [setAutoURL, msg]); + }, [contentBaseUrl, handleClick, importAssetBaseUrl, msg, setAutoURL, utils, vm, workspace]); const backToHome = () => { - (document.getElementById("block-sharing-article-iframe") as HTMLIFrameElement).src = String( - new URL("/tag/blueprint/new?hideHeader", LINK), - ); + if (!contentBaseUrl) return; + const iframe = document.getElementById("block-sharing-article-iframe") as HTMLIFrameElement | null; + if (iframe) iframe.src = buildPluginServiceUrl(contentBaseUrl, "tag/blueprint/new", { hideHeader: "" }); setCurrentIndex(0); }; + const homeUrl = contentBaseUrl + ? autoURL + ? buildPluginServiceUrl(contentBaseUrl, `article/${encodeURIComponent(blockValue)}`, { + hideHeader: "", + JumpLink: JumpLink || undefined, + }) + : buildPluginServiceUrl(contentBaseUrl, "tag/blueprint/new", { hideHeader: "" }) + : ""; + return ( - {ReactDOM.createPortal( - } - onClick={handleClick} - tipText={msg("plugins.blockSharing.title")} - />, - document.querySelector(".plugins-wrapper"), - )} + {remoteContentAvailable && + ReactDOM.createPortal( + } + onClick={handleClick} + tipText={msg("plugins.blockSharing.title")} + />, + document.querySelector(".plugins-wrapper"), + )} {visible && + remoteContentAvailable && ReactDOM.createPortal( = ({ msg, vm, workspace, blockly, ut <> )} - + diff --git a/src/plugins/code-find/index.tsx b/src/plugins/code-find/index.tsx index 1c5fb954..9608aaed 100644 --- a/src/plugins/code-find/index.tsx +++ b/src/plugins/code-find/index.tsx @@ -5,7 +5,6 @@ import { defineMessages } from "@formatjs/intl"; import ExpansionBox, { ExpansionRect } from "components/ExpansionBox"; import Tooltip from "components/Tooltip"; import scratchblocks from "gandiblocks"; -import IF from "components/IF"; import { hotkeyIsDown, transitionHotkeysToString } from "utils/hotkey-helper"; import useStorageInfo from "hooks/useStorageInfo"; import zh_cn from "gandiblocks/locales/zh-cn.json"; // 中文 @@ -548,9 +547,6 @@ const CodeFind: React.FC = ({ workspace, vm, intl, registerSettin ) : intl.formatMessage(messages.noResult)}
- - -