@growgroup/visual-editor 0.6.0 → 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 (35) hide show
  1. package/README.md +45 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/FrontendVisualEditor.tsx +173 -0
  5. package/src/editor/components/EditorCanvas.tsx +16 -0
  6. package/src/editor/components/EditorContextMenu.tsx +80 -0
  7. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  8. package/src/editor/components/EditorSidebar.tsx +2 -2
  9. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +38 -19
  10. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  11. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  12. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  13. package/src/editor/constants.ts +24 -0
  14. package/src/editor/contexts/MultiPageCanvasContext.tsx +108 -6
  15. package/src/editor/hooks/index.ts +3 -0
  16. package/src/editor/hooks/useDragResize.ts +30 -5
  17. package/src/editor/hooks/useElementActions.ts +41 -9
  18. package/src/editor/hooks/useElementSelection.ts +4 -2
  19. package/src/editor/hooks/useIframeSetup.ts +8 -1
  20. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  21. package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
  22. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  23. package/src/editor/parts.ts +2 -0
  24. package/src/editor/types.ts +4 -0
  25. package/src/editor/utils/dom-utils.ts +45 -0
  26. package/src/editor/utils/free-layout-tree.ts +562 -0
  27. package/src/editor/utils/free-layout.ts +794 -0
  28. package/src/editor/utils/geometry.ts +41 -3
  29. package/src/editor/utils/html-utils.ts +2 -1
  30. package/src/editor/utils/inline-text-style.ts +426 -0
  31. package/src/editor/utils/link-navigation.ts +474 -0
  32. package/src/editor/utils/style-utils.ts +24 -0
  33. package/src/editor/utils/tailwind-utils.ts +12 -0
  34. package/src/editor/utils/text-line-break.ts +121 -0
  35. package/src/version.ts +1 -1
@@ -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(() => {
@@ -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}
@@ -154,6 +154,11 @@ export const EDITOR_IFRAME_STYLES = `
154
154
  opacity: 0.7;
155
155
  cursor: grabbing !important;
156
156
  }
157
+ /* ⌘ / Ctrl を押してリンクの上にいる間は指のカーソル(utils/link-navigation.ts が <html> に印を付け外しする) */
158
+ html[data-gg-link-nav],
159
+ html[data-gg-link-nav] * {
160
+ cursor: pointer !important;
161
+ }
157
162
  ::selection {
158
163
  background-color: rgba(13, 153, 255, 0.3);
159
164
  }
@@ -415,6 +420,25 @@ export const EDITOR_IFRAME_STYLES = `
415
420
  z-index: 40;
416
421
  }
417
422
  .selection-box.selection-in-part .part-badge { background: rgba(123, 97, 255, 0.85); }
423
+ /* 自由配置(gg-freelayout)の器の札。部品の札は左上なので、ぶつからないよう右上に置く */
424
+ .selection-box .freelayout-badge {
425
+ position: absolute;
426
+ top: calc(-44px * var(--ov));
427
+ right: 0;
428
+ padding: calc(2px * var(--ov)) calc(6px * var(--ov));
429
+ border-radius: calc(3px * var(--ov));
430
+ background: #f2994a;
431
+ color: #fff;
432
+ font-size: calc(10px * var(--ov));
433
+ line-height: 1.4;
434
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans JP', sans-serif;
435
+ white-space: nowrap;
436
+ pointer-events: none;
437
+ z-index: 40;
438
+ }
439
+ /* 自由配置の器は、選んでいなくても薄い枠で範囲が分かるようにする
440
+ (中の要素だけを触っているとき、どこまでが自由配置かを見失わないため) */
441
+ .gg-freelayout.selected { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(242, 153, 74, 0.8); outline-offset: calc(-1px * var(--overlay-scale, 1)); }
418
442
  /* 選んでいる部品のスロット(編集できる範囲)は点線。中を選んでいるときは部品の外周を薄い点線で示す */
419
443
  [data-part].selected [data-slot] { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.75); outline-offset: calc(-1px * var(--overlay-scale, 1)); }
420
444
  [data-part]:not(.selected):has(.selected) { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.55); outline-offset: calc(2px * var(--overlay-scale, 1)); }