@growgroup/visual-editor 0.9.1 → 0.10.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 +66 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +5 -1
- package/src/editor/FrontendVisualEditor.tsx +8 -0
- package/src/editor/components/EditorCanvas.tsx +11 -0
- package/src/editor/components/EditorHeader.tsx +15 -2
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
- package/src/editor/components/ppt/PptComments.tsx +98 -1
- package/src/editor/constants.ts +9 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +57 -8
- package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
- package/src/editor/hooks/useCanvasControls.ts +2 -5
- package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
- package/src/editor/utils/canvas-gestures.ts +210 -0
- package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
- package/src/index.ts +5 -1
- package/src/io.ts +54 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -116,9 +116,73 @@ setEditorIO({
|
|
|
116
116
|
|
|
117
117
|
`onSave` には `contentId` が付くので、どのページの本文かはそれで見分けてください。
|
|
118
118
|
`contentId` プロップを変えると、エディタがそのページへ移ります。
|
|
119
|
-
操作(
|
|
119
|
+
操作(⌘+ホイール・ピンチで拡大縮小、Space+ドラッグで移動、⇧1 全体、⇧2 編集中のページ、⇧R 定規)と仕組みは
|
|
120
120
|
[docs/multi-frame-canvas-2026-09.md](docs/multi-frame-canvas-2026-09.md)。
|
|
121
121
|
|
|
122
|
+
### キャンバスの拡大縮小を Figma にそろえる・紙面の外の領域(0.10.0)
|
|
123
|
+
|
|
124
|
+
- **Safari・WKWebView のトラックパッドのピンチでも拡大縮小します**。WebKit はピンチを `gesturestart` / `gesturechange` で送る
|
|
125
|
+
(Chrome・Edge・Electron は ctrl 付きの wheel)。以前は止めるだけで何も起きませんでした。紙面(iframe)の上のピンチも効きます。
|
|
126
|
+
どの経路(ホイール・ピンチ・タッチ)もカーソル位置を中心にします
|
|
127
|
+
- IME の変換中のキー(⌘+ や Space 等)は奪いません
|
|
128
|
+
- `canvasZoomOptions` で拡大縮小の設定を変えられます。**省略時は従来の挙動のまま**です
|
|
129
|
+
- `min` / `max`: 倍率の範囲(省略時 2%〜400%)
|
|
130
|
+
- `steps`: ⌘+ / ⌘− とヘッダーの +/− の刻み。`'ratio'`(1.25 倍ずつ。省略時)か `'powers-of-two'`(…50% → 100% → 200%…。Figma)
|
|
131
|
+
- `fitShortcut`: ⌘2 / ⇧2 の合わせ先。`'page'`(編集中のページ。省略時)か `'selection'`(選んでいる要素。無ければページ。Figma。倍率メニューに「選択範囲に合わせる」も出る)
|
|
132
|
+
- 紙面(`#artboard`)の外、上下に利用側が表示だけの領域(サイトの共通のヘッダー・フッター等)を並べるときは、
|
|
133
|
+
その要素に `FRAME_REGION_ATTRIBUTE`(`data-editor-frame-region`)を付けるとフレームの高さに入ります(付けないと紙面の高さで切れます)。
|
|
134
|
+
保存・共同編集が送るのは `#artboard` の中身だけなので、本文には混ざりません
|
|
135
|
+
- 仕組みと確かめたことは [docs/figma-zoom-2026-09.md](docs/figma-zoom-2026-09.md) と [docs/frame-regions-2026-09.md](docs/frame-regions-2026-09.md)
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
<VisualEditor
|
|
139
|
+
enableMultiPageCanvas
|
|
140
|
+
// Figma にそろえる(省略時は 2%〜400%・1.25 倍ずつ・⌘2 は編集中のページ)
|
|
141
|
+
canvasZoomOptions={{ min: 0.02, max: 256, steps: "powers-of-two", fitShortcut: "selection" }}
|
|
142
|
+
/* … */
|
|
143
|
+
/>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### コメントに操作を足す(0.10.0)
|
|
147
|
+
|
|
148
|
+
- `io.commentActions` で、コメントパネルの**各スレッド**と**パネル上部(いま開いているページ)**に利用側のボタンを足せます。
|
|
149
|
+
何をするか(返信を足す・外部に依頼する・別画面を開く など)は利用側が決め、エディタは
|
|
150
|
+
ボタンを出す・押している間の表示(二重押しの防止)・失敗の表示・戻り値の deck の反映だけを受け持ちます
|
|
151
|
+
- **渡さなければ見た目も挙動も 0.9 系と同じ**です。コメントパネルは `commentAction` がある場合だけ出るので、
|
|
152
|
+
`commentActions` だけ渡しても出ません
|
|
153
|
+
- スレッドの操作は既定で**未解決のスレッドにだけ**、パネルの操作は既定で**そのページに未解決が 1 件以上あるときだけ**出ます(`when` で変えられます)
|
|
154
|
+
- `run` が `{ deck }` を返せば一覧に反映し、`{ message }` を返せば押した場所の下に出します。投げたエラーも同じ場所に出します
|
|
155
|
+
- 走っている間も他のコメント操作(投稿・返信・解決)は止めません
|
|
156
|
+
- `apiFetch` を渡している利用側に出る「AIで修正」「すべてAIで修正」(`/__comment-fix`)は今までどおりで、これとは別物です
|
|
157
|
+
- 出すのは編集画面の右のコメントパネルだけです(`CommentBoard` / `CommentBoardPanel` には出しません)
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
setEditorIO({
|
|
161
|
+
loadDeck,
|
|
162
|
+
commentAction,
|
|
163
|
+
commentActions: {
|
|
164
|
+
thread: [{
|
|
165
|
+
id: "ask-fix",
|
|
166
|
+
label: "直してもらう",
|
|
167
|
+
run: async ({ page, comment }) => {
|
|
168
|
+
await requestFix(page, comment.id); // 利用側の処理
|
|
169
|
+
return { message: "依頼しました" }; // 押した場所の下に出る
|
|
170
|
+
},
|
|
171
|
+
}],
|
|
172
|
+
panel: [{
|
|
173
|
+
id: "ask-fix-all",
|
|
174
|
+
label: "未解決をまとめて直してもらう",
|
|
175
|
+
run: async ({ page, comments }) => {
|
|
176
|
+
await requestFix(page, comments.filter((c) => !c.resolved).map((c) => c.id));
|
|
177
|
+
return { message: "依頼しました" };
|
|
178
|
+
},
|
|
179
|
+
}],
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- 仕組みと確かめたことは [docs/comment-actions-2026-09.md](docs/comment-actions-2026-09.md)
|
|
185
|
+
|
|
122
186
|
### リアルタイム共同編集(Figma 風、0.6.0)
|
|
123
187
|
|
|
124
188
|
`io.collab` を渡すと、同じページを複数人が同時に編集できます。渡さなければ今までどおり
|
|
@@ -233,6 +297,7 @@ setEditorIO({
|
|
|
233
297
|
|---|---|
|
|
234
298
|
| `loadDeck` | 一覧は空として振る舞う |
|
|
235
299
|
| `commentAction` | コメント UI を出さない |
|
|
300
|
+
| `commentActions` | コメントパネルに利用側の操作を足さない(0.9 系と同じ見た目) |
|
|
236
301
|
| `deckOps` | 並び替え・複製・削除を出さない |
|
|
237
302
|
| `apiFetch` | ページ設定・AI 機能は「未提供」として既定値へ落ちる |
|
|
238
303
|
| `uploadImage` | 画像は data URL のまま本文に埋め込む |
|
package/package.json
CHANGED
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
useMultiPageCanvasOptional,
|
|
81
81
|
} from './contexts/MultiPageCanvasContext';
|
|
82
82
|
import type { CSSVariableDefinition } from '../types/css-variables';
|
|
83
|
+
import type { CanvasZoomOptions } from './utils/canvas-gestures';
|
|
83
84
|
import { debugLog } from './utils/debug';
|
|
84
85
|
|
|
85
86
|
// 型の再エクスポート
|
|
@@ -284,6 +285,8 @@ interface EditorProviderProps {
|
|
|
284
285
|
enableMultiPageCanvas?: boolean;
|
|
285
286
|
/** キャンバスの表示位置(倍率・スクロール)を記憶するキー */
|
|
286
287
|
canvasStorageKey?: string | null;
|
|
288
|
+
/** マルチフレームのキャンバスの拡大縮小の設定(範囲・刻み・⌘2 の合わせ先。省略時は従来の挙動) — 0.10.0 */
|
|
289
|
+
canvasZoomOptions?: CanvasZoomOptions;
|
|
287
290
|
/** @deprecated Use contentList instead */
|
|
288
291
|
slides?: ContentListItem[];
|
|
289
292
|
/** @deprecated Use currentContentId instead */
|
|
@@ -541,6 +544,7 @@ export function EditorProvider({
|
|
|
541
544
|
// Multi-page canvas
|
|
542
545
|
enableMultiPageCanvas = false,
|
|
543
546
|
canvasStorageKey,
|
|
547
|
+
canvasZoomOptions,
|
|
544
548
|
// deprecated props
|
|
545
549
|
slides,
|
|
546
550
|
currentSlideId,
|
|
@@ -568,7 +572,7 @@ export function EditorProvider({
|
|
|
568
572
|
currentSlideId={currentSlideId}
|
|
569
573
|
onSlideChange={onSlideChange}
|
|
570
574
|
>
|
|
571
|
-
<ConditionalMultiPageProvider enabled={enableMultiPageCanvas} storageKey={canvasStorageKey}>
|
|
575
|
+
<ConditionalMultiPageProvider enabled={enableMultiPageCanvas} storageKey={canvasStorageKey} zoomOptions={canvasZoomOptions}>
|
|
572
576
|
<EditorUIStateProviderWrapper>
|
|
573
577
|
<EditorVariablesProviderWrapper
|
|
574
578
|
initialVariables={initialVariables}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import type { MoveElementResult } from './hooks/useElementActions';
|
|
23
23
|
import { EditorProvider, useEditorContext, type ContentListItem, type EditorMode } from './EditorContext';
|
|
24
24
|
import type { DocumentAttributes } from './contexts/EditorArtboardContext';
|
|
25
|
+
import type { CanvasZoomOptions } from './utils/canvas-gestures';
|
|
25
26
|
import { PptTitleBar, PptRibbon, PptThumbnails, PptStatusBar, initialPptTheme, PPT_PALETTES, type PptTheme } from './components/ppt/PptChrome';
|
|
26
27
|
import { LeftPanel } from './components/shell/LeftPanel';
|
|
27
28
|
import { useAltMeasure } from './hooks/useAltMeasure';
|
|
@@ -187,6 +188,11 @@ export interface FrontendVisualEditorProps {
|
|
|
187
188
|
onContentChange?: (contentId: string) => void;
|
|
188
189
|
/** キャンバスの表示位置(倍率・スクロール)を記憶するキー。省略時は parentId */
|
|
189
190
|
canvasStorageKey?: string;
|
|
191
|
+
/**
|
|
192
|
+
* マルチフレームのキャンバスの拡大縮小の設定(0.10.0)。省略時は従来の挙動(2%〜400%・1.25 倍ずつ・⌘2 は編集中のページ)。
|
|
193
|
+
* Figma にそろえるなら `{ min: 0.02, max: 256, steps: 'powers-of-two', fitShortcut: 'selection' }`
|
|
194
|
+
*/
|
|
195
|
+
canvasZoomOptions?: CanvasZoomOptions;
|
|
190
196
|
/**
|
|
191
197
|
* 外部から渡すコンテンツリスト(指定時はAPI取得をスキップ)。
|
|
192
198
|
* parentId を渡すとキャンバスが階層のツリーで並び、href を渡すと「別タブで開く」が付き、
|
|
@@ -3071,6 +3077,7 @@ export function FrontendVisualEditor({
|
|
|
3071
3077
|
headerExtra,
|
|
3072
3078
|
onContentChange: onContentChangeProp,
|
|
3073
3079
|
canvasStorageKey,
|
|
3080
|
+
canvasZoomOptions,
|
|
3074
3081
|
documentAttributes,
|
|
3075
3082
|
}: FrontendVisualEditorProps) {
|
|
3076
3083
|
const { getIdToken } = useAuth();
|
|
@@ -3363,6 +3370,7 @@ export function FrontendVisualEditor({
|
|
|
3363
3370
|
websiteId={effectiveParentId}
|
|
3364
3371
|
enableMultiPageCanvas={enableMultiPageCanvas}
|
|
3365
3372
|
canvasStorageKey={canvasStorageKey ?? effectiveParentId ?? null}
|
|
3373
|
+
canvasZoomOptions={canvasZoomOptions}
|
|
3366
3374
|
>
|
|
3367
3375
|
<FrontendVisualEditorInner
|
|
3368
3376
|
onSave={onSave}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
SLIDE_WIDTH,
|
|
18
18
|
SLIDE_HEIGHT,
|
|
19
19
|
WEBPAGE_MIN_HEIGHT,
|
|
20
|
+
FRAME_REGION_ATTRIBUTE,
|
|
20
21
|
} from "../constants";
|
|
21
22
|
import {
|
|
22
23
|
applyCanvasZoomDom,
|
|
@@ -344,6 +345,13 @@ export function EditorCanvas() {
|
|
|
344
345
|
}
|
|
345
346
|
});
|
|
346
347
|
|
|
348
|
+
// 利用側が紙面(#artboard)の外、上下に並べた表示だけの領域(サイトの共通のヘッダー・フッター等)も
|
|
349
|
+
// フレームの高さに入れる。目印は FRAME_REGION_ATTRIBUTE(0.10.0。docs/frame-regions-2026-09.md)
|
|
350
|
+
iframeDoc.querySelectorAll(`[${FRAME_REGION_ATTRIBUTE}]`).forEach((el) => {
|
|
351
|
+
const bottom = (el as HTMLElement).getBoundingClientRect().bottom;
|
|
352
|
+
if (bottom > maxBottom) maxBottom = bottom;
|
|
353
|
+
});
|
|
354
|
+
|
|
347
355
|
const scrollHeight = targetElement.scrollHeight;
|
|
348
356
|
const computedHeight = Math.max(scrollHeight, maxBottom);
|
|
349
357
|
// 埋め込み(マルチフレーム)ではフレーム = 紙面そのもの。下の余白は付けず、
|
|
@@ -949,6 +957,9 @@ export function EditorCanvas() {
|
|
|
949
957
|
updateContentHeightRef.current();
|
|
950
958
|
});
|
|
951
959
|
resizeObserver.observe(artboardEl);
|
|
960
|
+
// 紙面の外の領域の大きさが変わったときも測り直す(#artboard-wrapper はそれらを含む)
|
|
961
|
+
const artboardWrapperEl = iframeDoc.getElementById("artboard-wrapper");
|
|
962
|
+
if (artboardWrapperEl) resizeObserver.observe(artboardWrapperEl);
|
|
952
963
|
cleanupFunctions.push(() => resizeObserver.disconnect());
|
|
953
964
|
|
|
954
965
|
debugLog(
|
|
@@ -124,8 +124,11 @@ export function EditorHeader({
|
|
|
124
124
|
const canvas = useMultiPageCanvasOptional();
|
|
125
125
|
const canvasView = useCanvasViewStateOptional();
|
|
126
126
|
const zoom = canvas && canvasView ? canvasView.canvasZoom * 100 : singleZoom;
|
|
127
|
-
|
|
128
|
-
const
|
|
127
|
+
// キャンバスの範囲は利用側が変えられる(canvasZoomOptions)。表示と +/− の可否も同じ範囲で
|
|
128
|
+
const minZoom = canvas ? (canvas.zoomOptions?.min ?? CANVAS_MIN_ZOOM) * 100 : MIN_ZOOM;
|
|
129
|
+
const maxZoom = canvas ? (canvas.zoomOptions?.max ?? CANVAS_MAX_ZOOM) * 100 : MAX_ZOOM;
|
|
130
|
+
// ⇧2 の合わせ先(canvasZoomOptions.fitShortcut)。'selection' のときだけ「選択範囲に合わせる」を出す
|
|
131
|
+
const fitsSelection = canvas?.zoomOptions?.fitShortcut === 'selection';
|
|
129
132
|
const setZoom = (value: number) => {
|
|
130
133
|
if (canvas) canvas.zoomTo(value / 100, { animate: true });
|
|
131
134
|
else setSingleZoom(value);
|
|
@@ -420,6 +423,16 @@ export function EditorHeader({
|
|
|
420
423
|
className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
|
|
421
424
|
>
|
|
422
425
|
編集中のページに合わせる
|
|
426
|
+
{!fitsSelection && <span className="text-[10px] text-gray-500">⇧2</span>}
|
|
427
|
+
</DropdownMenuItem>
|
|
428
|
+
)}
|
|
429
|
+
{canvas && fitsSelection && (
|
|
430
|
+
<DropdownMenuItem
|
|
431
|
+
data-zoom-preset="selection"
|
|
432
|
+
onClick={() => editorZoomApiRef.current?.selection()}
|
|
433
|
+
className="cursor-pointer justify-between text-gray-300 hover:bg-[#444444] hover:text-white"
|
|
434
|
+
>
|
|
435
|
+
選択範囲に合わせる
|
|
423
436
|
<span className="text-[10px] text-gray-500">⇧2</span>
|
|
424
437
|
</DropdownMenuItem>
|
|
425
438
|
)}
|
|
@@ -45,6 +45,7 @@ import { useEditorContext } from '../../EditorContext';
|
|
|
45
45
|
import { useInfiniteCanvas } from '../../hooks/useInfiniteCanvas';
|
|
46
46
|
import { useCanvasMarquee } from '../../hooks/useCanvasMarquee';
|
|
47
47
|
import { editorZoomApiRef } from '../../hooks/useCanvasControls';
|
|
48
|
+
import { selectionCanvasRect } from '../../utils/canvas-gestures';
|
|
48
49
|
import { EditorAppearanceContext } from '../../contexts/EditorAppearanceContext';
|
|
49
50
|
import { EditorCanvas } from '../EditorCanvas';
|
|
50
51
|
import { PageFramePreview } from './PageFramePreview';
|
|
@@ -226,7 +227,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
226
227
|
// 倍率・位置は購読しない(ホイール 1 段ごとにキャンバス全体が React を通ってしまう)。
|
|
227
228
|
// 描画に要る「粗い倍率」だけを下の useLayoutEffect が間引いて渡す
|
|
228
229
|
const activePageId = useCanvasActivePageId();
|
|
229
|
-
const { setZoom, setFitZoom, activeTool, iframeReady } = useEditorContext();
|
|
230
|
+
const { setZoom, setFitZoom, activeTool, iframeReady, iframeRef } = useEditorContext();
|
|
230
231
|
const theme = useContext(EditorAppearanceContext) ?? 'light';
|
|
231
232
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
232
233
|
const layerRef = useRef<HTMLDivElement>(null);
|
|
@@ -339,20 +340,27 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
339
340
|
setFitZoom(100);
|
|
340
341
|
}, [setZoom, setFitZoom]);
|
|
341
342
|
|
|
342
|
-
// Cmd+0 / Cmd+1 / Cmd+2 とヘッダーの「全体表示」「100
|
|
343
|
+
// Cmd+0 / Cmd+1 / Cmd+2 とヘッダーの「全体表示」「100%」をキャンバスに向ける。
|
|
344
|
+
// ⌘2 / ⇧2 は既定で編集中のページ。`canvasZoomOptions.fitShortcut: 'selection'` なら Figma と同じく
|
|
345
|
+
// 選んでいる要素があればそこへ、無ければ編集中のページへ(0.10.0)
|
|
346
|
+
const { zoomToRect, zoomOptions } = canvas;
|
|
347
|
+
const fitsSelection = zoomOptions.fitShortcut === 'selection';
|
|
343
348
|
useEffect(() => {
|
|
344
349
|
editorZoomApiRef.current = {
|
|
345
350
|
fit: () => zoomToFit({ animate: true }),
|
|
346
351
|
actual: () => zoomToActual(),
|
|
347
352
|
selection: () => {
|
|
348
|
-
|
|
353
|
+
const frame = pages.find((page) => page.id === activePageId);
|
|
354
|
+
const rect = fitsSelection ? selectionCanvasRect(iframeRef.current, frame?.position) : null;
|
|
355
|
+
if (rect) zoomToRect(rect, { animate: true });
|
|
356
|
+
else if (activePageId) zoomToPage(activePageId, { animate: true });
|
|
349
357
|
else zoomToFit({ animate: true });
|
|
350
358
|
},
|
|
351
359
|
};
|
|
352
360
|
return () => {
|
|
353
361
|
editorZoomApiRef.current = null;
|
|
354
362
|
};
|
|
355
|
-
}, [zoomToFit, zoomToActual, zoomToPage, activePageId]);
|
|
363
|
+
}, [zoomToFit, zoomToActual, zoomToPage, zoomToRect, fitsSelection, activePageId, pages, iframeRef]);
|
|
356
364
|
|
|
357
365
|
// 初回の表示: 容器の大きさとフレームの並びが揃ったところで 1 回(決め方は applyInitialView)
|
|
358
366
|
useEffect(() => {
|
|
@@ -21,7 +21,7 @@ import { useEditorContext } from '../../EditorContext';
|
|
|
21
21
|
import { useCanvasViewStateOptional } from '../../contexts/MultiPageCanvasContext';
|
|
22
22
|
import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
|
|
23
23
|
import { commentAction, type SlideComment } from '../../../lib/deck';
|
|
24
|
-
import { can, io, type CommentRect, type EditorDeck } from '../../../io';
|
|
24
|
+
import { can, io, type CommentRect, type EditorCommentActionResult, type EditorDeck } from '../../../io';
|
|
25
25
|
import { useResizablePanel } from '../../hooks/useResizablePanel';
|
|
26
26
|
import { readStorage, writeStorage } from '../../utils/storage';
|
|
27
27
|
import { ConfirmDialog } from '../shell/ConfirmDialog';
|
|
@@ -696,6 +696,76 @@ export function PptCommentsPanel({
|
|
|
696
696
|
}
|
|
697
697
|
}, []);
|
|
698
698
|
|
|
699
|
+
/**
|
|
700
|
+
* 利用側が足した操作(io.commentActions)の進行状況。キーは `スレッド操作id:コメントid` /
|
|
701
|
+
* `パネル操作id:@page`。
|
|
702
|
+
*
|
|
703
|
+
* 共通の run() には載せない(「AIで修正」と同じ理由): 利用側の操作は外部に依頼して長くかかりうるので、
|
|
704
|
+
* 走らせている間ほかのコメント操作まで止めない。結果と失敗は押した場所の下に出す。
|
|
705
|
+
*/
|
|
706
|
+
const [hostActions, setHostActions] = useState<Record<string, { running: boolean; message?: string; error?: string }>>({});
|
|
707
|
+
const hostRunning = useRef(new Set<string>());
|
|
708
|
+
const runHostAction = useCallback(async (key: string, fn: () => Promise<EditorCommentActionResult | void>) => {
|
|
709
|
+
if (hostRunning.current.has(key)) return;
|
|
710
|
+
hostRunning.current.add(key);
|
|
711
|
+
setHostActions((prev) => ({ ...prev, [key]: { running: true } }));
|
|
712
|
+
try {
|
|
713
|
+
const result = await fn();
|
|
714
|
+
if (result?.deck) applyDeck(result.deck);
|
|
715
|
+
setHostActions((prev) => ({ ...prev, [key]: { running: false, message: result?.message } }));
|
|
716
|
+
} catch (e) {
|
|
717
|
+
setHostActions((prev) => ({ ...prev, [key]: { running: false, error: String(e instanceof Error ? e.message : e).slice(0, 200) } }));
|
|
718
|
+
} finally {
|
|
719
|
+
hostRunning.current.delete(key);
|
|
720
|
+
}
|
|
721
|
+
}, []);
|
|
722
|
+
|
|
723
|
+
/** 押した場所の下に出す知らせ・失敗(利用側の操作) */
|
|
724
|
+
const renderHostActionStatus = (key: string) => {
|
|
725
|
+
const state = hostActions[key];
|
|
726
|
+
if (!state || state.running || (!state.message && !state.error)) return null;
|
|
727
|
+
return (
|
|
728
|
+
<p key={`${key}:status`} role={state.error ? 'alert' : 'status'} className="w-full text-[11px] leading-snug" style={{ color: state.error ? '#d13438' : pal.sub }}>
|
|
729
|
+
{state.error ?? state.message}
|
|
730
|
+
</p>
|
|
731
|
+
);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/** 利用側がスレッドに足した操作。渡されていなければ何も出さない */
|
|
735
|
+
const renderThreadActions = (c: SlideComment) => {
|
|
736
|
+
const shown = (io().commentActions?.thread ?? []).filter((action) => (action.when ? action.when(c, { page }) : !c.resolved));
|
|
737
|
+
if (shown.length === 0) return null;
|
|
738
|
+
return (
|
|
739
|
+
<div className="mt-2 flex flex-wrap items-center gap-1.5" data-comment-actions>
|
|
740
|
+
{shown.map((action) => {
|
|
741
|
+
const key = `${action.id}:${c.id}`;
|
|
742
|
+
const running = hostActions[key]?.running === true;
|
|
743
|
+
return (
|
|
744
|
+
<button
|
|
745
|
+
key={action.id}
|
|
746
|
+
type="button"
|
|
747
|
+
data-comment-action={action.id}
|
|
748
|
+
disabled={running}
|
|
749
|
+
aria-busy={running}
|
|
750
|
+
onClick={(e) => { e.stopPropagation(); void runHostAction(key, () => action.run({ page, comment: c })); }}
|
|
751
|
+
title={action.title}
|
|
752
|
+
className="flex items-center gap-1 rounded border px-1.5 py-1 text-[11px] transition-colors"
|
|
753
|
+
style={{ borderColor: pal.border, color: 'var(--ed-accent)' }}
|
|
754
|
+
>
|
|
755
|
+
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : action.icon}
|
|
756
|
+
{action.label}
|
|
757
|
+
</button>
|
|
758
|
+
);
|
|
759
|
+
})}
|
|
760
|
+
{shown.map((action) => renderHostActionStatus(`${action.id}:${c.id}`))}
|
|
761
|
+
</div>
|
|
762
|
+
);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
/** 利用側がパネル上部に足した、いまのページ全体への操作 */
|
|
766
|
+
const panelActions = (io().commentActions?.panel ?? []).filter((action) =>
|
|
767
|
+
action.when ? action.when({ page, comments }) : unresolvedCount(comments) > 0);
|
|
768
|
+
|
|
699
769
|
/** 選択中の要素からアンカーを拾う(選択なしならページ全体へのコメント) */
|
|
700
770
|
const currentAnchor = useMemo(() => {
|
|
701
771
|
const doc = getIframeDoc();
|
|
@@ -851,6 +921,8 @@ export function PptCommentsPanel({
|
|
|
851
921
|
</div>
|
|
852
922
|
)}
|
|
853
923
|
|
|
924
|
+
{renderThreadActions(c)}
|
|
925
|
+
|
|
854
926
|
{replyFor === c.id ? (
|
|
855
927
|
<div className="mt-2 flex items-end gap-1.5">
|
|
856
928
|
<textarea
|
|
@@ -932,6 +1004,31 @@ export function PptCommentsPanel({
|
|
|
932
1004
|
すべてAIで修正({unresolvedTotal})
|
|
933
1005
|
</button>
|
|
934
1006
|
)}
|
|
1007
|
+
{panelActions.length > 0 && (
|
|
1008
|
+
<div className="my-2 flex flex-wrap items-center gap-1.5" data-comment-panel-actions>
|
|
1009
|
+
{panelActions.map((action) => {
|
|
1010
|
+
const key = `${action.id}:@${page}`;
|
|
1011
|
+
const running = hostActions[key]?.running === true;
|
|
1012
|
+
return (
|
|
1013
|
+
<button
|
|
1014
|
+
key={action.id}
|
|
1015
|
+
type="button"
|
|
1016
|
+
data-comment-panel-action={action.id}
|
|
1017
|
+
disabled={running}
|
|
1018
|
+
aria-busy={running}
|
|
1019
|
+
onClick={() => void runHostAction(key, () => action.run({ page, comments }))}
|
|
1020
|
+
title={action.title}
|
|
1021
|
+
className="flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px]"
|
|
1022
|
+
style={{ borderColor: pal.border, color: pal.text }}
|
|
1023
|
+
>
|
|
1024
|
+
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : action.icon}
|
|
1025
|
+
{action.label}
|
|
1026
|
+
</button>
|
|
1027
|
+
);
|
|
1028
|
+
})}
|
|
1029
|
+
{panelActions.map((action) => renderHostActionStatus(`${action.id}:@${page}`))}
|
|
1030
|
+
</div>
|
|
1031
|
+
)}
|
|
935
1032
|
</div>
|
|
936
1033
|
{(fixAll?.running || fixAll?.error) && (
|
|
937
1034
|
<div className="border-b px-3 py-1.5 text-[11px]" style={{ borderColor: pal.border, color: fixAll.error ? '#f66' : pal.sub }}>
|
package/src/editor/constants.ts
CHANGED
|
@@ -10,6 +10,15 @@ export const SLIDE_HEIGHT = 1080;
|
|
|
10
10
|
export const WEBPAGE_WIDTH = 1440;
|
|
11
11
|
export const WEBPAGE_MIN_HEIGHT = 900; // 最小高さ
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 紙面(#artboard)の外、上下に利用側が並べる「表示だけの領域」の目印(0.10.0)。
|
|
15
|
+
* 生きているエディタの文書で #artboard の兄弟(#artboard-wrapper の中)に置いた要素にこの属性を付けると、
|
|
16
|
+
* マルチフレームのキャンバスのフレームの高さに入る(付けないと紙面の高さで切れる)。
|
|
17
|
+
* 保存・共同編集が送るのは #artboard の中身だけなので、ここに置いたものは本文に混ざらない。
|
|
18
|
+
* 使い方は docs/frame-regions-2026-09.md
|
|
19
|
+
*/
|
|
20
|
+
export const FRAME_REGION_ATTRIBUTE = 'data-editor-frame-region';
|
|
21
|
+
|
|
13
22
|
// レスポンシブブレイクポイントプリセット(webpageモード用)
|
|
14
23
|
export const BREAKPOINT_PRESETS = [
|
|
15
24
|
{ id: 'default', name: 'デフォルト', width: 1400, icon: 'monitor', minRange: 1190, maxRange: 1920 },
|
|
@@ -42,6 +42,7 @@ import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
|
|
|
42
42
|
import type { EditorMode } from './EditorToolContext';
|
|
43
43
|
import { io, type PreviewStyle } from '../../io';
|
|
44
44
|
import { readStorage, writeStorage } from '../utils/storage';
|
|
45
|
+
import { nextZoomLevel, type CanvasZoomOptions, type ResolvedCanvasZoomOptions } from '../utils/canvas-gestures';
|
|
45
46
|
|
|
46
47
|
// ============================================================
|
|
47
48
|
// 定数
|
|
@@ -51,10 +52,13 @@ import { readStorage, writeStorage } from '../utils/storage';
|
|
|
51
52
|
export { WEBPAGE_WIDTH };
|
|
52
53
|
/** 高さが未測定の webpage フレームに仮に与える高さ */
|
|
53
54
|
export const WEBPAGE_MIN_HEIGHT = 1200;
|
|
54
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* ズーム範囲の既定(Figma は 2%〜25600%。紙面の編集で要るのはこの範囲)。
|
|
57
|
+
* 利用側は `canvasZoomOptions` で範囲・刻み・⌘2 の合わせ先を変えられる(0.10.0)
|
|
58
|
+
*/
|
|
55
59
|
export const MIN_ZOOM = 0.02;
|
|
56
60
|
export const MAX_ZOOM = 4;
|
|
57
|
-
/**
|
|
61
|
+
/** ボタン・キー操作の 1 段の倍率(`steps: 'ratio'`、既定) */
|
|
58
62
|
const ZOOM_STEP = 1.25;
|
|
59
63
|
/** 全体表示・ページ表示のときの余白(px、画面上) */
|
|
60
64
|
const FIT_PADDING = 72;
|
|
@@ -225,6 +229,10 @@ export interface MultiPageCanvasContextValue {
|
|
|
225
229
|
zoomToFit: (options?: { animate?: boolean }) => void;
|
|
226
230
|
zoomToPage: (id: string, options?: { animate?: boolean }) => void;
|
|
227
231
|
zoomToActual: () => void;
|
|
232
|
+
/** キャンバスの座標の矩形を、余白を取って画面に収める(100% を超えてもよい。選択範囲に合わせる等) — 0.10.0 */
|
|
233
|
+
zoomToRect: (rect: CanvasBounds, options?: { animate?: boolean }) => void;
|
|
234
|
+
/** 拡大縮小の設定(`canvasZoomOptions` を省略時の値で埋めたもの) — 0.10.0 */
|
|
235
|
+
zoomOptions: ResolvedCanvasZoomOptions;
|
|
228
236
|
/** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
|
|
229
237
|
revealPage: (id: string) => void;
|
|
230
238
|
/**
|
|
@@ -484,7 +492,17 @@ function fitView(
|
|
|
484
492
|
};
|
|
485
493
|
}
|
|
486
494
|
|
|
487
|
-
|
|
495
|
+
/** 利用側が渡した設定を省略時の値で埋める(範囲は片方だけ・逆転・0 以下を既定へ) */
|
|
496
|
+
export function resolveCanvasZoomOptions(options?: CanvasZoomOptions | null): ResolvedCanvasZoomOptions {
|
|
497
|
+
const min = Number.isFinite(options?.min) && (options?.min as number) > 0 ? (options?.min as number) : MIN_ZOOM;
|
|
498
|
+
const max = Number.isFinite(options?.max) && (options?.max as number) > 0 ? (options?.max as number) : MAX_ZOOM;
|
|
499
|
+
const range = min <= max ? { min, max } : { min: MIN_ZOOM, max: MAX_ZOOM };
|
|
500
|
+
return {
|
|
501
|
+
...range,
|
|
502
|
+
steps: options?.steps === 'powers-of-two' ? 'powers-of-two' : 'ratio',
|
|
503
|
+
fitShortcut: options?.fitShortcut === 'selection' ? 'selection' : 'page',
|
|
504
|
+
};
|
|
505
|
+
}
|
|
488
506
|
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
|
489
507
|
const prefersReducedMotion = () =>
|
|
490
508
|
typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
@@ -518,6 +536,8 @@ interface MultiPageCanvasProviderProps {
|
|
|
518
536
|
enabled: boolean;
|
|
519
537
|
/** 見えている範囲を保存するキー(利用側の parentId 等)。無ければ保存しない */
|
|
520
538
|
storageKey?: string | null;
|
|
539
|
+
/** 拡大縮小の設定(範囲・刻み・⌘2 の合わせ先)。省略時は従来の挙動(0.10.0) */
|
|
540
|
+
zoomOptions?: CanvasZoomOptions;
|
|
521
541
|
}
|
|
522
542
|
|
|
523
543
|
type PersistedView = {
|
|
@@ -546,7 +566,15 @@ function readPersistedView(key: string | null | undefined): PersistedView | null
|
|
|
546
566
|
}
|
|
547
567
|
}
|
|
548
568
|
|
|
549
|
-
export function MultiPageCanvasProvider({ children, enabled, storageKey }: MultiPageCanvasProviderProps) {
|
|
569
|
+
export function MultiPageCanvasProvider({ children, enabled, storageKey, zoomOptions: zoomOptionsProp }: MultiPageCanvasProviderProps) {
|
|
570
|
+
// 拡大縮小の設定。倍率はどの経路(ホイール・ピンチ・キー・ボタン・全体表示)も clampZoom で範囲に収める
|
|
571
|
+
const zoomOptions = useMemo<ResolvedCanvasZoomOptions>(
|
|
572
|
+
() => resolveCanvasZoomOptions(zoomOptionsProp),
|
|
573
|
+
[zoomOptionsProp?.min, zoomOptionsProp?.max, zoomOptionsProp?.steps, zoomOptionsProp?.fitShortcut],
|
|
574
|
+
);
|
|
575
|
+
const zoomOptionsRef = useRef(zoomOptions);
|
|
576
|
+
zoomOptionsRef.current = zoomOptions;
|
|
577
|
+
const clampZoom = useCallback((z: number) => Math.max(zoomOptionsRef.current.min, Math.min(zoomOptionsRef.current.max, z)), []);
|
|
550
578
|
const artboard = useEditorArtboard();
|
|
551
579
|
const { editorMode } = useEditorTool();
|
|
552
580
|
const { viewportWidth } = useEditorView();
|
|
@@ -864,8 +892,17 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
864
892
|
[setView, viewStore],
|
|
865
893
|
);
|
|
866
894
|
|
|
867
|
-
|
|
868
|
-
const
|
|
895
|
+
// 刻みは既定で 1.25 倍ずつ。`steps: 'powers-of-two'` なら 2 の累乗の段(Figma。…50% → 100% → 200%…。段の間からは向きの側の段へ)
|
|
896
|
+
const stepZoom = useCallback((direction: 1 | -1) => {
|
|
897
|
+
const current = viewStore.get().canvasZoom;
|
|
898
|
+
const options = zoomOptionsRef.current;
|
|
899
|
+
const next = options.steps === 'powers-of-two'
|
|
900
|
+
? nextZoomLevel(current, direction, options)
|
|
901
|
+
: current * (direction > 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
|
|
902
|
+
zoomTo(next, { animate: true });
|
|
903
|
+
}, [zoomTo, viewStore]);
|
|
904
|
+
const zoomIn = useCallback(() => stepZoom(1), [stepZoom]);
|
|
905
|
+
const zoomOut = useCallback(() => stepZoom(-1), [stepZoom]);
|
|
869
906
|
const zoomToActual = useCallback(() => zoomTo(1, { animate: true }), [zoomTo]);
|
|
870
907
|
|
|
871
908
|
const zoomToFit = useCallback<MultiPageCanvasContextValue['zoomToFit']>(
|
|
@@ -876,6 +913,14 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
876
913
|
[setView],
|
|
877
914
|
);
|
|
878
915
|
|
|
916
|
+
// 選択範囲に合わせる等。全体表示と違って 100% で止めない(小さい要素は大きく見せる。Figma と同じ)
|
|
917
|
+
const zoomToRect = useCallback<MultiPageCanvasContextValue['zoomToRect']>(
|
|
918
|
+
(rect, options) => {
|
|
919
|
+
setView(fitView(rect, containerSize(), { padding: FIT_PADDING, maxZoom: zoomOptionsRef.current.max, rulers: rulersRef.current }), options);
|
|
920
|
+
},
|
|
921
|
+
[setView],
|
|
922
|
+
);
|
|
923
|
+
|
|
879
924
|
const zoomToPage = useCallback<MultiPageCanvasContextValue['zoomToPage']>(
|
|
880
925
|
(id, options) => {
|
|
881
926
|
const page = pagesRef.current.find((p) => p.id === id);
|
|
@@ -1223,6 +1268,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1223
1268
|
zoomToFit,
|
|
1224
1269
|
zoomToPage,
|
|
1225
1270
|
zoomToActual,
|
|
1271
|
+
zoomToRect,
|
|
1272
|
+
zoomOptions,
|
|
1226
1273
|
revealPage,
|
|
1227
1274
|
focusPage,
|
|
1228
1275
|
activatePage,
|
|
@@ -1244,7 +1291,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1244
1291
|
[
|
|
1245
1292
|
enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
|
|
1246
1293
|
setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, shiftView, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
|
|
1247
|
-
zoomToActual, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
|
|
1294
|
+
zoomToActual, zoomToRect, zoomOptions, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
|
|
1248
1295
|
persisted, applyInitialView, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
|
|
1249
1296
|
getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
|
|
1250
1297
|
],
|
|
@@ -1259,17 +1306,19 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
|
|
|
1259
1306
|
export function ConditionalMultiPageProvider({
|
|
1260
1307
|
enabled,
|
|
1261
1308
|
storageKey,
|
|
1309
|
+
zoomOptions,
|
|
1262
1310
|
children,
|
|
1263
1311
|
}: {
|
|
1264
1312
|
enabled: boolean;
|
|
1265
1313
|
storageKey?: string | null;
|
|
1314
|
+
zoomOptions?: CanvasZoomOptions;
|
|
1266
1315
|
children: React.ReactNode;
|
|
1267
1316
|
}) {
|
|
1268
1317
|
if (!enabled) {
|
|
1269
1318
|
return <>{children}</>;
|
|
1270
1319
|
}
|
|
1271
1320
|
return (
|
|
1272
|
-
<MultiPageCanvasProvider enabled={enabled} storageKey={storageKey}>
|
|
1321
|
+
<MultiPageCanvasProvider enabled={enabled} storageKey={storageKey} zoomOptions={zoomOptions}>
|
|
1273
1322
|
{children}
|
|
1274
1323
|
</MultiPageCanvasProvider>
|
|
1275
1324
|
);
|
|
@@ -31,11 +31,11 @@ export function useBrowserZoomPrevention(): void {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
// Safari/WebKit
|
|
34
|
+
// Safari/WebKitのジェスチャーイベント(ピンチ)でのブラウザ拡大を防止
|
|
35
|
+
// 注: 伝播は止めない(0.10.0)。キャンバス(useInfiniteCanvas)がピンチを拡大縮小に使う。
|
|
36
|
+
// 以前は stopImmediatePropagation で握りつぶしていて、Safari・WKWebView ではピンチしても何も起きなかった
|
|
35
37
|
const preventGesture = (e: Event) => {
|
|
36
38
|
e.preventDefault();
|
|
37
|
-
e.stopPropagation();
|
|
38
|
-
e.stopImmediatePropagation();
|
|
39
39
|
return false;
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -610,12 +610,9 @@ export function useCanvasControls() {
|
|
|
610
610
|
window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
|
611
611
|
document.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
|
612
612
|
|
|
613
|
+
// ブラウザの拡大だけを止める。伝播は止めない(0.10.0。キャンバスがピンチを拡大縮小に使う)
|
|
613
614
|
const handleGesture = (e: Event) => {
|
|
614
|
-
if (isEditorOpen())
|
|
615
|
-
e.preventDefault();
|
|
616
|
-
e.stopPropagation();
|
|
617
|
-
e.stopImmediatePropagation();
|
|
618
|
-
}
|
|
615
|
+
if (isEditorOpen()) e.preventDefault();
|
|
619
616
|
};
|
|
620
617
|
|
|
621
618
|
window.addEventListener('gesturestart', handleGesture, { passive: false, capture: true });
|
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
* - ホイール: パン(2 本指スクロール)。Shift で横
|
|
8
8
|
* - Ctrl/Cmd + ホイール、トラックパッドのピンチ: カーソル位置を固定してズーム
|
|
9
9
|
* - Space + ドラッグ / 中ボタンドラッグ: パン
|
|
10
|
-
* -
|
|
10
|
+
* - Safari・WKWebView のトラックパッドのピンチ(gesturestart / gesturechange): カーソル位置を固定してズーム(0.10.0)
|
|
11
|
+
* - Cmd/Ctrl + / - : 段階ズーム(既定 1.25 倍。canvasZoomOptions.steps で 2 の累乗の段)。Shift+0 = 100%、Shift+1 = 全体、
|
|
12
|
+
* Shift+2 = 編集中のページ(canvasZoomOptions.fitShortcut: 'selection' なら選んでいる要素)、Shift+R = 定規
|
|
11
13
|
* (Cmd+0 / 1 / 2 は既存のディスパッチャ(editorZoomApiRef)が受ける)
|
|
12
14
|
* - 2 本指タッチ: ピンチズーム / 1 本指: パン
|
|
15
|
+
* - 拡大縮小はどの経路も zoomAt(カーソル位置が中心)に集める。キーとボタンは画面の中心
|
|
16
|
+
* - IME の変換中のキーは奪わない
|
|
13
17
|
*
|
|
14
18
|
* [iframe から来る操作]
|
|
15
19
|
* 生きているエディタ(EditorCanvas)と見るだけの紙面は iframe なので、その上で起きた
|
|
@@ -21,7 +25,8 @@
|
|
|
21
25
|
|
|
22
26
|
import { useEffect, useRef, useState, type RefObject } from 'react';
|
|
23
27
|
import { useMultiPageCanvasOptional, MIN_ZOOM, MAX_ZOOM } from '../contexts/MultiPageCanvasContext';
|
|
24
|
-
import { isSpaceActivatedControl } from './useCanvasControls';
|
|
28
|
+
import { isSpaceActivatedControl, editorZoomApiRef } from './useCanvasControls';
|
|
29
|
+
import { listenCanvasGestures, isImeKeyEvent } from '../utils/canvas-gestures';
|
|
25
30
|
|
|
26
31
|
/** iframe 側が転送してくるメッセージ */
|
|
27
32
|
export type EmbeddedCanvasMessage =
|
|
@@ -29,12 +34,12 @@ export type EmbeddedCanvasMessage =
|
|
|
29
34
|
| { type: 'EMBEDDED_PINCH'; coords?: 'parent'; previousDistance: number; currentDistance: number; centerX: number; centerY: number }
|
|
30
35
|
| { type: 'EMBEDDED_PAN_START'; coords?: 'parent'; clientX: number; clientY: number }
|
|
31
36
|
| { type: 'EMBEDDED_KEY'; kind: 'down' | 'up'; key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean };
|
|
37
|
+
// ピンチ(GestureEvent)の転送 EMBEDDED_GESTURE は listenCanvasGestures が受ける(utils/canvas-gestures.ts)
|
|
32
38
|
|
|
33
39
|
/** ホイール 1 目盛りのズーム係数。トラックパッド(小さい delta が連続)と、マウスホイール(大きい delta)で分ける */
|
|
34
40
|
const PINCH_K = 0.01;
|
|
35
41
|
const WHEEL_K = 0.0025;
|
|
36
42
|
const MAX_STEP = 1.25;
|
|
37
|
-
const KEY_STEP = 1.25;
|
|
38
43
|
|
|
39
44
|
function wheelFactor(deltaY: number): number {
|
|
40
45
|
const k = Math.abs(deltaY) < 50 ? PINCH_K : WHEEL_K;
|
|
@@ -87,10 +92,23 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
|
|
|
87
92
|
return { clientX: rect.left + x * scale, clientY: rect.top + y * scale };
|
|
88
93
|
};
|
|
89
94
|
|
|
95
|
+
// ---- Safari・WKWebView のピンチ(容器の上で起きたものと、紙面の iframe から転送されたもの)
|
|
96
|
+
const gestures = listenCanvasGestures({
|
|
97
|
+
win: window,
|
|
98
|
+
container,
|
|
99
|
+
target: () => ({
|
|
100
|
+
getZoom: () => c().viewStore.get().canvasZoom,
|
|
101
|
+
zoomAt: (zoom, clientX, clientY) => c().zoomAt(zoom, toContainerPoint(clientX, clientY)),
|
|
102
|
+
markInteracting: () => c().markInteracting(),
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
90
106
|
// ---- ズーム/パンの実処理(ホイールと転送メッセージで共通)
|
|
91
107
|
const applyWheel = (deltaX: number, deltaY: number, clientX: number, clientY: number, zoomGesture: boolean, shiftKey: boolean) => {
|
|
92
108
|
c().markInteracting();
|
|
93
109
|
if (zoomGesture) {
|
|
110
|
+
// ピンチ(GestureEvent)の最中に ctrl 付き wheel も届く環境で二重に拡大しない
|
|
111
|
+
if (gestures.active) return;
|
|
94
112
|
const { canvasZoom } = c().viewStore.get();
|
|
95
113
|
c().zoomAt(canvasZoom * wheelFactor(deltaY), toContainerPoint(clientX, clientY));
|
|
96
114
|
return;
|
|
@@ -129,7 +147,8 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
|
|
|
129
147
|
};
|
|
130
148
|
|
|
131
149
|
// ---- キー
|
|
132
|
-
|
|
150
|
+
// ヘッダーの +/− と同じ zoomIn / zoomOut(刻みは canvasZoomOptions.steps)
|
|
151
|
+
const stepZoom = (dir: 1 | -1) => (dir > 0 ? c().zoomIn() : c().zoomOut());
|
|
133
152
|
const handleKey = (kind: 'down' | 'up', key: string, code: string, mods: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }, preventDefault: () => void, typing: boolean) => {
|
|
134
153
|
if (code === 'Space') {
|
|
135
154
|
if (typing) return;
|
|
@@ -155,8 +174,11 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
|
|
|
155
174
|
if (key === '0' || code === 'Digit0') { preventDefault(); c().zoomToActual(); return; }
|
|
156
175
|
if (key === '1' || code === 'Digit1') { preventDefault(); c().zoomToFit({ animate: true }); return; }
|
|
157
176
|
if (key === '2' || code === 'Digit2') {
|
|
177
|
+
// ⌘2 と同じ合わせ先(既定は編集中のページ。fitShortcut: 'selection' なら選んでいる要素)
|
|
178
|
+
preventDefault();
|
|
179
|
+
if (editorZoomApiRef.current) { editorZoomApiRef.current.selection(); return; }
|
|
158
180
|
const id = c().viewStore.get().activePageId;
|
|
159
|
-
if (id)
|
|
181
|
+
if (id) c().zoomToPage(id, { animate: true });
|
|
160
182
|
return;
|
|
161
183
|
}
|
|
162
184
|
if (key.toLowerCase() === 'r' || code === 'KeyR') { preventDefault(); c().toggleRulers(); return; }
|
|
@@ -167,6 +189,7 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
|
|
|
167
189
|
const spaceBelongsToControl = (e: KeyboardEvent) =>
|
|
168
190
|
e.code === 'Space' && (isSpaceActivatedControl(e.target) || isSpaceActivatedControl(document.activeElement));
|
|
169
191
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
192
|
+
if (isImeKeyEvent(e)) return; // IME の変換中のキーは奪わない
|
|
170
193
|
if (spaceBelongsToControl(e)) return;
|
|
171
194
|
if (e.repeat && e.code === 'Space') { if (!isTypingTarget(e.target)) e.preventDefault(); return; }
|
|
172
195
|
handleKey('down', e.key, e.code, e, () => e.preventDefault(), isTypingTarget(e.target));
|
|
@@ -284,6 +307,7 @@ export function useInfiniteCanvas(containerRef: RefObject<HTMLDivElement | null>
|
|
|
284
307
|
container.addEventListener('touchcancel', handleTouchEnd);
|
|
285
308
|
|
|
286
309
|
return () => {
|
|
310
|
+
gestures.dispose();
|
|
287
311
|
window.removeEventListener('wheel', preventNativeZoom, { capture: true });
|
|
288
312
|
document.removeEventListener('wheel', preventNativeZoom, { capture: true });
|
|
289
313
|
container.removeEventListener('wheel', handleWheel);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* マルチフレームのキャンバスの拡大縮小を Figma にそろえる部品(0.10.0)。
|
|
3
|
+
*
|
|
4
|
+
* - Safari・WKWebView のトラックパッドのピンチは gesturestart / gesturechange / gestureend で届く
|
|
5
|
+
* (Chrome・Edge・Electron は ctrl 付きの wheel で届く)。以前は止めるだけで拡大縮小に使っていなかった。
|
|
6
|
+
* scale は gesturestart からの累積なので、始まりの倍率に掛ける。紙面(iframe)の中で起きたものは親へ転送する
|
|
7
|
+
* - ⌘+ / ⌘− とヘッダーの +/− は 2 の累乗の段(…50% → 100% → 200%…。段の間からは向きの側の段へ)
|
|
8
|
+
* - IME の変換中のキーは奪わない
|
|
9
|
+
* - 選んでいる要素に合わせる(⌘2 / ⇧2)ための矩形
|
|
10
|
+
*
|
|
11
|
+
* DOM と数値だけを扱い、React やキャンバスの Context には触らない(受け手が zoomAt 等を渡す)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ZoomRange {
|
|
15
|
+
min: number;
|
|
16
|
+
max: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* マルチフレームのキャンバスの拡大縮小の設定(`canvasZoomOptions`)。どれも省略時は従来の挙動。
|
|
21
|
+
* Figma にそろえるなら `{ min: 0.02, max: 256, steps: 'powers-of-two', fitShortcut: 'selection' }`
|
|
22
|
+
*/
|
|
23
|
+
export interface CanvasZoomOptions {
|
|
24
|
+
/** 倍率の下限(省略時 0.02 = 2%) */
|
|
25
|
+
min?: number;
|
|
26
|
+
/** 倍率の上限(省略時 4 = 400%。Figma は 256 = 25600%) */
|
|
27
|
+
max?: number;
|
|
28
|
+
/** ⌘+ / ⌘− とヘッダーの +/− の刻み。'ratio' = 1.25 倍ずつ(省略時)、'powers-of-two' = 2 の累乗の段(Figma) */
|
|
29
|
+
steps?: 'ratio' | 'powers-of-two';
|
|
30
|
+
/** ⌘2 / ⇧2 の合わせ先。'page' = 編集中のページ(省略時)、'selection' = 選んでいる要素(無ければページ。Figma) */
|
|
31
|
+
fitShortcut?: 'page' | 'selection';
|
|
32
|
+
}
|
|
33
|
+
export type ResolvedCanvasZoomOptions = ZoomRange & Required<Pick<CanvasZoomOptions, 'steps' | 'fitShortcut'>>;
|
|
34
|
+
|
|
35
|
+
const clamp = (zoom: number, range: ZoomRange) => Math.max(range.min, Math.min(range.max, zoom));
|
|
36
|
+
|
|
37
|
+
/** ⌘+ / ⌘− の次の倍率。2 の累乗の段へ寄せる(今が段の上なら 1 段、段の間なら向きの側の段へ) */
|
|
38
|
+
export function nextZoomLevel(current: number, direction: 1 | -1, range: ZoomRange): number {
|
|
39
|
+
if (!Number.isFinite(current) || current <= 0) return clamp(1, range);
|
|
40
|
+
const exponent = Math.log2(current);
|
|
41
|
+
const EPSILON = 1e-6;
|
|
42
|
+
const next = direction > 0 ? Math.floor(exponent + EPSILON) + 1 : Math.ceil(exponent - EPSILON) - 1;
|
|
43
|
+
return clamp(2 ** next, range);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** IME の変換中のキーか(Safari・Chrome は変換中の keydown に keyCode 229 を出す) */
|
|
47
|
+
export function isImeKeyEvent(event: KeyboardEvent): boolean {
|
|
48
|
+
return event.isComposing === true || event.keyCode === 229;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------ ピンチ(GestureEvent)
|
|
52
|
+
|
|
53
|
+
/** WebKit の GestureEvent(型定義は標準の lib に無い) */
|
|
54
|
+
export type CanvasGestureEvent = Event & { scale?: number; clientX?: number; clientY?: number };
|
|
55
|
+
export type CanvasGesturePhase = 'start' | 'change' | 'end';
|
|
56
|
+
export interface EmbeddedGestureMessage {
|
|
57
|
+
type: 'EMBEDDED_GESTURE';
|
|
58
|
+
coords: 'parent';
|
|
59
|
+
phase: CanvasGesturePhase;
|
|
60
|
+
scale: number;
|
|
61
|
+
clientX: number;
|
|
62
|
+
clientY: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GestureZoomTarget {
|
|
66
|
+
getZoom: () => number;
|
|
67
|
+
/** client 座標の点を動かさずに倍率を変える(倍率の範囲は受け手が収める) */
|
|
68
|
+
zoomAt: (zoom: number, clientX: number, clientY: number) => void;
|
|
69
|
+
markInteracting?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** これだけピンチの続きが来なければ、終わったものとみなす(ms) */
|
|
73
|
+
const GESTURE_IDLE_MS = 1000;
|
|
74
|
+
const PHASES: Array<[CanvasGesturePhase, string]> = [['start', 'gesturestart'], ['change', 'gesturechange'], ['end', 'gestureend']];
|
|
75
|
+
const LISTEN = { passive: false, capture: true } as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ピンチの状態。始まりの倍率を覚え、scale(累積)を掛ける。
|
|
79
|
+
* `active` の間は ctrl 付き wheel での拡大をしない(両方届く環境で二重に拡大しない)。
|
|
80
|
+
* end を取りこぼしたまま止まったら GESTURE_IDLE_MS で解く(ctrl 付き wheel の拡大が戻らなくならない)
|
|
81
|
+
*/
|
|
82
|
+
export function createGestureZoom(target: () => GestureZoomTarget | null, now: () => number = () => Date.now()) {
|
|
83
|
+
let base: number | null = null;
|
|
84
|
+
let lastAt = 0;
|
|
85
|
+
const isLive = () => base !== null && now() - lastAt < GESTURE_IDLE_MS;
|
|
86
|
+
return {
|
|
87
|
+
get active() { return isLive(); },
|
|
88
|
+
handle(phase: CanvasGesturePhase, scale: number, clientX: number, clientY: number) {
|
|
89
|
+
const canvas = target();
|
|
90
|
+
if (!canvas) return;
|
|
91
|
+
if (phase === 'start') { base = canvas.getZoom(); lastAt = now(); return; }
|
|
92
|
+
// start を取りこぼしても(iframe の読み込み直し等)、今の倍率を始まりにして続ける
|
|
93
|
+
const start = isLive() && base !== null ? base : canvas.getZoom();
|
|
94
|
+
base = start;
|
|
95
|
+
lastAt = now();
|
|
96
|
+
if (Number.isFinite(scale) && scale > 0) {
|
|
97
|
+
canvas.markInteracting?.();
|
|
98
|
+
canvas.zoomAt(start * scale, clientX, clientY);
|
|
99
|
+
}
|
|
100
|
+
if (phase === 'end') base = null;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 親(キャンバスの容器がある文書)でピンチを受ける。容器の上で起きたものと、紙面の iframe から転送されてきたもの。
|
|
107
|
+
* 容器の外(ヘッダー・パネル)のピンチは拡大しない(ブラウザ自体の拡大は useBrowserZoomPrevention が止める)。
|
|
108
|
+
* GestureEvent に座標が無い環境では、最後にポインタがあった所を中心にする。
|
|
109
|
+
*/
|
|
110
|
+
export function listenCanvasGestures(input: {
|
|
111
|
+
win: Window;
|
|
112
|
+
container: HTMLElement;
|
|
113
|
+
target: () => GestureZoomTarget | null;
|
|
114
|
+
}): { readonly active: boolean; dispose: () => void } {
|
|
115
|
+
const { win, container } = input;
|
|
116
|
+
const zoom = createGestureZoom(input.target);
|
|
117
|
+
let pointer: { x: number; y: number } | null = null;
|
|
118
|
+
const onPointer = (event: MouseEvent) => { pointer = { x: event.clientX, y: event.clientY }; };
|
|
119
|
+
const pointOf = (event: CanvasGestureEvent) => {
|
|
120
|
+
if (Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) return { x: event.clientX as number, y: event.clientY as number };
|
|
121
|
+
if (pointer) return pointer;
|
|
122
|
+
const rect = container.getBoundingClientRect();
|
|
123
|
+
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
124
|
+
};
|
|
125
|
+
const handlers = PHASES.map(([phase, type]) => {
|
|
126
|
+
const handler = (event: Event) => {
|
|
127
|
+
const target = event.target as Node | null;
|
|
128
|
+
if (!target || typeof target.nodeType !== 'number' || !container.contains(target)) return;
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
const gesture = event as CanvasGestureEvent;
|
|
131
|
+
const point = pointOf(gesture);
|
|
132
|
+
zoom.handle(phase, Number(gesture.scale ?? 1), point.x, point.y);
|
|
133
|
+
};
|
|
134
|
+
return [type, handler] as const;
|
|
135
|
+
});
|
|
136
|
+
const onMessage = (event: MessageEvent) => {
|
|
137
|
+
const data = event.data as Partial<EmbeddedGestureMessage> | null;
|
|
138
|
+
if (!data || typeof data !== 'object' || data.type !== 'EMBEDDED_GESTURE' || data.coords !== 'parent') return;
|
|
139
|
+
if (data.phase !== 'start' && data.phase !== 'change' && data.phase !== 'end') return;
|
|
140
|
+
zoom.handle(data.phase, Number(data.scale), Number(data.clientX), Number(data.clientY));
|
|
141
|
+
};
|
|
142
|
+
for (const [type, handler] of handlers) win.addEventListener(type, handler, LISTEN);
|
|
143
|
+
win.addEventListener('mousemove', onPointer, { passive: true });
|
|
144
|
+
win.addEventListener('message', onMessage);
|
|
145
|
+
return {
|
|
146
|
+
get active() { return zoom.active; },
|
|
147
|
+
dispose() {
|
|
148
|
+
for (const [type, handler] of handlers) win.removeEventListener(type, handler, LISTEN);
|
|
149
|
+
win.removeEventListener('mousemove', onPointer);
|
|
150
|
+
win.removeEventListener('message', onMessage);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 紙面(生きているエディタの iframe)で起きたピンチを親へ転送する。座標は送る前に親の座標へ直す
|
|
157
|
+
* (親の realm で作った関数なので、受け手からは送り元の iframe が分からない。ホイールの転送と同じ)。
|
|
158
|
+
*/
|
|
159
|
+
export function forwardEmbeddedGestures(input: {
|
|
160
|
+
doc: Document;
|
|
161
|
+
toParent: (x: number, y: number) => { clientX: number; clientY: number };
|
|
162
|
+
post: (message: EmbeddedGestureMessage) => void;
|
|
163
|
+
}): () => void {
|
|
164
|
+
const { doc } = input;
|
|
165
|
+
let pointer: { x: number; y: number } | null = null;
|
|
166
|
+
const onPointer = (event: MouseEvent) => { pointer = { x: event.clientX, y: event.clientY }; };
|
|
167
|
+
const handlers = PHASES.map(([phase, type]) => {
|
|
168
|
+
const handler = (event: Event) => {
|
|
169
|
+
const gesture = event as CanvasGestureEvent;
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
event.stopPropagation();
|
|
172
|
+
const hasPoint = Number.isFinite(gesture.clientX) && Number.isFinite(gesture.clientY);
|
|
173
|
+
const local = hasPoint
|
|
174
|
+
? { x: gesture.clientX as number, y: gesture.clientY as number }
|
|
175
|
+
: pointer ?? { x: (doc.defaultView?.innerWidth ?? 0) / 2, y: (doc.defaultView?.innerHeight ?? 0) / 2 };
|
|
176
|
+
input.post({ type: 'EMBEDDED_GESTURE', coords: 'parent', phase, scale: Number(gesture.scale ?? 1), ...input.toParent(local.x, local.y) });
|
|
177
|
+
};
|
|
178
|
+
return [type, handler] as const;
|
|
179
|
+
});
|
|
180
|
+
for (const [type, handler] of handlers) doc.addEventListener(type, handler, LISTEN);
|
|
181
|
+
doc.addEventListener('mousemove', onPointer, { passive: true, capture: true });
|
|
182
|
+
return () => {
|
|
183
|
+
for (const [type, handler] of handlers) doc.removeEventListener(type, handler, LISTEN);
|
|
184
|
+
doc.removeEventListener('mousemove', onPointer, { capture: true });
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------------ 選択範囲に合わせる
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 紙面で選んでいる要素(#artboard の .selected)を囲む矩形を、キャンバスの座標で返す。無ければ null。
|
|
192
|
+
* 埋め込みの紙面は iframe の中では等倍なので、iframe の中の矩形にフレームの位置を足せばよい。
|
|
193
|
+
*/
|
|
194
|
+
export function selectionCanvasRect(
|
|
195
|
+
iframe: HTMLIFrameElement | null | undefined,
|
|
196
|
+
framePosition: { x: number; y: number } | null | undefined,
|
|
197
|
+
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
|
198
|
+
const doc = iframe?.contentDocument;
|
|
199
|
+
if (!doc || !framePosition) return null;
|
|
200
|
+
const rects = Array.from(doc.querySelectorAll<HTMLElement>('#artboard .selected'))
|
|
201
|
+
.map((element) => element.getBoundingClientRect())
|
|
202
|
+
.filter((rect) => rect.width > 0 || rect.height > 0);
|
|
203
|
+
if (!rects.length) return null;
|
|
204
|
+
return {
|
|
205
|
+
minX: framePosition.x + Math.min(...rects.map((rect) => rect.left)),
|
|
206
|
+
minY: framePosition.y + Math.min(...rects.map((rect) => rect.top)),
|
|
207
|
+
maxX: framePosition.x + Math.max(...rects.map((rect) => rect.right)),
|
|
208
|
+
maxY: framePosition.y + Math.max(...rects.map((rect) => rect.bottom)),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* 座標は iframe の座標のまま送る(受け手が送り元 iframe の実測矩形で親の座標へ直す)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { forwardEmbeddedGestures, isImeKeyEvent } from './canvas-gestures';
|
|
12
|
+
|
|
11
13
|
const ZOOM_KEYS = new Set(['=', '+', '-', '_', ';']);
|
|
12
14
|
|
|
13
15
|
function isTypingTarget(target: EventTarget | null): boolean {
|
|
@@ -50,11 +52,8 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
50
52
|
);
|
|
51
53
|
};
|
|
52
54
|
|
|
53
|
-
// ---- Safari の gesture(ピンチ)
|
|
54
|
-
const
|
|
55
|
-
e.preventDefault();
|
|
56
|
-
e.stopPropagation();
|
|
57
|
-
};
|
|
55
|
+
// ---- Safari・WKWebView の gesture(ピンチ)も外へ(親が拡大縮小する。ブラウザの拡大は止める)
|
|
56
|
+
const disposeGestures = forwardEmbeddedGestures({ doc: iframeDoc, toParent, post: (message) => parent.postMessage(message, '*') });
|
|
58
57
|
|
|
59
58
|
// ---- 2 本指タッチ = ピンチ
|
|
60
59
|
let lastDistance: number | null = null;
|
|
@@ -90,6 +89,7 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
90
89
|
|
|
91
90
|
// ---- キー: Space(パン)、Cmd+/- (ズーム)、Shift+0/1/2(100%・全体・このページ)、Shift+R(定規)
|
|
92
91
|
const forwardKey = (kind: 'down' | 'up') => (e: KeyboardEvent) => {
|
|
92
|
+
if (isImeKeyEvent(e)) return; // IME の変換中のキーは奪わない
|
|
93
93
|
const cmd = e.metaKey || e.ctrlKey;
|
|
94
94
|
const typing = isTypingTarget(e.target);
|
|
95
95
|
const isSpace = e.code === 'Space';
|
|
@@ -128,9 +128,6 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
128
128
|
|
|
129
129
|
const options: AddEventListenerOptions = { passive: false, capture: true };
|
|
130
130
|
iframeDoc.addEventListener('wheel', handleWheel, options);
|
|
131
|
-
iframeDoc.addEventListener('gesturestart', handleGesture, options);
|
|
132
|
-
iframeDoc.addEventListener('gesturechange', handleGesture, options);
|
|
133
|
-
iframeDoc.addEventListener('gestureend', handleGesture, options);
|
|
134
131
|
iframeDoc.addEventListener('touchstart', handleTouchStart, options);
|
|
135
132
|
iframeDoc.addEventListener('touchmove', handleTouchMove, options);
|
|
136
133
|
iframeDoc.addEventListener('touchend', handleTouchEnd, options);
|
|
@@ -140,10 +137,8 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
140
137
|
iframeDoc.addEventListener('mousedown', handleMouseDown, options);
|
|
141
138
|
|
|
142
139
|
return () => {
|
|
140
|
+
disposeGestures();
|
|
143
141
|
iframeDoc.removeEventListener('wheel', handleWheel, options);
|
|
144
|
-
iframeDoc.removeEventListener('gesturestart', handleGesture, options);
|
|
145
|
-
iframeDoc.removeEventListener('gesturechange', handleGesture, options);
|
|
146
|
-
iframeDoc.removeEventListener('gestureend', handleGesture, options);
|
|
147
142
|
iframeDoc.removeEventListener('touchstart', handleTouchStart, options);
|
|
148
143
|
iframeDoc.removeEventListener('touchmove', handleTouchMove, options);
|
|
149
144
|
iframeDoc.removeEventListener('touchend', handleTouchEnd, options);
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type { ContentListItem, EditorMode } from "./editor/EditorContext";
|
|
|
25
25
|
export type { DocumentAttributes } from "./editor/contexts/EditorArtboardContext";
|
|
26
26
|
export { computeContentDepths } from "./editor/contexts/EditorArtboardContext";
|
|
27
27
|
|
|
28
|
-
export { WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT, SLIDE_WIDTH, SLIDE_HEIGHT, BREAKPOINT_PRESETS } from "./editor/constants";
|
|
28
|
+
export { WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT, SLIDE_WIDTH, SLIDE_HEIGHT, BREAKPOINT_PRESETS, FRAME_REGION_ATTRIBUTE } from "./editor/constants";
|
|
29
29
|
|
|
30
30
|
export { registerAutoSaveFlush, flushAutoSave } from "./editor/autosave";
|
|
31
31
|
|
|
@@ -55,6 +55,10 @@ export type {
|
|
|
55
55
|
EditorContent,
|
|
56
56
|
EditorComment,
|
|
57
57
|
CommentAction,
|
|
58
|
+
EditorCommentActions,
|
|
59
|
+
EditorCommentThreadAction,
|
|
60
|
+
EditorCommentPanelAction,
|
|
61
|
+
EditorCommentActionResult,
|
|
58
62
|
ExportFormat,
|
|
59
63
|
UploadResult,
|
|
60
64
|
EditorPartDef,
|
package/src/io.ts
CHANGED
|
@@ -81,6 +81,54 @@ export type CommentAction =
|
|
|
81
81
|
| { action: "resolve"; commentId: string; resolved: boolean }
|
|
82
82
|
| { action: "delete"; commentId: string };
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* コメントに利用側が足す操作(0.10.0)。コメントパネルの各スレッドと、パネル上部(いま開いているページ)に
|
|
86
|
+
* ボタンを出す。何をするか(返信を足す・外部に依頼する・別画面を開く など)はエディタの関心事ではない。
|
|
87
|
+
*
|
|
88
|
+
* エディタが受け持つのは、ボタンを出す・押している間の表示(二重押しの防止)・失敗の表示・
|
|
89
|
+
* 戻り値の deck の反映だけ。`run` が投げたエラーは押した場所の下にそのまま出す。
|
|
90
|
+
*/
|
|
91
|
+
export type EditorCommentActionResult = {
|
|
92
|
+
/** 一覧とコメントの最新。渡せばパネルに反映する(返信を足した操作など) */
|
|
93
|
+
deck?: EditorDeck;
|
|
94
|
+
/** 押した場所の下に出す短い知らせ(「依頼しました」など)。無ければ何も出さない */
|
|
95
|
+
message?: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type EditorCommentThreadAction = {
|
|
99
|
+
/** 識別子。コメントパネルの中で一意にする(`data-comment-action` 属性にも出る) */
|
|
100
|
+
id: string;
|
|
101
|
+
label: string;
|
|
102
|
+
/** ツールチップ */
|
|
103
|
+
title?: string;
|
|
104
|
+
/** ラベルの前に出すアイコン */
|
|
105
|
+
icon?: ReactNode;
|
|
106
|
+
/** 出す条件。無ければ未解決のスレッドにだけ出す。`page` はいま開いているページの番号 */
|
|
107
|
+
when?: (comment: EditorComment, ctx: { page: number }) => boolean;
|
|
108
|
+
run: (ctx: { page: number; comment: EditorComment }) => Promise<EditorCommentActionResult | void>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type EditorCommentPanelAction = {
|
|
112
|
+
/** 識別子。コメントパネルの中で一意にする(`data-comment-panel-action` 属性にも出る) */
|
|
113
|
+
id: string;
|
|
114
|
+
label: string;
|
|
115
|
+
/** ツールチップ */
|
|
116
|
+
title?: string;
|
|
117
|
+
/** ラベルの前に出すアイコン */
|
|
118
|
+
icon?: ReactNode;
|
|
119
|
+
/** 出す条件。無ければ、いまのページに未解決のコメントが 1 件以上あるときだけ出す */
|
|
120
|
+
when?: (ctx: { page: number; comments: EditorComment[] }) => boolean;
|
|
121
|
+
/** `comments` はいまのページのコメント全部(解決済みも含む。絞るのは利用側) */
|
|
122
|
+
run: (ctx: { page: number; comments: EditorComment[] }) => Promise<EditorCommentActionResult | void>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type EditorCommentActions = {
|
|
126
|
+
/** 各スレッドに出す操作 */
|
|
127
|
+
thread?: EditorCommentThreadAction[];
|
|
128
|
+
/** パネル上部に出す、いまのページ全体への操作 */
|
|
129
|
+
panel?: EditorCommentPanelAction[];
|
|
130
|
+
};
|
|
131
|
+
|
|
84
132
|
export type ExportFormat = "pdf" | "pdf-lite" | "pptx" | "pptx-edit";
|
|
85
133
|
|
|
86
134
|
export type UploadResult = { url: string; width?: number; height?: number };
|
|
@@ -176,6 +224,12 @@ export type EditorIO = {
|
|
|
176
224
|
/** コメントの追加・返信・解決・削除。無ければコメントUIを出さない */
|
|
177
225
|
commentAction?: (page: number, action: CommentAction) => Promise<EditorDeck>;
|
|
178
226
|
|
|
227
|
+
/**
|
|
228
|
+
* コメントに足す操作(スレッドごと・パネル上部)。無ければ何も足さない(0.10.0)。
|
|
229
|
+
* コメントパネルは commentAction がある場合だけ出るので、これだけ渡しても出ない
|
|
230
|
+
*/
|
|
231
|
+
commentActions?: EditorCommentActions;
|
|
232
|
+
|
|
179
233
|
/** 並び替え・複製・削除など、一覧を編集する操作。無ければその操作を出さない */
|
|
180
234
|
deckOps?: {
|
|
181
235
|
move?: (from: number, to: number) => Promise<EditorDeck>;
|
package/src/version.ts
CHANGED