@growgroup/visual-editor 0.9.0 → 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/comments/CommentBoard.tsx +5 -5
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
- package/src/editor/components/ppt/PptComments.tsx +125 -7
- 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/useContextMenuHandler.ts +3 -1
- package/src/editor/hooks/useElementSelection.ts +5 -1
- package/src/editor/hooks/useIframeSetup.ts +11 -2
- package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
- package/src/editor/parts.ts +2 -1
- package/src/editor/utils/canvas-gestures.ts +210 -0
- package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
- package/src/editor/utils/svg-hit.ts +141 -0
- 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
|
)}
|
|
@@ -24,7 +24,7 @@ import { useState } from 'react';
|
|
|
24
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 { describeRect, fmtTime, unresolvedCount } from '../ppt/PptComments';
|
|
27
|
+
import { anchorRectOf, describeRect, fmtTime, unresolvedCount } from '../ppt/PptComments';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* アンカー・返信のリンク色。
|
|
@@ -371,14 +371,14 @@ function BoardCard({
|
|
|
371
371
|
{/* 何に対する指摘か。範囲 > 要素 > ページ全体 */}
|
|
372
372
|
<div
|
|
373
373
|
className="mt-1.5 flex items-center gap-1 text-[10px]"
|
|
374
|
-
style={{ color: comment
|
|
374
|
+
style={{ color: anchorRectOf(comment) || comment.anchorSrc ? accent : pal.sub }}
|
|
375
375
|
title={comment.anchorSrc}
|
|
376
376
|
>
|
|
377
|
-
{comment
|
|
377
|
+
{anchorRectOf(comment) ? <Crosshair className="h-3 w-3 shrink-0" /> : <MapPin className="h-3 w-3 shrink-0" />}
|
|
378
378
|
<span className="truncate">
|
|
379
379
|
{comment.seq != null && <span className="mr-1 tabular-nums">#{comment.seq}</span>}
|
|
380
|
-
{comment
|
|
381
|
-
? describeRect(comment
|
|
380
|
+
{anchorRectOf(comment)
|
|
381
|
+
? describeRect(anchorRectOf(comment)!)
|
|
382
382
|
: comment.anchorSrc ? (comment.anchorLabel ? `「${comment.anchorLabel}」` : '要素に添付') : 'ページ全体'}
|
|
383
383
|
</span>
|
|
384
384
|
</div>
|
|
@@ -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';
|
|
@@ -223,6 +223,26 @@ export function fitRect(rect: CommentRect, artboard: HTMLElement): CommentRect {
|
|
|
223
223
|
return { x: rect.x * sx, y: rect.y, width: rect.width * sx, height: rect.height };
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* コメントの範囲。anchorRect が無くても、表示用の文言(「範囲 (x, y) w×h」「点 (x, y)」)から戻す。
|
|
228
|
+
*
|
|
229
|
+
* 【なぜ文言から戻すか】
|
|
230
|
+
* 範囲コメント(0.4.0)より前の提案書・構成ラフの殻は、コメントの保存で anchorRect と seq を捨て、
|
|
231
|
+
* anchorLabel だけを残す(殻の vite-plugin の許可リストに無いため)。そのままだと範囲を指定して投稿しても
|
|
232
|
+
* 紙面に枠が出ず、「範囲が表示されない」になる。文言はエディタが describeRect で書いた形なので、そこから戻せる。
|
|
233
|
+
* 要素に付けたコメント(anchorSrc)の anchorLabel は要素の文字なので読まない
|
|
234
|
+
*/
|
|
235
|
+
export function anchorRectOf(c: Pick<SlideComment, 'anchorRect' | 'anchorLabel' | 'anchorSrc'>): CommentRect | undefined {
|
|
236
|
+
if (c.anchorRect) return c.anchorRect;
|
|
237
|
+
if (c.anchorSrc || !c.anchorLabel) return undefined;
|
|
238
|
+
const num = '(-?\\d+(?:\\.\\d+)?)';
|
|
239
|
+
const range = c.anchorLabel.match(new RegExp(`^範囲 \\(${num}, ${num}\\) ${num}×${num}$`));
|
|
240
|
+
if (range) return { x: +range[1], y: +range[2], width: +range[3], height: +range[4] };
|
|
241
|
+
const point = c.anchorLabel.match(new RegExp(`^点 \\(${num}, ${num}\\)$`));
|
|
242
|
+
if (point) return { x: +point[1], y: +point[2], width: 0, height: 0 };
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
226
246
|
/** 矩形を人が読む形に(「範囲 (120, 840) 600×240」「点 (120, 840)」) */
|
|
227
247
|
export function describeRect(r: CommentRect): string {
|
|
228
248
|
const n = (v: number) => String(Math.round(v));
|
|
@@ -282,7 +302,7 @@ export function useCommentMarkers({
|
|
|
282
302
|
const artboard = doc?.getElementById('artboard');
|
|
283
303
|
if (!doc || !artboard) return;
|
|
284
304
|
const layer = ensureCommentLayer(doc, artboard);
|
|
285
|
-
const shown = comments.filter((c) => (c
|
|
305
|
+
const shown = comments.filter((c) => (anchorRectOf(c) || c.anchorSrc) && !c.resolved);
|
|
286
306
|
const wanted = new Set(shown.map((c) => c.id));
|
|
287
307
|
layer.querySelectorAll<HTMLElement>('[data-comment-id]').forEach((el) => {
|
|
288
308
|
if (!wanted.has(el.dataset.commentId!)) el.remove();
|
|
@@ -318,8 +338,9 @@ export function useCommentMarkers({
|
|
|
318
338
|
const isActive = c.id === activeThreadId;
|
|
319
339
|
let x = 0;
|
|
320
340
|
let y = 0;
|
|
321
|
-
|
|
322
|
-
|
|
341
|
+
const anchorRect = anchorRectOf(c);
|
|
342
|
+
if (anchorRect) {
|
|
343
|
+
const r = fitRect(anchorRect, artboard);
|
|
323
344
|
if (isPointRect(r)) {
|
|
324
345
|
layer.querySelector(`[data-comment-id="${CSS.escape(c.id)}"][data-comment-rect]`)?.remove();
|
|
325
346
|
x = r.x - size / 2;
|
|
@@ -404,7 +425,7 @@ export function useCommentMarkers({
|
|
|
404
425
|
}, [
|
|
405
426
|
active, page, getIframeDoc, outerZoom, activeThreadId,
|
|
406
427
|
JSON.stringify(draftRect),
|
|
407
|
-
JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.anchorRect, c.seq, c.resolved, c.author, c.text])),
|
|
428
|
+
JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.anchorRect, c.anchorLabel, c.seq, c.resolved, c.author, c.text])),
|
|
408
429
|
]);
|
|
409
430
|
}
|
|
410
431
|
|
|
@@ -675,6 +696,76 @@ export function PptCommentsPanel({
|
|
|
675
696
|
}
|
|
676
697
|
}, []);
|
|
677
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
|
+
|
|
678
769
|
/** 選択中の要素からアンカーを拾う(選択なしならページ全体へのコメント) */
|
|
679
770
|
const currentAnchor = useMemo(() => {
|
|
680
771
|
const doc = getIframeDoc();
|
|
@@ -770,10 +861,10 @@ export function PptCommentsPanel({
|
|
|
770
861
|
</button>
|
|
771
862
|
</div>
|
|
772
863
|
|
|
773
|
-
{c
|
|
864
|
+
{anchorRectOf(c) ? (
|
|
774
865
|
<div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: 'var(--ed-accent)' }} data-comment-rect-label>
|
|
775
866
|
<Crosshair className="h-3 w-3" />
|
|
776
|
-
{describeRect(c
|
|
867
|
+
{describeRect(anchorRectOf(c)!)}
|
|
777
868
|
</div>
|
|
778
869
|
) : c.anchorSrc && (
|
|
779
870
|
<div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: 'var(--ed-accent)' }}>
|
|
@@ -830,6 +921,8 @@ export function PptCommentsPanel({
|
|
|
830
921
|
</div>
|
|
831
922
|
)}
|
|
832
923
|
|
|
924
|
+
{renderThreadActions(c)}
|
|
925
|
+
|
|
833
926
|
{replyFor === c.id ? (
|
|
834
927
|
<div className="mt-2 flex items-end gap-1.5">
|
|
835
928
|
<textarea
|
|
@@ -911,6 +1004,31 @@ export function PptCommentsPanel({
|
|
|
911
1004
|
すべてAIで修正({unresolvedTotal})
|
|
912
1005
|
</button>
|
|
913
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
|
+
)}
|
|
914
1032
|
</div>
|
|
915
1033
|
{(fixAll?.running || fixAll?.error) && (
|
|
916
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 },
|