@growgroup/visual-editor 0.4.1 → 0.7.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 +92 -0
- package/dist/editor.css +1 -1
- package/package.json +7 -2
- package/src/editor/FrontendVisualEditor.tsx +244 -17
- package/src/editor/collab/CollabPresence.tsx +172 -0
- package/src/editor/collab/binding.ts +269 -0
- package/src/editor/collab/color.ts +28 -0
- package/src/editor/collab/connection.ts +110 -0
- package/src/editor/collab/layer.ts +168 -0
- package/src/editor/collab/merge.ts +112 -0
- package/src/editor/collab/morph.ts +113 -0
- package/src/editor/collab/room.ts +82 -0
- package/src/editor/collab/runtime.ts +442 -0
- package/src/editor/collab/signals.ts +11 -0
- package/src/editor/collab/store.ts +192 -0
- package/src/editor/collab/useCollab.ts +266 -0
- package/src/editor/components/AiPromptPopover.tsx +3 -2
- package/src/editor/components/EditorCanvas.tsx +27 -0
- package/src/editor/components/EditorContextMenu.tsx +80 -0
- package/src/editor/components/EditorPropertyPanel.tsx +248 -80
- package/src/editor/components/EditorSidebar.tsx +2 -2
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +41 -19
- package/src/editor/components/ppt/PptChrome.tsx +46 -4
- package/src/editor/components/ppt/PptComments.tsx +3 -8
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
- package/src/editor/components/shell/EditorTopBar.tsx +13 -1
- package/src/editor/components/shell/PagesPanel.tsx +3 -0
- package/src/editor/constants.ts +24 -0
- package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
- package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
- package/src/editor/hooks/index.ts +3 -0
- package/src/editor/hooks/useDragResize.ts +30 -5
- package/src/editor/hooks/useElementActions.ts +41 -9
- package/src/editor/hooks/useElementSelection.ts +4 -2
- package/src/editor/hooks/useGoogleFonts.ts +6 -8
- package/src/editor/hooks/useIframeSetup.ts +10 -1
- package/src/editor/hooks/useInlineTextSelection.ts +16 -0
- package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
- package/src/editor/hooks/useLinkNavigation.ts +160 -0
- package/src/editor/hooks/useResizablePanel.ts +3 -2
- package/src/editor/parts.ts +2 -0
- package/src/editor/types.ts +4 -0
- package/src/editor/utils/debug.ts +2 -5
- package/src/editor/utils/dom-utils.ts +45 -0
- package/src/editor/utils/drop-target.ts +1 -1
- package/src/editor/utils/flex-reorder.ts +1 -1
- package/src/editor/utils/free-layout-tree.ts +562 -0
- package/src/editor/utils/free-layout.ts +794 -0
- package/src/editor/utils/geometry.ts +41 -3
- package/src/editor/utils/html-utils.ts +4 -1
- package/src/editor/utils/inline-text-style.ts +426 -0
- package/src/editor/utils/link-navigation.ts +474 -0
- package/src/editor/utils/restore-flow.ts +1 -1
- package/src/editor/utils/smart-guides.ts +1 -1
- package/src/editor/utils/storage.ts +47 -0
- package/src/editor/utils/style-utils.ts +24 -0
- package/src/editor/utils/tailwind-utils.ts +12 -0
- package/src/editor/utils/text-line-break.ts +121 -0
- package/src/index.ts +15 -0
- package/src/io.ts +29 -0
- package/src/styles/skin.css +17 -0
- package/src/version.ts +1 -1
|
@@ -52,6 +52,7 @@ import { CanvasRulers } from './CanvasRulers';
|
|
|
52
52
|
import { CanvasFrameDecor } from './CanvasFrameDecor';
|
|
53
53
|
import { cn } from '../../../lib/utils';
|
|
54
54
|
import { ArrowUpRight } from 'lucide-react';
|
|
55
|
+
import { CollabPageDots } from '../../collab/CollabPresence';
|
|
55
56
|
|
|
56
57
|
/** フレーム名の高さ(px、画面上)。フレームの上端との間隔 */
|
|
57
58
|
const LABEL_HEIGHT = 20;
|
|
@@ -168,6 +169,8 @@ const FrameChrome = memo(function FrameChrome({ page, showLabel, showLink, isSel
|
|
|
168
169
|
<span className="ed-frame-label-index">{page.index + 1}</span>
|
|
169
170
|
<span className="ed-frame-label-title">{page.title || page.id}</span>
|
|
170
171
|
{page.isDirty && <span className="ed-frame-label-dirty" aria-label="未保存の変更" />}
|
|
172
|
+
{/* 共同編集: このページに居る参加者の色の点(io.collab が無ければ何も出ない) */}
|
|
173
|
+
<CollabPageDots contentId={page.id} />
|
|
171
174
|
{isBusy && <span className="ed-frame-label-loading" aria-label="読み込み中" />}
|
|
172
175
|
</button>
|
|
173
176
|
{page.href && showLink && (
|
|
@@ -206,9 +209,9 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
206
209
|
const canvas = useMultiPageCanvas();
|
|
207
210
|
const {
|
|
208
211
|
pages,
|
|
209
|
-
bounds,
|
|
210
212
|
viewStore,
|
|
211
213
|
registerContainer,
|
|
214
|
+
shiftView,
|
|
212
215
|
activatePage,
|
|
213
216
|
activatingPageId,
|
|
214
217
|
zoomToFit,
|
|
@@ -216,7 +219,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
216
219
|
zoomToActual,
|
|
217
220
|
rulersVisible,
|
|
218
221
|
isInteracting,
|
|
219
|
-
|
|
222
|
+
applyInitialView,
|
|
220
223
|
getPreviewImageZoomCap,
|
|
221
224
|
previewImageCapVersion,
|
|
222
225
|
} = canvas;
|
|
@@ -244,6 +247,38 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
244
247
|
return () => registerContainer(null);
|
|
245
248
|
}, [registerContainer]);
|
|
246
249
|
|
|
250
|
+
// 左のパネルを出し入れする(部品パネルに替わる・幅をドラッグする・利用側のレールが変わるも同じ)と、
|
|
251
|
+
// 容器の左端が動く。位置は容器の左上が基準なので、そのままでは紙面もパネルの幅だけ一緒に動く。
|
|
252
|
+
// 動いた分だけ逆にずらして、紙面を画面上の同じ位置に留める(Figma と同じ。見える範囲が狭くなるだけ)。
|
|
253
|
+
// 入口ごとには手当てせず、容器の位置の変化そのものを見る。左端は幅と一緒にしか動かないので ResizeObserver で拾える。
|
|
254
|
+
// 呼ばれるのはレイアウトの後・描画の前で、viewStore は購読者(転写層の transform)へ同期で書くので、ずれた姿は描かれない
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const el = containerRef.current;
|
|
257
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
258
|
+
let last: { left: number; top: number } | null = null;
|
|
259
|
+
const observer = new ResizeObserver(() => {
|
|
260
|
+
const rect = el.getBoundingClientRect();
|
|
261
|
+
if (last) shiftView(last.left - rect.left, last.top - rect.top);
|
|
262
|
+
last = { left: rect.left, top: rect.top };
|
|
263
|
+
});
|
|
264
|
+
observer.observe(el);
|
|
265
|
+
return () => observer.disconnect();
|
|
266
|
+
}, [shiftView]);
|
|
267
|
+
|
|
268
|
+
// 容器は overflow: hidden で、位置は transform が持つ。ところがフォーカスを移すと(部品パネルを閉じたときに
|
|
269
|
+
// エディタへ戻す iframe.focus() など)、ブラウザは要素を見せようとして容器そのものをスクロールし、
|
|
270
|
+
// 紙面がずれたまま戻らない(縮小した構成ラフで縦に 61px。直す前の版から)。容器のスクロールは常に 0 へ戻す
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const el = containerRef.current;
|
|
273
|
+
if (!el) return;
|
|
274
|
+
const reset = () => {
|
|
275
|
+
if (el.scrollTop) el.scrollTop = 0;
|
|
276
|
+
if (el.scrollLeft) el.scrollLeft = 0;
|
|
277
|
+
};
|
|
278
|
+
el.addEventListener('scroll', reset);
|
|
279
|
+
return () => el.removeEventListener('scroll', reset);
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
247
282
|
// ---- 倍率・位置の反映(React を通さない)
|
|
248
283
|
const [coarseZoom, setCoarseZoom] = useState(() => viewStore.get().canvasZoom);
|
|
249
284
|
const coarseZoomRef = useRef(coarseZoom);
|
|
@@ -319,26 +354,13 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
|
|
|
319
354
|
};
|
|
320
355
|
}, [zoomToFit, zoomToActual, zoomToPage, activePageId]);
|
|
321
356
|
|
|
322
|
-
// 初回の表示:
|
|
323
|
-
const initialViewDoneRef = useRef(false);
|
|
357
|
+
// 初回の表示: 容器の大きさとフレームの並びが揃ったところで 1 回(決め方は applyInitialView)
|
|
324
358
|
useEffect(() => {
|
|
325
|
-
if (
|
|
359
|
+
if (pages.length === 0) return;
|
|
326
360
|
const el = containerRef.current;
|
|
327
361
|
if (!el || el.clientWidth === 0) return;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const { canvasOffset: o, canvasZoom: z } = canvas.viewStore.get();
|
|
331
|
-
const left = o.x + bounds.minX * z;
|
|
332
|
-
const top = o.y + bounds.minY * z;
|
|
333
|
-
const right = o.x + bounds.maxX * z;
|
|
334
|
-
const bottom = o.y + bounds.maxY * z;
|
|
335
|
-
const intersects = right > 0 && left < el.clientWidth && bottom > 0 && top < el.clientHeight;
|
|
336
|
-
if (intersects) return;
|
|
337
|
-
}
|
|
338
|
-
if (activePageId) zoomToPage(activePageId);
|
|
339
|
-
else zoomToFit();
|
|
340
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
341
|
-
}, [pages.length, activePageId]);
|
|
362
|
+
applyInitialView();
|
|
363
|
+
}, [pages.length, activePageId, applyInitialView]);
|
|
342
364
|
|
|
343
365
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
|
344
366
|
const handleHover = useCallback((id: string | null) => {
|
|
@@ -21,6 +21,8 @@ import { generateElementId, updateSelectionBox } from '../../utils/dom-utils';
|
|
|
21
21
|
import { alignElements, distributeElements, type AlignMode, type DistributeAxis } from '../../utils/align-elements';
|
|
22
22
|
import { isEyeDropperSupported, pickScreenColor } from '../../utils/eyedropper';
|
|
23
23
|
import { applyTextHighlight, applyTextColor } from '../../utils/text-highlight';
|
|
24
|
+
import { INLINE_TEXT_PROPERTIES, MIXED, applyInlineTextStyle, getInlineTextRange, type InlineTextSummary } from '../../utils/inline-text-style';
|
|
25
|
+
import { useInlineTextSelection } from '../../hooks/useInlineTextSelection';
|
|
24
26
|
import { SHAPE_GROUPS, setPendingShape, pendingShape, shapeStyles, type ShapeDef } from '../../utils/shape-library';
|
|
25
27
|
import { applyShadowPreset, paintTarget } from '../../utils/element-effects';
|
|
26
28
|
import {
|
|
@@ -271,9 +273,21 @@ const PAINT_PROPS = new Set([
|
|
|
271
273
|
'borderTopWidth', 'borderTopStyle', 'borderTopColor',
|
|
272
274
|
]);
|
|
273
275
|
|
|
276
|
+
/** リボンが読む computed style の名前 → 選んだ文字の見た目(InlineTextSummary)の項目 */
|
|
277
|
+
const INLINE_SUMMARY_KEYS: Record<string, keyof InlineTextSummary> = {
|
|
278
|
+
'font-size': 'fontSize',
|
|
279
|
+
'font-weight': 'fontWeight',
|
|
280
|
+
'font-style': 'fontStyle',
|
|
281
|
+
'text-decoration-line': 'textDecoration',
|
|
282
|
+
'letter-spacing': 'letterSpacing',
|
|
283
|
+
color: 'color',
|
|
284
|
+
'font-family': 'fontFamily',
|
|
285
|
+
};
|
|
286
|
+
|
|
274
287
|
function useSelectionStyle() {
|
|
275
288
|
const { getIframeDoc, selectedElement, selectedElementIds, notifyIframeChange } =
|
|
276
289
|
useEditorContext();
|
|
290
|
+
const inlineText = useInlineTextSelection();
|
|
277
291
|
|
|
278
292
|
const targets = useCallback((): HTMLElement[] => {
|
|
279
293
|
const doc = getIframeDoc();
|
|
@@ -292,6 +306,18 @@ function useSelectionStyle() {
|
|
|
292
306
|
(styles: Record<string, string>) => {
|
|
293
307
|
const els = targets();
|
|
294
308
|
if (!els.length) return;
|
|
309
|
+
// テキストの一部を選んでいれば、文字の属性はその範囲だけに当てる(右パネルと同じ)
|
|
310
|
+
const doc = getIframeDoc();
|
|
311
|
+
const id = els.length === 1 ? els[0].getAttribute('data-element-id') : null;
|
|
312
|
+
const partial =
|
|
313
|
+
doc && id && Object.keys(styles).every((k) => INLINE_TEXT_PROPERTIES.has(k))
|
|
314
|
+
? getInlineTextRange(doc, id)
|
|
315
|
+
: null;
|
|
316
|
+
if (partial) {
|
|
317
|
+
applyInlineTextStyle(partial, styles);
|
|
318
|
+
notifyIframeChange();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
295
321
|
for (const el of els) {
|
|
296
322
|
for (const [k, v] of Object.entries(styles)) {
|
|
297
323
|
// 効果用の枠が選択されている場合、塗り・枠線・切り抜きは中身の図形へ、
|
|
@@ -302,19 +328,27 @@ function useSelectionStyle() {
|
|
|
302
328
|
}
|
|
303
329
|
notifyIframeChange();
|
|
304
330
|
},
|
|
305
|
-
[targets, notifyIframeChange],
|
|
331
|
+
[targets, notifyIframeChange, getIframeDoc],
|
|
306
332
|
);
|
|
307
333
|
|
|
308
334
|
const readComputed = useCallback(
|
|
309
335
|
(prop: string): string => {
|
|
310
336
|
const els = targets();
|
|
311
337
|
if (!els.length) return '';
|
|
338
|
+
const key = INLINE_SUMMARY_KEYS[prop];
|
|
339
|
+
if (
|
|
340
|
+
key && inlineText && els.length === 1 &&
|
|
341
|
+
inlineText.hostId === els[0].getAttribute('data-element-id') &&
|
|
342
|
+
inlineText[key] !== MIXED
|
|
343
|
+
) {
|
|
344
|
+
return inlineText[key];
|
|
345
|
+
}
|
|
312
346
|
const camel = prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
313
347
|
const el = PAINT_PROPS.has(camel) ? paintTarget(els[0]) : els[0];
|
|
314
348
|
const win = el.ownerDocument.defaultView;
|
|
315
349
|
return win ? win.getComputedStyle(el).getPropertyValue(prop) : '';
|
|
316
350
|
},
|
|
317
|
-
[targets],
|
|
351
|
+
[targets, inlineText],
|
|
318
352
|
);
|
|
319
353
|
|
|
320
354
|
/** 属性の付け外し(アニメーション data-anim 等)。null で除去 */
|
|
@@ -1164,10 +1198,18 @@ export function PptRibbon({
|
|
|
1164
1198
|
(color: string) => {
|
|
1165
1199
|
const doc = getIframeDoc();
|
|
1166
1200
|
if (!doc) return;
|
|
1167
|
-
|
|
1201
|
+
// テキストの一部を選んでいれば、大きさ・太字と同じ経路(apply)で当てる。
|
|
1202
|
+
// 色の入力は選んでいる間ずっと change が来るので、毎回 span を足さずに同じ span へ当て、履歴も 1 段にまとめる
|
|
1203
|
+
const els = targets();
|
|
1204
|
+
const id = els.length === 1 ? els[0].getAttribute('data-element-id') : null;
|
|
1205
|
+
if (id && getInlineTextRange(doc, id)) {
|
|
1206
|
+
apply({ color });
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (applyTextColor(doc, els, color)) notifyIframeChange();
|
|
1168
1210
|
setRev((n) => n + 1);
|
|
1169
1211
|
},
|
|
1170
|
-
[getIframeDoc, targets, notifyIframeChange],
|
|
1212
|
+
[getIframeDoc, targets, notifyIframeChange, apply],
|
|
1171
1213
|
);
|
|
1172
1214
|
// 実機同様、選択が無くなったらコンテキストタブを閉じる
|
|
1173
1215
|
useEffect(() => {
|
|
@@ -23,6 +23,7 @@ import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
|
|
|
23
23
|
import { commentAction, type SlideComment } from '../../../lib/deck';
|
|
24
24
|
import { can, io, type CommentRect, type EditorDeck } from '../../../io';
|
|
25
25
|
import { useResizablePanel } from '../../hooks/useResizablePanel';
|
|
26
|
+
import { readStorage, writeStorage } from '../../utils/storage';
|
|
26
27
|
import { ConfirmDialog } from '../shell/ConfirmDialog';
|
|
27
28
|
import { PPT_PALETTES, type PptTheme } from './PptChrome';
|
|
28
29
|
|
|
@@ -155,18 +156,12 @@ export function findAnchored(root: HTMLElement, value: string, label?: string):
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
export function loadAuthor(): string {
|
|
158
|
-
|
|
159
|
-
return localStorage.getItem(AUTHOR_KEY) || '';
|
|
160
|
-
} catch {
|
|
161
|
-
return '';
|
|
162
|
-
}
|
|
159
|
+
return readStorage(AUTHOR_KEY) || '';
|
|
163
160
|
}
|
|
164
161
|
|
|
165
162
|
/** 名前を記憶する。右パネルとコメントボードで同じ置き場を使う */
|
|
166
163
|
export function storeAuthor(value: string): void {
|
|
167
|
-
|
|
168
|
-
localStorage.setItem(AUTHOR_KEY, value);
|
|
169
|
-
} catch { /* 記憶できなくても続行 */ }
|
|
164
|
+
writeStorage(AUTHOR_KEY, value);
|
|
170
165
|
}
|
|
171
166
|
|
|
172
167
|
/** 未解決コメント数(サムネイルのバッジ用) */
|
|
@@ -45,6 +45,15 @@ interface VariableAwareSizeInputProps {
|
|
|
45
45
|
dimension?: 'width' | 'height';
|
|
46
46
|
/** 単位変換コンテキスト */
|
|
47
47
|
conversionContext?: UnitConversionContext;
|
|
48
|
+
/** 欄の名前(data-size-field)。省略時は dimension から w / h */
|
|
49
|
+
fieldId?: string;
|
|
50
|
+
/**
|
|
51
|
+
* 'size' … W / H(固定・Fill・Hug を切り替える)
|
|
52
|
+
* 'limit' … 最小・最大の幅と高さ。空にできる(空 = 指定なし)。モードの切替とメニューの矢印は出さない
|
|
53
|
+
*/
|
|
54
|
+
variant?: 'size' | 'limit';
|
|
55
|
+
/** 値が空のときに出す文字(variant="limit") */
|
|
56
|
+
placeholder?: string;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export function VariableAwareSizeInput({
|
|
@@ -60,6 +69,9 @@ export function VariableAwareSizeInput({
|
|
|
60
69
|
canHug = false,
|
|
61
70
|
dimension = 'width',
|
|
62
71
|
conversionContext,
|
|
72
|
+
fieldId,
|
|
73
|
+
variant = 'size',
|
|
74
|
+
placeholder,
|
|
63
75
|
}: VariableAwareSizeInputProps) {
|
|
64
76
|
const [isOpen, setIsOpen] = useState(false);
|
|
65
77
|
const [searchQuery, setSearchQuery] = useState('');
|
|
@@ -94,6 +106,9 @@ export function VariableAwareSizeInput({
|
|
|
94
106
|
return variables.find(v => v.cssName === varName);
|
|
95
107
|
}, [isLinked, value, variables]);
|
|
96
108
|
|
|
109
|
+
// 最小・最大の欄で指定が無い
|
|
110
|
+
const isEmpty = variant === 'limit' && (value === '' || value === undefined || value === null);
|
|
111
|
+
|
|
97
112
|
// 値と単位をパース
|
|
98
113
|
const parsed = useMemo(() => {
|
|
99
114
|
if (isLinked) {
|
|
@@ -135,11 +150,12 @@ export function VariableAwareSizeInput({
|
|
|
135
150
|
|
|
136
151
|
// 表示値
|
|
137
152
|
const displayValue = useMemo(() => {
|
|
153
|
+
if (isEmpty) return '';
|
|
138
154
|
if (isLinked && linkedVariable) {
|
|
139
155
|
return linkedVariable.name;
|
|
140
156
|
}
|
|
141
157
|
return parsed.numericValue;
|
|
142
|
-
}, [isLinked, linkedVariable, parsed]);
|
|
158
|
+
}, [isEmpty, isLinked, linkedVariable, parsed]);
|
|
143
159
|
|
|
144
160
|
// 変数を選択
|
|
145
161
|
const handleSelectVariable = useCallback((variable: CSSVariableDefinition) => {
|
|
@@ -155,9 +171,9 @@ export function VariableAwareSizeInput({
|
|
|
155
171
|
if (linkedVariable) {
|
|
156
172
|
onChangeValue(linkedVariable.value);
|
|
157
173
|
} else {
|
|
158
|
-
onChangeValue('auto');
|
|
174
|
+
onChangeValue(variant === 'limit' ? '' : 'auto');
|
|
159
175
|
}
|
|
160
|
-
}, [linkedVariable, onChangeValue]);
|
|
176
|
+
}, [linkedVariable, onChangeValue, variant]);
|
|
161
177
|
|
|
162
178
|
// モード変更
|
|
163
179
|
const handleModeChange = useCallback((newMode: SizeMode) => {
|
|
@@ -170,6 +186,12 @@ export function VariableAwareSizeInput({
|
|
|
170
186
|
const newValue = e.target.value;
|
|
171
187
|
setLocalValue(newValue);
|
|
172
188
|
|
|
189
|
+
// 最小・最大の欄は、空にすると指定を消す
|
|
190
|
+
if (variant === 'limit' && newValue.trim() === '') {
|
|
191
|
+
onChangeValue('');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
173
195
|
const num = parseFloat(newValue);
|
|
174
196
|
if (!isNaN(num)) {
|
|
175
197
|
let clampedValue = num;
|
|
@@ -177,11 +199,16 @@ export function VariableAwareSizeInput({
|
|
|
177
199
|
if (max !== undefined) clampedValue = Math.min(max, clampedValue);
|
|
178
200
|
onChangeValue(formatValueWithUnit(clampedValue, currentUnit));
|
|
179
201
|
}
|
|
180
|
-
}, [onChangeValue, min, max, currentUnit]);
|
|
202
|
+
}, [onChangeValue, min, max, currentUnit, variant]);
|
|
181
203
|
|
|
182
204
|
// 単位変更(値を変換)
|
|
183
205
|
const handleUnitChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
184
206
|
const newUnit = e.target.value;
|
|
207
|
+
// 指定が無いときは単位だけ覚える(0 を書き込まない)
|
|
208
|
+
if (isEmpty) {
|
|
209
|
+
setTrackedUnit(newUnit);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
185
212
|
const context: UnitConversionContext = {
|
|
186
213
|
...conversionContext,
|
|
187
214
|
property: dimension === 'height' ? 'height' : 'width',
|
|
@@ -190,14 +217,14 @@ export function VariableAwareSizeInput({
|
|
|
190
217
|
// 新しい単位を追跡
|
|
191
218
|
setTrackedUnit(newUnit);
|
|
192
219
|
onChangeValue(formatValueWithUnit(convertedValue, newUnit));
|
|
193
|
-
}, [onChangeValue, parsed.numericValue, currentUnit, conversionContext, dimension]);
|
|
220
|
+
}, [onChangeValue, parsed.numericValue, currentUnit, conversionContext, dimension, isEmpty]);
|
|
194
221
|
|
|
195
222
|
// 入力のフォーカス時
|
|
196
223
|
const handleInputFocus = useCallback(() => {
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}, [isLinked, parsed.numericValue]);
|
|
224
|
+
if (isLinked || isEmpty) return;
|
|
225
|
+
// W / H は整数で打つ前提で丸める。最小・最大は入っている値をそのまま出す
|
|
226
|
+
setLocalValue(String(variant === 'limit' ? parsed.numericValue : Math.round(parsed.numericValue)));
|
|
227
|
+
}, [isLinked, isEmpty, variant, parsed.numericValue]);
|
|
201
228
|
|
|
202
229
|
// 現在の値から変数を作成
|
|
203
230
|
const handleCreateVariable = useCallback(() => {
|
|
@@ -291,24 +318,36 @@ export function VariableAwareSizeInput({
|
|
|
291
318
|
[disabled, isLinked, mode, parsed, min, max, currentUnit, onChangeValue]
|
|
292
319
|
);
|
|
293
320
|
|
|
321
|
+
// 単位の欄が付くのは固定値で変数に紐づいていないときだけ
|
|
322
|
+
const hasUnit = mode === 'fixed' && !isLinked;
|
|
323
|
+
|
|
294
324
|
return (
|
|
295
|
-
|
|
325
|
+
// [.group] 外の group はパネルの共通の入力の見た目(高さ 32px・左右 8px の余白の塗り)を中に当てないための印。
|
|
326
|
+
// 中の数値と単位は枠を持たず、箱(下の .group.h-5)が 1 つの入力として見える
|
|
327
|
+
<div
|
|
328
|
+
className="group flex items-center gap-1 min-w-0"
|
|
329
|
+
data-size-field={fieldId ?? (dimension === 'height' ? 'h' : 'w')}
|
|
330
|
+
>
|
|
296
331
|
{/* Label area (W/H) */}
|
|
297
332
|
{label && <div className="shrink-0">{label}</div>}
|
|
298
333
|
|
|
299
334
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
300
335
|
<PopoverTrigger asChild>
|
|
336
|
+
{/* [幅] 以前は固定値の箱を w-14(56px)に決め打ちし、その外に単位(40px)を置いていたため、
|
|
337
|
+
数値に使える幅が 26px しか残らず「1499」「1820」が切れていた。
|
|
338
|
+
箱はモードによらず列の残りをすべて使い、数値・単位・モード切替をこの中に並べる */}
|
|
301
339
|
<div
|
|
340
|
+
data-size-box
|
|
302
341
|
className={cn(
|
|
303
|
-
"flex items-center h-5 bg-[#383838] border border-[#4a4a4a] rounded
|
|
342
|
+
"group flex flex-1 min-w-0 items-center h-5 bg-[#383838] border border-[#4a4a4a] rounded cursor-pointer",
|
|
304
343
|
"hover:border-[#5d5d5d]",
|
|
344
|
+
variant === 'limit' && "pr-1",
|
|
305
345
|
disabled && "opacity-50 cursor-not-allowed",
|
|
306
|
-
isOpen && "ring-1 ring-[#0d99ff] border-[#0d99ff]"
|
|
307
|
-
mode === 'fixed' ? 'w-14' : 'flex-1'
|
|
346
|
+
isOpen && "ring-1 ring-[#0d99ff] border-[#0d99ff]"
|
|
308
347
|
)}
|
|
309
348
|
>
|
|
310
349
|
{/* コンテンツ */}
|
|
311
|
-
<div className="flex-1 min-w-0 h-full flex items-center
|
|
350
|
+
<div className="flex-1 min-w-0 h-full flex items-center pl-1.5">
|
|
312
351
|
{mode === 'fixed' ? (
|
|
313
352
|
isLinked ? (
|
|
314
353
|
<div className="flex items-center gap-0.5 flex-1 min-w-0">
|
|
@@ -335,13 +374,16 @@ export function VariableAwareSizeInput({
|
|
|
335
374
|
ref={inputRef}
|
|
336
375
|
type="number"
|
|
337
376
|
value={localValue || displayValue}
|
|
377
|
+
placeholder={placeholder}
|
|
338
378
|
onChange={handleInputChange}
|
|
339
379
|
onFocus={handleInputFocus}
|
|
380
|
+
// 打ちかけの文字は外れたら捨てる(次に選んだ要素の値の上に残さない)
|
|
381
|
+
onBlur={() => setLocalValue('')}
|
|
340
382
|
onMouseDown={handleMouseDown}
|
|
341
383
|
onClick={(e) => e.stopPropagation()}
|
|
342
384
|
disabled={disabled}
|
|
343
385
|
className={cn(
|
|
344
|
-
"w-full bg-transparent border-none outline-none text-gray-200 text-[10px]",
|
|
386
|
+
"w-full min-w-0 p-0 bg-transparent border-none outline-none text-gray-200 text-[10px] tabular-nums",
|
|
345
387
|
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
|
|
346
388
|
isDragging ? "cursor-ew-resize" : "cursor-ew-resize"
|
|
347
389
|
)}
|
|
@@ -358,14 +400,40 @@ export function VariableAwareSizeInput({
|
|
|
358
400
|
)}
|
|
359
401
|
</div>
|
|
360
402
|
|
|
361
|
-
{/*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
403
|
+
{/* 単位セレクター(fixed モードで変数リンクがない場合のみ)。
|
|
404
|
+
箱の中にあるので、押してもモード切替のメニューは開かない */}
|
|
405
|
+
{hasUnit && (
|
|
406
|
+
<select
|
|
407
|
+
value={currentUnit}
|
|
408
|
+
onChange={handleUnitChange}
|
|
409
|
+
onClick={(e) => e.stopPropagation()}
|
|
410
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
411
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
412
|
+
disabled={disabled}
|
|
413
|
+
title="単位"
|
|
414
|
+
data-size-unit
|
|
415
|
+
className={cn(
|
|
416
|
+
"h-full shrink-0 px-0.5 appearance-none bg-transparent border-none outline-none",
|
|
417
|
+
"text-[9px] text-gray-400 hover:text-white cursor-pointer",
|
|
418
|
+
"disabled:text-gray-500"
|
|
419
|
+
)}
|
|
420
|
+
>
|
|
421
|
+
{unitList.map((u) => (
|
|
422
|
+
<option key={u} value={u}>{u}</option>
|
|
423
|
+
))}
|
|
424
|
+
</select>
|
|
425
|
+
)}
|
|
426
|
+
|
|
427
|
+
{/* ドロップダウンアイコン(最小・最大の欄は幅を数値に回すので出さない。箱を押せばメニューは開く) */}
|
|
428
|
+
{variant === 'size' && (
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
className="h-full w-4 shrink-0 flex items-center justify-center text-gray-500 hover:text-white transition-colors"
|
|
432
|
+
disabled={disabled}
|
|
433
|
+
>
|
|
434
|
+
<ChevronDown className="h-2.5 w-2.5" />
|
|
435
|
+
</button>
|
|
436
|
+
)}
|
|
369
437
|
</div>
|
|
370
438
|
</PopoverTrigger>
|
|
371
439
|
|
|
@@ -391,7 +459,8 @@ export function VariableAwareSizeInput({
|
|
|
391
459
|
</div>
|
|
392
460
|
|
|
393
461
|
<ScrollArea className="max-h-56">
|
|
394
|
-
{/* リサイズモード */}
|
|
462
|
+
{/* リサイズモード(最小・最大の欄では出さない) */}
|
|
463
|
+
{variant === 'size' && (
|
|
395
464
|
<div className="p-1 border-b border-[#444444]">
|
|
396
465
|
<div className="px-2 py-0.5 text-[9px] text-gray-500 uppercase">
|
|
397
466
|
リサイズ
|
|
@@ -452,6 +521,7 @@ export function VariableAwareSizeInput({
|
|
|
452
521
|
{mode === 'hug' && <span>✓</span>}
|
|
453
522
|
</button>
|
|
454
523
|
</div>
|
|
524
|
+
)}
|
|
455
525
|
|
|
456
526
|
{/* スペーシング変数 */}
|
|
457
527
|
<div className="p-1">
|
|
@@ -557,32 +627,6 @@ export function VariableAwareSizeInput({
|
|
|
557
627
|
</PopoverContent>
|
|
558
628
|
</Popover>
|
|
559
629
|
|
|
560
|
-
{/* 単位セレクター(fixed モードで変数リンクがない場合のみ) */}
|
|
561
|
-
{mode === 'fixed' && !isLinked && (
|
|
562
|
-
<select
|
|
563
|
-
value={currentUnit}
|
|
564
|
-
onChange={handleUnitChange}
|
|
565
|
-
disabled={disabled}
|
|
566
|
-
className={cn(
|
|
567
|
-
"h-5 w-10 px-0 text-[8px] border border-l-0 rounded-r border-[#4a4a4a]",
|
|
568
|
-
"bg-[#4a4a4a] text-gray-300 focus:outline-none focus:border-[#0d99ff]",
|
|
569
|
-
"disabled:bg-[#383838] disabled:text-gray-500 cursor-pointer"
|
|
570
|
-
)}
|
|
571
|
-
>
|
|
572
|
-
{unitList.map((u) => (
|
|
573
|
-
<option key={u} value={u}>{u}</option>
|
|
574
|
-
))}
|
|
575
|
-
</select>
|
|
576
|
-
)}
|
|
577
|
-
|
|
578
|
-
{/* fill/hug モードまたはリンク時は角丸を調整 */}
|
|
579
|
-
{(mode !== 'fixed' || isLinked) && (
|
|
580
|
-
<style>{`
|
|
581
|
-
:global(.group\\/size-input > div:first-of-type > div:first-child) {
|
|
582
|
-
border-radius: 0.25rem !important;
|
|
583
|
-
}
|
|
584
|
-
`}</style>
|
|
585
|
-
)}
|
|
586
630
|
</div>
|
|
587
631
|
);
|
|
588
632
|
}
|
|
@@ -58,6 +58,8 @@ export interface VariableAwareUnitInputProps {
|
|
|
58
58
|
hideVariableLink?: boolean;
|
|
59
59
|
/** 単位変換コンテキスト */
|
|
60
60
|
conversionContext?: UnitConversionContext;
|
|
61
|
+
/** 選んだ文字の中で値が揃っていない。欄を空にして「混在」と出し、打ったときだけ値を返す */
|
|
62
|
+
mixed?: boolean;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// ============================================
|
|
@@ -80,6 +82,7 @@ export function VariableAwareUnitInput({
|
|
|
80
82
|
enableScrubbing = true,
|
|
81
83
|
hideVariableLink = false,
|
|
82
84
|
conversionContext,
|
|
85
|
+
mixed = false,
|
|
83
86
|
}: VariableAwareUnitInputProps) {
|
|
84
87
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
85
88
|
const [isOpen, setIsOpen] = useState(false);
|
|
@@ -165,6 +168,8 @@ export function VariableAwareUnitInput({
|
|
|
165
168
|
const inputValue = e.target.value;
|
|
166
169
|
|
|
167
170
|
if (inputValue === "" || inputValue === "-") {
|
|
171
|
+
// 混在のまま消しただけなら何も当てない(0 にしない)
|
|
172
|
+
if (mixed) return;
|
|
168
173
|
onChange(formatValueWithUnit(0, currentUnit));
|
|
169
174
|
return;
|
|
170
175
|
}
|
|
@@ -178,7 +183,7 @@ export function VariableAwareUnitInput({
|
|
|
178
183
|
|
|
179
184
|
onChange(formatValueWithUnit(clampedValue, currentUnit));
|
|
180
185
|
},
|
|
181
|
-
[onChange, currentUnit, min, max]
|
|
186
|
+
[onChange, currentUnit, min, max, mixed]
|
|
182
187
|
);
|
|
183
188
|
|
|
184
189
|
// 単位変更ハンドラ(値を変換)
|
|
@@ -272,7 +277,7 @@ export function VariableAwareUnitInput({
|
|
|
272
277
|
// 改善: クリックでフォーカスを許可し、ドラッグ閾値を超えた場合のみスクラブを開始
|
|
273
278
|
const handleMouseDown = useCallback(
|
|
274
279
|
(e: React.MouseEvent<HTMLInputElement>) => {
|
|
275
|
-
if (!enableScrubbing || disabled || isLinked) return;
|
|
280
|
+
if (!enableScrubbing || disabled || isLinked || mixed) return;
|
|
276
281
|
if (e.button !== 0) return;
|
|
277
282
|
// 既にフォーカスされている場合は通常の入力を許可
|
|
278
283
|
if (document.activeElement === inputRef.current) return;
|
|
@@ -325,13 +330,13 @@ export function VariableAwareUnitInput({
|
|
|
325
330
|
document.addEventListener("mousemove", handleMouseMove);
|
|
326
331
|
document.addEventListener("mouseup", handleMouseUp);
|
|
327
332
|
},
|
|
328
|
-
[enableScrubbing, disabled, isLinked, parsed, step, min, max, currentUnit, onChange]
|
|
333
|
+
[enableScrubbing, disabled, isLinked, parsed, step, min, max, currentUnit, onChange, mixed]
|
|
329
334
|
);
|
|
330
335
|
|
|
331
336
|
// キーボード操作
|
|
332
337
|
const handleKeyDown = useCallback(
|
|
333
338
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
334
|
-
if (disabled || isLinked) return;
|
|
339
|
+
if (disabled || isLinked || mixed) return;
|
|
335
340
|
|
|
336
341
|
const multiplier = e.shiftKey ? 10 : 1;
|
|
337
342
|
let newValue = parsed.numericValue;
|
|
@@ -351,7 +356,7 @@ export function VariableAwareUnitInput({
|
|
|
351
356
|
|
|
352
357
|
onChange(formatValueWithUnit(newValue, currentUnit));
|
|
353
358
|
},
|
|
354
|
-
[disabled, isLinked, parsed, step, min, max, currentUnit, onChange]
|
|
359
|
+
[disabled, isLinked, parsed, step, min, max, currentUnit, onChange, mixed]
|
|
355
360
|
);
|
|
356
361
|
|
|
357
362
|
const inputWidth = compact ? "w-10" : "w-12";
|
|
@@ -493,7 +498,8 @@ export function VariableAwareUnitInput({
|
|
|
493
498
|
<input
|
|
494
499
|
ref={inputRef}
|
|
495
500
|
type={isLinked ? "text" : "number"}
|
|
496
|
-
value={isLinked ? (linkedVariable?.name ?? extractVariableName(String(value)) ?? "") : parsed.numericValue}
|
|
501
|
+
value={isLinked ? (linkedVariable?.name ?? extractVariableName(String(value)) ?? "") : mixed ? "" : parsed.numericValue}
|
|
502
|
+
placeholder={mixed && !isLinked ? "混在" : undefined}
|
|
497
503
|
onChange={handleNumberChange}
|
|
498
504
|
onMouseDown={handleMouseDown}
|
|
499
505
|
onKeyDown={handleKeyDown}
|
|
@@ -27,6 +27,8 @@ import { useEditorContext } from '../../EditorContext';
|
|
|
27
27
|
import { getCleanHtml } from '../../utils/html-utils';
|
|
28
28
|
import { startExport, waitForExport, downloadExport, type ExportFormat } from '../../../lib/export';
|
|
29
29
|
import { can } from '../../../io';
|
|
30
|
+
import { CollabPresence, CollabStatusPill } from '../../collab/CollabPresence';
|
|
31
|
+
import { useCollabSelector, selectCollabPageActive, flushCollab } from '../../collab/store';
|
|
30
32
|
|
|
31
33
|
export type EditorSaveStatus = 'saved' | 'dirty' | 'saving' | 'error';
|
|
32
34
|
|
|
@@ -108,6 +110,7 @@ export function EditorTopBar({
|
|
|
108
110
|
const pal = palette ?? FIGMA_PALETTE;
|
|
109
111
|
const share = SHARE_COLOR[variant];
|
|
110
112
|
const { getIframeDoc, html, saving, hasChanges } = useEditorContext();
|
|
113
|
+
const collabPageActive = useCollabSelector(selectCollabPageActive);
|
|
111
114
|
const [exporting, setExporting] = useState<string | null>(null);
|
|
112
115
|
|
|
113
116
|
/**
|
|
@@ -116,6 +119,13 @@ export function EditorTopBar({
|
|
|
116
119
|
*/
|
|
117
120
|
const handleSave = async () => {
|
|
118
121
|
try {
|
|
122
|
+
// 共同編集でそのページの部屋に入っている間は、ファイルへ書くのは書き戻し役。
|
|
123
|
+
// ここでは「まだ送っていない変更を今すぐ送る」だけにして、二重に書かせない
|
|
124
|
+
if (collabPageActive) {
|
|
125
|
+
flushCollab();
|
|
126
|
+
toast.success('同期済み', { description: '共同編集中の変更は、書き戻し役がファイルへ保存します' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
119
129
|
const doc = getIframeDoc();
|
|
120
130
|
// 保存してもエディタは閉じない。保存は作業の中断ではない
|
|
121
131
|
await onSave(doc ? getCleanHtml(doc) : html);
|
|
@@ -216,7 +226,9 @@ export function EditorTopBar({
|
|
|
216
226
|
{/* 右: UI固有の操作 / 自動保存 / 保存 / プレビュー / 共有 / …/ 閉じる */}
|
|
217
227
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
218
228
|
{rightExtra}
|
|
219
|
-
<
|
|
229
|
+
<CollabPresence />
|
|
230
|
+
{/* 共同編集でページの部屋に入っている間は、保存の状態の代わりに接続状態を出す(ファイルに書くのは書き戻し役) */}
|
|
231
|
+
{collabPageActive ? <CollabStatusPill /> : <SaveStatusPill variant={variant} status={saveStatus} pal={pal} />}
|
|
220
232
|
<button
|
|
221
233
|
data-topbar="save"
|
|
222
234
|
onClick={() => void handleSave()}
|
|
@@ -17,6 +17,7 @@ import { useDeck } from '../../../components/viewer/useDeck';
|
|
|
17
17
|
import { DeckSlideRender } from '../../../components/DeckSlideRender';
|
|
18
18
|
import { flushAutoSave } from '../../autosave';
|
|
19
19
|
import { useMultiPageCanvasOptional } from '../../contexts/MultiPageCanvasContext';
|
|
20
|
+
import { CollabPageDots } from '../../collab/CollabPresence';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* スライド描画はメモ化する。パネル幅の変更や他ページの選択で親が再レンダーしても、
|
|
@@ -120,6 +121,8 @@ export function PagesPanel({ page, height = '33vh' }: { page: number; height?: s
|
|
|
120
121
|
)}
|
|
121
122
|
</span>
|
|
122
123
|
<span className="min-w-0 flex-1 self-center text-xs leading-relaxed">{s.title || "無題"}</span>
|
|
124
|
+
{/* 共同編集: このページに居る参加者の色の点(io.collab が無ければ何も出ない) */}
|
|
125
|
+
<span className="self-center"><CollabPageDots contentId={canvas?.pages[i]?.id ?? String(n)} /></span>
|
|
123
126
|
</button>
|
|
124
127
|
);
|
|
125
128
|
})}
|