Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .mf/diagnostics/latest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"latestErrorEvent": {
"code": "TYPE-001",
"message": "Failed to generate type declaration. Execute the below cmd to reproduce and fix the error. #TYPE-001\nargs: {\"cmd\":\"npx tsc --project /Users/ninojunghans/Projects/k3-sample-plugin/node_modules/.federation/tsconfig.e13503699e2d9fbd8a96160998c952e9.json\"}\nView the docs to see how to solve: https://module-federation.io/guide/troubleshooting/type#type-001",
"args": {
"cmd": "npx tsc --project /Users/ninojunghans/Projects/k3-sample-plugin/node_modules/.federation/tsconfig.e13503699e2d9fbd8a96160998c952e9.json"
},
"timestamp": 1783330058246
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"deploy": "gh-pages -d dist --dotfiles"
},
"devDependencies": {
"k3-plugin-api": "^1.5.0",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.5",
Expand All @@ -30,6 +29,7 @@
"eslint-plugin-react-refresh": "^0.4.16",
"gh-pages": "^6.3.0",
"globals": "^15.14.0",
"k3-plugin-api": "^2.2.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-ga4": "^2.0.0",
Expand All @@ -51,4 +51,4 @@
"react-ga4": "^2.0.0",
"three": "^0.177.0"
}
}
}
11 changes: 6 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 94 additions & 0 deletions src/HoverInfoModel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* 3D side of the HoverInfo demo.
*
* A dynamic model that highlights on hover and opens a context popup on
* click. The popup (see InfoPopup.tsx) renders the K3 `StandaloneVariable`
* component for every variable picked in this model's props dialog —
* the admin selects the variables via the `type: "variable"` props below.
*/
import type { ThreeEvent } from "@react-three/fiber";
import { DynamicModel } from "k3-plugin-api";
import { useState } from "react";
import { openInfoPopup } from "./InfoPopup";

/** K3 resolves variable props to their runtime VALUES before passing them to
* the component; the raw refs arrive separately in props.variableRefs
* (keyed by prop name) — that is where the variable identity for the popup
* comes from. */
const variableIdsFromProps = (props: any): (number | string)[] =>
Object.values(
(props.variableRefs ?? {}) as Record<
string,
{ variableId: number | string }
>,
).map((ref) => ref.variableId);

// eslint-disable-next-line react-refresh/only-export-components
const HoverInfoMesh = (props: any) => {
const [hovered, setHovered] = useState(false);
const variableIds = variableIdsFromProps(props);

const onClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
openInfoPopup(
e.nativeEvent.clientX,
e.nativeEvent.clientY,
variableIds,
);
};

return (
<group
position={props.position}
scale={[props.width, props.height, props.depth]}
userData={{ modelId: props.id }} // camera focusing needs the modelId
>
<mesh
onPointerOver={(e) => {
e.stopPropagation();
setHovered(true);
document.body.style.cursor = "pointer";
}}
onPointerOut={() => {
setHovered(false);
document.body.style.cursor = "auto";
}}
onClick={onClick}
scale={hovered ? 1.05 : 1}
>
<torusKnotGeometry args={[0.4, 0.12, 96, 16]} />
<meshStandardMaterial
color={hovered ? "#00c4ff" : "#00A4DD"}
emissive={hovered ? "#004a66" : "#000000"}
roughness={0.3}
/>
</mesh>
</group>
);
};

export const hoverInfoModel: DynamicModel = {
type: "hoverInfoDemo",
label: "Hover-Info Demo",
description: `<p><strong>Hover-Info Demo</strong></p>
<p>Ein 3D-Modell, das beim Hovern hervorgehoben wird und beim Klick ein
Kontext-Popup öffnet. Das Popup rendert die im Props-Dialog ausgewählten
Merkmale als vollwertige K3-Eingabekomponenten (<em>StandaloneVariable</em>)
sowie den Live-Gesamtpreis — direkt aus dem Plugin heraus über die
k3-plugin-api Komponenten-Proxies und <em>K3Providers</em>.</p>`,
disabledForAR: false,
materials: [],
tag: "demo",
component: HoverInfoMesh,
propsDialog: {
basic: { type: "basic" },
popupVar1: { type: "variable", label: "Popup Merkmal 1" },
popupVar2: { type: "variable", label: "Popup Merkmal 2" },
popupVar3: { type: "variable", label: "Popup Merkmal 3" },
},
defaultProps: {
width: { expression: "1" },
height: { expression: "1" },
depth: { expression: "1" },
},
};
117 changes: 117 additions & 0 deletions src/InfoPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* DOM side of the HoverInfo demo.
*
* Renders a context popup into its OWN React root (createRoot on a div
* appended to document.body) — deliberately outside the K3 tree, to
* demonstrate `K3Providers`: it bridges the host's Redux store, theme and
* query client, so K3 components like `StandaloneVariable` and `Price`
* work inside a plugin-owned root.
*/
import { K3Providers, Price, StandaloneVariable } from "k3-plugin-api";
import { useEffect, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";

type PopupState = {
/** Screen position of the click that opened the popup. */
anchor: { x: number; y: number } | null;
/** Database IDs of the variables to show (from the model's VariableRef props). */
variableIds: (number | string)[];
};

// ponytail: module-level store instead of zustand — the plugin has no state
// lib as dependency and this is one popup. Upgrade path: zustand vanilla store.
let state: PopupState = { anchor: null, variableIds: [] };
const listeners = new Set<() => void>();
const setState = (next: PopupState) => {
state = next;
listeners.forEach((l) => l());
};
const subscribe = (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
};

export const closeInfoPopup = () => setState({ anchor: null, variableIds: [] });

/** Opens the popup at the given screen position. Mounts the root on first use. */
export const openInfoPopup = (
x: number,
y: number,
variableIds: (number | string)[],
) => {
mountOnce();
setState({ anchor: { x, y }, variableIds });
};

const InfoPopupHost = () => {
const { anchor, variableIds } = useSyncExternalStore(subscribe, () => state);

// Close on Escape
useEffect(() => {
if (!anchor) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && closeInfoPopup();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [anchor]);

if (!anchor) return null;

return (
<>
{/* click-outside backdrop. z-index stays BELOW MUI's modal layer (1300)
so Select dropdowns from StandaloneVariable open ABOVE the popup. */}
<div
style={{ position: "fixed", inset: 0, zIndex: 1250 }}
onClick={closeInfoPopup}
/>
<div
style={{
position: "fixed",
// keep the popup on screen near the click point
left: Math.min(anchor.x, window.innerWidth - 340),
top: Math.min(anchor.y, window.innerHeight - 320),
width: 320,
maxHeight: 300,
overflowY: "auto",
zIndex: 1251,
background: "white",
borderRadius: 8,
boxShadow: "0 4px 20px rgba(0,0,0,0.25)",
padding: 12,
}}
>
{/* Plain header: host components like TranslatableText grow an
editor-only edit affordance that needs the router — which this
standalone root intentionally does not have. */}
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Komponente anpassen
</div>
{variableIds.length === 0 && (
<div>
No variables assigned — pick some in the model's props dialog.
</div>
)}
{variableIds.map((id) => (
<StandaloneVariable key={id} variableId={id} sticky dense />
))}
<div style={{ marginTop: 8, textAlign: "right" }}>
<Price />
</div>
</div>
</>
);
};

let mounted = false;
const mountOnce = () => {
if (mounted) return;
mounted = true;
const el = document.createElement("div");
el.id = "k3-sample-plugin-info-popup";
document.body.appendChild(el);
createRoot(el).render(
<K3Providers>
<InfoPopupHost />
</K3Providers>,
);
};
3 changes: 2 additions & 1 deletion src/Plugin.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ColorChooser } from "./ColorChooser";
import { dynamicRing } from "./DynamicRing";
import { hoverInfoModel } from "./HoverInfoModel";
import { PriceDisplay } from "./PriceDisplay";
import { K3PluginDescriptor } from "k3-plugin-api";

Expand All @@ -19,7 +20,7 @@ export default {
},
},
viewer: {
models: [dynamicRing],
models: [dynamicRing, hoverInfoModel],
customLayoutComponents: { PriceDisplay },
},
} satisfies K3PluginDescriptor;
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default defineConfig({
shared: {
react: { singleton: true, requiredVersion: "19.1.1" },
"react-dom": { singleton: true, requiredVersion: "19.1.1" },
"k3-plugin-api": { singleton: true, requiredVersion: "*" },

"@mui/material": { singleton: true, requiredVersion: "^7.1.1" },
"@mui/styled-engine": { singleton: true, requiredVersion: "^7.1.1" },
Expand Down