@growgroup/visual-editor 0.1.6 → 0.2.0

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.
Files changed (42) hide show
  1. package/README.md +59 -2
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -3
  4. package/src/components/ui/alert-dialog.tsx +18 -4
  5. package/src/components/ui/context-menu.tsx +18 -4
  6. package/src/components/ui/dialog.tsx +19 -5
  7. package/src/components/ui/dropdown-menu.tsx +18 -4
  8. package/src/components/ui/popover.tsx +10 -2
  9. package/src/components/ui/select.tsx +10 -2
  10. package/src/components/ui/tooltip.tsx +10 -2
  11. package/src/editor/EditorToolbar.tsx +10 -20
  12. package/src/editor/FrontendVisualEditor.tsx +157 -43
  13. package/src/editor/components/EditorCanvas.tsx +1 -1
  14. package/src/editor/components/EditorContextMenu.tsx +53 -71
  15. package/src/editor/components/EditorFooter.tsx +36 -70
  16. package/src/editor/components/EditorHeader.tsx +37 -20
  17. package/src/editor/components/EditorLayerPanel.tsx +7 -9
  18. package/src/editor/components/EditorPropertyPanel.tsx +84 -8
  19. package/src/editor/components/LayerTreeItem.tsx +28 -5
  20. package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
  21. package/src/editor/components/ppt/PptChrome.tsx +156 -344
  22. package/src/editor/components/ppt/PptComments.tsx +135 -87
  23. package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
  24. package/src/editor/components/ppt/PptNotes.tsx +15 -15
  25. package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
  26. package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
  27. package/src/editor/components/shell/EditorTopBar.tsx +29 -68
  28. package/src/editor/components/shell/LeftPanel.tsx +20 -94
  29. package/src/editor/components/shell/PagesPanel.tsx +4 -2
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
  31. package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
  32. package/src/editor/editor-skin.css +2 -122
  33. package/src/editor/hooks/useComponentEditMode.ts +95 -1
  34. package/src/editor/hooks/useIframeSetup.ts +10 -0
  35. package/src/editor/hooks/useMediaLibrary.ts +4 -5
  36. package/src/editor/hooks/useResizablePanel.ts +40 -11
  37. package/src/editor/parts.ts +321 -0
  38. package/src/editor/utils/dom-utils.ts +5 -4
  39. package/src/index.ts +25 -0
  40. package/src/io.ts +54 -0
  41. package/src/styles/skin.css +159 -0
  42. package/src/types/editor-components.ts +23 -1
@@ -43,6 +43,76 @@ import {
43
43
  deleteComponentInstance,
44
44
  getPageComponentInstances,
45
45
  } from '../../lib/firebase/editor-components';
46
+ import { io } from '../../io';
47
+ import type { EditorPartDef, EditorPartsLibrary } from '../../io';
48
+ import {
49
+ materializePart,
50
+ partDefFromElement,
51
+ partInfoOf,
52
+ stampAsInstance,
53
+ stripEditorAttrs,
54
+ toPartId,
55
+ } from '../parts';
56
+
57
+ // ============================================================
58
+ // Parts (HTML template) helpers
59
+ // ============================================================
60
+
61
+ /** 分類の無い部品が入る既定のカテゴリ */
62
+ const DEFAULT_PART_CATEGORY = 'parts';
63
+
64
+ /**
65
+ * 部品の定義(HTML)を、既存のパネル・ドラッグ経路が読める MasterComponent に写す。
66
+ * id は部品 id をそのまま使う(ドロップ時の componentId がそのまま部品 id になる)。
67
+ */
68
+ function masterFromPartDef(part: EditorPartDef, parser: DOMParser, websiteId: string): MasterComponent | null {
69
+ const doc = parser.parseFromString(`<!doctype html><body>${part.html}</body>`, 'text/html');
70
+ const root = doc.body.firstElementChild as HTMLElement | null;
71
+ if (!root) return null;
72
+ const rootElement = htmlElementToComponentElement(root);
73
+ const now = new Date().toISOString();
74
+ const variant: ComponentVariant = { id: 'default', name: 'Default', rootElement, isDefault: true };
75
+ return {
76
+ id: part.id,
77
+ name: part.name || part.id,
78
+ description: part.description,
79
+ categoryId: part.category || DEFAULT_PART_CATEGORY,
80
+ tags: [],
81
+ variants: [variant],
82
+ defaultVariantId: 'default',
83
+ exposedProperties: [],
84
+ createdAt: now,
85
+ updatedAt: now,
86
+ websiteId,
87
+ version: part.version,
88
+ };
89
+ }
90
+
91
+ /** 部品の分類をパネルのカテゴリにする。宣言された順 → 宣言に無い分類の順 */
92
+ function categoriesFromParts(
93
+ lib: EditorPartsLibrary,
94
+ masters: Map<string, MasterComponent>,
95
+ ): ComponentLibraryCategory[] {
96
+ const ids = new Map<string, string[]>();
97
+ for (const m of masters.values()) {
98
+ if (!ids.has(m.categoryId)) ids.set(m.categoryId, []);
99
+ ids.get(m.categoryId)!.push(m.id);
100
+ }
101
+ const cats: ComponentLibraryCategory[] = (lib.categories ?? []).map((c, i) => ({
102
+ id: c.id,
103
+ name: c.name,
104
+ description: c.description,
105
+ componentIds: ids.get(c.id) ?? [],
106
+ order: i + 1,
107
+ }));
108
+ const known = new Set(cats.map((c) => c.id));
109
+ let order = cats.length;
110
+ for (const [id, componentIds] of ids) {
111
+ if (known.has(id)) continue;
112
+ cats.push({ id, name: id === DEFAULT_PART_CATEGORY ? '部品' : id, componentIds, order: ++order });
113
+ }
114
+ return cats;
115
+ }
46
116
 
47
117
  // ============================================================
48
118
  // Context Creation
@@ -424,6 +494,10 @@ export function EditorComponentsProvider({
424
494
  const [error, setError] = useState<string | null>(null);
425
495
  const [pendingNavigationTarget, setPendingNavigationTarget] = useState<string | null>(null);
426
496
 
497
+ // 部品(HTML template)モード。io.loadParts が渡されていれば、JSON コンポーネントではなく部品を使う
498
+ const partsMode = typeof io().loadParts === 'function';
499
+ const [partDefs, setPartDefs] = useState<Map<string, EditorPartDef>>(new Map());
500
+
427
501
  // ============================================================
428
502
  // Navigation
429
503
  // ============================================================
@@ -508,6 +582,30 @@ export function EditorComponentsProvider({
508
582
  `[EditorComponentsContext] Loading components for website: ${targetWebsiteId}`
509
583
  );
510
584
 
585
+ // 部品モード: 利用側の loadParts から HTML の定義を読む(localStorage は見ない)
586
+ const loadParts = io().loadParts;
587
+ if (loadParts) {
588
+ const lib = await loadParts();
589
+ const parser = new DOMParser();
590
+ const defs = new Map<string, EditorPartDef>();
591
+ const componentsMap = new Map<string, MasterComponent>();
592
+ for (const part of lib.parts) {
593
+ const master = masterFromPartDef(part, parser, targetWebsiteId);
594
+ if (!master) {
595
+ console.warn(`[EditorComponentsContext] 部品 ${part.id} の HTML にルート要素がありません`);
596
+ continue;
597
+ }
598
+ defs.set(part.id, part);
599
+ componentsMap.set(master.id, master);
600
+ }
601
+ setPartDefs(defs);
602
+ setMasterComponents(componentsMap);
603
+ setComponentLibrary(categoriesFromParts(lib, componentsMap));
604
+ setComponentInstances(new Map());
605
+ console.log(`[EditorComponentsContext] Loaded ${defs.size} parts`);
606
+ return;
607
+ }
608
+
511
609
  // Load master components from Firestore
512
610
  const components = await getAllMasterComponents(targetWebsiteId);
513
611
  console.log(`[EditorComponentsContext] Loaded ${components.length} components`);
@@ -550,22 +648,25 @@ export function EditorComponentsProvider({
550
648
  // Initialize on mount or when websiteId changes
551
649
  const loadedWebsiteIdRef = useRef<string | null>(null);
552
650
  useEffect(() => {
651
+ // 部品モードは websiteId が無くても読む(部品の置き場は利用側が知っている)
652
+ const key = websiteId || (partsMode ? '__parts__' : '');
553
653
  // Only load if websiteId exists and is different from the last loaded one
554
- if (websiteId && websiteId !== loadedWebsiteIdRef.current) {
555
- loadedWebsiteIdRef.current = websiteId;
556
- loadComponents(websiteId);
557
- } else if (!websiteId) {
654
+ if (key && key !== loadedWebsiteIdRef.current) {
655
+ loadedWebsiteIdRef.current = key;
656
+ loadComponents(key);
657
+ } else if (!key) {
558
658
  // Reset to default categories if no websiteId
559
659
  setComponentLibrary(defaultCategories);
560
660
  setMasterComponents(new Map());
561
661
  loadedWebsiteIdRef.current = null;
562
662
  }
563
- }, [websiteId, loadComponents, defaultCategories]);
663
+ }, [websiteId, partsMode, loadComponents, defaultCategories]);
564
664
 
565
665
  // Load instances when pageId changes
566
666
  const loadedPageIdRef = useRef<string | null>(null);
567
667
  useEffect(() => {
568
- if (!websiteId || !pageId) {
668
+ // 部品モードではインスタンスを別に持たない(ページの HTML そのものがインスタンス)
669
+ if (partsMode || !websiteId || !pageId) {
569
670
  setComponentInstances(new Map());
570
671
  loadedPageIdRef.current = null;
571
672
  return;
@@ -597,7 +698,7 @@ export function EditorComponentsProvider({
597
698
  };
598
699
 
599
700
  loadInstances();
600
- }, [websiteId, pageId]);
701
+ }, [websiteId, pageId, partsMode]);
601
702
 
602
703
  // ============================================================
603
704
  // Master Component Operations
@@ -650,7 +751,7 @@ export function EditorComponentsProvider({
650
751
  );
651
752
 
652
753
  // Save to Firestore asynchronously
653
- if (websiteId) {
754
+ if (websiteId && !partsMode) {
654
755
  saveToFirestore(websiteId, masterComponent).catch((err) => {
655
756
  console.error('[EditorComponentsContext] Failed to save component to Firestore:', err);
656
757
  setError('Failed to save component');
@@ -662,7 +763,116 @@ export function EditorComponentsProvider({
662
763
  );
663
764
  return masterComponent;
664
765
  },
665
- [websiteId]
766
+ [websiteId, partsMode]
767
+ );
768
+
769
+ // ============================================================
770
+ // Parts (HTML template) Operations
771
+ // ============================================================
772
+
773
+ const getPartDef = useCallback(
774
+ (id: string): EditorPartDef | null => partDefs.get(id) ?? null,
775
+ [partDefs]
776
+ );
777
+
778
+ const materializePartInstance = useCallback(
779
+ (id: string, doc: Document): HTMLElement | null => {
780
+ const def = partDefs.get(id);
781
+ return def ? materializePart(def, doc) : null;
782
+ },
783
+ [partDefs]
784
+ );
785
+
786
+ /** 定義を状態に入れる(新規・更新の両方)。パネルの分類も揃える */
787
+ const upsertPartDef = useCallback((def: EditorPartDef) => {
788
+ const master = masterFromPartDef(def, new DOMParser(), websiteId || '');
789
+ if (!master) return;
790
+ setPartDefs((prev) => {
791
+ const next = new Map(prev);
792
+ next.set(def.id, def);
793
+ return next;
794
+ });
795
+ setMasterComponents((prev) => {
796
+ const next = new Map(prev);
797
+ next.set(master.id, master);
798
+ return next;
799
+ });
800
+ setComponentLibrary((prev) => {
801
+ const categoryId = master.categoryId;
802
+ const cleared = prev.map((cat) => ({
803
+ ...cat,
804
+ componentIds: cat.componentIds.filter((cid) => cid !== master.id),
805
+ }));
806
+ const found = cleared.find((cat) => cat.id === categoryId);
807
+ if (found) {
808
+ return cleared.map((cat) =>
809
+ cat.id === categoryId ? { ...cat, componentIds: [...cat.componentIds, master.id] } : cat
810
+ );
811
+ }
812
+ return [
813
+ ...cleared,
814
+ {
815
+ id: categoryId,
816
+ name: categoryId === DEFAULT_PART_CATEGORY ? '部品' : categoryId,
817
+ componentIds: [master.id],
818
+ order: cleared.length + 1,
819
+ },
820
+ ];
821
+ });
822
+ }, [websiteId]);
823
+
824
+ const savePartFromElement = useCallback(
825
+ async (
826
+ el: HTMLElement,
827
+ meta: { name: string; category?: string; description?: string }
828
+ ): Promise<EditorPartDef> => {
829
+ const save = io().savePart;
830
+ let id = toPartId(meta.name);
831
+ // 同名の部品があれば番号を足す(既存の定義を黙って上書きしない)
832
+ if (partDefs.has(id)) {
833
+ let n = 2;
834
+ while (partDefs.has(`${id}-${n}`)) n++;
835
+ id = `${id}-${n}`;
836
+ }
837
+ const def = partDefFromElement(
838
+ el,
839
+ {
840
+ id,
841
+ name: meta.name,
842
+ category: meta.category || DEFAULT_PART_CATEGORY,
843
+ description: meta.description,
844
+ version: 1,
845
+ },
846
+ { inferSlots: true },
847
+ );
848
+ const saved = (save ? await save(def) : undefined) ?? def;
849
+ upsertPartDef(saved);
850
+ stampAsInstance(el, saved, el.ownerDocument);
851
+ return saved;
852
+ },
853
+ [partDefs, upsertPartDef]
854
+ );
855
+
856
+ const updatePartFromElement = useCallback(
857
+ async (el: HTMLElement): Promise<EditorPartDef | null> => {
858
+ const info = partInfoOf(el);
859
+ if (!info) return null;
860
+ const current = partDefs.get(info.id);
861
+ if (!current) return null;
862
+ const next = partDefFromElement(el, {
863
+ id: current.id,
864
+ name: current.name,
865
+ category: current.category,
866
+ description: current.description,
867
+ version: current.version + 1,
868
+ });
869
+ const save = io().savePart;
870
+ const saved = (save ? await save(next) : undefined) ?? next;
871
+ upsertPartDef(saved);
872
+ el.setAttribute('data-part-v', String(saved.version));
873
+ return saved;
874
+ },
875
+ [partDefs, upsertPartDef]
666
876
  );
667
877
 
668
878
  const updateMasterComponent = useCallback(
@@ -687,7 +897,7 @@ export function EditorComponentsProvider({
687
897
  next.set(id, updatedComponent);
688
898
 
689
899
  // Save to Firestore asynchronously
690
- if (websiteId) {
900
+ if (websiteId && !partsMode) {
691
901
  updateMasterComponentInFirestore(websiteId, id, updates).catch((err) => {
692
902
  console.error('[EditorComponentsContext] Failed to update component in Firestore:', err);
693
903
  setError('Failed to save component changes');
@@ -696,8 +906,38 @@ export function EditorComponentsProvider({
696
906
 
697
907
  return next;
698
908
  });
909
+
910
+ // 部品モード: 名前・説明・分類・(エディタで直した)構造を定義に写して利用側へ保存する
911
+ if (partsMode) {
912
+ const current = partDefs.get(id);
913
+ if (current) {
914
+ let html = current.html;
915
+ const rootElement = updates.variants?.[0]?.rootElement;
916
+ if (rootElement) {
917
+ const rendered = componentElementToHtml(rootElement, document);
918
+ stripEditorAttrs(rendered);
919
+ html = rendered.outerHTML;
920
+ }
921
+ const next: EditorPartDef = {
922
+ ...current,
923
+ name: updates.name ?? current.name,
924
+ description: updates.description ?? current.description,
925
+ category: updates.categoryId ?? current.category,
926
+ html,
927
+ };
928
+ setPartDefs((prev) => {
929
+ const m = new Map(prev);
930
+ m.set(id, next);
931
+ return m;
932
+ });
933
+ io().savePart?.(next).catch((err) => {
934
+ console.error('[EditorComponentsContext] 部品の保存に失敗:', err);
935
+ setError('部品の保存に失敗しました');
936
+ });
937
+ }
938
+ }
699
939
  },
700
- [websiteId]
940
+ [websiteId, partsMode, partDefs]
701
941
  );
702
942
 
703
943
  const deleteMasterComponent = useCallback((id: string) => {
@@ -727,14 +967,24 @@ export function EditorComponentsProvider({
727
967
  });
728
968
 
729
969
  // Delete from Firestore asynchronously
730
- if (websiteId) {
970
+ if (websiteId && !partsMode) {
731
971
  deleteFromFirestore(websiteId, id).catch((err) => {
732
972
  console.error('[EditorComponentsContext] Failed to delete component from Firestore:', err);
733
973
  });
734
974
  }
975
+ if (partsMode) {
976
+ setPartDefs((prev) => {
977
+ const next = new Map(prev);
978
+ next.delete(id);
979
+ return next;
980
+ });
981
+ io().deletePart?.(id).catch((err) => {
982
+ console.error('[EditorComponentsContext] 部品の削除に失敗:', err);
983
+ });
984
+ }
735
985
 
736
986
  console.log(`[EditorComponentsContext] Deleted master component: ${id}`);
737
- }, [websiteId]);
987
+ }, [websiteId, partsMode]);
738
988
 
739
989
  const getMasterComponent = useCallback(
740
990
  (id: string): MasterComponent | null => {
@@ -798,8 +1048,8 @@ export function EditorComponentsProvider({
798
1048
  return next;
799
1049
  });
800
1050
 
801
- // Save to Firestore asynchronously
802
- if (websiteId && effectivePageId) {
1051
+ // Save to Firestore asynchronously(部品モードではインスタンスを別に持たない)
1052
+ if (websiteId && effectivePageId && !partsMode) {
803
1053
  saveComponentInstance(websiteId, effectivePageId, instance).catch((err) => {
804
1054
  console.error('[EditorComponentsContext] Failed to save instance to Firestore:', err);
805
1055
  setError('Failed to save component instance');
@@ -811,7 +1061,7 @@ export function EditorComponentsProvider({
811
1061
  );
812
1062
  return instance;
813
1063
  },
814
- [masterComponents, websiteId, pageId]
1064
+ [masterComponents, websiteId, pageId, partsMode]
815
1065
  );
816
1066
 
817
1067
  const updateInstance = useCallback(
@@ -1280,6 +1530,13 @@ export function EditorComponentsProvider({
1280
1530
  resolveInstance,
1281
1531
  navigateToMasterComponent,
1282
1532
  clearNavigationRequest,
1533
+
1534
+ // Parts
1535
+ partsMode,
1536
+ getPartDef,
1537
+ materializePartInstance,
1538
+ savePartFromElement,
1539
+ updatePartFromElement,
1283
1540
  }),
1284
1541
  [
1285
1542
  masterComponents,
@@ -1307,6 +1564,11 @@ export function EditorComponentsProvider({
1307
1564
  resolveInstance,
1308
1565
  navigateToMasterComponent,
1309
1566
  clearNavigationRequest,
1567
+ partsMode,
1568
+ getPartDef,
1569
+ materializePartInstance,
1570
+ savePartFromElement,
1571
+ updatePartFromElement,
1310
1572
  ]
1311
1573
  );
1312
1574
 
@@ -1,122 +1,2 @@
1
- /**
2
- * [移植時の追加] エディタの配色スキン。配色の正本はこの1ファイル。
3
- *
4
- * 移植元のエディタはVS Code風のグレーが47ファイルに直書きされている。
5
- * 全ファイルを書き換えるとマージが破綻するので、エディタのルートに `.gg-editor-skin` を付け、
6
- * その配下でだけ元のクラスが指す色を差し替える。
7
- *
8
- * 意匠はFigmaデスクトップ(ダーク)の実測値:
9
- * キャンバス=#1e1e1e / パネル=#2c2c2c / 入力=#383838 / 罫線=white 10% / アクセント=#0d99ff
10
- */
11
-
12
- .gg-editor-skin {
13
- --ed-bg: #1e1e1e; /* キャンバスの地(Figmaのカンバス) */
14
- --ed-panel: #2c2c2c; /* パネル・ツールバーの面 */
15
- --ed-panel-raised: #2c2c2c; /* Figmaはパネルとカードが同面 */
16
- --ed-surface: #383838; /* 入力・ボタンの面 */
17
- --ed-surface-hover: #444444;
18
- --ed-line: rgba(255, 255, 255, 0.1);
19
- --ed-line-strong: rgba(255, 255, 255, 0.16);
20
- --ed-accent: #0d99ff; /* Figmaブルー */
21
- --ed-accent-strong: #0d99ff;
22
- --ed-accent-hover: #0c8ce9;
23
- }
24
-
25
- /* ── 地・面 ───────────────────────────────────────── */
26
- .gg-editor-skin .bg-\[\#1e1e1e\] {
27
- background-color: var(--ed-bg);
28
- }
29
- .gg-editor-skin .bg-\[\#2c2c2c\],
30
- .gg-editor-skin .bg-\[\#2a2a2a\] {
31
- background-color: var(--ed-panel);
32
- }
33
- .gg-editor-skin .bg-\[\#383838\] {
34
- background-color: var(--ed-surface);
35
- }
36
- .gg-editor-skin .bg-\[\#444444\],
37
- .gg-editor-skin .bg-\[\#4a4a4a\],
38
- .gg-editor-skin .bg-\[\#5d5d5d\] {
39
- background-color: var(--ed-surface-hover);
40
- }
41
- .gg-editor-skin .hover\:bg-\[\#444444\]:hover,
42
- .gg-editor-skin .hover\:bg-\[\#383838\]:hover {
43
- background-color: rgba(255, 255, 255, 0.08);
44
- }
45
- .gg-editor-skin .hover\:bg-\[\#4a4a4a\]:hover {
46
- background-color: var(--ed-surface-hover);
47
- }
48
-
49
- /* ── 罫線 ─────────────────────────────────────────── */
50
- .gg-editor-skin .border-\[\#444444\],
51
- .gg-editor-skin .border-\[\#383838\],
52
- .gg-editor-skin .border-\[\#2c2c2c\] {
53
- border-color: var(--ed-line);
54
- }
55
- .gg-editor-skin .border-\[\#4a4a4a\],
56
- .gg-editor-skin .border-\[\#5d5d5d\] {
57
- border-color: var(--ed-line-strong);
58
- }
59
- .gg-editor-skin .hover\:border-\[\#4a4a4a\]:hover,
60
- .gg-editor-skin .hover\:border-\[\#5d5d5d\]:hover {
61
- border-color: rgba(13, 153, 255, 0.6);
62
- }
63
- /* 区切り線として使われている塗り */
64
- .gg-editor-skin .w-px.bg-\[\#444444\],
65
- .gg-editor-skin .h-px.bg-\[\#444444\] {
66
- background-color: var(--ed-line);
67
- }
68
-
69
- /* ── アクセント(選択・保存・共有ボタン等) ─────────── */
70
- .gg-editor-skin .bg-\[\#0d99ff\],
71
- .gg-editor-skin .bg-blue-500,
72
- .gg-editor-skin .bg-blue-600 {
73
- background-color: var(--ed-accent-strong);
74
- }
75
- .gg-editor-skin .hover\:bg-\[\#0c8ce9\]:hover,
76
- .gg-editor-skin .hover\:bg-blue-600:hover,
77
- .gg-editor-skin .hover\:bg-blue-700:hover {
78
- background-color: var(--ed-accent-hover);
79
- }
80
- .gg-editor-skin .text-\[\#0d99ff\],
81
- .gg-editor-skin .text-\[\#4fb8ff\],
82
- .gg-editor-skin .text-blue-400,
83
- .gg-editor-skin .text-blue-500 {
84
- color: var(--ed-accent);
85
- }
86
- .gg-editor-skin .border-\[\#0d99ff\],
87
- .gg-editor-skin .border-blue-500 {
88
- border-color: var(--ed-accent);
89
- }
90
- .gg-editor-skin .ring-\[\#0d99ff\],
91
- .gg-editor-skin .ring-blue-500 {
92
- --tw-ring-color: var(--ed-accent);
93
- }
94
-
95
- /* ── 文字色(Figmaの白/70%/45%) ────────────────────── */
96
- .gg-editor-skin .text-gray-300 {
97
- color: rgba(255, 255, 255, 0.85);
98
- }
99
- .gg-editor-skin .text-gray-400 {
100
- color: rgba(255, 255, 255, 0.7);
101
- }
102
- .gg-editor-skin .text-gray-500 {
103
- color: rgba(255, 255, 255, 0.45);
104
- }
105
-
106
- /* ── 角丸・影(Figmaのメニュー影) ─────────────────── */
107
- .gg-editor-skin .rounded {
108
- border-radius: 0.375rem;
109
- }
110
- .gg-editor-skin .shadow-lg,
111
- .gg-editor-skin .shadow-xl,
112
- .gg-editor-skin .shadow-2xl {
113
- box-shadow: 0 5px 17px rgba(0, 0, 0, 0.35), 0 2px 7px rgba(0, 0, 0, 0.15);
114
- }
115
-
116
- /* ── フォーカスリング(Figmaブルー) ────────────────── */
117
- .gg-editor-skin input:focus-visible,
118
- .gg-editor-skin button:focus-visible,
119
- .gg-editor-skin select:focus-visible {
120
- outline: 2px solid var(--ed-accent);
121
- outline-offset: 1px;
122
- }
1
+ /* 配色の正本は src/styles/skin.css。dist/editor.css にビルドして同梱する。 */
2
+ @import "../styles/skin.css";
@@ -4,6 +4,7 @@ import { htmlElementToComponentElement } from '../contexts/EditorComponentsConte
4
4
  import { renderComponentElement } from '../utils/component-renderer';
5
5
  import { propagateMasterChanges } from '../utils/component-sync';
6
6
  import { buildDomTree } from '../utils/dom-utils';
7
+ import { applyLockInside, detachPart, partInfoOf, unlockPartDescendants } from '../parts';
7
8
  import { toast } from 'sonner';
8
9
  import type { MasterComponent, ComponentInstance } from '../../types/editor-components';
9
10
 
@@ -37,6 +38,10 @@ interface UseComponentEditModeReturn {
37
38
  selectedElementInstance: ComponentInstance | null;
38
39
  isComponentInstance: boolean;
39
40
  hasOverrides: boolean;
41
+ /** 選択要素が部品(data-part)のインスタンスか */
42
+ isPartInstance: boolean;
43
+ /** 「名前 vN」。インスタンスでなければ空 */
44
+ partLabel: string;
40
45
  // Handlers
41
46
  handleCreateComponent: () => void;
42
47
  handleConfirmCreateComponent: () => Promise<void>;
@@ -49,6 +54,10 @@ interface UseComponentEditModeReturn {
49
54
  handleDetachInstance: () => void;
50
55
  handleResetOverrides: () => Promise<void>;
51
56
  handlePushOverridesToMain: () => void;
57
+ /** 部品から切り離す(出自の記録を外して普通の HTML にする) */
58
+ handleDetachPart: () => void;
59
+ /** インスタンスの今の姿で定義を更新する(版 +1) */
60
+ handleUpdatePart: () => Promise<void>;
52
61
  }
53
62
 
54
63
  /**
@@ -86,6 +95,10 @@ export function useComponentEditMode({
86
95
  deleteMasterComponent,
87
96
  pendingNavigationTarget,
88
97
  clearNavigationRequest,
98
+ partsMode,
99
+ getPartDef,
100
+ savePartFromElement,
101
+ updatePartFromElement,
89
102
  } = useEditorComponents();
90
103
 
91
104
  // コンポーネントパネル状態
@@ -128,6 +141,20 @@ export function useComponentEditMode({
128
141
  return selectedElementInstance.overrides.length > 0;
129
142
  }, [selectedElementInstance]);
130
143
 
144
+ // 部品(data-part)のインスタンスか。状態は持たず、その時点の DOM の属性で決める
145
+ const selectedPart = useMemo(() => {
146
+ if (!selectedElement?.id) return null;
147
+ const doc = getIframeDoc();
148
+ const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`);
149
+ return partInfoOf(el);
150
+ }, [selectedElement, getIframeDoc]);
151
+ const isPartInstance = !!selectedPart;
152
+ const partLabel = useMemo(() => {
153
+ if (!selectedPart) return '';
154
+ const def = getPartDef(selectedPart.id);
155
+ return `${def?.name || selectedPart.id} v${selectedPart.version}`;
156
+ }, [selectedPart, getPartDef]);
157
+
131
158
  // Create component from selected element
132
159
  const handleCreateComponent = useCallback(() => {
133
160
  if (!selectedElement?.id || !iframeRef.current) return;
@@ -149,6 +176,29 @@ export function useComponentEditMode({
149
176
  const element = iframeDoc.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement;
150
177
  if (!element) return;
151
178
 
179
+ // 部品モード: 選択要素を HTML の定義として利用側へ保存し、その場でインスタンスにする
180
+ if (partsMode) {
181
+ try {
182
+ const def = await savePartFromElement(element, {
183
+ name: newComponentName.trim(),
184
+ category: newComponentCategory || undefined,
185
+ });
186
+ applyLockInside(element);
187
+ notifyIframeChange(true);
188
+ setCreateComponentDialogOpen(false);
189
+ setNewComponentName('');
190
+ setNewComponentCategory('');
191
+ closeContextMenu();
192
+ toast.success(`部品「${def.name ?? def.id}」として保存しました`, {
193
+ description: `parts/${def.id}.html。スロット(data-slot)の外は編集できなくなります(切り離すと戻ります)`,
194
+ });
195
+ } catch (error) {
196
+ console.error('Failed to save part:', error);
197
+ toast.error('部品の保存に失敗しました');
198
+ }
199
+ return;
200
+ }
201
+
152
202
  try {
153
203
  // Debug: Log the original element
154
204
  console.log('[handleConfirmCreateComponent] Original element:', {
@@ -243,7 +293,7 @@ export function useComponentEditMode({
243
293
  console.error('Failed to create component:', error);
244
294
  toast.error('コンポーネントの作成に失敗しました');
245
295
  }
246
- }, [selectedElement?.id, iframeRef, newComponentName, newComponentCategory, createMasterComponent, createInstance, contentId, notifyIframeChange, closeContextMenu, setSelectedElement, setSelectedElementIds]);
296
+ }, [selectedElement?.id, iframeRef, newComponentName, newComponentCategory, createMasterComponent, createInstance, contentId, notifyIframeChange, closeContextMenu, setSelectedElement, setSelectedElementIds, partsMode, savePartFromElement]);
247
297
 
248
298
  // Open master component editor
249
299
  const handleEditMasterComponent = useCallback((masterId: string) => {
@@ -617,6 +667,46 @@ export function useComponentEditMode({
617
667
  toast.info('この機能は現在開発中です');
618
668
  }, [selectedElementInstance, closeContextMenu]);
619
669
 
670
+ // 部品から切り離す: 出自の記録(data-part)だけ外し、ロックされていた子孫を編集できるようにする
671
+ const handleDetachPart = useCallback(() => {
672
+ if (!selectedElement?.id) return;
673
+ const doc = getIframeDoc();
674
+ const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
675
+ if (!doc || !el || !partInfoOf(el)) return;
676
+ detachPart(el);
677
+ unlockPartDescendants(el);
678
+ notifyIframeChange(true);
679
+ // 部品バッジを消すため、選択情報を作り直す
680
+ void import('../utils/style-utils').then(({ extractElementInfo }) => {
681
+ const info = extractElementInfo(el, doc);
682
+ if (info) setSelectedElement(info);
683
+ });
684
+ closeContextMenu();
685
+ toast.success('部品から切り離しました', {
686
+ description: 'この要素は普通の HTML になり、parts:sync の対象から外れます',
687
+ });
688
+ }, [selectedElement?.id, getIframeDoc, notifyIframeChange, setSelectedElement, closeContextMenu]);
689
+
690
+ // インスタンスの今の姿で定義を更新する(版 +1)。他ページへは利用側の parts:sync で反映する
691
+ const handleUpdatePart = useCallback(async () => {
692
+ if (!selectedElement?.id) return;
693
+ const doc = getIframeDoc();
694
+ const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
695
+ if (!el) return;
696
+ try {
697
+ const def = await updatePartFromElement(el);
698
+ if (!def) return;
699
+ notifyIframeChange(true);
700
+ closeContextMenu();
701
+ toast.success(`部品「${def.name ?? def.id}」を v${def.version} に更新しました`, {
702
+ description: '他のページのインスタンスへは parts:sync で反映します',
703
+ });
704
+ } catch (error) {
705
+ console.error('Failed to update part:', error);
706
+ toast.error('部品の更新に失敗しました');
707
+ }
708
+ }, [selectedElement?.id, getIframeDoc, updatePartFromElement, notifyIframeChange, closeContextMenu]);
709
+
620
710
  return {
621
711
  // Panel state
622
712
  isComponentPanelOpen,
@@ -642,6 +732,8 @@ export function useComponentEditMode({
642
732
  selectedElementInstance,
643
733
  isComponentInstance,
644
734
  hasOverrides,
735
+ isPartInstance,
736
+ partLabel,
645
737
  // Handlers
646
738
  handleCreateComponent,
647
739
  handleConfirmCreateComponent,
@@ -654,5 +746,7 @@ export function useComponentEditMode({
654
746
  handleDetachInstance,
655
747
  handleResetOverrides,
656
748
  handlePushOverridesToMain,
749
+ handleDetachPart,
750
+ handleUpdatePart,
657
751
  };
658
752
  }