@growgroup/visual-editor 0.3.0 → 0.4.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.
- package/README.md +10 -1
- package/dist/editor.css +1 -1
- package/package.json +3 -2
- package/src/editor/EditorContext.tsx +11 -2
- package/src/editor/EditorToolbar.tsx +8 -2
- package/src/editor/FrontendVisualEditor.tsx +128 -45
- package/src/editor/components/EditorCanvas.tsx +87 -15
- package/src/editor/components/EditorContextMenu.tsx +12 -3
- package/src/editor/components/EditorFooter.tsx +2 -0
- package/src/editor/components/EditorHeader.tsx +3 -2
- package/src/editor/components/EditorLayerPanel.tsx +28 -2
- package/src/editor/components/EditorPropertyPanel.tsx +54 -25
- package/src/editor/components/FigmaColorPicker.tsx +2 -1
- package/src/editor/components/JsEditorDialog.tsx +1 -0
- package/src/editor/components/PageSettingsDialog.tsx +1 -0
- package/src/editor/components/comments/CommentBoard.tsx +9 -6
- package/src/editor/components/multi-page/CanvasFrameDecor.tsx +172 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +204 -58
- package/src/editor/components/multi-page/PageFramePreview.tsx +131 -22
- package/src/editor/components/multi-page/index.ts +1 -0
- package/src/editor/components/ppt/PptChrome.tsx +6 -2
- package/src/editor/components/ppt/PptComments.tsx +411 -71
- package/src/editor/constants.ts +44 -1
- package/src/editor/contexts/EditorArtboardContext.tsx +60 -1
- package/src/editor/contexts/EditorComponentsContext.tsx +48 -31
- package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
- package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
- package/src/editor/contexts/MultiPageCanvasContext.tsx +211 -34
- package/src/editor/hooks/useAiReplace.ts +4 -3
- package/src/editor/hooks/useBrowserZoomPrevention.ts +2 -1
- package/src/editor/hooks/useComponentEditMode.ts +27 -26
- package/src/editor/hooks/useEditorMessages.ts +3 -2
- package/src/editor/hooks/useElementActions.ts +27 -26
- package/src/editor/hooks/useElementSelection.ts +19 -16
- package/src/editor/hooks/useFocusManagement.ts +7 -6
- package/src/editor/hooks/useGoogleFonts.ts +3 -2
- package/src/editor/hooks/useIframeInitializer.ts +6 -3
- package/src/editor/hooks/useIframeSetup.ts +4 -3
- package/src/editor/hooks/useImageUpload.ts +4 -3
- package/src/editor/hooks/useMarqueeSelection.ts +2 -1
- package/src/editor/hooks/usePageSettingsManager.ts +24 -23
- package/src/editor/hooks/useRichPaste.ts +11 -10
- package/src/editor/parts.ts +7 -25
- package/src/editor/utils/component-renderer.ts +8 -7
- package/src/editor/utils/component-sync.ts +16 -15
- package/src/editor/utils/debug.ts +52 -0
- package/src/editor/utils/dom-utils.ts +27 -10
- package/src/editor/utils/drop-target.ts +135 -0
- package/src/editor/utils/eyedropper.ts +3 -1
- package/src/editor/utils/figma-export.ts +6 -5
- package/src/editor/utils/figma-kiwi-decoder.ts +28 -27
- package/src/editor/utils/figma-kiwi-encoder.ts +9 -8
- package/src/editor/utils/figma-paste.ts +57 -56
- package/src/editor/utils/flex-utils.ts +12 -10
- package/src/editor/utils/html-utils.ts +19 -6
- package/src/editor/utils/override-apply.ts +3 -2
- package/src/editor/utils/override-detection.ts +11 -10
- package/src/editor/utils/tailwind-utils.ts +4 -3
- package/src/index.ts +5 -0
- package/src/io.ts +22 -1
- package/src/lib/firebase/css-variables.ts +4 -3
- package/src/lib/firebase/storage.ts +3 -2
- package/src/styles/skin.css +63 -4
- package/src/types/editor.ts +6 -2
- package/src/version.ts +9 -0
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { useState } from 'react';
|
|
24
|
-
import { Check, CornerUpLeft, ExternalLink, MapPin, MessageSquare, RotateCcw, Send, Trash2 } from 'lucide-react';
|
|
24
|
+
import { Check, CornerUpLeft, Crosshair, ExternalLink, MapPin, MessageSquare, RotateCcw, Send, Trash2 } from 'lucide-react';
|
|
25
25
|
import type { Deck, SlideComment } from '../../../lib/deck';
|
|
26
26
|
import { PPT_PALETTES, type PptTheme } from '../ppt/PptChrome';
|
|
27
|
-
import { fmtTime, unresolvedCount } from '../ppt/PptComments';
|
|
27
|
+
import { describeRect, fmtTime, unresolvedCount } from '../ppt/PptComments';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* アンカー・返信のリンク色。
|
|
@@ -368,15 +368,18 @@ function BoardCard({
|
|
|
368
368
|
</span>
|
|
369
369
|
</div>
|
|
370
370
|
|
|
371
|
-
{/*
|
|
371
|
+
{/* 何に対する指摘か。範囲 > 要素 > ページ全体 */}
|
|
372
372
|
<div
|
|
373
373
|
className="mt-1.5 flex items-center gap-1 text-[10px]"
|
|
374
|
-
style={{ color: comment.anchorSrc ? accent : pal.sub }}
|
|
374
|
+
style={{ color: comment.anchorRect || comment.anchorSrc ? accent : pal.sub }}
|
|
375
375
|
title={comment.anchorSrc}
|
|
376
376
|
>
|
|
377
|
-
<MapPin className="h-3 w-3 shrink-0" />
|
|
377
|
+
{comment.anchorRect ? <Crosshair className="h-3 w-3 shrink-0" /> : <MapPin className="h-3 w-3 shrink-0" />}
|
|
378
378
|
<span className="truncate">
|
|
379
|
-
{comment.
|
|
379
|
+
{comment.seq != null && <span className="mr-1 tabular-nums">#{comment.seq}</span>}
|
|
380
|
+
{comment.anchorRect
|
|
381
|
+
? describeRect(comment.anchorRect)
|
|
382
|
+
: comment.anchorSrc ? (comment.anchorLabel ? `「${comment.anchorLabel}」` : '要素に添付') : 'ページ全体'}
|
|
380
383
|
</span>
|
|
381
384
|
</div>
|
|
382
385
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* フレームの枠線と階層の線を <canvas> に描く(画面の座標)。
|
|
5
|
+
*
|
|
6
|
+
* [なぜ DOM ではなく canvas か]
|
|
7
|
+
* 枠線を転写層の中の要素に持たせる(box-shadow / outline / border のどれでも)と、
|
|
8
|
+
* 倍率が変わるたびに転写層まるごとが塗り直される。26 ページで 1 段あたり 10〜20ms、
|
|
9
|
+
* 30 段の縮小で主スレッドの Paint が 870ms になる(実測)。階層の線(SVG)も同じ。
|
|
10
|
+
* 逆に、転写層の中身が「白い矩形 + 合成済みの iframe」だけなら 1 段の Paint は 0.5ms 前後。
|
|
11
|
+
*
|
|
12
|
+
* 26 ページ・30 段の縮小での実測(DPR 2、CPU 4 倍遅く):
|
|
13
|
+
* 枠線 box-shadow + 線 SVG … Paint 287 回 / 1293ms、フレーム p95 42ms
|
|
14
|
+
* 枠線 outline + 線 SVG … Paint 218 回 / 793ms、フレーム p95 42ms
|
|
15
|
+
* どちらも消す(この実装と同じ状態) … Paint 82 回 / 64ms、フレーム p95 25ms
|
|
16
|
+
*
|
|
17
|
+
* そこで枠線と線は転写層から出し、画面の座標で 1 枚の canvas に描く。倍率・位置が
|
|
18
|
+
* 変わるたびに描き直すが、26 本の矩形と 25 本の折れ線を 1600×1000 に描くだけなので
|
|
19
|
+
* 1ms もかからない。定規(CanvasRulers)と同じ考え方
|
|
20
|
+
*
|
|
21
|
+
* [書き方の約束]
|
|
22
|
+
* - canvas の寸法(width / height / style)は載せたときと容器の大きさが変わったときだけ書く。
|
|
23
|
+
* 毎段書くと再確保が起き、パン・ズームで style が変わる要素の数も増える(検証が数えている)
|
|
24
|
+
* - 色は載せたときとテーマが変わったときだけ読む(毎回 getComputedStyle すると同期のレイアウトが走る)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { memo, useEffect, useRef } from 'react';
|
|
28
|
+
import { FRAME_GAP, useMultiPageCanvas, type CanvasLayoutKind, type PageFrameLayout } from '../../contexts/MultiPageCanvasContext';
|
|
29
|
+
|
|
30
|
+
/** 枠線の太さ(画面上の px)。以前の box-shadow: 0 0 0 1px と同じ見え方 */
|
|
31
|
+
const EDGE_WIDTH = 1;
|
|
32
|
+
/** 階層の線の太さ(画面上の px) */
|
|
33
|
+
const CONNECTOR_WIDTH = 1.5;
|
|
34
|
+
|
|
35
|
+
function readVar(el: HTMLElement, name: string, fallback: string): string {
|
|
36
|
+
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
|
37
|
+
return v || fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const CanvasFrameDecor = memo(function CanvasFrameDecor({ theme }: { theme: 'light' | 'dark' }) {
|
|
41
|
+
const { pages, layout, editorMode, viewStore } = useMultiPageCanvas();
|
|
42
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
43
|
+
// 描き直しの入口。倍率・位置の購読と、ページ・テーマの変化の両方から呼ぶ
|
|
44
|
+
const drawRef = useRef<() => void>(() => undefined);
|
|
45
|
+
// 購読のコールバックは載せたときの props を閉じ込めてしまうので、ref 越しに読む
|
|
46
|
+
const pagesRef = useRef<PageFrameLayout[]>(pages);
|
|
47
|
+
pagesRef.current = pages;
|
|
48
|
+
const layoutRef = useRef<CanvasLayoutKind>(layout);
|
|
49
|
+
layoutRef.current = layout;
|
|
50
|
+
const gapYRef = useRef(FRAME_GAP[editorMode].y);
|
|
51
|
+
gapYRef.current = FRAME_GAP[editorMode].y;
|
|
52
|
+
const colorsRef = useRef({ edge: 'rgba(0,0,0,0.08)', line: '#e6e6e6' });
|
|
53
|
+
const sizeRef = useRef({ width: 0, height: 0, dpr: 1 });
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const canvas = canvasRef.current;
|
|
57
|
+
if (!canvas) return;
|
|
58
|
+
// 容器は自分の親をそのまま使う。context の containerRef は親(MultiPageCanvasView)の
|
|
59
|
+
// useEffect で入るので、子のこの useEffect が走る時点ではまだ null
|
|
60
|
+
const host = canvas.parentElement as HTMLElement | null;
|
|
61
|
+
if (!host) return;
|
|
62
|
+
|
|
63
|
+
const resize = () => {
|
|
64
|
+
const dpr = window.devicePixelRatio || 1;
|
|
65
|
+
const width = host.clientWidth;
|
|
66
|
+
const height = host.clientHeight;
|
|
67
|
+
const prev = sizeRef.current;
|
|
68
|
+
if (width === prev.width && height === prev.height && dpr === prev.dpr) return width > 0 && height > 0;
|
|
69
|
+
sizeRef.current = { width, height, dpr };
|
|
70
|
+
canvas.width = Math.round(width * dpr);
|
|
71
|
+
canvas.height = Math.round(height * dpr);
|
|
72
|
+
canvas.style.width = `${width}px`;
|
|
73
|
+
canvas.style.height = `${height}px`;
|
|
74
|
+
return width > 0 && height > 0;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const readColors = () => {
|
|
78
|
+
colorsRef.current = {
|
|
79
|
+
edge: readVar(host, '--ed-frame-edge', theme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'),
|
|
80
|
+
line: readVar(host, '--ed-line-strong', theme === 'dark' ? '#5a5a5a' : '#d0d0d0'),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const draw = () => {
|
|
85
|
+
const { width, height, dpr } = sizeRef.current;
|
|
86
|
+
if (width === 0 || height === 0) return;
|
|
87
|
+
const ctx = canvas.getContext('2d');
|
|
88
|
+
if (!ctx) return;
|
|
89
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
90
|
+
ctx.clearRect(0, 0, width, height);
|
|
91
|
+
const { canvasOffset: o, canvasZoom: z } = viewStore.get();
|
|
92
|
+
const list = pagesRef.current;
|
|
93
|
+
if (list.length === 0) return;
|
|
94
|
+
// 画面の座標へ。半 px ずらして 1px の線がぼやけないようにする
|
|
95
|
+
const rectOf = (p: PageFrameLayout) => ({
|
|
96
|
+
x: Math.round(o.x + p.position.x * z),
|
|
97
|
+
y: Math.round(o.y + p.position.y * z),
|
|
98
|
+
w: Math.round(p.size.width * z),
|
|
99
|
+
h: Math.round(p.size.height * z),
|
|
100
|
+
});
|
|
101
|
+
const visible = (r: { x: number; y: number; w: number; h: number }) =>
|
|
102
|
+
r.x + r.w >= -2 && r.x <= width + 2 && r.y + r.h >= -2 && r.y <= height + 2;
|
|
103
|
+
|
|
104
|
+
// 階層の線 → 枠線の順(線の端が枠に隠れる)
|
|
105
|
+
if (layoutRef.current === 'tree') {
|
|
106
|
+
const byId = new Map(list.map((p) => [p.id, p] as const));
|
|
107
|
+
ctx.strokeStyle = colorsRef.current.line;
|
|
108
|
+
ctx.lineWidth = CONNECTOR_WIDTH;
|
|
109
|
+
ctx.lineJoin = 'round';
|
|
110
|
+
ctx.beginPath();
|
|
111
|
+
for (const child of list) {
|
|
112
|
+
const parent = child.parentId ? byId.get(child.parentId) : undefined;
|
|
113
|
+
if (!parent) continue;
|
|
114
|
+
// 画素の境目に載せる(端数のままだと 1.5px の線が 3 画素に散ってぼやける)
|
|
115
|
+
const x1 = Math.round(o.x + (parent.position.x + parent.size.width / 2) * z);
|
|
116
|
+
const y1 = Math.round(o.y + (parent.position.y + parent.size.height) * z);
|
|
117
|
+
const x2 = Math.round(o.x + (child.position.x + child.size.width / 2) * z);
|
|
118
|
+
const y2 = Math.round(o.y + child.position.y * z);
|
|
119
|
+
// 画面から外れている組はたどらない(150 枚でも描く本数は見えている分だけ)
|
|
120
|
+
if (Math.max(x1, x2) < -2 || Math.min(x1, x2) > width + 2 || y2 < -2 || y1 > height + 2) continue;
|
|
121
|
+
// 親の下から、子の行との隙間の真ん中まで下り、横へ移って子の上へ
|
|
122
|
+
const ym = Math.round(y2 - (gapYRef.current * z) / 2);
|
|
123
|
+
ctx.moveTo(x1, y1);
|
|
124
|
+
ctx.lineTo(x1, ym);
|
|
125
|
+
ctx.lineTo(x2, ym);
|
|
126
|
+
ctx.lineTo(x2, y2);
|
|
127
|
+
}
|
|
128
|
+
ctx.stroke();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
ctx.strokeStyle = colorsRef.current.edge;
|
|
132
|
+
ctx.lineWidth = EDGE_WIDTH;
|
|
133
|
+
ctx.lineJoin = 'miter';
|
|
134
|
+
ctx.beginPath();
|
|
135
|
+
for (const p of list) {
|
|
136
|
+
const r = rectOf(p);
|
|
137
|
+
if (!visible(r)) continue;
|
|
138
|
+
// 以前の box-shadow: 0 0 0 1px は枠の外側に出ていたので、境目に重ねて同じ見え方にする
|
|
139
|
+
ctx.rect(r.x - 0.5, r.y - 0.5, r.w + 1, r.h + 1);
|
|
140
|
+
}
|
|
141
|
+
ctx.stroke();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
drawRef.current = draw;
|
|
145
|
+
readColors();
|
|
146
|
+
resize();
|
|
147
|
+
draw();
|
|
148
|
+
const unsubscribe = viewStore.subscribe(draw);
|
|
149
|
+
let observer: ResizeObserver | null = null;
|
|
150
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
151
|
+
observer = new ResizeObserver(() => { resize(); draw(); });
|
|
152
|
+
observer.observe(host);
|
|
153
|
+
}
|
|
154
|
+
return () => {
|
|
155
|
+
unsubscribe();
|
|
156
|
+
observer?.disconnect();
|
|
157
|
+
};
|
|
158
|
+
}, [viewStore, theme]);
|
|
159
|
+
|
|
160
|
+
// ページの並び・高さが変わったら描き直す(倍率・位置は上の購読が受ける)。
|
|
161
|
+
// viewStore を空更新して呼ぶことはしない ── 他の購読者(定規・コメントのピン)まで巻き込むので
|
|
162
|
+
useEffect(() => { drawRef.current(); }, [pages, layout, editorMode]);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<canvas
|
|
166
|
+
ref={canvasRef}
|
|
167
|
+
data-canvas-decor
|
|
168
|
+
aria-hidden="true"
|
|
169
|
+
className="pointer-events-none absolute left-0 top-0"
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
});
|
|
@@ -9,25 +9,37 @@
|
|
|
9
9
|
* 層の構成(下から):
|
|
10
10
|
* 1. 転写層(translate + scale)… フレームの白地 + 見るだけの紙面(PageFramePreview)
|
|
11
11
|
* 2. 転写層の中、編集中のフレームの位置に EditorCanvas(1 つだけ。ページを移っても作り直さない)
|
|
12
|
-
* 3.
|
|
13
|
-
* 4.
|
|
12
|
+
* 3. 飾りの canvas(画面の座標)… フレームの枠線・階層の線
|
|
13
|
+
* 4. オーバーレイ(転写層と同じ translate + scale)… フレーム名・選択リング。
|
|
14
|
+
* 中身は紙面の座標に置いて scale(1/倍率) で打ち消すので、画面上は倍率に関係なく同じ大きさ
|
|
15
|
+
* 5. 定規
|
|
14
16
|
*
|
|
15
17
|
* EditorCanvas を pages.map の中に置くとページを移るたびに React が作り直して
|
|
16
18
|
* iframe が読み直される。map の外に 1 つだけ置き、位置だけ動かす
|
|
17
19
|
*
|
|
18
20
|
* [パン・ズームの軽さ]
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* ホイール 1 段の仕事を「合成だけ」にする。倍率・位置は React を通さず、viewStore を
|
|
22
|
+
* 直接購読して 2 つの層の style へ書く(転写層とオーバーレイの transform、オーバーレイの
|
|
23
|
+
* --ed-zoom / --ed-inv、容器の data-canvas-zoom の 4 つだけ)。フレーム 1 枚ずつの要素は
|
|
24
|
+
* memo で、倍率を props に持たない。
|
|
25
|
+
*
|
|
26
|
+
* 転写層の中には「白い矩形 + 合成済みの iframe」しか置かない。枠線(box-shadow / outline /
|
|
27
|
+
* border のどれでも)や階層の線(SVG)を中に置くと、倍率が変わるたびに転写層まるごとが
|
|
28
|
+
* 塗り直され、1 段 10〜20ms の Paint になる(実測)。枠線と線は画面の座標の canvas
|
|
29
|
+
* (CanvasFrameDecor)へ出した。
|
|
30
|
+
*
|
|
31
|
+
* 名前の幅と出し分けだけは DOM のままなので、段ごとではなく **粗い倍率**
|
|
32
|
+
* (coarseZoom、150ms に 1 回まで。操作の終わりに必ず追いつく)から決める
|
|
23
33
|
*/
|
|
24
34
|
|
|
25
|
-
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
35
|
+
import { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
26
36
|
import {
|
|
27
37
|
useMultiPageCanvas,
|
|
28
|
-
|
|
38
|
+
useCanvasActivePageId,
|
|
29
39
|
RULER_SIZE,
|
|
30
40
|
FRAME_GAP,
|
|
41
|
+
PREVIEW_IMAGE_ZOOM,
|
|
42
|
+
PREVIEW_IMAGE_ZOOM_OUT,
|
|
31
43
|
type PageFrameLayout,
|
|
32
44
|
} from '../../contexts/MultiPageCanvasContext';
|
|
33
45
|
import { useEditorContext } from '../../EditorContext';
|
|
@@ -38,10 +50,25 @@ import { EditorAppearanceContext } from '../../contexts/EditorAppearanceContext'
|
|
|
38
50
|
import { EditorCanvas } from '../EditorCanvas';
|
|
39
51
|
import { PageFramePreview } from './PageFramePreview';
|
|
40
52
|
import { CanvasRulers } from './CanvasRulers';
|
|
53
|
+
import { CanvasFrameDecor } from './CanvasFrameDecor';
|
|
41
54
|
import { cn } from '../../../lib/utils';
|
|
55
|
+
import { ArrowUpRight } from 'lucide-react';
|
|
42
56
|
|
|
43
57
|
/** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
|
|
44
58
|
const LABEL_HEIGHT = 20;
|
|
59
|
+
/**
|
|
60
|
+
* 粗い倍率を更新する間隔(ms)。これを短くすると縮小中の塗り直しが増える。
|
|
61
|
+
* 操作の終わりには必ず本当の倍率に追いつく(末尾で 1 回)
|
|
62
|
+
*/
|
|
63
|
+
const COARSE_ZOOM_MS = 150;
|
|
64
|
+
/** 紙面を画像に落とすかの判定を、操作が止まってから何 ms 後に見るか(全体表示の 240ms の動きを跨がない) */
|
|
65
|
+
const PREVIEW_MODE_SETTLE_MS = 300;
|
|
66
|
+
/**
|
|
67
|
+
* この倍率より小さいところにいる間は、紙面の画像(thumbnail)を裏で先に読んでおく。
|
|
68
|
+
* 読めていない画像は縮小の途中で使えない(空白のフレームになるので iframe を見せ続ける)。
|
|
69
|
+
* 1 ページを大きく開いて編集しているときは読まない(利用側に 26 枚の画像を作らせない)
|
|
70
|
+
*/
|
|
71
|
+
const PREVIEW_IMAGE_PRELOAD_ZOOM = 1.2;
|
|
45
72
|
|
|
46
73
|
// ------------------------------------------------------------
|
|
47
74
|
// フレームの白地 + 見るだけの紙面(転写層の中。紙面の座標)
|
|
@@ -49,47 +76,53 @@ const LABEL_HEIGHT = 20;
|
|
|
49
76
|
|
|
50
77
|
interface FrameShellProps {
|
|
51
78
|
page: PageFrameLayout;
|
|
52
|
-
zoom: number;
|
|
53
79
|
isActive: boolean;
|
|
54
80
|
passThrough: boolean;
|
|
81
|
+
/** 大きく縮小している間は紙面を画像で描く(この層の zoom には依存しない真偽値) */
|
|
82
|
+
imageMode: boolean;
|
|
83
|
+
/** 画像を裏で先に読んでおく(縮小の途中で切り替えられるように) */
|
|
84
|
+
preloadImage: boolean;
|
|
55
85
|
rootRef: React.RefObject<HTMLDivElement | null>;
|
|
56
86
|
onMouseDown: (e: React.MouseEvent, id: string) => void;
|
|
57
87
|
onHover: (id: string | null) => void;
|
|
58
88
|
}
|
|
59
89
|
|
|
60
|
-
|
|
90
|
+
/**
|
|
91
|
+
* 倍率を props に持たない(持たせるとホイール 1 段ごとに 26 枚が React を通る)。
|
|
92
|
+
* 枠線はこの要素には無い ── 画面の座標の canvas(CanvasFrameDecor)が描く
|
|
93
|
+
*/
|
|
94
|
+
const FrameShell = memo(function FrameShell({ page, isActive, passThrough, imageMode, preloadImage, rootRef, onMouseDown, onHover }: FrameShellProps) {
|
|
61
95
|
return (
|
|
62
96
|
<div
|
|
63
97
|
data-page-frame={page.id}
|
|
64
98
|
data-page-active={isActive ? 'true' : undefined}
|
|
65
|
-
className="absolute"
|
|
99
|
+
className="ed-frame-shell absolute"
|
|
66
100
|
style={{
|
|
67
101
|
left: page.position.x,
|
|
68
102
|
top: page.position.y,
|
|
69
103
|
width: page.size.width,
|
|
70
104
|
height: page.size.height,
|
|
71
|
-
background: '#fff',
|
|
72
|
-
boxShadow: `0 0 0 ${1 / zoom}px var(--ed-frame-edge, rgba(0,0,0,0.08))`,
|
|
73
105
|
cursor: isActive || passThrough ? undefined : 'pointer',
|
|
74
106
|
}}
|
|
75
107
|
onMouseDown={(e) => onMouseDown(e, page.id)}
|
|
76
108
|
onMouseEnter={() => onHover(page.id)}
|
|
77
109
|
onMouseLeave={() => onHover(null)}
|
|
78
110
|
>
|
|
79
|
-
<PageFramePreview page={page} rootRef={rootRef} />
|
|
111
|
+
<PageFramePreview page={page} rootRef={rootRef} imageMode={imageMode} preloadImage={preloadImage} />
|
|
80
112
|
</div>
|
|
81
113
|
);
|
|
82
114
|
});
|
|
83
115
|
|
|
84
116
|
// ------------------------------------------------------------
|
|
85
|
-
// フレーム名・リング(
|
|
117
|
+
// フレーム名・リング(オーバーレイの中)
|
|
86
118
|
// ------------------------------------------------------------
|
|
87
119
|
|
|
88
120
|
interface FrameChromeProps {
|
|
89
121
|
page: PageFrameLayout;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
122
|
+
/** 名前を出すか(粗い倍率から親が判定する。全フレーム共通の条件 + このフレームの幅) */
|
|
123
|
+
showLabel: boolean;
|
|
124
|
+
/** 「別タブで開く」を出すか(名前の右。フレームが細いときは出さない) */
|
|
125
|
+
showLink: boolean;
|
|
93
126
|
/** リングと名前の強調(切替中はクリックしたページへ先に移る) */
|
|
94
127
|
isSelected: boolean;
|
|
95
128
|
isHover: boolean;
|
|
@@ -99,38 +132,67 @@ interface FrameChromeProps {
|
|
|
99
132
|
onZoomTo: (id: string) => void;
|
|
100
133
|
}
|
|
101
134
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
135
|
+
/**
|
|
136
|
+
* オーバーレイは転写層と同じ transform(translate + scale)なので、ここで scale(1/倍率) を
|
|
137
|
+
* 掛けて打ち消す。こうすると中身は画面上の大きさのまま、位置は紙面の座標で書ける
|
|
138
|
+
* (段ごとに React を通さず、--ed-inv が変わるだけで動く)。
|
|
139
|
+
* リングだけは画面上の寸法が要るので --ed-zoom から CSS で出す(段ごとに変わるのは 1〜2 個)
|
|
140
|
+
*/
|
|
141
|
+
const FrameChrome = memo(function FrameChrome({ page, showLabel, showLink, isSelected, isHover, isBusy, onActivate, onZoomTo }: FrameChromeProps) {
|
|
142
|
+
const hasRing = isSelected || isHover;
|
|
109
143
|
return (
|
|
110
|
-
<div
|
|
111
|
-
|
|
144
|
+
<div
|
|
145
|
+
className="absolute left-0 top-0"
|
|
146
|
+
data-frame-chrome={page.id}
|
|
147
|
+
style={{
|
|
148
|
+
transform: `translate(${page.position.x}px, ${page.position.y}px) scale(var(--ed-inv, 1))`,
|
|
149
|
+
transformOrigin: '0 0',
|
|
150
|
+
// 名前だけの小さな層にしておく(倍率が変わっても塗り直さずに動く)。
|
|
151
|
+
// リングが出ているフレームはフレームと同じ大きさになるので層にしない(巨大な合成層を作らない)
|
|
152
|
+
willChange: hasRing ? undefined : 'transform',
|
|
153
|
+
['--ed-fw' as string]: `${page.size.width}px`,
|
|
154
|
+
['--ed-fh' as string]: `${page.size.height}px`,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ)+ 別タブで開く(href があるとき) */}
|
|
112
158
|
{showLabel && (
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
159
|
+
<div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth: 'max(28px, calc(var(--ed-fw) * var(--ed-zoom, 1)))' }}>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
data-frame-label={page.id}
|
|
163
|
+
className={cn('ed-frame-label', isSelected && 'ed-frame-label-active')}
|
|
164
|
+
title={`${page.index + 1}. ${page.title || page.id}${isSelected ? '(編集中)' : ''}。ダブルクリックでこのページを画面に合わせる`}
|
|
165
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
166
|
+
onClick={() => (isSelected ? onZoomTo(page.id) : onActivate(page.id))}
|
|
167
|
+
onDoubleClick={() => onZoomTo(page.id)}
|
|
168
|
+
>
|
|
169
|
+
<span className="ed-frame-label-index">{page.index + 1}</span>
|
|
170
|
+
<span className="ed-frame-label-title">{page.title || page.id}</span>
|
|
171
|
+
{page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
|
|
172
|
+
{isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
|
|
173
|
+
</button>
|
|
174
|
+
{page.href && showLink && (
|
|
175
|
+
<a
|
|
176
|
+
className="ed-frame-label-link"
|
|
177
|
+
data-frame-link={page.id}
|
|
178
|
+
href={page.href}
|
|
179
|
+
target="_blank"
|
|
180
|
+
rel="noreferrer"
|
|
181
|
+
title="別タブで開く(編集しない表示)"
|
|
182
|
+
aria-label={`${page.title || page.id} を別タブで開く`}
|
|
183
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
184
|
+
onClick={(e) => e.stopPropagation()}
|
|
185
|
+
>
|
|
186
|
+
<ArrowUpRight aria-hidden="true" />
|
|
187
|
+
</a>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
128
190
|
)}
|
|
129
|
-
{/* リング(選択中 = 実線、ホバー = 淡い実線) */}
|
|
130
|
-
{
|
|
191
|
+
{/* リング(選択中 = 実線、ホバー = 淡い実線)。画面上の寸法 = 紙面の寸法 × 倍率 */}
|
|
192
|
+
{hasRing && (
|
|
131
193
|
<div
|
|
132
194
|
className={cn('ed-frame-ring', isSelected ? 'ed-frame-ring-active' : 'ed-frame-ring-hover')}
|
|
133
|
-
style={{ width:
|
|
195
|
+
style={{ width: 'calc(var(--ed-fw) * var(--ed-zoom, 1))', height: 'calc(var(--ed-fh) * var(--ed-zoom, 1))' }}
|
|
134
196
|
/>
|
|
135
197
|
)}
|
|
136
198
|
</div>
|
|
@@ -146,6 +208,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
146
208
|
const {
|
|
147
209
|
pages,
|
|
148
210
|
bounds,
|
|
211
|
+
viewStore,
|
|
149
212
|
registerContainer,
|
|
150
213
|
activatePage,
|
|
151
214
|
activatingPageId,
|
|
@@ -156,10 +219,14 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
156
219
|
isInteracting,
|
|
157
220
|
initialViewRestored,
|
|
158
221
|
} = canvas;
|
|
159
|
-
|
|
222
|
+
// 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
|
|
223
|
+
// 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
|
|
224
|
+
const activePageId = useCanvasActivePageId();
|
|
160
225
|
const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
|
|
161
226
|
const theme = useContext(EditorAppearanceContext) ?? 'light';
|
|
162
227
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
228
|
+
const layerRef = useRef<HTMLDivElement>(null);
|
|
229
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
163
230
|
const active = useMemo(() => pages.find((p) => p.id === activePageId) ?? null, [pages, activePageId]);
|
|
164
231
|
// リング・名前の強調は「移ろうとしているページ」を優先する(押した瞬間に手応えを返す)
|
|
165
232
|
const selectedId = activatingPageId ?? activePageId;
|
|
@@ -176,6 +243,60 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
176
243
|
return () => registerContainer(null);
|
|
177
244
|
}, [registerContainer]);
|
|
178
245
|
|
|
246
|
+
// ---- 倍率・位置の反映(React を通さない)
|
|
247
|
+
const [coarseZoom, setCoarseZoom] = useState(() => viewStore.get().canvasZoom);
|
|
248
|
+
const coarseZoomRef = useRef(coarseZoom);
|
|
249
|
+
coarseZoomRef.current = coarseZoom;
|
|
250
|
+
|
|
251
|
+
useLayoutEffect(() => {
|
|
252
|
+
let lastZoom = NaN;
|
|
253
|
+
let coarseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
254
|
+
let coarseAt = 0;
|
|
255
|
+
const pushCoarse = () => {
|
|
256
|
+
coarseAt = performance.now();
|
|
257
|
+
const z = viewStore.get().canvasZoom;
|
|
258
|
+
if (coarseZoomRef.current === z) return;
|
|
259
|
+
coarseZoomRef.current = z;
|
|
260
|
+
setCoarseZoom(z);
|
|
261
|
+
};
|
|
262
|
+
const write = () => {
|
|
263
|
+
const { canvasOffset: o, canvasZoom: z } = viewStore.get();
|
|
264
|
+
const transform = `translate(${o.x}px, ${o.y}px) scale(${z})`;
|
|
265
|
+
if (layerRef.current) layerRef.current.style.transform = transform;
|
|
266
|
+
const overlay = overlayRef.current;
|
|
267
|
+
if (overlay) overlay.style.transform = transform;
|
|
268
|
+
if (z === lastZoom) return;
|
|
269
|
+
lastZoom = z;
|
|
270
|
+
if (overlay) {
|
|
271
|
+
// 中身の scale(1/倍率) とリングの寸法に使う。この 1 要素だけが段ごとに変わる
|
|
272
|
+
overlay.style.setProperty('--ed-zoom', String(z));
|
|
273
|
+
overlay.style.setProperty('--ed-inv', String(1 / z));
|
|
274
|
+
}
|
|
275
|
+
// 検証スクリプトとヘッダーの表示が読む(四捨五入した %)
|
|
276
|
+
const el = containerRef.current;
|
|
277
|
+
const pct = String(Math.round(z * 100));
|
|
278
|
+
if (el && el.getAttribute('data-canvas-zoom') !== pct) el.setAttribute('data-canvas-zoom', pct);
|
|
279
|
+
// 縮小の途中でも 0.4 を割ったら、画像が読めている紙面は iframe を止めて画像を前に出す
|
|
280
|
+
// (CSS が拾う。属性 1 つの書き換えで 26 枚がいっぺんに切り替わり、React は通らない)。
|
|
281
|
+
// iframe を外してメモリを返すのは操作が止まってから(下の imageMode)。
|
|
282
|
+
// 外す向き(画像 → iframe)もここではやらない ── 止まってから 0.5 以上で判定する
|
|
283
|
+
if (el && z < PREVIEW_IMAGE_ZOOM_OUT && el.getAttribute('data-canvas-preview-image') !== '1') {
|
|
284
|
+
el.setAttribute('data-canvas-preview-image', '1');
|
|
285
|
+
}
|
|
286
|
+
// 粗い倍率: 150ms に 1 回まで。末尾のタイマーで操作の終わりに必ず追いつく
|
|
287
|
+
if (coarseTimer) clearTimeout(coarseTimer);
|
|
288
|
+
const elapsed = performance.now() - coarseAt;
|
|
289
|
+
if (elapsed >= COARSE_ZOOM_MS) pushCoarse();
|
|
290
|
+
else coarseTimer = setTimeout(() => { coarseTimer = null; pushCoarse(); }, COARSE_ZOOM_MS - elapsed);
|
|
291
|
+
};
|
|
292
|
+
write();
|
|
293
|
+
const unsubscribe = viewStore.subscribe(write);
|
|
294
|
+
return () => {
|
|
295
|
+
unsubscribe();
|
|
296
|
+
if (coarseTimer) clearTimeout(coarseTimer);
|
|
297
|
+
};
|
|
298
|
+
}, [viewStore]);
|
|
299
|
+
|
|
179
300
|
// エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
|
|
180
301
|
useEffect(() => {
|
|
181
302
|
setZoom(100);
|
|
@@ -242,30 +363,47 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
242
363
|
const handleActivate = useCallback((id: string) => { void activatePage(id, { reveal: true }); }, [activatePage]);
|
|
243
364
|
const handleZoomTo = useCallback((id: string) => zoomToPage(id, { animate: true }), [zoomToPage]);
|
|
244
365
|
|
|
366
|
+
// ---- 紙面を画像に落とすか(大きく縮小している間だけ)
|
|
367
|
+
// 判定は操作が止まってからしか動かさない。縮小の途中で 26 枚の iframe を差し替えると、
|
|
368
|
+
// 軽くするための切替そのものが重いフレームを作る
|
|
369
|
+
const [imageMode, setImageMode] = useState(() => viewStore.get().canvasZoom < PREVIEW_IMAGE_ZOOM_OUT);
|
|
370
|
+
const imageModeRef = useRef(imageMode);
|
|
371
|
+
imageModeRef.current = imageMode;
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (isInteracting) return;
|
|
374
|
+
const timer = setTimeout(() => {
|
|
375
|
+
const z = viewStore.get().canvasZoom;
|
|
376
|
+
// ヒステリシス: 画像 → iframe は 0.5 以上、iframe → 画像は 0.4 未満(境目で行き来しない)
|
|
377
|
+
const next = imageModeRef.current ? z < PREVIEW_IMAGE_ZOOM : z < PREVIEW_IMAGE_ZOOM_OUT;
|
|
378
|
+
if (next !== imageModeRef.current) setImageMode(next);
|
|
379
|
+
// iframe に戻す向きに決まったら、縮小の途中で立てた掛け金も外す(iframe がまた見える)
|
|
380
|
+
if (!next) containerRef.current?.removeAttribute('data-canvas-preview-image');
|
|
381
|
+
}, PREVIEW_MODE_SETTLE_MS);
|
|
382
|
+
return () => clearTimeout(timer);
|
|
383
|
+
}, [isInteracting, coarseZoom, viewStore]);
|
|
384
|
+
// 画像を裏で先に読んでおくか(粗い倍率なので、段ごとには変わらない真偽値)
|
|
385
|
+
const preloadImage = coarseZoom < PREVIEW_IMAGE_PRELOAD_ZOOM;
|
|
386
|
+
|
|
245
387
|
const cursor = isPanning ? 'grabbing' : isSpaceHeld || activeTool === 'move' ? 'grab' : undefined;
|
|
246
388
|
// 行の隙間(画面上)に名前が収まらない倍率では名前を出さない。出すと下の行の名前が
|
|
247
389
|
// 上の行のフレームに被り、フレームのクリックを奪う(150 枚の全体表示で実測)
|
|
248
|
-
const labelsVisible = FRAME_GAP[canvas.editorMode].y *
|
|
249
|
-
// data-interacting はズーム・パン中の印(CSS・検証用のフック。中では willChange に使う)
|
|
250
|
-
const layerTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`;
|
|
251
|
-
const overlayTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px)`;
|
|
390
|
+
const labelsVisible = FRAME_GAP[canvas.editorMode].y * coarseZoom >= LABEL_HEIGHT + 6;
|
|
252
391
|
|
|
253
392
|
return (
|
|
254
393
|
<div
|
|
255
394
|
ref={containerRef}
|
|
256
395
|
data-infinite-canvas="true"
|
|
257
|
-
data-canvas-zoom={Math.round(canvasZoom * 100)}
|
|
258
396
|
data-interacting={isInteracting ? 'true' : undefined}
|
|
259
397
|
className="absolute inset-0 overflow-hidden select-none"
|
|
260
398
|
style={{ background: 'var(--ed-canvas, var(--ed-bg))', cursor, touchAction: 'none', overscrollBehavior: 'none' }}
|
|
261
399
|
onMouseDown={onMarqueeMouseDown}
|
|
262
400
|
>
|
|
263
|
-
{/* 転写層 */}
|
|
401
|
+
{/* 転写層(transform は useLayoutEffect が直接書く。React の style には入れない) */}
|
|
264
402
|
<div
|
|
403
|
+
ref={layerRef}
|
|
265
404
|
data-canvas-transform-layer
|
|
266
405
|
className="absolute left-0 top-0"
|
|
267
406
|
style={{
|
|
268
|
-
transform: layerTransform,
|
|
269
407
|
transformOrigin: '0 0',
|
|
270
408
|
willChange: isInteracting ? 'transform' : undefined,
|
|
271
409
|
}}
|
|
@@ -274,9 +412,10 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
274
412
|
<FrameShell
|
|
275
413
|
key={page.id}
|
|
276
414
|
page={page}
|
|
277
|
-
zoom={canvasZoom}
|
|
278
415
|
isActive={page.id === activePageId}
|
|
279
416
|
passThrough={passThrough}
|
|
417
|
+
imageMode={imageMode}
|
|
418
|
+
preloadImage={preloadImage}
|
|
280
419
|
rootRef={containerRef}
|
|
281
420
|
onMouseDown={handleFrameMouseDown}
|
|
282
421
|
onHover={handleHover}
|
|
@@ -306,20 +445,27 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
306
445
|
)}
|
|
307
446
|
</div>
|
|
308
447
|
|
|
309
|
-
{/*
|
|
448
|
+
{/* フレームの枠線と階層の線(画面の座標の canvas)。転写層の中に置くと 1 段ごとに
|
|
449
|
+
転写層まるごとが塗り直される ── 詳しくは CanvasFrameDecor の頭の注記 */}
|
|
450
|
+
<CanvasFrameDecor theme={theme} />
|
|
451
|
+
|
|
452
|
+
{/* オーバーレイ: フレーム名・リング。転写層と同じ transform を掛け、中身が scale(1/倍率) で戻す */}
|
|
310
453
|
<div
|
|
454
|
+
ref={overlayRef}
|
|
311
455
|
className="pointer-events-none absolute left-0 top-0 z-10"
|
|
312
456
|
data-canvas-overlay
|
|
313
|
-
style={{
|
|
457
|
+
style={{ transformOrigin: '0 0', willChange: isInteracting ? 'transform' : undefined }}
|
|
314
458
|
>
|
|
315
459
|
{pages.map((page) => {
|
|
316
460
|
const isSelected = page.id === selectedId;
|
|
461
|
+
// 縮小してフレームが細くなったら名前は出さない(重ならない)。粗い倍率で判定する
|
|
462
|
+
const screenWidth = page.size.width * coarseZoom;
|
|
317
463
|
return (
|
|
318
464
|
<FrameChrome
|
|
319
465
|
key={page.id}
|
|
320
466
|
page={page}
|
|
321
|
-
|
|
322
|
-
|
|
467
|
+
showLabel={labelsVisible && screenWidth >= 28}
|
|
468
|
+
showLink={screenWidth >= 96}
|
|
323
469
|
isSelected={isSelected}
|
|
324
470
|
isHover={hoverId === page.id && !isSelected && !passThrough}
|
|
325
471
|
isBusy={page.loading || page.id === activatingPageId || (page.id === activePageId && !iframeReady)}
|