@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.
Files changed (64) hide show
  1. package/README.md +92 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -2
  4. package/src/editor/FrontendVisualEditor.tsx +244 -17
  5. package/src/editor/collab/CollabPresence.tsx +172 -0
  6. package/src/editor/collab/binding.ts +269 -0
  7. package/src/editor/collab/color.ts +28 -0
  8. package/src/editor/collab/connection.ts +110 -0
  9. package/src/editor/collab/layer.ts +168 -0
  10. package/src/editor/collab/merge.ts +112 -0
  11. package/src/editor/collab/morph.ts +113 -0
  12. package/src/editor/collab/room.ts +82 -0
  13. package/src/editor/collab/runtime.ts +442 -0
  14. package/src/editor/collab/signals.ts +11 -0
  15. package/src/editor/collab/store.ts +192 -0
  16. package/src/editor/collab/useCollab.ts +266 -0
  17. package/src/editor/components/AiPromptPopover.tsx +3 -2
  18. package/src/editor/components/EditorCanvas.tsx +27 -0
  19. package/src/editor/components/EditorContextMenu.tsx +80 -0
  20. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  21. package/src/editor/components/EditorSidebar.tsx +2 -2
  22. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +41 -19
  23. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  24. package/src/editor/components/ppt/PptComments.tsx +3 -8
  25. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  26. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  27. package/src/editor/components/shell/EditorTopBar.tsx +13 -1
  28. package/src/editor/components/shell/PagesPanel.tsx +3 -0
  29. package/src/editor/constants.ts +24 -0
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
  31. package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
  32. package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
  33. package/src/editor/hooks/index.ts +3 -0
  34. package/src/editor/hooks/useDragResize.ts +30 -5
  35. package/src/editor/hooks/useElementActions.ts +41 -9
  36. package/src/editor/hooks/useElementSelection.ts +4 -2
  37. package/src/editor/hooks/useGoogleFonts.ts +6 -8
  38. package/src/editor/hooks/useIframeSetup.ts +10 -1
  39. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  40. package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
  41. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  42. package/src/editor/hooks/useResizablePanel.ts +3 -2
  43. package/src/editor/parts.ts +2 -0
  44. package/src/editor/types.ts +4 -0
  45. package/src/editor/utils/debug.ts +2 -5
  46. package/src/editor/utils/dom-utils.ts +45 -0
  47. package/src/editor/utils/drop-target.ts +1 -1
  48. package/src/editor/utils/flex-reorder.ts +1 -1
  49. package/src/editor/utils/free-layout-tree.ts +562 -0
  50. package/src/editor/utils/free-layout.ts +794 -0
  51. package/src/editor/utils/geometry.ts +41 -3
  52. package/src/editor/utils/html-utils.ts +4 -1
  53. package/src/editor/utils/inline-text-style.ts +426 -0
  54. package/src/editor/utils/link-navigation.ts +474 -0
  55. package/src/editor/utils/restore-flow.ts +1 -1
  56. package/src/editor/utils/smart-guides.ts +1 -1
  57. package/src/editor/utils/storage.ts +47 -0
  58. package/src/editor/utils/style-utils.ts +24 -0
  59. package/src/editor/utils/tailwind-utils.ts +12 -0
  60. package/src/editor/utils/text-line-break.ts +121 -0
  61. package/src/index.ts +15 -0
  62. package/src/io.ts +29 -0
  63. package/src/styles/skin.css +17 -0
  64. 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
- initialViewRestored,
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 (initialViewDoneRef.current || pages.length === 0) return;
359
+ if (pages.length === 0) return;
326
360
  const el = containerRef.current;
327
361
  if (!el || el.clientWidth === 0) return;
328
- initialViewDoneRef.current = true;
329
- if (initialViewRestored) {
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
- if (applyTextColor(doc, targets(), color)) notifyIframeChange();
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
- try {
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
- try {
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 (!isLinked) {
198
- setLocalValue(String(Math.round(parsed.numericValue)));
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
- <div className="flex items-center gap-0.5 group/size-input">
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-l cursor-pointer",
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 px-1.5">
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
- <button
363
- type="button"
364
- className="h-full px-0.5 flex items-center justify-center text-gray-500 hover:text-white transition-colors"
365
- disabled={disabled}
366
- >
367
- <ChevronDown className="h-2.5 w-2.5" />
368
- </button>
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
- <SaveStatusPill variant={variant} status={saveStatus} pal={pal} />
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
  })}