@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
@@ -67,7 +67,8 @@ import {
67
67
  import { Checkbox } from "../../components/ui/checkbox";
68
68
  import { buildFilterString, buildTransformString } from "../utils/style-utils";
69
69
  import { useEditorContext } from "../EditorContext";
70
- import { useElementActions, useEditorColors } from "../hooks";
70
+ import { useElementActions, useEditorColors, useInlineTextSelection } from "../hooks";
71
+ import { MIXED } from "../utils/inline-text-style";
71
72
  import { FONT_WEIGHTS } from "../constants";
72
73
  import type { PanelSections, SelectedElementInfo } from "../types";
73
74
  import {
@@ -104,6 +105,8 @@ import {
104
105
  pxValue,
105
106
  MIN_ELEMENT_SIZE,
106
107
  } from "../utils/geometry";
108
+ import { setElementAbsolute, setElementAuto, keepsInlineGeometry } from "../utils/free-layout";
109
+ import { extractElementInfo } from "../utils/style-utils";
107
110
  import { convertInlineStylesToTailwind } from "../utils/tailwind-utils";
108
111
  import {
109
112
  isAspectRatioLocked,
@@ -418,6 +421,21 @@ const isPlainPxValue = (raw: string | number | undefined | null): boolean => {
418
421
  return /^-?\d+(\.\d+)?px$/.test(raw.trim());
419
422
  };
420
423
 
424
+ /** サイズ欄の最小・最大。raw は SelectedElementInfo の読み戻し先 */
425
+ // 2 列の格子に行優先で並べるので、左の列(W の下)が幅、右の列(H の下)が高さになる順にする
426
+ const SIZE_LIMIT_FIELDS = [
427
+ { id: "min-w", prop: "minWidth", raw: "rawMinWidth", label: "最小", title: "最小幅(min-width)", dimension: "width" },
428
+ { id: "min-h", prop: "minHeight", raw: "rawMinHeight", label: "最小", title: "最小の高さ(min-height)", dimension: "height" },
429
+ { id: "max-w", prop: "maxWidth", raw: "rawMaxWidth", label: "最大", title: "最大幅(max-width)", dimension: "width" },
430
+ { id: "max-h", prop: "maxHeight", raw: "rawMaxHeight", label: "最大", title: "最大の高さ(max-height)", dimension: "height" },
431
+ ] as const;
432
+
433
+ /**
434
+ * サイズ欄のラベルの列(W / H / 最小 / 最大)。幅をそろえて、どの行も箱の左端と右端が縦に並ぶようにする。
435
+ * 幅は「最小」の 2 文字(パネルでは 12px)が収まる 24px
436
+ */
437
+ const SIZE_LABEL_CLASS = "text-[10px] text-gray-500 w-6 block whitespace-nowrap";
438
+
421
439
  /**
422
440
  * 表示値の決定
423
441
  * raw が var(--x) / % / vw など「px 以外の指定」なら、実測 px で上書きせず raw をそのまま返す。
@@ -463,7 +481,7 @@ function translateGroup(doc: Document, ids: string[], dx: number, dy: number): b
463
481
  if (dy !== 0) {
464
482
  el.style.top = pxValue((parseFloat(cs?.top ?? "") || 0) + dy);
465
483
  }
466
- convertInlineStylesToTailwind(el, ["left", "top"]);
484
+ if (!keepsInlineGeometry(el)) convertInlineStylesToTailwind(el, ["left", "top"]);
467
485
  changed = true;
468
486
  });
469
487
 
@@ -517,7 +535,7 @@ function scaleGroup(
517
535
  el.style.height = `${newHeight}px`;
518
536
  }
519
537
 
520
- convertInlineStylesToTailwind(el, ["left", "top", "width", "height"]);
538
+ if (!keepsInlineGeometry(el)) convertInlineStylesToTailwind(el, ["left", "top", "width", "height"]);
521
539
  changed = true;
522
540
  });
523
541
 
@@ -821,6 +839,7 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
821
839
  } = useEditorContext();
822
840
  const {
823
841
  updateElementStyle,
842
+ updateTextStyle,
824
843
  updateLinkAttribute,
825
844
  updateElementAttribute,
826
845
  deleteElement,
@@ -828,6 +847,14 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
828
847
  alignElements,
829
848
  } = useElementActions();
830
849
  const { colors: editorColors } = useEditorColors();
850
+ // テキストの一部を選んでいるとき、文字の欄(書体・太さ・大きさ・字間・色・斜体・下線)は
851
+ // その範囲の値を出し、変えるとその範囲だけに当てる(updateTextStyle)。揃っていなければ「混在」
852
+ const inlineText = useInlineTextSelection();
853
+ const partialText =
854
+ inlineText && selectedElement && inlineText.hostId === selectedElement.id ? inlineText : null;
855
+ const textFontWeight = partialText ? partialText.fontWeight : selectedElement?.fontWeight;
856
+ const textFontStyle = partialText ? partialText.fontStyle : selectedElement?.fontStyle;
857
+ const textDecorationLine = partialText ? partialText.textDecoration : selectedElement?.textDecoration;
831
858
 
832
859
  // Component instance management
833
860
  const {
@@ -860,6 +887,30 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
860
887
  const slot = root === el ? null : (el.closest("[data-slot]") as HTMLElement | null);
861
888
  return { root, isRoot: root === el, slotName: slot && root.contains(slot) && slot !== root ? slot.getAttribute("data-slot") : null };
862
889
  }, [selectedElement, getIframeDoc]);
890
+ /**
891
+ * 配置の切替(Figma の「絶対位置」トグル)。webpage だけに出す。
892
+ *
893
+ * slide は開いた時点で全部が絶対配置なので、このトグルには意味がない。
894
+ * webpage は流し込みが既定で、ここが「この要素だけ流れから外す」入口になる。
895
+ */
896
+ const setPositionMode = useCallback(
897
+ (mode: "auto" | "absolute") => {
898
+ const doc = getIframeDoc();
899
+ const el = selectedElement?.id
900
+ ? doc?.querySelector<HTMLElement>(`[data-element-id="${selectedElement.id}"]`)
901
+ : null;
902
+ if (!doc || !el) return;
903
+ const changed = mode === "absolute" ? setElementAbsolute(el, doc) : setElementAuto(el, doc);
904
+ if (!changed) return;
905
+ notifyIframeChange();
906
+ // 枠とパネルの数値をその場で追従させる(X/Y が使えるようになった/なくなったの反映)
907
+ refreshSelectionOverlay(doc);
908
+ const info = extractElementInfo(el, doc);
909
+ if (info) setSelectedElement(info);
910
+ },
911
+ [getIframeDoc, selectedElement?.id, notifyIframeChange, setSelectedElement],
912
+ );
913
+
863
914
  const selectedPart = useMemo(() => (selectedPartRoot ? partInfoOf(selectedPartRoot.root) : null), [selectedPartRoot]);
864
915
  const selectedPartLabel = useMemo(() => {
865
916
  if (!selectedPart) return "";
@@ -943,8 +994,18 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
943
994
  storageKey: 'editor-property-panel-width',
944
995
  });
945
996
 
997
+ // サイズ欄の「最小・最大」を開いているか(値が入っている要素では常に出す。これは空の要素で開いたとき)。
998
+ // 別の要素を選んだら閉じる
999
+ const [sizeLimitsOpen, setSizeLimitsOpen] = useState(false);
1000
+ useEffect(() => {
1001
+ setSizeLimitsOpen(false);
1002
+ }, [selectedElement?.id]);
1003
+
946
1004
  const [openSections, setOpenSections] = useState<PanelSections>({
947
- position: editorMode !== "webpage", // 流し込みのページでは詳細操作として畳む
1005
+ // 以前は流し込みのページで畳んでいた(X/Y が効かず詳細操作だったため)。
1006
+ // 自由配置の「配置: 自動 / 絶対」トグルがこの中に入り、流し込みを抜ける
1007
+ // 入口になったので、webpage でも開いておく(畳んだままだと見つけられない)
1008
+ position: true,
948
1009
  layout: true, // レイアウトセクション
949
1010
  appearance: true, // 外見セクション
950
1011
  image: false, // 塗りに統合されたため非表示
@@ -1387,7 +1448,11 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1387
1448
 
1388
1449
  {/* 位置 X/Y
1389
1450
  LiveGeom で包むことで、ここだけがドラッグ中のフレーム更新を受ける。
1390
- raw が var()/% 等の場合は実測 px で上書きしない(liveOrRaw 参照)。 */}
1451
+ raw が var()/% 等の場合は実測 px で上書きしない(liveOrRaw 参照)。
1452
+
1453
+ webpage では上に「配置: 自動 / 絶対」を出す。流し込みの要素に
1454
+ left/top を書いても見た目は動かないので、絶対でないときは
1455
+ X/Y を触れなくして「効かない入力」を無くす。 */}
1391
1456
  <div>
1392
1457
  <Label className="text-[10px] text-gray-500 mb-1 block">
1393
1458
  位置
@@ -1397,39 +1462,77 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1397
1462
  const live = geometry?.members.find(
1398
1463
  (m) => m.id === selectedElement.id,
1399
1464
  );
1465
+ // 流し込みの要素かどうかは computed position が正。
1466
+ // 計測がまだなら従来どおり編集できる状態にしておく
1467
+ const isAbsolute = live ? live.canMove : true;
1468
+ const lockedHint =
1469
+ "絶対配置ではないため、X / Y を直接指定できません(上の「配置」を絶対にすると指定できます)";
1400
1470
  return (
1401
- <div className="grid grid-cols-2 gap-2">
1402
- <VariableAwareUnitInput
1403
- value={liveOrRaw(
1404
- selectedElement.rawLeft,
1405
- live?.styleLeft,
1406
- selectedElement.x,
1407
- )}
1408
- onChange={(val) => updateElementStyle({ left: val })}
1409
- units={POSITION_UNITS}
1410
- defaultUnit="px"
1411
- category="spacing"
1412
- label="X"
1413
- compact
1414
- hideVariableLink
1415
- conversionContext={conversionContext}
1416
- />
1417
- <VariableAwareUnitInput
1418
- value={liveOrRaw(
1419
- selectedElement.rawTop,
1420
- live?.styleTop,
1421
- selectedElement.y,
1422
- )}
1423
- onChange={(val) => updateElementStyle({ top: val })}
1424
- units={POSITION_UNITS}
1425
- defaultUnit="px"
1426
- category="spacing"
1427
- label="Y"
1428
- compact
1429
- hideVariableLink
1430
- conversionContext={conversionContext}
1431
- />
1432
- </div>
1471
+ <>
1472
+ {editorMode === "webpage" && (
1473
+ <div
1474
+ className="mb-2 flex items-center gap-2"
1475
+ data-gg-position-mode={isAbsolute ? "absolute" : "auto"}
1476
+ >
1477
+ <span className="text-[10px] text-gray-500 shrink-0">配置</span>
1478
+ <div className="flex rounded overflow-hidden border border-[#4a4a4a]">
1479
+ <button
1480
+ type="button"
1481
+ onClick={() => setPositionMode("auto")}
1482
+ className={`px-2 h-6 text-[10px] ${!isAbsolute ? "bg-[#0d99ff] text-white" : "bg-[#383838] text-gray-400 hover:bg-[#4a4a4a]"}`}
1483
+ title="流し込み(親の並びに従う)"
1484
+ >
1485
+ 自動
1486
+ </button>
1487
+ <button
1488
+ type="button"
1489
+ onClick={() => setPositionMode("absolute")}
1490
+ className={`px-2 h-6 text-[10px] ${isAbsolute ? "bg-[#0d99ff] text-white" : "bg-[#383838] text-gray-400 hover:bg-[#4a4a4a]"}`}
1491
+ title="絶対配置(今の位置のまま流れから外し、X / Y で動かせるようにする)"
1492
+ >
1493
+ 絶対
1494
+ </button>
1495
+ </div>
1496
+ </div>
1497
+ )}
1498
+ <div
1499
+ className="grid grid-cols-2 gap-2"
1500
+ title={editorMode === "webpage" && !isAbsolute ? lockedHint : undefined}
1501
+ >
1502
+ <VariableAwareUnitInput
1503
+ value={liveOrRaw(
1504
+ selectedElement.rawLeft,
1505
+ live?.styleLeft,
1506
+ selectedElement.x,
1507
+ )}
1508
+ onChange={(val) => updateElementStyle({ left: val })}
1509
+ units={POSITION_UNITS}
1510
+ defaultUnit="px"
1511
+ category="spacing"
1512
+ label="X"
1513
+ compact
1514
+ hideVariableLink
1515
+ conversionContext={conversionContext}
1516
+ disabled={editorMode === "webpage" && !isAbsolute}
1517
+ />
1518
+ <VariableAwareUnitInput
1519
+ value={liveOrRaw(
1520
+ selectedElement.rawTop,
1521
+ live?.styleTop,
1522
+ selectedElement.y,
1523
+ )}
1524
+ onChange={(val) => updateElementStyle({ top: val })}
1525
+ units={POSITION_UNITS}
1526
+ defaultUnit="px"
1527
+ category="spacing"
1528
+ label="Y"
1529
+ compact
1530
+ hideVariableLink
1531
+ conversionContext={conversionContext}
1532
+ disabled={editorMode === "webpage" && !isAbsolute}
1533
+ />
1534
+ </div>
1535
+ </>
1433
1536
  );
1434
1537
  }}
1435
1538
  </LiveGeom>
@@ -1526,7 +1629,7 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1526
1629
  />
1527
1630
 
1528
1631
  {/* サイズ W/H */}
1529
- <div>
1632
+ <div className="relative" data-size-section>
1530
1633
  <Label className="text-[10px] text-gray-500 mb-1 block">
1531
1634
  サイズ
1532
1635
  </Label>
@@ -1612,11 +1715,7 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1612
1715
  });
1613
1716
  }
1614
1717
  }}
1615
- label={
1616
- <span className="text-[10px] text-gray-500 w-3 text-center block">
1617
- W
1618
- </span>
1619
- }
1718
+ label={<span className={SIZE_LABEL_CLASS}>W</span>}
1620
1719
  dimension="width"
1621
1720
  canFill={isParentFlexContainer}
1622
1721
  canHug={true}
@@ -1706,11 +1805,7 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1706
1805
  });
1707
1806
  }
1708
1807
  }}
1709
- label={
1710
- <span className="text-[10px] text-gray-500 w-3 text-center block">
1711
- H
1712
- </span>
1713
- }
1808
+ label={<span className={SIZE_LABEL_CLASS}>H</span>}
1714
1809
  dimension="height"
1715
1810
  canFill={isParentFlexContainer}
1716
1811
  canHug={true}
@@ -1722,6 +1817,58 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1722
1817
  </LiveGeom>
1723
1818
  </div>
1724
1819
  </div>
1820
+
1821
+ {/* 最小・最大の幅と高さ(min-width / max-width / min-height / max-height)。
1822
+ 既定では隠し、「+ 最小・最大」で出す。値が入っている要素では最初から出す。
1823
+ 適用は W / H と同じ updateElementStyle(複数選択なら全要素に同じ値)。空にすると指定を消す */}
1824
+ {(() => {
1825
+ // 最小の 0(Tailwind の min-w-0。構成ラフに多い)は「制約なし」と見分けがつかないので、それだけでは開かない
1826
+ const hasLimit = SIZE_LIMIT_FIELDS.some((f) => {
1827
+ const v = selectedElement[f.raw];
1828
+ return !!v && !(f.prop.startsWith("min") && /^0(px)?$/.test(v));
1829
+ });
1830
+ const visible = hasLimit || sizeLimitsOpen;
1831
+ return (
1832
+ <>
1833
+ {!hasLimit && (
1834
+ <button
1835
+ type="button"
1836
+ data-size-limits-toggle
1837
+ aria-expanded={visible}
1838
+ onClick={() => setSizeLimitsOpen((v) => !v)}
1839
+ className="absolute right-0 top-0 text-[10px] text-gray-400 hover:text-white"
1840
+ title="最小・最大の幅と高さ"
1841
+ >
1842
+ {visible ? "− 最小・最大" : "+ 最小・最大"}
1843
+ </button>
1844
+ )}
1845
+ {visible && (
1846
+ <div className="grid grid-cols-2 gap-x-2 gap-y-1.5 mt-1.5" data-size-limits>
1847
+ {SIZE_LIMIT_FIELDS.map((f) => (
1848
+ <VariableAwareSizeInput
1849
+ key={f.id}
1850
+ fieldId={f.id}
1851
+ variant="limit"
1852
+ value={selectedElement[f.raw] ?? ""}
1853
+ mode="fixed"
1854
+ onChangeMode={() => {}}
1855
+ onChangeValue={(val) => updateElementStyle({ [f.prop]: val })}
1856
+ label={
1857
+ <span className={SIZE_LABEL_CLASS} title={f.title}>
1858
+ {f.label}
1859
+ </span>
1860
+ }
1861
+ placeholder="—"
1862
+ dimension={f.dimension}
1863
+ min={0}
1864
+ conversionContext={conversionContext}
1865
+ />
1866
+ ))}
1867
+ </div>
1868
+ )}
1869
+ </>
1870
+ );
1871
+ })()}
1725
1872
  {/* [修復] 「サイズ」ブロックの閉じタグ。
1726
1873
  縦横比ロックの追加作業が中断され、この1つが欠けていた */}
1727
1874
  </div>
@@ -2107,30 +2254,39 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2107
2254
  />
2108
2255
  </CollapsibleTrigger>
2109
2256
  <CollapsibleContent className="space-y-2 pb-3">
2257
+ {partialText && (
2258
+ <p data-typo-scope="selection" className="text-[10px] text-[#0d99ff]">
2259
+ 選んだ文字だけに当てます(行間・配置は要素全体)
2260
+ </p>
2261
+ )}
2110
2262
  <div>
2111
2263
  <Label className="text-[10px] text-gray-500">フォント</Label>
2112
2264
  <GoogleFontPicker
2113
- value={selectedElement.fontFamily}
2265
+ value={
2266
+ partialText
2267
+ ? partialText.fontFamily === MIXED ? "" : partialText.fontFamily
2268
+ : selectedElement.fontFamily
2269
+ }
2114
2270
  onChange={(value) =>
2115
- updateElementStyle({ fontFamily: value })
2271
+ updateTextStyle({ fontFamily: value })
2116
2272
  }
2117
2273
  iframeDoc={iframeRef.current?.contentDocument || null}
2118
2274
  className="w-full"
2119
2275
  />
2120
2276
  </div>
2121
2277
  <div className="grid grid-cols-2 gap-2">
2122
- <div>
2278
+ <div data-typo-field="font-weight">
2123
2279
  <Label className="text-[10px] text-gray-500">
2124
2280
  ウェイト
2125
2281
  </Label>
2126
2282
  <Select
2127
- value={selectedElement.fontWeight}
2283
+ value={textFontWeight === MIXED ? "" : textFontWeight}
2128
2284
  onValueChange={(value) =>
2129
- updateElementStyle({ fontWeight: value })
2285
+ updateTextStyle({ fontWeight: value })
2130
2286
  }
2131
2287
  >
2132
2288
  <SelectTrigger className="h-7 text-xs bg-[#383838] border-[#444444] text-white">
2133
- <SelectValue />
2289
+ <SelectValue placeholder={textFontWeight === MIXED ? "混在" : undefined} />
2134
2290
  </SelectTrigger>
2135
2291
  <SelectContent>
2136
2292
  {FONT_WEIGHTS.map((w) => (
@@ -2141,13 +2297,18 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2141
2297
  </SelectContent>
2142
2298
  </Select>
2143
2299
  </div>
2144
- <div>
2300
+ <div data-typo-field="font-size">
2145
2301
  <span className="text-[10px] text-gray-500 mb-1 block">
2146
2302
  サイズ
2147
2303
  </span>
2148
2304
  <VariableAwareUnitInput
2149
- value={selectedElement.rawFontSize || `${Math.round(selectedElement.fontSize)}px`}
2150
- onChange={(val) => updateElementStyle({ fontSize: val })}
2305
+ value={
2306
+ partialText
2307
+ ? partialText.fontSize === MIXED ? "" : `${Math.round(parseFloat(partialText.fontSize) * 100) / 100}px`
2308
+ : selectedElement.rawFontSize || `${Math.round(selectedElement.fontSize)}px`
2309
+ }
2310
+ mixed={partialText?.fontSize === MIXED}
2311
+ onChange={(val) => updateTextStyle({ fontSize: val })}
2151
2312
  units={FONT_SIZE_UNITS}
2152
2313
  defaultUnit="px"
2153
2314
  category="typography"
@@ -2191,14 +2352,14 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2191
2352
  variant="ghost"
2192
2353
  size="icon"
2193
2354
  onClick={() =>
2194
- updateElementStyle({
2355
+ updateTextStyle({
2195
2356
  fontWeight:
2196
- selectedElement.fontWeight === "700"
2357
+ textFontWeight === "700"
2197
2358
  ? "400"
2198
2359
  : "700",
2199
2360
  })
2200
2361
  }
2201
- className={`h-7 w-7 text-gray-400 ${selectedElement.fontWeight === "700" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2362
+ className={`h-7 w-7 text-gray-400 ${textFontWeight === "700" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2202
2363
  >
2203
2364
  <Bold className="w-3.5 h-3.5" />
2204
2365
  </Button>
@@ -2206,14 +2367,14 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2206
2367
  variant="ghost"
2207
2368
  size="icon"
2208
2369
  onClick={() =>
2209
- updateElementStyle({
2370
+ updateTextStyle({
2210
2371
  fontStyle:
2211
- selectedElement.fontStyle === "italic"
2372
+ textFontStyle === "italic"
2212
2373
  ? "normal"
2213
2374
  : "italic",
2214
2375
  })
2215
2376
  }
2216
- className={`h-7 w-7 text-gray-400 ${selectedElement.fontStyle === "italic" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2377
+ className={`h-7 w-7 text-gray-400 ${textFontStyle === "italic" ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2217
2378
  >
2218
2379
  <Italic className="w-3.5 h-3.5" />
2219
2380
  </Button>
@@ -2221,22 +2382,22 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2221
2382
  variant="ghost"
2222
2383
  size="icon"
2223
2384
  onClick={() =>
2224
- updateElementStyle({
2385
+ updateTextStyle({
2225
2386
  textDecoration:
2226
- selectedElement.textDecoration?.includes(
2387
+ textDecorationLine?.includes(
2227
2388
  "underline",
2228
2389
  )
2229
2390
  ? "none"
2230
2391
  : "underline",
2231
2392
  })
2232
2393
  }
2233
- className={`h-7 w-7 text-gray-400 ${selectedElement.textDecoration?.includes("underline") ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2394
+ className={`h-7 w-7 text-gray-400 ${textDecorationLine?.includes("underline") ? "bg-[#0d99ff] text-white" : "hover:bg-[#4a4a4a] hover:text-white"}`}
2234
2395
  >
2235
2396
  <Underline className="w-3.5 h-3.5" />
2236
2397
  </Button>
2237
2398
  </div>
2238
2399
  <div className="grid grid-cols-2 gap-2 mt-2">
2239
- <div>
2400
+ <div data-typo-field="line-height">
2240
2401
  <span className="text-[10px] text-gray-500 mb-1 block">
2241
2402
  行間
2242
2403
  </span>
@@ -2258,17 +2419,22 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2258
2419
  conversionContext={conversionContext}
2259
2420
  />
2260
2421
  </div>
2261
- <div>
2422
+ <div data-typo-field="letter-spacing">
2262
2423
  <span className="text-[10px] text-gray-500 mb-1 block">
2263
2424
  文字間
2264
2425
  </span>
2265
2426
  <VariableAwareUnitInput
2266
- value={selectedElement.rawLetterSpacing || (() => {
2267
- const ls = selectedElement.letterSpacing;
2268
- if (!ls || ls === "normal") return "0em";
2269
- return ls;
2270
- })()}
2271
- onChange={(val) => updateElementStyle({ letterSpacing: val })}
2427
+ value={
2428
+ partialText
2429
+ ? partialText.letterSpacing === MIXED ? "" : partialText.letterSpacing === "normal" ? "0em" : partialText.letterSpacing
2430
+ : selectedElement.rawLetterSpacing || (() => {
2431
+ const ls = selectedElement.letterSpacing;
2432
+ if (!ls || ls === "normal") return "0em";
2433
+ return ls;
2434
+ })()
2435
+ }
2436
+ mixed={partialText?.letterSpacing === MIXED}
2437
+ onChange={(val) => updateTextStyle({ letterSpacing: val })}
2272
2438
  units={LETTER_SPACING_UNITS}
2273
2439
  defaultUnit="em"
2274
2440
  category="typography"
@@ -2280,13 +2446,14 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2280
2446
  </div>
2281
2447
  </div>
2282
2448
  </div>
2449
+ <div data-typo-field="color">
2283
2450
  <FigmaColorPicker
2284
- label="文字色"
2451
+ label={partialText?.color === MIXED ? "文字色(混在)" : "文字色"}
2285
2452
  value={parseCssToFillConfig(
2286
- selectedElement.color,
2453
+ partialText && partialText.color !== MIXED ? partialText.color : selectedElement.color,
2287
2454
  undefined,
2288
2455
  undefined,
2289
- selectedElement.rawColor,
2456
+ partialText ? undefined : selectedElement.rawColor,
2290
2457
  )}
2291
2458
  onChange={(config) => {
2292
2459
  if (config.type === "solid" && config.color) {
@@ -2295,22 +2462,23 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2295
2462
  const r = parseInt(config.color.slice(1, 3), 16);
2296
2463
  const g = parseInt(config.color.slice(3, 5), 16);
2297
2464
  const b = parseInt(config.color.slice(5, 7), 16);
2298
- updateElementStyle({
2465
+ updateTextStyle({
2299
2466
  color: `rgba(${r}, ${g}, ${b}, ${opacity / 100})`,
2300
2467
  });
2301
2468
  } else {
2302
- updateElementStyle({ color: config.color });
2469
+ updateTextStyle({ color: config.color });
2303
2470
  }
2304
2471
  }
2305
2472
  }}
2306
2473
  onVariableSelect={(varRef) => {
2307
- updateElementStyle({ color: varRef });
2474
+ updateTextStyle({ color: varRef });
2308
2475
  }}
2309
2476
  presetColors={colorPresets}
2310
2477
  showImageTab={false}
2311
2478
  showVariablesTab={true}
2312
2479
  onClose={restoreFocus}
2313
2480
  />
2481
+ </div>
2314
2482
  </CollapsibleContent>
2315
2483
  </Collapsible>
2316
2484
  )}
@@ -32,7 +32,7 @@ export function EditorSidebar() {
32
32
  iframeRef,
33
33
  } = useEditorContext();
34
34
 
35
- const { updateElementStyle } = useElementActions();
35
+ const { updateElementStyle, updateTextStyle } = useElementActions();
36
36
 
37
37
  // レイヤーノードの展開/折りたたみ
38
38
  const toggleNode = (nodeId: string) => {
@@ -177,7 +177,7 @@ export function EditorSidebar() {
177
177
  onOpenChange={(open) =>
178
178
  setOpenSections((prev) => ({ ...prev, typography: open }))
179
179
  }
180
- onStyleChange={updateElementStyle}
180
+ onStyleChange={updateTextStyle}
181
181
  iframeDoc={iframeRef.current?.contentDocument || null}
182
182
  />
183
183
 
@@ -209,9 +209,9 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
209
209
  const canvas = useMultiPageCanvas();
210
210
  const {
211
211
  pages,
212
- bounds,
213
212
  viewStore,
214
213
  registerContainer,
214
+ shiftView,
215
215
  activatePage,
216
216
  activatingPageId,
217
217
  zoomToFit,
@@ -219,7 +219,7 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
219
219
  zoomToActual,
220
220
  rulersVisible,
221
221
  isInteracting,
222
- initialViewRestored,
222
+ applyInitialView,
223
223
  getPreviewImageZoomCap,
224
224
  previewImageCapVersion,
225
225
  } = canvas;
@@ -247,6 +247,38 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
247
247
  return () => registerContainer(null);
248
248
  }, [registerContainer]);
249
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
+
250
282
  // ---- 倍率・位置の反映(React を通さない)
251
283
  const [coarseZoom, setCoarseZoom] = useState(() => viewStore.get().canvasZoom);
252
284
  const coarseZoomRef = useRef(coarseZoom);
@@ -322,26 +354,13 @@ export const MultiPageCanvasView = memo(function MultiPageCanvasView() {
322
354
  };
323
355
  }, [zoomToFit, zoomToActual, zoomToPage, activePageId]);
324
356
 
325
- // 初回の表示: 前回の場所が保存されていて内容と重なるならそこへ。無ければ編集中のページを大きく
326
- const initialViewDoneRef = useRef(false);
357
+ // 初回の表示: 容器の大きさとフレームの並びが揃ったところで 1 回(決め方は applyInitialView)
327
358
  useEffect(() => {
328
- if (initialViewDoneRef.current || pages.length === 0) return;
359
+ if (pages.length === 0) return;
329
360
  const el = containerRef.current;
330
361
  if (!el || el.clientWidth === 0) return;
331
- initialViewDoneRef.current = true;
332
- if (initialViewRestored) {
333
- const { canvasOffset: o, canvasZoom: z } = canvas.viewStore.get();
334
- const left = o.x + bounds.minX * z;
335
- const top = o.y + bounds.minY * z;
336
- const right = o.x + bounds.maxX * z;
337
- const bottom = o.y + bounds.maxY * z;
338
- const intersects = right > 0 && left < el.clientWidth && bottom > 0 && top < el.clientHeight;
339
- if (intersects) return;
340
- }
341
- if (activePageId) zoomToPage(activePageId);
342
- else zoomToFit();
343
- // eslint-disable-next-line react-hooks/exhaustive-deps
344
- }, [pages.length, activePageId]);
362
+ applyInitialView();
363
+ }, [pages.length, activePageId, applyInitialView]);
345
364
 
346
365
  const [hoverId, setHoverId] = useState<string | null>(null);
347
366
  const handleHover = useCallback((id: string | null) => {