@growgroup/visual-editor 0.3.1 → 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 +6 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +3 -2
- package/src/editor/EditorToolbar.tsx +8 -2
- package/src/editor/FrontendVisualEditor.tsx +114 -44
- package/src/editor/components/EditorCanvas.tsx +73 -15
- package/src/editor/components/EditorFooter.tsx +2 -0
- package/src/editor/components/EditorHeader.tsx +3 -2
- 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 +173 -77
- package/src/editor/components/multi-page/PageFramePreview.tsx +96 -20
- 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 +16 -1
- package/src/editor/contexts/EditorArtboardContext.tsx +8 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +31 -27
- package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
- package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
- package/src/editor/contexts/MultiPageCanvasContext.tsx +26 -2
- 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 +11 -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/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/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 +9 -4
- package/src/types/editor.ts +6 -2
- package/src/version.ts +1 -1
|
@@ -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,11 +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';
|
|
42
55
|
import { ArrowUpRight } from 'lucide-react';
|
|
43
56
|
|
|
44
57
|
/** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
|
|
45
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;
|
|
46
72
|
|
|
47
73
|
// ------------------------------------------------------------
|
|
48
74
|
// フレームの白地 + 見るだけの紙面(転写層の中。紙面の座標)
|
|
@@ -50,47 +76,53 @@ const LABEL_HEIGHT = 20;
|
|
|
50
76
|
|
|
51
77
|
interface FrameShellProps {
|
|
52
78
|
page: PageFrameLayout;
|
|
53
|
-
zoom: number;
|
|
54
79
|
isActive: boolean;
|
|
55
80
|
passThrough: boolean;
|
|
81
|
+
/** 大きく縮小している間は紙面を画像で描く(この層の zoom には依存しない真偽値) */
|
|
82
|
+
imageMode: boolean;
|
|
83
|
+
/** 画像を裏で先に読んでおく(縮小の途中で切り替えられるように) */
|
|
84
|
+
preloadImage: boolean;
|
|
56
85
|
rootRef: React.RefObject<HTMLDivElement | null>;
|
|
57
86
|
onMouseDown: (e: React.MouseEvent, id: string) => void;
|
|
58
87
|
onHover: (id: string | null) => void;
|
|
59
88
|
}
|
|
60
89
|
|
|
61
|
-
|
|
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) {
|
|
62
95
|
return (
|
|
63
96
|
<div
|
|
64
97
|
data-page-frame={page.id}
|
|
65
98
|
data-page-active={isActive ? 'true' : undefined}
|
|
66
|
-
className="absolute"
|
|
99
|
+
className="ed-frame-shell absolute"
|
|
67
100
|
style={{
|
|
68
101
|
left: page.position.x,
|
|
69
102
|
top: page.position.y,
|
|
70
103
|
width: page.size.width,
|
|
71
104
|
height: page.size.height,
|
|
72
|
-
background: '#fff',
|
|
73
|
-
boxShadow: `0 0 0 ${1 / zoom}px var(--ed-frame-edge, rgba(0,0,0,0.08))`,
|
|
74
105
|
cursor: isActive || passThrough ? undefined : 'pointer',
|
|
75
106
|
}}
|
|
76
107
|
onMouseDown={(e) => onMouseDown(e, page.id)}
|
|
77
108
|
onMouseEnter={() => onHover(page.id)}
|
|
78
109
|
onMouseLeave={() => onHover(null)}
|
|
79
110
|
>
|
|
80
|
-
<PageFramePreview page={page} rootRef={rootRef} />
|
|
111
|
+
<PageFramePreview page={page} rootRef={rootRef} imageMode={imageMode} preloadImage={preloadImage} />
|
|
81
112
|
</div>
|
|
82
113
|
);
|
|
83
114
|
});
|
|
84
115
|
|
|
85
116
|
// ------------------------------------------------------------
|
|
86
|
-
// フレーム名・リング(
|
|
117
|
+
// フレーム名・リング(オーバーレイの中)
|
|
87
118
|
// ------------------------------------------------------------
|
|
88
119
|
|
|
89
120
|
interface FrameChromeProps {
|
|
90
121
|
page: PageFrameLayout;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
122
|
+
/** 名前を出すか(粗い倍率から親が判定する。全フレーム共通の条件 + このフレームの幅) */
|
|
123
|
+
showLabel: boolean;
|
|
124
|
+
/** 「別タブで開く」を出すか(名前の右。フレームが細いときは出さない) */
|
|
125
|
+
showLink: boolean;
|
|
94
126
|
/** リングと名前の強調(切替中はクリックしたページへ先に移る) */
|
|
95
127
|
isSelected: boolean;
|
|
96
128
|
isHover: boolean;
|
|
@@ -100,18 +132,31 @@ interface FrameChromeProps {
|
|
|
100
132
|
onZoomTo: (id: string) => void;
|
|
101
133
|
}
|
|
102
134
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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;
|
|
110
143
|
return (
|
|
111
|
-
<div
|
|
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
|
+
>
|
|
112
157
|
{/* フレーム名(Figma: フレームの左上、倍率に関係なく同じ大きさ)+ 別タブで開く(href があるとき) */}
|
|
113
158
|
{showLabel && (
|
|
114
|
-
<div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth:
|
|
159
|
+
<div className="ed-frame-label-row" style={{ top: -LABEL_HEIGHT, maxWidth: 'max(28px, calc(var(--ed-fw) * var(--ed-zoom, 1)))' }}>
|
|
115
160
|
<button
|
|
116
161
|
type="button"
|
|
117
162
|
data-frame-label={page.id}
|
|
@@ -126,7 +171,7 @@ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSel
|
|
|
126
171
|
{page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
|
|
127
172
|
{isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
|
|
128
173
|
</button>
|
|
129
|
-
{page.href &&
|
|
174
|
+
{page.href && showLink && (
|
|
130
175
|
<a
|
|
131
176
|
className="ed-frame-label-link"
|
|
132
177
|
data-frame-link={page.id}
|
|
@@ -143,46 +188,17 @@ const FrameChrome = memo(function FrameChrome({ page, zoom, labelsVisible, isSel
|
|
|
143
188
|
)}
|
|
144
189
|
</div>
|
|
145
190
|
)}
|
|
146
|
-
{/* リング(選択中 = 実線、ホバー = 淡い実線) */}
|
|
147
|
-
{
|
|
191
|
+
{/* リング(選択中 = 実線、ホバー = 淡い実線)。画面上の寸法 = 紙面の寸法 × 倍率 */}
|
|
192
|
+
{hasRing && (
|
|
148
193
|
<div
|
|
149
194
|
className={cn('ed-frame-ring', isSelected ? 'ed-frame-ring-active' : 'ed-frame-ring-hover')}
|
|
150
|
-
style={{ width:
|
|
195
|
+
style={{ width: 'calc(var(--ed-fw) * var(--ed-zoom, 1))', height: 'calc(var(--ed-fh) * var(--ed-zoom, 1))' }}
|
|
151
196
|
/>
|
|
152
197
|
)}
|
|
153
198
|
</div>
|
|
154
199
|
);
|
|
155
200
|
});
|
|
156
201
|
|
|
157
|
-
// ------------------------------------------------------------
|
|
158
|
-
// 階層の線(親の下端の中央 → 子の上端の中央。転写層の中、紙面の座標)
|
|
159
|
-
// ------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
const TreeConnectors = memo(function TreeConnectors({ pages, gapY }: { pages: PageFrameLayout[]; gapY: number }) {
|
|
162
|
-
const byId = useMemo(() => new Map(pages.map((p) => [p.id, p] as const)), [pages]);
|
|
163
|
-
const d = useMemo(() => {
|
|
164
|
-
const parts: string[] = [];
|
|
165
|
-
pages.forEach((child) => {
|
|
166
|
-
const parent = child.parentId ? byId.get(child.parentId) : undefined;
|
|
167
|
-
if (!parent) return;
|
|
168
|
-
const x1 = parent.position.x + parent.size.width / 2;
|
|
169
|
-
const y1 = parent.position.y + parent.size.height;
|
|
170
|
-
const x2 = child.position.x + child.size.width / 2;
|
|
171
|
-
const y2 = child.position.y;
|
|
172
|
-
// 親の下から、子の行との隙間の真ん中まで下り、横へ移って子の上へ
|
|
173
|
-
const ym = y2 - gapY / 2;
|
|
174
|
-
parts.push(`M${x1} ${y1} V${ym} H${x2} V${y2}`);
|
|
175
|
-
});
|
|
176
|
-
return parts.join(' ');
|
|
177
|
-
}, [pages, byId, gapY]);
|
|
178
|
-
if (!d) return null;
|
|
179
|
-
return (
|
|
180
|
-
<svg className="ed-frame-connectors" data-canvas-connectors aria-hidden="true" width={1} height={1} style={{ overflow: 'visible' }}>
|
|
181
|
-
<path d={d} fill="none" vectorEffect="non-scaling-stroke" />
|
|
182
|
-
</svg>
|
|
183
|
-
);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
202
|
// ------------------------------------------------------------
|
|
187
203
|
// キャンバス
|
|
188
204
|
// ------------------------------------------------------------
|
|
@@ -192,7 +208,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
192
208
|
const {
|
|
193
209
|
pages,
|
|
194
210
|
bounds,
|
|
195
|
-
|
|
211
|
+
viewStore,
|
|
196
212
|
registerContainer,
|
|
197
213
|
activatePage,
|
|
198
214
|
activatingPageId,
|
|
@@ -203,10 +219,14 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
203
219
|
isInteracting,
|
|
204
220
|
initialViewRestored,
|
|
205
221
|
} = canvas;
|
|
206
|
-
|
|
222
|
+
// 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
|
|
223
|
+
// 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
|
|
224
|
+
const activePageId = useCanvasActivePageId();
|
|
207
225
|
const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
|
|
208
226
|
const theme = useContext(EditorAppearanceContext) ?? 'light';
|
|
209
227
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
228
|
+
const layerRef = useRef<HTMLDivElement>(null);
|
|
229
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
210
230
|
const active = useMemo(() => pages.find((p) => p.id === activePageId) ?? null, [pages, activePageId]);
|
|
211
231
|
// リング・名前の強調は「移ろうとしているページ」を優先する(押した瞬間に手応えを返す)
|
|
212
232
|
const selectedId = activatingPageId ?? activePageId;
|
|
@@ -223,6 +243,60 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
223
243
|
return () => registerContainer(null);
|
|
224
244
|
}, [registerContainer]);
|
|
225
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
|
+
|
|
226
300
|
// エディタの内側の倍率は 100% 固定(倍率は外側の transform が持つ)
|
|
227
301
|
useEffect(() => {
|
|
228
302
|
setZoom(100);
|
|
@@ -289,44 +363,59 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
289
363
|
const handleActivate = useCallback((id: string) => { void activatePage(id, { reveal: true }); }, [activatePage]);
|
|
290
364
|
const handleZoomTo = useCallback((id: string) => zoomToPage(id, { animate: true }), [zoomToPage]);
|
|
291
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
|
+
|
|
292
387
|
const cursor = isPanning ? 'grabbing' : isSpaceHeld || activeTool === 'move' ? 'grab' : undefined;
|
|
293
388
|
// 行の隙間(画面上)に名前が収まらない倍率では名前を出さない。出すと下の行の名前が
|
|
294
389
|
// 上の行のフレームに被り、フレームのクリックを奪う(150 枚の全体表示で実測)
|
|
295
|
-
const labelsVisible = FRAME_GAP[canvas.editorMode].y *
|
|
296
|
-
// data-interacting はズーム・パン中の印(CSS・検証用のフック。中では willChange に使う)
|
|
297
|
-
const layerTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasZoom})`;
|
|
298
|
-
const overlayTransform = `translate(${canvasOffset.x}px, ${canvasOffset.y}px)`;
|
|
390
|
+
const labelsVisible = FRAME_GAP[canvas.editorMode].y * coarseZoom >= LABEL_HEIGHT + 6;
|
|
299
391
|
|
|
300
392
|
return (
|
|
301
393
|
<div
|
|
302
394
|
ref={containerRef}
|
|
303
395
|
data-infinite-canvas="true"
|
|
304
|
-
data-canvas-zoom={Math.round(canvasZoom * 100)}
|
|
305
396
|
data-interacting={isInteracting ? 'true' : undefined}
|
|
306
397
|
className="absolute inset-0 overflow-hidden select-none"
|
|
307
398
|
style={{ background: 'var(--ed-canvas, var(--ed-bg))', cursor, touchAction: 'none', overscrollBehavior: 'none' }}
|
|
308
399
|
onMouseDown={onMarqueeMouseDown}
|
|
309
400
|
>
|
|
310
|
-
{/* 転写層 */}
|
|
401
|
+
{/* 転写層(transform は useLayoutEffect が直接書く。React の style には入れない) */}
|
|
311
402
|
<div
|
|
403
|
+
ref={layerRef}
|
|
312
404
|
data-canvas-transform-layer
|
|
313
405
|
className="absolute left-0 top-0"
|
|
314
406
|
style={{
|
|
315
|
-
transform: layerTransform,
|
|
316
407
|
transformOrigin: '0 0',
|
|
317
408
|
willChange: isInteracting ? 'transform' : undefined,
|
|
318
409
|
}}
|
|
319
410
|
>
|
|
320
|
-
{/* 階層の線(フレームの下に描く。線の太さは倍率に関係なく一定 = non-scaling-stroke) */}
|
|
321
|
-
{layout === 'tree' && <TreeConnectors pages={pages} gapY={FRAME_GAP[canvas.editorMode].y} />}
|
|
322
|
-
|
|
323
411
|
{pages.map((page) => (
|
|
324
412
|
<FrameShell
|
|
325
413
|
key={page.id}
|
|
326
414
|
page={page}
|
|
327
|
-
zoom={canvasZoom}
|
|
328
415
|
isActive={page.id === activePageId}
|
|
329
416
|
passThrough={passThrough}
|
|
417
|
+
imageMode={imageMode}
|
|
418
|
+
preloadImage={preloadImage}
|
|
330
419
|
rootRef={containerRef}
|
|
331
420
|
onMouseDown={handleFrameMouseDown}
|
|
332
421
|
onHover={handleHover}
|
|
@@ -356,20 +445,27 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
356
445
|
)}
|
|
357
446
|
</div>
|
|
358
447
|
|
|
359
|
-
{/*
|
|
448
|
+
{/* フレームの枠線と階層の線(画面の座標の canvas)。転写層の中に置くと 1 段ごとに
|
|
449
|
+
転写層まるごとが塗り直される ── 詳しくは CanvasFrameDecor の頭の注記 */}
|
|
450
|
+
<CanvasFrameDecor theme={theme} />
|
|
451
|
+
|
|
452
|
+
{/* オーバーレイ: フレーム名・リング。転写層と同じ transform を掛け、中身が scale(1/倍率) で戻す */}
|
|
360
453
|
<div
|
|
454
|
+
ref={overlayRef}
|
|
361
455
|
className="pointer-events-none absolute left-0 top-0 z-10"
|
|
362
456
|
data-canvas-overlay
|
|
363
|
-
style={{
|
|
457
|
+
style={{ transformOrigin: '0 0', willChange: isInteracting ? 'transform' : undefined }}
|
|
364
458
|
>
|
|
365
459
|
{pages.map((page) => {
|
|
366
460
|
const isSelected = page.id === selectedId;
|
|
461
|
+
// 縮小してフレームが細くなったら名前は出さない(重ならない)。粗い倍率で判定する
|
|
462
|
+
const screenWidth = page.size.width * coarseZoom;
|
|
367
463
|
return (
|
|
368
464
|
<FrameChrome
|
|
369
465
|
key={page.id}
|
|
370
466
|
page={page}
|
|
371
|
-
|
|
372
|
-
|
|
467
|
+
showLabel={labelsVisible && screenWidth >= 28}
|
|
468
|
+
showLink={screenWidth >= 96}
|
|
373
469
|
isSelected={isSelected}
|
|
374
470
|
isHover={hoverId === page.id && !isSelected && !passThrough}
|
|
375
471
|
isBusy={page.loading || page.id === activatingPageId || (page.id === activePageId && !iframeReady)}
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* 1 枚ごとに「Blocked script execution in 'about:srcdoc'」をコンソールへ出す(実測 26 枚 = 26 件)
|
|
15
15
|
* - 本文が外から変わった(revision / invalidatePages)ときは、次の本文が来るまで前の姿を出したまま読み直す
|
|
16
16
|
* - 文書の属性(documentAttributes)は読み直さずに <html> へ付け替える
|
|
17
|
+
*
|
|
18
|
+
* [大きく縮小している間は画像]
|
|
19
|
+
* 全体が見えるところまで縮めると、26 枚の iframe を倍率が変わるたびにラスタライズし直す
|
|
20
|
+
* のが一番重い。利用側が contentList[].thumbnail(ページ全体を写した画像)を渡していれば、
|
|
21
|
+
* 倍率が PREVIEW_IMAGE_ZOOM 未満の間は iframe を外して画像 1 枚にする(本文も読まない)。
|
|
22
|
+
* 画像が無いページ・画像が読めなかったページは今までどおり iframe
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import { memo, useEffect, useRef, useState } from 'react';
|
|
@@ -36,9 +42,16 @@ interface PageFramePreviewProps {
|
|
|
36
42
|
page: PageFrameLayout;
|
|
37
43
|
/** 画面上の判定に使う容器(IntersectionObserver の root) */
|
|
38
44
|
rootRef: React.RefObject<HTMLDivElement | null>;
|
|
45
|
+
/** キャンバスが大きく縮小されている(親が操作の終わりに決める。段ごとには変わらない) */
|
|
46
|
+
imageMode?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* 画像を裏で先に読んでおく。縮小の途中(倍率 0.4 未満)で iframe を止めて画像を前に出すのは
|
|
49
|
+
* CSS の仕事だが、そのとき画像が読めていないと空白のフレームになる。だから先に読ませる
|
|
50
|
+
*/
|
|
51
|
+
preloadImage?: boolean;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }: PageFramePreviewProps) {
|
|
54
|
+
export const PageFramePreview = memo(function PageFramePreview({ page, rootRef, imageMode = false, preloadImage = false }: PageFramePreviewProps) {
|
|
42
55
|
const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes } = useMultiPageCanvas();
|
|
43
56
|
const documentAttributesRef = useRef(documentAttributes);
|
|
44
57
|
documentAttributesRef.current = documentAttributes;
|
|
@@ -47,6 +60,13 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
|
|
|
47
60
|
const [nearViewport, setNearViewport] = useState(false);
|
|
48
61
|
const [loaded, setLoaded] = useState(false);
|
|
49
62
|
|
|
63
|
+
// 画像の URL が変わったら、前の URL で失敗した記録は捨てる(直った画像が二度と出ないのを防ぐ)
|
|
64
|
+
const [imageFailed, setImageFailed] = useState(false);
|
|
65
|
+
useEffect(() => { setImageFailed(false); }, [page.thumbnail]);
|
|
66
|
+
const [imageLoaded, setImageLoaded] = useState(false);
|
|
67
|
+
useEffect(() => { setImageLoaded(false); }, [page.thumbnail]);
|
|
68
|
+
const useImage = imageMode && !!page.thumbnail && !imageFailed;
|
|
69
|
+
|
|
50
70
|
// 画面に近づいたら描く(遠ざかっても消さない: 戻ったときに白く抜けないため)
|
|
51
71
|
useEffect(() => {
|
|
52
72
|
const host = hostRef.current;
|
|
@@ -63,8 +83,9 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
|
|
|
63
83
|
}, [rootRef]);
|
|
64
84
|
|
|
65
85
|
// webpage は高さの実測がレイアウトに要る(未測定だと仮の高さで並ぶ)ので、見えていなくても読む。
|
|
66
|
-
// slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)
|
|
67
|
-
|
|
86
|
+
// slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)。
|
|
87
|
+
// 画像で描いている間は本文を読まない(iframe も載せない)
|
|
88
|
+
const wantsMount = !useImage && (editorMode === 'webpage' || nearViewport);
|
|
68
89
|
const [granted, setGranted] = useState(false);
|
|
69
90
|
const grantedRef = useRef(false);
|
|
70
91
|
useEffect(() => {
|
|
@@ -79,7 +100,17 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
|
|
|
79
100
|
};
|
|
80
101
|
}, [wantsMount, page.id, requestPreviewSlot, releasePreviewSlot]);
|
|
81
102
|
useEffect(() => () => releasePreviewSlot(page.id), [page.id, releasePreviewSlot]);
|
|
82
|
-
|
|
103
|
+
|
|
104
|
+
// 画像に落ちたら iframe を外してメモリを返す(戻ってきたら順番待ちから取り直す)
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!useImage) return;
|
|
107
|
+
grantedRef.current = false;
|
|
108
|
+
setGranted(false);
|
|
109
|
+
setLoaded(false);
|
|
110
|
+
releasePreviewSlot(page.id);
|
|
111
|
+
}, [useImage, page.id, releasePreviewSlot]);
|
|
112
|
+
|
|
113
|
+
const shouldMount = granted && !useImage;
|
|
83
114
|
|
|
84
115
|
useEffect(() => {
|
|
85
116
|
if (shouldMount && page.html == null && !page.loading && !page.error) void ensurePageHtml(page.id);
|
|
@@ -92,10 +123,12 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
|
|
|
92
123
|
setDoc(generatePreviewHtml(page.html, editorMode, page.size.width, previewStyles));
|
|
93
124
|
}, [shouldMount, page.html, editorMode, page.size.width, previewStyles]);
|
|
94
125
|
|
|
95
|
-
// 高さの実測(同一オリジンなので親から読む)
|
|
126
|
+
// 高さの実測(同一オリジンなので親から読む)。
|
|
127
|
+
// shouldMount を deps に入れるのは、画像から iframe に戻ったとき(doc は前のまま)にも
|
|
128
|
+
// 新しい iframe へ load を張り直すため
|
|
96
129
|
useEffect(() => {
|
|
97
130
|
const iframe = iframeRef.current;
|
|
98
|
-
if (!iframe || doc == null) return;
|
|
131
|
+
if (!iframe || doc == null || !shouldMount) return;
|
|
99
132
|
let observer: ResizeObserver | null = null;
|
|
100
133
|
let disposed = false;
|
|
101
134
|
const measure = () => {
|
|
@@ -126,12 +159,14 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
|
|
|
126
159
|
}
|
|
127
160
|
};
|
|
128
161
|
iframe.addEventListener('load', onLoad);
|
|
162
|
+
// 画像から戻ったときは srcdoc が同じなので load が来ないことがある。読めていればその場で測る
|
|
163
|
+
if (iframe.contentDocument?.readyState === 'complete') onLoad();
|
|
129
164
|
return () => {
|
|
130
165
|
disposed = true;
|
|
131
166
|
iframe.removeEventListener('load', onLoad);
|
|
132
167
|
observer?.disconnect();
|
|
133
168
|
};
|
|
134
|
-
}, [doc, page.id, setPageHeight]);
|
|
169
|
+
}, [doc, shouldMount, page.id, setPageHeight]);
|
|
135
170
|
|
|
136
171
|
// 文書の属性が変わったら、読み直さずに <html> へ付け替える(読み込み前なら onLoad が付ける)
|
|
137
172
|
useEffect(() => {
|
|
@@ -154,21 +189,60 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
|
|
|
154
189
|
return () => clearTimeout(timer);
|
|
155
190
|
}, [granted, loaded, page.error, page.id, releasePreviewSlot]);
|
|
156
191
|
|
|
192
|
+
/**
|
|
193
|
+
* 画像を出す(または iframe の下に敷いておく)か。敷いておく理由は 2 つ:
|
|
194
|
+
* - 画像 → iframe に戻る途中、本文が描けるまで下に見せる(26 枚が一度白く抜けない)
|
|
195
|
+
* - 縮小の途中で 0.4 を割ったとき、CSS が iframe を止めるだけで画像に切り替わる
|
|
196
|
+
* (読めていない画像は data-image-ready が付かないので、iframe を見せたまま)
|
|
197
|
+
*/
|
|
198
|
+
const showImage = !!page.thumbnail && !imageFailed && (useImage || preloadImage || !loaded);
|
|
199
|
+
|
|
157
200
|
return (
|
|
158
201
|
<div
|
|
159
202
|
ref={hostRef}
|
|
160
203
|
className="absolute inset-0 overflow-hidden"
|
|
161
204
|
data-page-preview={page.id}
|
|
205
|
+
data-preview-mode={useImage ? 'image' : 'iframe'}
|
|
206
|
+
// 画像が読めている = 縮小の途中でも iframe を止めて前に出せる(skin.css が拾う)
|
|
207
|
+
data-image-ready={showImage && imageLoaded ? '1' : undefined}
|
|
162
208
|
style={{ background: '#fff' }}
|
|
163
209
|
>
|
|
164
|
-
{
|
|
210
|
+
{showImage && (
|
|
211
|
+
<img
|
|
212
|
+
src={page.thumbnail}
|
|
213
|
+
alt=""
|
|
214
|
+
aria-hidden="true"
|
|
215
|
+
draggable={false}
|
|
216
|
+
decoding="async"
|
|
217
|
+
className="absolute inset-0 block"
|
|
218
|
+
style={{
|
|
219
|
+
width: '100%', height: '100%', objectFit: 'fill', pointerEvents: 'none',
|
|
220
|
+
// iframe と同じ理由。無いと倍率が変わるたびに 26 枚の画像がラスタライズし直され、
|
|
221
|
+
// 画像に落としたのに縮小 p95 が 19〜23ms のままになる(付けると 16ms 台。実測)
|
|
222
|
+
willChange: 'transform',
|
|
223
|
+
}}
|
|
224
|
+
onLoad={(e) => {
|
|
225
|
+
setImageLoaded(true);
|
|
226
|
+
// 画像しか出していないページは iframe が高さを測れない。画像はページと同じ縦横比
|
|
227
|
+
// (利用側との約束)なので、フレームの幅から高さを出して並びを正す
|
|
228
|
+
const img = e.currentTarget;
|
|
229
|
+
if (!img.naturalWidth || !img.naturalHeight) return;
|
|
230
|
+
setPageHeight(page.id, (page.size.width * img.naturalHeight) / img.naturalWidth, 'preview');
|
|
231
|
+
}}
|
|
232
|
+
onError={() => setImageFailed(true)}
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
{useImage ? (
|
|
236
|
+
// 画像がまだ来ていない間は骨組みを下に敷く(白く抜けない)
|
|
237
|
+
!imageLoaded && <div className="ed-frame-skeleton" aria-hidden="true" />
|
|
238
|
+
) : doc != null ? (
|
|
165
239
|
<iframe
|
|
166
240
|
ref={iframeRef}
|
|
167
241
|
title={page.title || page.id}
|
|
168
242
|
srcDoc={doc}
|
|
169
243
|
tabIndex={-1}
|
|
170
244
|
aria-hidden="true"
|
|
171
|
-
className="block border-0"
|
|
245
|
+
className="relative block border-0"
|
|
172
246
|
style={{
|
|
173
247
|
width: page.size.width,
|
|
174
248
|
height: page.size.height,
|
|
@@ -180,17 +254,19 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
|
|
|
180
254
|
}}
|
|
181
255
|
/>
|
|
182
256
|
) : (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
257
|
+
!showImage && (
|
|
258
|
+
<div className="ed-frame-skeleton" aria-hidden="true">
|
|
259
|
+
{page.error ? (
|
|
260
|
+
<span className="ed-frame-skeleton-note">読み込めませんでした</span>
|
|
261
|
+
) : (
|
|
262
|
+
<>
|
|
263
|
+
<span className="ed-frame-skeleton-bar" style={{ width: '38%' }} />
|
|
264
|
+
<span className="ed-frame-skeleton-bar" style={{ width: '72%' }} />
|
|
265
|
+
<span className="ed-frame-skeleton-bar" style={{ width: '56%' }} />
|
|
266
|
+
</>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
194
270
|
)}
|
|
195
271
|
</div>
|
|
196
272
|
);
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
Undo2, Redo2, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
|
|
28
28
|
Type, Square, Circle, Minus, MoveUpRight, Image as ImageIcon, LayoutGrid,
|
|
29
29
|
Boxes, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
|
|
30
|
-
Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play,
|
|
30
|
+
Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play, Crosshair,
|
|
31
31
|
Palette, Sparkles, EyeOff, ArrowUp, ArrowDown, Film,
|
|
32
32
|
MoreHorizontal, MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
|
|
33
33
|
PaintBucket, PenLine, Eraser, Highlighter, Table, Shapes, Sticker, Wand2,
|
|
@@ -1010,7 +1010,7 @@ export function PptRibbon({
|
|
|
1010
1010
|
onSwitchUi: () => void;
|
|
1011
1011
|
page: number;
|
|
1012
1012
|
deckTitle?: string;
|
|
1013
|
-
comments: { open: boolean; toggle: () => void; newComment: () => void };
|
|
1013
|
+
comments: { open: boolean; toggle: () => void; newComment: () => void; pickRegion?: () => void };
|
|
1014
1014
|
}) {
|
|
1015
1015
|
const pal = PALETTES[theme];
|
|
1016
1016
|
const [tab, setTab] = useState<TabId>('home');
|
|
@@ -1820,6 +1820,10 @@ export function PptRibbon({
|
|
|
1820
1820
|
<>
|
|
1821
1821
|
<BigButton icon={MessageSquare} label={'新しい\nコメント'} onClick={comments.newComment}
|
|
1822
1822
|
title="コメントを追加(要素を選択していればその要素に添付)" pal={pal} />
|
|
1823
|
+
{comments.pickRegion && (
|
|
1824
|
+
<BigButton icon={Crosshair} label={'範囲を\n指定'} active={actions.activeTool === 'comment'} onClick={comments.pickRegion}
|
|
1825
|
+
title="スライドをドラッグして、コメントする範囲を指定します (C)" pal={pal} />
|
|
1826
|
+
)}
|
|
1823
1827
|
<BigButton icon={MessageSquare} label={'コメント\nの表示'} active={comments.open} onClick={comments.toggle} pal={pal} />
|
|
1824
1828
|
<Sep pal={pal} />
|
|
1825
1829
|
{(() => {
|