@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.
- package/README.md +41 -1
- package/dist/editor.css +1 -1
- package/package.json +3 -2
- package/src/editor/EditorContext.tsx +15 -3
- package/src/editor/FrontendVisualEditor.tsx +168 -55
- package/src/editor/autosave.ts +32 -0
- package/src/editor/components/EditorCanvas.tsx +107 -8
- package/src/editor/components/EditorContextMenu.tsx +12 -3
- package/src/editor/components/EditorHeader.tsx +47 -12
- package/src/editor/components/EditorLayerPanel.tsx +28 -2
- package/src/editor/components/EditorPropertyPanel.tsx +56 -25
- package/src/editor/components/multi-page/CanvasRulers.tsx +166 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +345 -1345
- package/src/editor/components/multi-page/PageFramePreview.tsx +197 -0
- package/src/editor/components/multi-page/index.ts +2 -6
- package/src/editor/components/ppt/PptComments.tsx +8 -4
- package/src/editor/components/shell/CanvasAppearance.tsx +3 -1
- package/src/editor/components/shell/PagesPanel.tsx +8 -0
- package/src/editor/constants.ts +28 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +60 -3
- package/src/editor/contexts/EditorComponentsContext.tsx +17 -4
- package/src/editor/contexts/MultiPageCanvasContext.tsx +859 -354
- package/src/editor/hooks/useCanvasControls.ts +1 -1
- package/src/editor/hooks/useCanvasMarquee.ts +196 -0
- package/src/editor/hooks/useContextMenuHandler.ts +5 -14
- package/src/editor/hooks/useCoordinateTransform.ts +11 -8
- package/src/editor/hooks/useDrawingMode.ts +1 -0
- package/src/editor/hooks/useGoogleFonts.ts +6 -2
- package/src/editor/hooks/useInfiniteCanvas.ts +228 -220
- package/src/editor/hooks/useMarqueeSelection.ts +2 -2
- package/src/editor/utils/dom-utils.ts +23 -1
- package/src/editor/utils/embedded-canvas-bridge.ts +155 -0
- package/src/editor/utils/html-utils.ts +102 -14
- package/src/index.ts +5 -0
- package/src/io.ts +20 -0
- package/src/styles/skin.css +122 -36
- package/src/version.ts +9 -0
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +0 -71
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +0 -95
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +0 -76
- package/src/editor/components/multi-page/PageLabel.tsx +0 -34
- package/src/editor/components/multi-page/PageLivePreview.tsx +0 -242
- 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
|
-
|
|
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(
|
|
249
|
-
const canZoomIn = zoom <
|
|
250
|
-
const canZoomOut = zoom >
|
|
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 ? "縮小" : `これ以上縮小できません(下限 ${
|
|
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={
|
|
365
|
-
max={
|
|
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={`表示倍率(%) ${
|
|
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 ? "拡大" : `これ以上拡大できません(上限 ${
|
|
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
|
-
|
|
851
|
+
// 部品(data-part)のインスタンスか(DOM の属性で判定)。ルートを選んでいても、中のスロットを選んでいても
|
|
852
|
+
// 「どの部品の中か」を出す(中を編集しているときに、部品だと分からずに直してしまうのを防ぐ)
|
|
853
|
+
const selectedPartRoot = useMemo(() => {
|
|
851
854
|
if (!selectedElement?.id) return null;
|
|
852
855
|
const doc = getIframeDoc();
|
|
853
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
const
|
|
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
|
-
}, [
|
|
874
|
+
}, [selectedPartRoot, updatePartFromElement, notifyIframeChange]);
|
|
868
875
|
const handleDetachPartFromPanel = useCallback(() => {
|
|
869
|
-
if (!selectedElement?.id) return;
|
|
870
876
|
const doc = getIframeDoc();
|
|
871
|
-
const
|
|
872
|
-
if (!doc || !
|
|
873
|
-
detachPart(
|
|
874
|
-
unlockPartDescendants(
|
|
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(
|
|
883
|
+
const info = extractElementInfo(root, doc);
|
|
878
884
|
if (info) setSelectedElement(info);
|
|
879
885
|
});
|
|
880
|
-
}, [
|
|
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
|
|
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
|
|
1293
|
-
<span className="truncate font-medium"
|
|
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
|
|
1296
|
-
|
|
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
|
+
}
|