@designtools/next-plugin 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,12 +9,18 @@
9
9
  * with proper lifecycle management.
10
10
  */
11
11
 
12
- import { useEffect, useRef } from "react";
12
+ import { useEffect, useRef, useState, createElement } from "react";
13
+ import { createPortal } from "react-dom";
13
14
 
14
15
  // Overlay elements are created imperatively (not React-rendered)
15
16
  // because they need to be fixed-position overlays that don't interfere
16
17
  // with the app's React tree.
17
18
 
19
+ interface PreviewCombination {
20
+ label: string;
21
+ props: Record<string, string>;
22
+ }
23
+
18
24
  export function CodeSurface() {
19
25
  const stateRef = useRef({
20
26
  selectionMode: false,
@@ -31,6 +37,29 @@ export function CodeSurface() {
31
37
  selectedOverlay: null as HTMLDivElement | null,
32
38
  });
33
39
 
40
+ // Preview overlay state
41
+ const [previewComponent, setPreviewComponent] = useState<React.ComponentType<any> | null>(null);
42
+ const [previewCombinations, setPreviewCombinations] = useState<PreviewCombination[]>([]);
43
+ const [previewDefaultChildren, setPreviewDefaultChildren] = useState("");
44
+ const [previewError, setPreviewError] = useState<string | null>(null);
45
+ const [showPreview, setShowPreview] = useState(false);
46
+
47
+ // Ref so the imperative useEffect can trigger state updates
48
+ const setPreviewRef = useRef({
49
+ setPreviewComponent,
50
+ setPreviewCombinations,
51
+ setPreviewDefaultChildren,
52
+ setPreviewError,
53
+ setShowPreview,
54
+ });
55
+ setPreviewRef.current = {
56
+ setPreviewComponent,
57
+ setPreviewCombinations,
58
+ setPreviewDefaultChildren,
59
+ setPreviewError,
60
+ setShowPreview,
61
+ };
62
+
34
63
  useEffect(() => {
35
64
  const s = stateRef.current;
36
65
 
@@ -593,6 +622,49 @@ export function CodeSurface() {
593
622
  "letter-spacing", "text-align", "text-transform", "white-space",
594
623
  ];
595
624
 
625
+ /**
626
+ * Walk UP the fiber tree from a DOM element's fiber to find the
627
+ * component that renders this element as its root host element.
628
+ * Checks tags 0 (Function), 1 (Class), 11 (ForwardRef),
629
+ * 14 (Memo), 15 (SimpleMemo) as component boundaries.
630
+ */
631
+ function findComponentFiberAbove(el: Element): any | null {
632
+ const fiber = getFiber(el);
633
+ if (!fiber) return null;
634
+ let candidate = fiber.return;
635
+ while (candidate) {
636
+ const tag = candidate.tag;
637
+ if (tag === 0 || tag === 1 || tag === 11 || tag === 14 || tag === 15) {
638
+ // Check if this component's root host element is our element
639
+ if (findOwnHostElement(candidate) === el) return candidate;
640
+ }
641
+ candidate = candidate.return;
642
+ }
643
+ return null;
644
+ }
645
+
646
+ /**
647
+ * Read memoizedProps from a fiber, filtering to simple values only.
648
+ * Skips children, ref, key, className, style, and data-* props.
649
+ */
650
+ function extractFiberProps(fiber: any): Record<string, string | number | boolean> | null {
651
+ const props = fiber?.memoizedProps;
652
+ if (!props || typeof props !== "object") return null;
653
+ const skipKeys = new Set(["children", "ref", "key", "className", "style"]);
654
+ const result: Record<string, string | number | boolean> = {};
655
+ let count = 0;
656
+ for (const k of Object.keys(props)) {
657
+ if (skipKeys.has(k)) continue;
658
+ if (k.startsWith("data-")) continue;
659
+ const v = props[k];
660
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
661
+ result[k] = v;
662
+ count++;
663
+ }
664
+ }
665
+ return count > 0 ? result : null;
666
+ }
667
+
596
668
  function extractElementData(el: Element) {
597
669
  const computed = getComputedStyle(el);
598
670
  const rect = el.getBoundingClientRect();
@@ -635,16 +707,14 @@ export function CodeSurface() {
635
707
  }
636
708
 
637
709
  // For component instances: read data-instance-source directly from the DOM element.
638
- // The Babel transform adds this attribute to component JSX (<Button>, <Card>)
639
- // and it propagates via {...props} to the rendered DOM element, carrying exact
640
- // page-level coordinates of each component usage site.
641
710
  let instanceSourceFile: string | null = null;
642
711
  let instanceSourceLine: number | null = null;
643
712
  let instanceSourceCol: number | null = null;
644
713
  let componentName: string | null = null;
645
714
 
715
+ const dataSlot = el.getAttribute("data-slot");
646
716
  const instanceSource = el.getAttribute("data-instance-source");
647
- if (instanceSource && el.getAttribute("data-slot")) {
717
+ if (instanceSource && dataSlot) {
648
718
  const lc = instanceSource.lastIndexOf(":");
649
719
  const slc = instanceSource.lastIndexOf(":", lc - 1);
650
720
  if (slc > 0) {
@@ -654,13 +724,21 @@ export function CodeSurface() {
654
724
  }
655
725
 
656
726
  // Derive component name from data-slot (e.g. "card-title" -> "CardTitle")
657
- const slot = el.getAttribute("data-slot") || "";
658
- componentName = slot
727
+ componentName = dataSlot
659
728
  .split("-")
660
729
  .map((s: string) => s.charAt(0).toUpperCase() + s.slice(1))
661
730
  .join("");
662
731
  }
663
732
 
733
+ // Extract runtime props from React fiber for component instances
734
+ let fiberProps: Record<string, string | number | boolean> | null = null;
735
+ if (dataSlot) {
736
+ const compFiber = findComponentFiberAbove(el);
737
+ if (compFiber) {
738
+ fiberProps = extractFiberProps(compFiber);
739
+ }
740
+ }
741
+
664
742
  return {
665
743
  tag: el.tagName.toLowerCase(),
666
744
  className: (el.getAttribute("class") || "").trim(),
@@ -677,6 +755,7 @@ export function CodeSurface() {
677
755
  instanceSourceLine,
678
756
  instanceSourceCol,
679
757
  componentName,
758
+ fiberProps,
680
759
  };
681
760
  }
682
761
 
@@ -732,6 +811,27 @@ export function CodeSurface() {
732
811
  tick();
733
812
  }
734
813
 
814
+ // --- Helper to hide/show selection overlays ---
815
+ function hideSelectionOverlays() {
816
+ if (s.highlightOverlay) s.highlightOverlay.style.display = "none";
817
+ if (s.tooltip) s.tooltip.style.display = "none";
818
+ if (s.selectedOverlay) s.selectedOverlay.style.display = "none";
819
+ s.hoveredElement = null;
820
+ }
821
+
822
+ function enterSelectionMode() {
823
+ s.selectionMode = true;
824
+ document.body.style.cursor = "crosshair";
825
+ }
826
+
827
+ function exitSelectionMode() {
828
+ s.selectionMode = false;
829
+ document.body.style.cursor = "";
830
+ if (s.highlightOverlay) s.highlightOverlay.style.display = "none";
831
+ if (s.tooltip) s.tooltip.style.display = "none";
832
+ s.hoveredElement = null;
833
+ }
834
+
735
835
  // --- Event handlers ---
736
836
  function onMouseMove(e: MouseEvent) {
737
837
  if (!s.selectionMode || !s.highlightOverlay || !s.tooltip) return;
@@ -772,15 +872,10 @@ export function CodeSurface() {
772
872
 
773
873
  switch (msg.type) {
774
874
  case "tool:enterSelectionMode":
775
- s.selectionMode = true;
776
- document.body.style.cursor = "crosshair";
875
+ enterSelectionMode();
777
876
  break;
778
877
  case "tool:exitSelectionMode":
779
- s.selectionMode = false;
780
- document.body.style.cursor = "";
781
- if (s.highlightOverlay) s.highlightOverlay.style.display = "none";
782
- if (s.tooltip) s.tooltip.style.display = "none";
783
- s.hoveredElement = null;
878
+ exitSelectionMode();
784
879
  break;
785
880
  case "tool:previewInlineStyle": {
786
881
  if (s.selectedElement && s.selectedElement instanceof HTMLElement) {
@@ -809,12 +904,7 @@ export function CodeSurface() {
809
904
  case "tool:previewTokenValue": {
810
905
  const prop = msg.property as string;
811
906
  const value = msg.value as string;
812
- // Track current preview value
813
907
  s.tokenPreviewValues.set(prop, value);
814
- // Inject a <style> tag override.
815
- // Tailwind v4 resolves @theme variables at build time and inlines
816
- // them into utility classes, so setting CSS custom properties on
817
- // :root has no effect. Instead we target utility classes directly.
818
908
  if (!s.tokenPreviewStyle) {
819
909
  s.tokenPreviewStyle = document.createElement("style");
820
910
  s.tokenPreviewStyle.id = "codesurface-token-preview";
@@ -822,13 +912,10 @@ export function CodeSurface() {
822
912
  }
823
913
  const cssRules: string[] = [];
824
914
  for (const [k, v] of s.tokenPreviewValues) {
825
- // Derive Tailwind utility class from CSS variable name:
826
- // --shadow-sm → .shadow-sm, --shadow → .shadow
827
915
  if (k.startsWith("--shadow")) {
828
- const cls = k.slice(2); // "--shadow-sm" → "shadow-sm"
916
+ const cls = k.slice(2);
829
917
  cssRules.push(`.${cls}, [class*="${cls}"] { box-shadow: ${v} !important; }`);
830
918
  } else {
831
- // For other tokens (colors, spacing, etc.) override the custom property
832
919
  cssRules.push(`*, *::before, *::after { ${k}: ${v} !important; }`);
833
920
  }
834
921
  }
@@ -886,6 +973,66 @@ export function CodeSurface() {
886
973
  }
887
974
  break;
888
975
  }
976
+ case "tool:renderPreview": {
977
+ const { componentPath, exportName, combinations: combos, defaultChildren: children } = msg;
978
+ const currentRegistry = (window as any).__DESIGNTOOLS_REGISTRY__ as Record<string, () => Promise<any>> | undefined;
979
+
980
+ if (!currentRegistry) {
981
+ setPreviewRef.current.setPreviewError("No component registry available. Ensure designtools-registry.ts is imported.");
982
+ setPreviewRef.current.setShowPreview(true);
983
+ return;
984
+ }
985
+
986
+ const loader = currentRegistry[componentPath];
987
+ if (!loader) {
988
+ setPreviewRef.current.setPreviewError(
989
+ `Component "${componentPath}" not found in registry. Available: ${Object.keys(currentRegistry).join(", ")}`
990
+ );
991
+ setPreviewRef.current.setShowPreview(true);
992
+ return;
993
+ }
994
+
995
+ // Disable selection mode and hide overlays
996
+ exitSelectionMode();
997
+ hideSelectionOverlays();
998
+
999
+ // Load the component
1000
+ loader().then((mod: any) => {
1001
+ const Comp = mod[exportName] || mod.default;
1002
+ if (!Comp) {
1003
+ setPreviewRef.current.setPreviewError(`Export "${exportName}" not found in ${componentPath}`);
1004
+ setPreviewRef.current.setShowPreview(true);
1005
+ return;
1006
+ }
1007
+
1008
+ setPreviewRef.current.setPreviewError(null);
1009
+ setPreviewRef.current.setPreviewComponent(() => Comp);
1010
+ setPreviewRef.current.setPreviewCombinations(combos || []);
1011
+ setPreviewRef.current.setPreviewDefaultChildren(children || exportName);
1012
+ setPreviewRef.current.setShowPreview(true);
1013
+
1014
+ // Notify editor that preview is ready
1015
+ window.parent.postMessage(
1016
+ { type: "tool:previewReady", cellCount: (combos || []).length },
1017
+ "*"
1018
+ );
1019
+ }).catch((err: any) => {
1020
+ setPreviewRef.current.setPreviewError(`Failed to load component: ${err.message}`);
1021
+ setPreviewRef.current.setShowPreview(true);
1022
+ });
1023
+ break;
1024
+ }
1025
+ case "tool:exitPreview": {
1026
+ setPreviewRef.current.setShowPreview(false);
1027
+ setPreviewRef.current.setPreviewComponent(null);
1028
+ setPreviewRef.current.setPreviewCombinations([]);
1029
+ setPreviewRef.current.setPreviewDefaultChildren("");
1030
+ setPreviewRef.current.setPreviewError(null);
1031
+
1032
+ // Restore selection mode
1033
+ enterSelectionMode();
1034
+ break;
1035
+ }
889
1036
  }
890
1037
  }
891
1038
 
@@ -923,6 +1070,88 @@ export function CodeSurface() {
923
1070
  };
924
1071
  }, []);
925
1072
 
926
- // This component renders nothing overlays are created imperatively
927
- return null;
1073
+ // --- Preview overlay rendered via portal ---
1074
+ if (!showPreview) return null;
1075
+
1076
+ if (previewError) {
1077
+ return createPortal(
1078
+ <div style={{
1079
+ position: "fixed",
1080
+ inset: 0,
1081
+ zIndex: 999999,
1082
+ background: "var(--background, white)",
1083
+ overflow: "auto",
1084
+ padding: 32,
1085
+ }}>
1086
+ <div style={{ color: "var(--destructive, #ef4444)", fontFamily: "monospace", fontSize: 14 }}>
1087
+ {previewError}
1088
+ </div>
1089
+ </div>,
1090
+ document.body
1091
+ );
1092
+ }
1093
+
1094
+ if (!previewComponent) {
1095
+ return createPortal(
1096
+ <div style={{
1097
+ position: "fixed",
1098
+ inset: 0,
1099
+ zIndex: 999999,
1100
+ background: "var(--background, white)",
1101
+ overflow: "auto",
1102
+ padding: 32,
1103
+ }}>
1104
+ <div style={{ color: "var(--muted-foreground, #888)", fontFamily: "inherit", fontSize: 14 }}>
1105
+ Loading component...
1106
+ </div>
1107
+ </div>,
1108
+ document.body
1109
+ );
1110
+ }
1111
+
1112
+ const Component = previewComponent;
1113
+
1114
+ return createPortal(
1115
+ <div style={{
1116
+ position: "fixed",
1117
+ inset: 0,
1118
+ zIndex: 999999,
1119
+ background: "var(--background, white)",
1120
+ overflow: "auto",
1121
+ padding: 32,
1122
+ }}>
1123
+ <div style={{
1124
+ display: "grid",
1125
+ gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
1126
+ gap: 24,
1127
+ }}>
1128
+ {previewCombinations.map((combo, i) => (
1129
+ <div key={i} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
1130
+ <div style={{
1131
+ fontSize: 11,
1132
+ fontWeight: 600,
1133
+ color: "var(--muted-foreground, #888)",
1134
+ textTransform: "uppercase" as const,
1135
+ letterSpacing: "0.05em",
1136
+ }}>
1137
+ {combo.label}
1138
+ </div>
1139
+ <div style={{
1140
+ padding: 16,
1141
+ border: "1px solid var(--border, #e5e7eb)",
1142
+ borderRadius: 8,
1143
+ display: "flex",
1144
+ alignItems: "center",
1145
+ justifyContent: "center",
1146
+ minHeight: 64,
1147
+ background: "var(--card, var(--background, #fff))",
1148
+ }}>
1149
+ {createElement(Component, combo.props, previewDefaultChildren)}
1150
+ </div>
1151
+ </div>
1152
+ ))}
1153
+ </div>
1154
+ </div>,
1155
+ document.body
1156
+ );
928
1157
  }
package/src/index.ts CHANGED
@@ -8,10 +8,20 @@
8
8
  */
9
9
 
10
10
  import path from "path";
11
- import { generatePreviewRoute } from "./preview-route.js";
11
+ import { generateComponentRegistry } from "./preview-route.js";
12
12
 
13
13
  export function withDesigntools<T extends Record<string, any>>(nextConfig: T = {} as T): T {
14
- return {
14
+ // Generate the component registry unconditionally (filesystem operation, not loader-specific).
15
+ // Use process.cwd() since withDesigntools() is called at config time from the project root.
16
+ const projectRoot = process.cwd();
17
+ const appDir = path.resolve(projectRoot, "app");
18
+ try {
19
+ generateComponentRegistry(appDir);
20
+ } catch {
21
+ // Non-fatal — isolation feature just won't work
22
+ }
23
+
24
+ const result: any = {
15
25
  ...nextConfig,
16
26
  webpack(config: any, context: any) {
17
27
  // Only add the loader in development
@@ -42,16 +52,6 @@ export function withDesigntools<T extends Record<string, any>>(nextConfig: T = {
42
52
  },
43
53
  ],
44
54
  });
45
-
46
- // Generate the component isolation preview route
47
- // This creates app/__designtools/preview/page.tsx which Next.js
48
- // picks up as a route automatically via file-system routing.
49
- const appDir = path.resolve(context.dir, "app");
50
- try {
51
- generatePreviewRoute(appDir);
52
- } catch {
53
- // Non-fatal — isolation feature just won't work
54
- }
55
55
  }
56
56
 
57
57
  // Call the user's webpack config if provided
@@ -62,4 +62,64 @@ export function withDesigntools<T extends Record<string, any>>(nextConfig: T = {
62
62
  return config;
63
63
  },
64
64
  };
65
+
66
+ // Turbopack support — register the same loaders via turbopack.rules
67
+ // following the exact pattern used by @next/mdx.
68
+ if (process.env.TURBOPACK) {
69
+ const sourceLoader = {
70
+ loader: path.resolve(__dirname, "loader.js"),
71
+ options: {
72
+ // In webpack, context.dir provides this. In turbopack config time,
73
+ // process.cwd() is equivalent since next.config runs from the project root.
74
+ cwd: projectRoot,
75
+ },
76
+ };
77
+
78
+ const mountLoader = {
79
+ loader: path.resolve(__dirname, "codesurface-mount-loader.js"),
80
+ };
81
+
82
+ // Source annotation rule — uses a unique glob like @next/mdx does
83
+ const sourceRule = {
84
+ loaders: [sourceLoader],
85
+ as: "*.tsx",
86
+ condition: {
87
+ path: /\.(tsx|jsx)$/,
88
+ },
89
+ };
90
+
91
+ const wildcardGlob = "{*,designtools-source}";
92
+ let wildcardRule = (nextConfig as any).turbopack?.rules?.[wildcardGlob] ?? [];
93
+ wildcardRule = [
94
+ ...(Array.isArray(wildcardRule) ? wildcardRule : [wildcardRule]),
95
+ sourceRule,
96
+ ];
97
+
98
+ // Mount loader rule for root layout files
99
+ const mountRule = {
100
+ loaders: [mountLoader],
101
+ as: "*.tsx",
102
+ condition: {
103
+ path: /layout\.(tsx|jsx)$/,
104
+ },
105
+ };
106
+
107
+ const mountGlob = "{*,designtools-mount}";
108
+ let mountGlobRule = (nextConfig as any).turbopack?.rules?.[mountGlob] ?? [];
109
+ mountGlobRule = [
110
+ ...(Array.isArray(mountGlobRule) ? mountGlobRule : [mountGlobRule]),
111
+ mountRule,
112
+ ];
113
+
114
+ result.turbopack = {
115
+ ...(nextConfig as any).turbopack,
116
+ rules: {
117
+ ...(nextConfig as any).turbopack?.rules,
118
+ [wildcardGlob]: wildcardRule,
119
+ [mountGlob]: mountGlobRule,
120
+ },
121
+ };
122
+ }
123
+
124
+ return result as T;
65
125
  }