@growgroup/visual-editor 0.2.2 → 0.3.1

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 (43) hide show
  1. package/README.md +41 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +15 -3
  5. package/src/editor/FrontendVisualEditor.tsx +168 -55
  6. package/src/editor/autosave.ts +32 -0
  7. package/src/editor/components/EditorCanvas.tsx +107 -8
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorHeader.tsx +47 -12
  10. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  11. package/src/editor/components/EditorPropertyPanel.tsx +56 -25
  12. package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
  13. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
  14. package/src/editor/components/multi-page/PageFramePreview.tsx +197 -0
  15. package/src/editor/components/multi-page/index.ts +2 -6
  16. package/src/editor/components/ppt/PptComments.tsx +8 -4
  17. package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
  18. package/src/editor/components/shell/PagesPanel.tsx +8 -0
  19. package/src/editor/constants.ts +28 -0
  20. package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
  21. package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
  22. package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
  23. package/src/editor/hooks/useCanvasControls.ts +1 -1
  24. package/src/editor/hooks/useCanvasMarquee.ts +196 -0
  25. package/src/editor/hooks/useContextMenuHandler.ts +5 -14
  26. package/src/editor/hooks/useCoordinateTransform.ts +11 -8
  27. package/src/editor/hooks/useDrawingMode.ts +1 -0
  28. package/src/editor/hooks/useGoogleFonts.ts +6 -2
  29. package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
  30. package/src/editor/hooks/useMarqueeSelection.ts +2 -2
  31. package/src/editor/utils/dom-utils.ts +23 -1
  32. package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
  33. package/src/editor/utils/html-utils.ts +102 -14
  34. package/src/index.ts +5 -0
  35. package/src/io.ts +20 -0
  36. package/src/styles/skin.css +122 -36
  37. package/src/version.ts +9 -0
  38. package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
  39. package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
  40. package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
  41. package/src/editor/components/multi-page/PageLabel.tsx +0 -34
  42. package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
  43. package/src/editor/components/multi-page/PageThumbnail.tsx +0 -59
@@ -5,6 +5,7 @@
5
5
  * 右クリックで要素操作メニューを表示
6
6
  */
7
7
 
8
+ import { createPortal } from 'react-dom';
8
9
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../components/ui/dropdown-menu';
9
10
  import { useEditorContext } from '../EditorContext';
10
11
  import {
@@ -201,11 +202,19 @@ export function EditorContextMenu({
201
202
  }
202
203
  };
203
204
 
205
+ // 見えない起点(右クリックの位置)。position は画面(viewport)の座標なので、document.body に出して
206
+ // position: fixed の基準を viewport にする。エディタの木の中に置くと、利用側がエディタを transform の付いた
207
+ // 器に入れているとき(構成ラフの殻の .wf-editor-main は translateZ(0))、fixed の基準がその器になり、
208
+ // メニューが右クリックの位置から器の左端ぶん(左パネルの幅 248px)ずれていた(実測)
209
+ const anchor = (
210
+ <DropdownMenuTrigger asChild>
211
+ <button tabIndex={-1} aria-label="要素の操作" style={{ position: 'fixed', left: position.x, top: position.y, width: 1, height: 1, opacity: 0, pointerEvents: 'none' }} />
212
+ </DropdownMenuTrigger>
213
+ );
214
+
204
215
  return (
205
216
  <DropdownMenu open onOpenChange={(open) => { if (!open) onClose(); }}>
206
- <DropdownMenuTrigger asChild>
207
- <button tabIndex={-1} aria-label="要素の操作" style={{ position: 'fixed', left: position.x, top: position.y, width: 1, height: 1, opacity: 0, pointerEvents: 'none' }} />
208
- </DropdownMenuTrigger>
217
+ {typeof document === 'undefined' ? anchor : createPortal(anchor, document.body)}
209
218
  <DropdownMenuContent align="start" sideOffset={0} collisionPadding={8} className="w-60"
210
219
  onCloseAutoFocus={(e) => { e.preventDefault(); restoreFocus(); }}>
211
220
  {/* クリップボード操作 */}
@@ -28,6 +28,7 @@ import { useEditorContext } from "../EditorContext";
28
28
  import { ConfirmDialog } from "./shell/ConfirmDialog";
29
29
  import { EditorTopBar } from "./shell/EditorTopBar";
30
30
  import { MIN_ZOOM, MAX_ZOOM, editorZoomApiRef } from "../hooks/useCanvasControls";
31
+ import { useMultiPageCanvasOptional, useCanvasViewStateOptional, MIN_ZOOM as CANVAS_MIN_ZOOM, MAX_ZOOM as CANVAS_MAX_ZOOM } from "../contexts/MultiPageCanvasContext";
31
32
  import { generateEditableHtml } from "../utils/html-utils";
32
33
  import { useDeck } from "../../components/viewer/useDeck";
33
34
  import { unresolvedCount } from "./ppt/PptComments";
@@ -99,8 +100,8 @@ export function EditorHeader({
99
100
  const {
100
101
  iframeRef,
101
102
  originalHtml,
102
- zoom,
103
- setZoom,
103
+ zoom: singleZoom,
104
+ setZoom: setSingleZoom,
104
105
  fitZoom,
105
106
  setSelectedElement,
106
107
  setSelectedElementIds,
@@ -118,6 +119,17 @@ export function EditorHeader({
118
119
 
119
120
  const [resetOpen, setResetOpen] = useState(false);
120
121
 
122
+ // マルチフレームのキャンバスでは倍率はキャンバス全体のもの。表示も操作もそちらへ向ける
123
+ const canvas = useMultiPageCanvasOptional();
124
+ const canvasView = useCanvasViewStateOptional();
125
+ const zoom = canvas && canvasView ? canvasView.canvasZoom * 100 : singleZoom;
126
+ const minZoom = canvas ? CANVAS_MIN_ZOOM * 100 : MIN_ZOOM;
127
+ const maxZoom = canvas ? CANVAS_MAX_ZOOM * 100 : MAX_ZOOM;
128
+ const setZoom = (value: number) => {
129
+ if (canvas) canvas.zoomTo(value / 100, { animate: true });
130
+ else setSingleZoom(value);
131
+ };
132
+
121
133
  // 保存・共有・プレビュー・閉じるは共通トップバー(EditorTopBar)が持つ。
122
134
  // ここに実装を残すと、PowerPoint風UIと2本の保存経路ができてしまう
123
135
 
@@ -245,12 +257,12 @@ export function EditorHeader({
245
257
  // 以前は上限200%/下限25%で止めていたが、キャンバス側(useCanvasControls)の
246
258
  // 実際の可動域は 10〜400%。ボタンだけ手前で止まるため、ホイールで250%まで
247
259
  // 拡大したあとに「+を押しても何も起きない」状態が生まれていた。
248
- const clampZoom = (v: number) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, v));
249
- const canZoomIn = zoom < MAX_ZOOM - 0.01;
250
- const canZoomOut = zoom > MIN_ZOOM + 0.01;
260
+ const clampZoom = (v: number) => Math.max(minZoom, Math.min(maxZoom, v));
261
+ const canZoomIn = zoom < maxZoom - 0.01;
262
+ const canZoomOut = zoom > minZoom + 0.01;
251
263
 
252
- const handleZoomIn = () => setZoom(clampZoom(Math.floor(zoom / 10) * 10 + 10));
253
- const handleZoomOut = () => setZoom(clampZoom(Math.ceil(zoom / 10) * 10 - 10));
264
+ const handleZoomIn = () => (canvas ? canvas.zoomIn() : setZoom(clampZoom(Math.floor(zoom / 10) * 10 + 10)));
265
+ const handleZoomOut = () => (canvas ? canvas.zoomOut() : setZoom(clampZoom(Math.ceil(zoom / 10) * 10 - 10)));
254
266
  // 全体表示/100% はキャンバス側(useCanvasControls)の実装に委ねる。
255
267
  // Cmd+0 / Cmd+1 と同じ関数を呼ぶことで、「ヘッダーから押したときだけ
256
268
  // 固定点の扱いが違う」というズレを作らない。未登録時は従来どおり値を入れる。
@@ -333,7 +345,7 @@ export function EditorHeader({
333
345
  data-zoom-out
334
346
  onClick={handleZoomOut}
335
347
  disabled={!canZoomOut}
336
- title={canZoomOut ? "縮小" : `これ以上縮小できません(下限 ${MIN_ZOOM}%)`}
348
+ title={canZoomOut ? "縮小" : `これ以上縮小できません(下限 ${minZoom}%)`}
337
349
  className="h-8 w-8 text-gray-400 hover:text-white hover:bg-[#444444] disabled:opacity-30"
338
350
  >
339
351
  <ZoomOut className="h-4 w-4" />
@@ -361,8 +373,8 @@ export function EditorHeader({
361
373
  ref={zoomInputRef}
362
374
  data-zoom-input
363
375
  type="number"
364
- min={MIN_ZOOM}
365
- max={MAX_ZOOM}
376
+ min={minZoom}
377
+ max={maxZoom}
366
378
  value={zoomInput}
367
379
  onChange={(e) => setZoomInput(e.target.value)}
368
380
  onKeyDown={(e) => {
@@ -373,7 +385,7 @@ export function EditorHeader({
373
385
  if (e.key === "Escape") setZoomMenuOpen(false);
374
386
  }}
375
387
  className="h-7 w-full rounded border border-[#4a4a4a] bg-[#1e1e1e] px-2 text-xs tabular-nums text-white outline-none focus:border-[#0d99ff]"
376
- aria-label={`表示倍率(%) ${MIN_ZOOM}〜${MAX_ZOOM}`}
388
+ aria-label={`表示倍率(%) ${minZoom}〜${maxZoom}`}
377
389
  />
378
390
  <span className="text-xs text-gray-500">%</span>
379
391
  <Button
@@ -403,6 +415,16 @@ export function EditorHeader({
403
415
  100%
404
416
  <span className="text-[10px] text-gray-500">⌘1</span>
405
417
  </DropdownMenuItem>
418
+ {canvas && (
419
+ <DropdownMenuItem
420
+ data-zoom-preset="page"
421
+ onClick={() => { const id = canvas.viewStore.get().activePageId; if (id) canvas.zoomToPage(id, { animate: true }); }}
422
+ className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
423
+ >
424
+ 編集中のページに合わせる
425
+ <span className="text-[10px] text-gray-500">⇧2</span>
426
+ </DropdownMenuItem>
427
+ )}
406
428
  <DropdownMenuItem
407
429
  data-zoom-preset="50"
408
430
  onClick={() => setZoom(50)}
@@ -417,6 +439,19 @@ export function EditorHeader({
417
439
  >
418
440
  200%
419
441
  </DropdownMenuItem>
442
+ {canvas && (
443
+ <>
444
+ <DropdownMenuSeparator className="bg-[#444444]" />
445
+ <DropdownMenuItem
446
+ data-zoom-rulers
447
+ onClick={canvas.toggleRulers}
448
+ className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
449
+ >
450
+ <span className="flex items-center gap-2"><Ruler className="h-3.5 w-3.5" />定規{canvas.rulersVisible ? "を隠す" : "を表示"}</span>
451
+ <span className="text-[10px] text-gray-500">⇧R</span>
452
+ </DropdownMenuItem>
453
+ </>
454
+ )}
420
455
  </DropdownMenuContent>
421
456
  </DropdownMenu>
422
457
  <Button
@@ -425,7 +460,7 @@ export function EditorHeader({
425
460
  data-zoom-in
426
461
  onClick={handleZoomIn}
427
462
  disabled={!canZoomIn}
428
- title={canZoomIn ? "拡大" : `これ以上拡大できません(上限 ${MAX_ZOOM}%)`}
463
+ title={canZoomIn ? "拡大" : `これ以上拡大できません(上限 ${maxZoom}%)`}
429
464
  className="h-8 w-8 text-gray-400 hover:text-white hover:bg-[#444444] disabled:opacity-30"
430
465
  >
431
466
  <ZoomIn className="h-4 w-4" />
@@ -17,8 +17,10 @@ import {
17
17
  ChevronRight,
18
18
  ChevronDown,
19
19
  Presentation,
20
+ ArrowUpRight,
20
21
  } from 'lucide-react';
21
22
  import { useEditorContext, type ContentListItem } from '../EditorContext';
23
+ import { computeContentDepths } from '../contexts/EditorArtboardContext';
22
24
  import { useElementActions } from '../hooks/useElementActions';
23
25
  import { useResizablePanel } from '../hooks/useResizablePanel';
24
26
  import { refreshSelectionOverlay, buildDomTree } from '../utils/dom-utils';
@@ -297,10 +299,13 @@ return (
297
299
  const ContentListItemComponent = memo(function ContentListItemComponent({
298
300
  slide,
299
301
  isCurrent,
302
+ depth = 0,
300
303
  onClick,
301
304
  }: {
302
305
  slide: ContentListItem;
303
306
  isCurrent: boolean;
307
+ /** 階層の深さ(parentId から)。段を付ける */
308
+ depth?: number;
304
309
  onClick: () => void;
305
310
  }) {
306
311
  return (
@@ -313,6 +318,8 @@ const ContentListItemComponent = memo(function ContentListItemComponent({
313
318
  : 'text-gray-400 hover:bg-[#2c2c2c] border-l-2 border-transparent'
314
319
  }
315
320
  `}
321
+ style={depth > 0 ? { paddingLeft: 8 + depth * 14 } : undefined}
322
+ data-depth={depth}
316
323
  disabled={isCurrent}
317
324
  title={slide.title || `スライド ${slide.order}`}
318
325
  >
@@ -347,12 +354,14 @@ const ContentList = memo(function ContentList({
347
354
  currentContentId: string | null;
348
355
  onContentClick: (slideId: string) => void;
349
356
  }) {
357
+ const depths = useMemo(() => computeContentDepths(slides), [slides]);
350
358
  return (
351
359
  <>
352
360
  {slides.map((slide) => (
353
361
  <ContentListItemComponent
354
362
  key={slide.id}
355
363
  slide={slide}
364
+ depth={depths.get(slide.id) ?? 0}
356
365
  isCurrent={slide.id === currentContentId}
357
366
  onClick={() => onContentClick(slide.id)}
358
367
  />
@@ -387,6 +396,8 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
387
396
  } = useEditorContext();
388
397
 
389
398
  const [contentListExpanded, setContentListExpanded] = useState(true);
399
+ // 階層(parentId)の深さ。一覧に段を付ける
400
+ const slideDepths = useMemo(() => computeContentDepths(slides), [slides]);
390
401
  // 各スライドのツリー展開状態(スライドID → 展開状態)
391
402
  const [slideTreeExpanded, setSlideTreeExpanded] = useState<Map<string, boolean>>(new Map());
392
403
 
@@ -1132,6 +1143,7 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1132
1143
  {!hideSlideList && slides.length > 0 ? (
1133
1144
  slides.map((slide, index) => {
1134
1145
  const isActive = slide.id === currentContentId;
1146
+ const depth = slideDepths.get(slide.id) ?? 0;
1135
1147
  const isTreeExpanded = slideTreeExpanded.get(slide.id) ?? isActive;
1136
1148
  const slideTree = searchQuery ?
1137
1149
  // 検索時は該当スライドのフィルタ済みツリーを表示
@@ -1139,14 +1151,15 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1139
1151
  getSlideTree(slide.id);
1140
1152
 
1141
1153
  return (
1142
- <div key={slide.id} className="mb-1">
1143
- {/* スライドヘッダー */}
1154
+ <div key={slide.id} className="mb-1" data-depth={depth}>
1155
+ {/* スライドヘッダー(階層があれば深さぶん段を付ける) */}
1144
1156
  <div
1145
1157
  className={`flex items-center gap-1 py-1 px-1 rounded cursor-pointer text-xs transition-colors ${
1146
1158
  isActive
1147
1159
  ? 'bg-[#0d99ff]/20 text-[#4fb8ff] border-l-2 border-[#0d99ff]'
1148
1160
  : 'text-gray-300 hover:bg-[#444444] border-l-2 border-transparent'
1149
1161
  }`}
1162
+ style={depth > 0 ? { marginLeft: depth * 14 } : undefined}
1150
1163
  onClick={() => handleContentClick(slide.id)}
1151
1164
  >
1152
1165
  {/* 展開/折りたたみボタン */}
@@ -1175,6 +1188,19 @@ export function EditorLayerPanel({ hideSlideList = false }: { hideSlideList?: bo
1175
1188
  編集中
1176
1189
  </span>
1177
1190
  )}
1191
+ {slide.href && (
1192
+ <a
1193
+ href={slide.href}
1194
+ target="_blank"
1195
+ rel="noreferrer"
1196
+ className="ed-icon-button !h-5 !w-5 flex-shrink-0 opacity-60 hover:opacity-100"
1197
+ title="別タブで開く(編集しない表示)"
1198
+ aria-label={`${slide.title || '無題'} を別タブで開く`}
1199
+ onClick={(e) => e.stopPropagation()}
1200
+ >
1201
+ <ArrowUpRight className="w-3 h-3" />
1202
+ </a>
1203
+ )}
1178
1204
  </div>
1179
1205
 
1180
1206
  {/* スライドのDOMツリー */}
@@ -370,6 +370,8 @@ function LiveGeometryProvider({
370
370
  // ドラッグ/リサイズは style を、確定時の Tailwind 変換は class を書き換える。
371
371
  // 親のリフローで矩形が変わる場合もあるので artboard 全体を subtree で監視する。
372
372
  const target = doc.getElementById("artboard") || doc.body;
373
+ // 読み込み中の文書(body 無し)には付けない。準備できたら iframeReady で張り直る
374
+ if (!target) return;
373
375
  const observer = new MutationObserver(schedule);
374
376
  observer.observe(target, {
375
377
  attributes: true,
@@ -846,38 +848,52 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
846
848
  return getInstanceByDomId(selectedElement.id);
847
849
  }, [selectedElement?.id, getInstanceByDomId]);
848
850
 
849
- // 部品(data-part)のインスタンスか(DOM の属性で判定)
850
- const selectedPart = useMemo(() => {
851
+ // 部品(data-part)のインスタンスか(DOM の属性で判定)。ルートを選んでいても、中のスロットを選んでいても
852
+ // 「どの部品の中か」を出す(中を編集しているときに、部品だと分からずに直してしまうのを防ぐ)
853
+ const selectedPartRoot = useMemo(() => {
851
854
  if (!selectedElement?.id) return null;
852
855
  const doc = getIframeDoc();
853
- return partInfoOf(doc?.querySelector(`[data-element-id="${selectedElement.id}"]`));
856
+ const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
857
+ if (!el) return null;
858
+ const root = el.hasAttribute("data-part") ? el : (el.parentElement?.closest("[data-part]") as HTMLElement | null) ?? null;
859
+ if (!root) return null;
860
+ const slot = root === el ? null : (el.closest("[data-slot]") as HTMLElement | null);
861
+ return { root, isRoot: root === el, slotName: slot && root.contains(slot) && slot !== root ? slot.getAttribute("data-slot") : null };
854
862
  }, [selectedElement, getIframeDoc]);
863
+ const selectedPart = useMemo(() => (selectedPartRoot ? partInfoOf(selectedPartRoot.root) : null), [selectedPartRoot]);
855
864
  const selectedPartLabel = useMemo(() => {
856
865
  if (!selectedPart) return "";
857
866
  const def = getPartDef(selectedPart.id);
858
867
  return `${def?.name || selectedPart.id} v${selectedPart.version}`;
859
868
  }, [selectedPart, getPartDef]);
860
869
  const handleUpdatePartFromPanel = useCallback(async () => {
861
- if (!selectedElement?.id) return;
862
- const doc = getIframeDoc();
863
- const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
864
- if (!el) return;
865
- const def = await updatePartFromElement(el);
870
+ const root = selectedPartRoot?.root;
871
+ if (!root) return;
872
+ const def = await updatePartFromElement(root);
866
873
  if (def) notifyIframeChange(true);
867
- }, [selectedElement?.id, getIframeDoc, updatePartFromElement, notifyIframeChange]);
874
+ }, [selectedPartRoot, updatePartFromElement, notifyIframeChange]);
868
875
  const handleDetachPartFromPanel = useCallback(() => {
869
- if (!selectedElement?.id) return;
870
876
  const doc = getIframeDoc();
871
- const el = doc?.querySelector(`[data-element-id="${selectedElement.id}"]`) as HTMLElement | null;
872
- if (!doc || !el || !partInfoOf(el)) return;
873
- detachPart(el);
874
- unlockPartDescendants(el);
877
+ const root = selectedPartRoot?.root;
878
+ if (!doc || !root || !partInfoOf(root)) return;
879
+ detachPart(root);
880
+ unlockPartDescendants(root);
875
881
  notifyIframeChange(true);
876
882
  void import("../utils/style-utils").then(({ extractElementInfo }) => {
877
- const info = extractElementInfo(el, doc);
883
+ const info = extractElementInfo(root, doc);
878
884
  if (info) setSelectedElement(info);
879
885
  });
880
- }, [selectedElement?.id, getIframeDoc, notifyIframeChange, setSelectedElement]);
886
+ }, [selectedPartRoot, getIframeDoc, notifyIframeChange, setSelectedElement]);
887
+ // 中のスロットを選んでいるとき: 部品のルートを選び直す(移動・削除・「この姿で更新」の対象を部品全体にする)
888
+ const handleSelectPartRoot = useCallback(() => {
889
+ const doc = getIframeDoc();
890
+ const root = selectedPartRoot?.root;
891
+ if (!doc || !root) return;
892
+ void import("../utils/style-utils").then(({ extractElementInfo }) => {
893
+ const info = extractElementInfo(root, doc);
894
+ if (info) setSelectedElement(info);
895
+ });
896
+ }, [selectedPartRoot, getIframeDoc, setSelectedElement]);
881
897
 
882
898
  const selectedMaster = useMemo(() => {
883
899
  if (!selectedInstance) return null;
@@ -1285,29 +1301,44 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1285
1301
  />
1286
1302
  )}
1287
1303
 
1288
- {/* 部品(data-part)のインスタンス */}
1289
- {selectedPart && (
1290
- <div className="space-y-2 rounded border border-[#444444] bg-[#2a2a2a] p-2 text-xs text-gray-300">
1304
+ {/* 部品(data-part)のインスタンス。紙面の選択枠も紫になる(部品だと一目で分かるように) */}
1305
+ {selectedPart && selectedPartRoot && (
1306
+ <div className="ed-part-card space-y-2 rounded p-2 text-xs" data-part-card={selectedPart.id} data-part-inside={selectedPartRoot.isRoot ? undefined : "true"}>
1291
1307
  <div className="flex items-center gap-2">
1292
- <PartIcon className="h-3.5 w-3.5 text-purple-400" />
1293
- <span className="truncate font-medium">部品: {selectedPartLabel}</span>
1308
+ <PartIcon className="h-3.5 w-3.5 ed-part-accent" />
1309
+ <span className="truncate font-medium">部品 {selectedPartLabel}</span>
1294
1310
  </div>
1295
- <p className="text-[10px] leading-snug text-gray-500">
1296
- スロット(data-slot)の中だけ編集できます。「この姿で部品を更新」で定義が変わり、他ページへの反映は利用側(savePart)が行います。
1311
+ <p className="text-[10px] leading-snug ed-part-note">
1312
+ {selectedPartRoot.isRoot
1313
+ ? "スロット(点線の枠)の中だけ編集できます。外側を直したいときは切り離してください。"
1314
+ : `スロット「${selectedPartRoot.slotName ?? "—"}」の中を編集しています。ここの変更はこのページだけに残ります(定義には入りません)。`}
1297
1315
  </p>
1298
- <div className="flex gap-1">
1316
+ <div className="flex flex-wrap gap-1">
1317
+ {!selectedPartRoot.isRoot && (
1318
+ <Button
1319
+ size="sm"
1320
+ variant="outline"
1321
+ className="h-6 px-2 text-[10px]"
1322
+ title="部品のルート要素を選ぶ(移動・削除・更新の対象を部品全体にする)"
1323
+ onClick={handleSelectPartRoot}
1324
+ >
1325
+ 部品全体を選ぶ
1326
+ </Button>
1327
+ )}
1299
1328
  <Button
1300
1329
  size="sm"
1301
1330
  variant="outline"
1302
1331
  className="h-6 px-2 text-[10px]"
1332
+ title="このインスタンスの今の姿を定義にする(版 +1)。他のページの同じ部品もその場で描き直されます"
1303
1333
  onClick={() => void handleUpdatePartFromPanel()}
1304
1334
  >
1305
- この姿で更新
1335
+ この姿で更新(全ページ)
1306
1336
  </Button>
1307
1337
  <Button
1308
1338
  size="sm"
1309
1339
  variant="outline"
1310
1340
  className="h-6 px-2 text-[10px]"
1341
+ title="部品から切り離して普通の HTML にする(以後、定義の更新が及ばない)"
1311
1342
  onClick={handleDetachPartFromPanel}
1312
1343
  >
1313
1344
  切り離す
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * 定規(上・左)。Figma と同じく紙面の座標(等倍の px)で目盛りを打ち、
5
+ * 編集中のフレームの範囲を淡く塗る。倍率に応じて目盛りの間隔を切り替える。
6
+ * <canvas> に描くので、ズーム・パンの毎フレームでも DOM を作り直さない
7
+ */
8
+
9
+ import { memo, useEffect, useRef } from 'react';
10
+ import { RULER_SIZE, useMultiPageCanvas, useCanvasViewState } from '../../contexts/MultiPageCanvasContext';
11
+
12
+ /** 目盛りの候補(紙面の px)。画面上で 60px 以上離れる最小のものを選ぶ */
13
+ const STEPS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
14
+
15
+ function pickStep(zoom: number): number {
16
+ return STEPS.find((s) => s * zoom >= 60) ?? STEPS[STEPS.length - 1];
17
+ }
18
+
19
+ function readVar(el: HTMLElement, name: string, fallback: string): string {
20
+ const v = getComputedStyle(el).getPropertyValue(name).trim();
21
+ return v || fallback;
22
+ }
23
+
24
+ export const CanvasRulers = memo(function CanvasRulers({ theme }: { theme: 'light' | 'dark' }) {
25
+ const { pages } = useMultiPageCanvas();
26
+ const viewState = useCanvasViewState();
27
+ const topRef = useRef<HTMLCanvasElement>(null);
28
+ const leftRef = useRef<HTMLCanvasElement>(null);
29
+ const { canvasOffset, canvasZoom, activePageId } = viewState;
30
+ const active = pages.find((p) => p.id === activePageId);
31
+ const activeKey = active ? `${active.position.x},${active.position.y},${active.size.width},${active.size.height}` : '';
32
+
33
+ useEffect(() => {
34
+ const top = topRef.current;
35
+ const left = leftRef.current;
36
+ if (!top || !left) return;
37
+ const host = top.parentElement as HTMLElement | null;
38
+ if (!host) return;
39
+ const dpr = window.devicePixelRatio || 1;
40
+ const width = host.clientWidth;
41
+ const height = host.clientHeight;
42
+ if (width === 0 || height === 0) return;
43
+
44
+ const colors = {
45
+ bg: readVar(host, '--ed-panel', theme === 'dark' ? '#2c2c2c' : '#ffffff'),
46
+ line: readVar(host, '--ed-line', theme === 'dark' ? '#444444' : '#e6e6e6'),
47
+ text: readVar(host, '--ed-muted', theme === 'dark' ? '#b3b3b3' : '#6b6b6b'),
48
+ accent: readVar(host, '--ed-accent', '#0d99ff'),
49
+ };
50
+ const step = pickStep(canvasZoom);
51
+ const minor = step / 5;
52
+
53
+ const setup = (cv: HTMLCanvasElement, w: number, h: number) => {
54
+ cv.width = Math.round(w * dpr);
55
+ cv.height = Math.round(h * dpr);
56
+ cv.style.width = `${w}px`;
57
+ cv.style.height = `${h}px`;
58
+ const ctx = cv.getContext('2d')!;
59
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
60
+ ctx.fillStyle = colors.bg;
61
+ ctx.fillRect(0, 0, w, h);
62
+ ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif';
63
+ ctx.fillStyle = colors.text;
64
+ ctx.strokeStyle = colors.line;
65
+ ctx.lineWidth = 1;
66
+ return ctx;
67
+ };
68
+
69
+ // ---- 上の定規
70
+ {
71
+ const ctx = setup(top, width, RULER_SIZE);
72
+ if (active) {
73
+ ctx.fillStyle = theme === 'dark' ? 'rgba(13,153,255,0.22)' : 'rgba(13,153,255,0.16)';
74
+ const x0 = canvasOffset.x + active.position.x * canvasZoom;
75
+ ctx.fillRect(x0, 0, active.size.width * canvasZoom, RULER_SIZE);
76
+ ctx.fillStyle = colors.text;
77
+ }
78
+ const first = Math.floor((RULER_SIZE - canvasOffset.x) / canvasZoom / minor) * minor;
79
+ const last = (width - canvasOffset.x) / canvasZoom;
80
+ ctx.textAlign = 'left';
81
+ ctx.textBaseline = 'top';
82
+ ctx.beginPath();
83
+ for (let v = first; v <= last; v += minor) {
84
+ const x = Math.round(canvasOffset.x + v * canvasZoom) + 0.5;
85
+ if (x < RULER_SIZE) continue;
86
+ const major = Math.abs(v / step - Math.round(v / step)) < 1e-6;
87
+ ctx.moveTo(x, RULER_SIZE);
88
+ ctx.lineTo(x, major ? RULER_SIZE - 9 : RULER_SIZE - 4);
89
+ if (major) ctx.fillText(String(Math.round(v)), x + 3, 3);
90
+ }
91
+ ctx.stroke();
92
+ ctx.beginPath();
93
+ ctx.moveTo(0, RULER_SIZE - 0.5);
94
+ ctx.lineTo(width, RULER_SIZE - 0.5);
95
+ ctx.stroke();
96
+ }
97
+
98
+ // ---- 左の定規(文字は縦書きにせず、横のまま小さく置く)
99
+ {
100
+ const ctx = setup(left, RULER_SIZE, height);
101
+ if (active) {
102
+ ctx.fillStyle = theme === 'dark' ? 'rgba(13,153,255,0.22)' : 'rgba(13,153,255,0.16)';
103
+ const y0 = canvasOffset.y + active.position.y * canvasZoom;
104
+ ctx.fillRect(0, y0, RULER_SIZE, active.size.height * canvasZoom);
105
+ ctx.fillStyle = colors.text;
106
+ }
107
+ const first = Math.floor((RULER_SIZE - canvasOffset.y) / canvasZoom / minor) * minor;
108
+ const last = (height - canvasOffset.y) / canvasZoom;
109
+ ctx.beginPath();
110
+ for (let v = first; v <= last; v += minor) {
111
+ const y = Math.round(canvasOffset.y + v * canvasZoom) + 0.5;
112
+ if (y < RULER_SIZE) continue;
113
+ const major = Math.abs(v / step - Math.round(v / step)) < 1e-6;
114
+ ctx.moveTo(RULER_SIZE, y);
115
+ ctx.lineTo(major ? RULER_SIZE - 9 : RULER_SIZE - 4, y);
116
+ if (major) {
117
+ ctx.save();
118
+ ctx.translate(3, y + 3);
119
+ ctx.rotate(-Math.PI / 2);
120
+ ctx.textAlign = 'right';
121
+ ctx.textBaseline = 'top';
122
+ ctx.fillText(String(Math.round(v)), 0, 0);
123
+ ctx.restore();
124
+ }
125
+ }
126
+ ctx.stroke();
127
+ ctx.beginPath();
128
+ ctx.moveTo(RULER_SIZE - 0.5, 0);
129
+ ctx.lineTo(RULER_SIZE - 0.5, height);
130
+ ctx.stroke();
131
+ }
132
+ }, [canvasOffset.x, canvasOffset.y, canvasZoom, activeKey, theme, active]);
133
+
134
+ // 容器のサイズ変更でも描き直す
135
+ const [, force] = useForceRedraw(topRef);
136
+
137
+ return (
138
+ <div className="pointer-events-none absolute inset-0 z-20" data-canvas-rulers aria-hidden="true" data-redraw={force}>
139
+ <canvas ref={topRef} className="absolute left-0 top-0" />
140
+ <canvas ref={leftRef} className="absolute left-0 top-0" />
141
+ <div
142
+ className="absolute left-0 top-0"
143
+ style={{ width: RULER_SIZE, height: RULER_SIZE, background: 'var(--ed-panel)', borderRight: '1px solid var(--ed-line)', borderBottom: '1px solid var(--ed-line)' }}
144
+ />
145
+ </div>
146
+ );
147
+ });
148
+
149
+ function useForceRedraw(ref: React.RefObject<HTMLCanvasElement | null>): [number, number] {
150
+ const tick = useRef(0);
151
+ const [, set] = useStateSafe();
152
+ useEffect(() => {
153
+ const host = ref.current?.parentElement;
154
+ if (!host || typeof ResizeObserver === 'undefined') return;
155
+ const observer = new ResizeObserver(() => { tick.current++; set(tick.current); });
156
+ observer.observe(host);
157
+ return () => observer.disconnect();
158
+ }, [ref, set]);
159
+ return [tick.current, tick.current];
160
+ }
161
+
162
+ import { useState } from 'react';
163
+ function useStateSafe(): [number, (v: number) => void] {
164
+ const [v, set] = useState(0);
165
+ return [v, set];
166
+ }