@growgroup/visual-editor 0.7.1 → 0.8.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.
@@ -69,6 +69,7 @@ import { buildFilterString, buildTransformString } from "../utils/style-utils";
69
69
  import { useEditorContext } from "../EditorContext";
70
70
  import { useElementActions, useEditorColors, useInlineTextSelection } from "../hooks";
71
71
  import { MIXED } from "../utils/inline-text-style";
72
+ import { borderWidthStyles } from "../utils/border-sides";
72
73
  import { FONT_WEIGHTS } from "../constants";
73
74
  import type { PanelSections, SelectedElementInfo } from "../types";
74
75
  import {
@@ -2571,7 +2572,8 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2571
2572
  const needsStyleChange = selectedElement.borderStyle === "none" &&
2572
2573
  !val.startsWith('var(') && parseFloat(val) > 0;
2573
2574
  updateElementStyle({
2574
- borderWidth: val,
2575
+ // 片側だけの線(border-l-2 など)は、その辺の線幅だけを変える(四辺の枠にしない)
2576
+ ...borderWidthStyles(val, selectedElement.borderSides),
2575
2577
  ...(needsStyleChange ? { borderStyle: "solid" } : {}),
2576
2578
  });
2577
2579
  }}
@@ -2613,12 +2615,10 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2613
2615
  )}
2614
2616
  onChange={(config) => {
2615
2617
  if (config.type === "solid" && config.color) {
2618
+ // スタイルは線が無いときだけ実線にする(今のスタイルを書き直さない)
2616
2619
  updateElementStyle({
2617
2620
  borderColor: config.color,
2618
- borderStyle:
2619
- selectedElement.borderStyle === "none"
2620
- ? "solid"
2621
- : selectedElement.borderStyle,
2621
+ ...(selectedElement.borderStyle === "none" ? { borderStyle: "solid" } : {}),
2622
2622
  });
2623
2623
  } else if (config.type === "none") {
2624
2624
  updateElementStyle({ borderStyle: "none" });
@@ -2627,10 +2627,7 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
2627
2627
  onVariableSelect={(varRef) => {
2628
2628
  updateElementStyle({
2629
2629
  borderColor: varRef,
2630
- borderStyle:
2631
- selectedElement.borderStyle === "none"
2632
- ? "solid"
2633
- : selectedElement.borderStyle,
2630
+ ...(selectedElement.borderStyle === "none" ? { borderStyle: "solid" } : {}),
2634
2631
  });
2635
2632
  }}
2636
2633
  presetColors={colorPresets}
@@ -46,6 +46,7 @@ import { moveSlide, deleteSlide, duplicateSlide, updateSlideMeta, insertSlide }
46
46
  import { startCleanup } from '../../../components/SaveNote';
47
47
  import { unresolvedCount } from './PptComments';
48
48
  import { enterCropMode, type CropSession } from '../../utils/crop-mode';
49
+ import { borderWidthStyles, summarizeBorder } from '../../utils/border-sides';
49
50
  import { useGoogleFonts } from '../../hooks/useGoogleFonts';
50
51
  import { DeckSlideRender } from '../../../components/DeckSlideRender';
51
52
  const PptDesignProposals = lazy(() => import('./PptDesignProposals').then((m) => ({ default: m.PptDesignProposals })));
@@ -270,7 +271,11 @@ function Dropdown({
270
271
  const PAINT_PROPS = new Set([
271
272
  'backgroundColor', 'background', 'backgroundImage', 'clipPath', 'borderRadius',
272
273
  'border', 'borderColor', 'borderWidth', 'borderStyle',
274
+ // 辺ごとの線も中身の図形へ(片側だけの線の読み書き。上の辺だけだと、枠の右・下・左を枠の外側から読んでいた)
273
275
  'borderTopWidth', 'borderTopStyle', 'borderTopColor',
276
+ 'borderRightWidth', 'borderRightStyle', 'borderRightColor',
277
+ 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor',
278
+ 'borderLeftWidth', 'borderLeftStyle', 'borderLeftColor',
274
279
  ]);
275
280
 
276
281
  /** リボンが読む computed style の名前 → 選んだ文字の見た目(InlineTextSummary)の項目 */
@@ -1264,13 +1269,22 @@ export function PptRibbon({
1264
1269
 
1265
1270
  /** 枠線の色/太さを変えるとき、枠線が無ければ実線を立てる(PowerPointの挙動) */
1266
1271
  const ensureBorder = useCallback((styles: Record<string, string>) => {
1267
- const w = parseFloat(readComputed('border-top-width'));
1268
- const st = readComputed('border-top-style');
1269
- const out = { ...styles };
1270
- if (!('borderWidth' in out) && (!Number.isFinite(w) || w === 0)) out.borderWidth = '2px';
1271
- if (!('borderStyle' in out) && (st === 'none' || st === '')) out.borderStyle = 'solid';
1272
+ // 片側だけの線(border-l-2 など)は上の辺が 0 なので、上だけ見ると「線が無い」と判定して
1273
+ // 四辺に 2px を足していた。辺ごとに見て、線が見えていれば足さず、太さはその辺だけに当てる
1274
+ const first = targets()[0];
1275
+ const border = summarizeBorder({ getPropertyValue: readComputed }, first ? paintTarget(first) : null);
1276
+ let out: Record<string, string> = { ...styles };
1277
+ if ('borderWidth' in out && border.sides) {
1278
+ const { borderWidth, ...rest } = out;
1279
+ out = { ...rest, ...borderWidthStyles(borderWidth, border.sides) };
1280
+ }
1281
+ // 線が無ければ 2px を立てる(片側だけの線を 0 にしていたら、その辺に)
1282
+ if (!('borderWidth' in styles) && (!Number.isFinite(border.width) || border.width === 0)) {
1283
+ Object.assign(out, borderWidthStyles('2px', border.sides));
1284
+ }
1285
+ if (!('borderStyle' in out) && (border.style === 'none' || border.style === '')) out.borderStyle = 'solid';
1272
1286
  apply(out);
1273
- }, [readComputed, apply]);
1287
+ }, [readComputed, apply, targets]);
1274
1288
 
1275
1289
  const borderRadiusPx = useMemo(() => {
1276
1290
  const v = parseFloat(readComputed('border-radius'));
@@ -16,6 +16,7 @@ import {
16
16
  } from '../../../components/ui/collapsible';
17
17
  import { Circle, ChevronRight } from 'lucide-react';
18
18
  import { rgbToHex } from '../../utils/style-utils';
19
+ import { borderWidthStyles } from '../../utils/border-sides';
19
20
  import { BORDER_STYLES } from '../../constants';
20
21
  import type { SelectedElementInfo } from '../../types';
21
22
 
@@ -52,7 +53,8 @@ export function BorderSection({
52
53
  value={Math.round(selectedElement.borderWidth)}
53
54
  onChange={(e) =>
54
55
  onStyleChange({
55
- borderWidth: `${e.target.value}px`,
56
+ // 片側だけの線は、その辺の線幅だけを変える
57
+ ...borderWidthStyles(`${e.target.value}px`, selectedElement.borderSides),
56
58
  borderStyle:
57
59
  selectedElement.borderStyle === 'none'
58
60
  ? 'solid'
@@ -525,14 +525,10 @@ export function useIframeInitializer() {
525
525
 
526
526
  iframe.addEventListener('load', handleLoad);
527
527
 
528
- const blob = new Blob([generateEditableHtml(html, editorMode)], { type: 'text/html' });
529
- iframe.src = URL.createObjectURL(blob);
528
+ iframe.srcdoc = generateEditableHtml(html, editorMode);
530
529
 
531
530
  return () => {
532
531
  iframe.removeEventListener('load', handleLoad);
533
- if (iframe.src.startsWith('blob:')) {
534
- URL.revokeObjectURL(iframe.src);
535
- }
536
532
  };
537
533
  }, []);
538
534
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ⌘ / Ctrl + クリックでリンク先へ移る(utils/link-navigation.ts)の、エディタ側の行き先。
2
+ * Alt(⌥)+ クリックでリンク先へ移る(utils/link-navigation.ts)の、エディタ側の行き先。
3
3
  *
4
4
  * ページの切替は、ページ一覧で切り替えるときと同じ経路を通す
5
5
  * (未保存の変更は FrontendVisualEditor.handleContentChange が保存してから移る):
@@ -122,7 +122,7 @@ export function useLinkNavigation() {
122
122
  [revealAnchor],
123
123
  );
124
124
 
125
- /** 紙面に ⌘ / Ctrl + クリックの移動を付ける。戻り値は後始末 */
125
+ /** 紙面に Alt(⌥)+ クリックの移動を付ける。戻り値は後始末 */
126
126
  const setupLinkNavigation = useCallback(
127
127
  (iframeDoc: Document) => {
128
128
  const detach = attachLinkNavigation(iframeDoc, {
@@ -59,7 +59,7 @@ async function fetchMediaItems(): Promise<MediaItem[]> {
59
59
  /**
60
60
  * カタログのURL(`/media/...`)をエディタのiframeで解決できる形へ変換する。
61
61
  *
62
- * エディタのキャンバスは blob: URL の iframe なのでルート相対パスが解決できない。
62
+ * エディタのキャンバスは埋め込みiframe。実行時のbaseに依存させず、元のページと同じ素材を参照する。
63
63
  * `src/lib/slide-html.ts` の toEditorUrls / fromEditorUrls と同じ規約に合わせ、
64
64
  * 挿入時は絶対URLにしておく(保存時に fromEditorUrls が相対へ戻す)。
65
65
  */
@@ -3,6 +3,8 @@
3
3
  * (スライド、ページ、コンポーネント等で共用)
4
4
  */
5
5
 
6
+ import type { BorderSide } from './utils/border-sides';
7
+
6
8
  // 再エクスポート(既存の型)
7
9
  export type {
8
10
  EditorTool,
@@ -184,7 +186,9 @@ export interface SelectedElementInfo {
184
186
  rawFontSize?: string; // font-size
185
187
  rawLineHeight?: string; // line-height
186
188
  rawLetterSpacing?: string; // letter-spacing
187
- rawBorderWidth?: string; // border-width
189
+ rawBorderWidth?: string; // border-width(片側だけの線ならその辺の値)
190
+ /** 線が見えている辺が 1〜3 辺のときだけ、その辺(utils/border-sides.ts)。線幅の変更をその辺だけに当てる */
191
+ borderSides?: BorderSide[];
188
192
  rawGap?: string; // gap (flexbox/grid)
189
193
  rawLeft?: string; // left (position)
190
194
  rawTop?: string; // top (position)
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 片側だけの線(`border-l-2` など)を、右パネル・リボンの「線」の欄で扱う。
3
+ *
4
+ * 欄は 1 本の線幅・スタイル・色しか持たない。以前は computed の `border-width` を
5
+ * parseFloat していたので、左だけ 2px の線は「0px」、スタイルは「none none none solid」と読めず、
6
+ * 線幅を変えると `border-[3px]` で四辺の枠になっていた。
7
+ * 線が見えている辺が 1〜3 辺なら、その辺の値を出し、線幅の変更もその辺だけに当てる。
8
+ */
9
+
10
+ import { isBorderWidthClass, isZeroBorderWidthClass, type SideLetter } from './tailwind-utils';
11
+
12
+ export type BorderSide = 'top' | 'right' | 'bottom' | 'left';
13
+
14
+ export const BORDER_SIDES: readonly BorderSide[] = ['top', 'right', 'bottom', 'left'];
15
+
16
+ const SIDE_WIDTH_PROPERTY: Record<BorderSide, string> = {
17
+ top: 'borderTopWidth',
18
+ right: 'borderRightWidth',
19
+ bottom: 'borderBottomWidth',
20
+ left: 'borderLeftWidth',
21
+ };
22
+
23
+ export interface BorderSummary {
24
+ width: number;
25
+ style: string;
26
+ color: string;
27
+ /**
28
+ * 線が見えている辺が 1〜3 辺のときだけ、その辺(上・右・下・左の順)。四辺とも・線なしは undefined。
29
+ * 線が見えていなくても、要素自身が 1〜3 辺だけに線幅を指定していればその辺(declaredBorderSides)
30
+ */
31
+ sides?: BorderSide[];
32
+ }
33
+
34
+ /**
35
+ * 線が見えていないとき(片側だけの線を線幅 0 にした・スタイルを「なし」にした)の辺を、要素自身の指定から読む。
36
+ * computed では辺が分からず、次に線幅を入れると四辺の枠になっていた(実エディタで確認)。
37
+ * インラインの辺ごとの線幅(border-left-width)と、辺の太さのクラス(border-l-0 / border-x-2)を見る。
38
+ * 四辺まとめての指定(インラインの border-width・border / border-2 クラス)があれば四辺とみなす
39
+ */
40
+ export function declaredBorderSides(element: Element): BorderSide[] | undefined {
41
+ const inline = (element as HTMLElement).style;
42
+ const inlineSides = inline ? BORDER_SIDES.filter((side) => inline.getPropertyValue(`border-${side}-width`) !== '') : [];
43
+ if (inlineSides.length === BORDER_SIDES.length) return undefined;
44
+ if (inline?.getPropertyValue('border-width')) return undefined;
45
+ const classes = (element.getAttribute('class') || '').split(/\s+/).filter(Boolean);
46
+ // 四辺まとめての太さ(border / border-2。border-0 は除く)があれば、辺ごとの指定があっても四辺とみなす
47
+ if (classes.some((cls) => isBorderWidthClass(cls, '') && !isZeroBorderWidthClass(cls))) return undefined;
48
+ // 太さ 0 のクラス(border-b-0 など)は「線を消す指定」なので、辺を名乗るものとして数えない。
49
+ // 欄で線幅を 0 にしたときは、同時に書くインラインの辺ごとの線幅(上)で辺が分かる
50
+ const has = (letter: SideLetter) =>
51
+ classes.some((cls) => isBorderWidthClass(cls, letter) && !isZeroBorderWidthClass(cls));
52
+ const classSides: BorderSide[] = [];
53
+ // s / e は書字方向の始め・終わり、bs / be はブロック方向。LTR の横書きを既定として当てる
54
+ if (has('t') || has('y') || has('bs')) classSides.push('top');
55
+ if (has('r') || has('x') || has('e')) classSides.push('right');
56
+ if (has('b') || has('y') || has('be')) classSides.push('bottom');
57
+ if (has('l') || has('x') || has('s')) classSides.push('left');
58
+ // その辺を 0 にするクラス(border-x-2 border-l-0 の左)は、辺から外す
59
+ const zero = (letter: SideLetter) =>
60
+ classes.some((cls) => isBorderWidthClass(cls, letter) && isZeroBorderWidthClass(cls));
61
+ const zeroSides = new Set<BorderSide>();
62
+ if (zero('t') || zero('y') || zero('bs')) zeroSides.add('top');
63
+ if (zero('r') || zero('x') || zero('e')) zeroSides.add('right');
64
+ if (zero('b') || zero('y') || zero('be')) zeroSides.add('bottom');
65
+ if (zero('l') || zero('x') || zero('s')) zeroSides.add('left');
66
+ const sides = BORDER_SIDES.filter(
67
+ (side) => inlineSides.includes(side) || (classSides.includes(side) && !zeroSides.has(side)),
68
+ );
69
+ return sides.length > 0 && sides.length < BORDER_SIDES.length ? sides : undefined;
70
+ }
71
+
72
+ /**
73
+ * computed style(`getPropertyValue` を持つもの)から、「線」の欄に出す値。
74
+ * element を渡すと、線が見えていないときに要素自身の指定から辺を読む(declaredBorderSides)
75
+ */
76
+ export function summarizeBorder(
77
+ style: Pick<CSSStyleDeclaration, 'getPropertyValue'>,
78
+ element?: Element | null,
79
+ ): BorderSummary {
80
+ const perSide = BORDER_SIDES.map((side) => ({
81
+ side,
82
+ width: parseFloat(style.getPropertyValue(`border-${side}-width`)) || 0,
83
+ style: style.getPropertyValue(`border-${side}-style`),
84
+ color: style.getPropertyValue(`border-${side}-color`),
85
+ }));
86
+ const shown = perSide.filter((s) => s.width > 0 && s.style !== 'none' && s.style !== 'hidden');
87
+ if (shown.length === 0 || shown.length === BORDER_SIDES.length) {
88
+ // 四辺とも・線なしは今までどおり(省略形の値)
89
+ const sides = shown.length === 0 && element ? declaredBorderSides(element) : undefined;
90
+ return {
91
+ width: parseFloat(style.getPropertyValue('border-width')) || 0,
92
+ style: sides ? style.getPropertyValue(`border-${sides[0]}-style`) : style.getPropertyValue('border-style'),
93
+ color: sides ? style.getPropertyValue(`border-${sides[0]}-color`) : style.getPropertyValue('border-color'),
94
+ ...(sides ? { sides } : {}),
95
+ };
96
+ }
97
+ const first = shown[0];
98
+ return { width: first.width, style: first.style, color: first.color, sides: shown.map((s) => s.side) };
99
+ }
100
+
101
+ /** 線幅の変更。片側だけの線なら、その辺の線幅(borderLeftWidth など)にする */
102
+ export function borderWidthStyles(value: string, sides?: readonly BorderSide[]): Record<string, string> {
103
+ if (!sides || sides.length === 0 || sides.length === BORDER_SIDES.length) return { borderWidth: value };
104
+ const out: Record<string, string> = {};
105
+ for (const side of sides) out[SIDE_WIDTH_PROPERTY[side]] = value;
106
+ return out;
107
+ }
108
+
109
+ /** その辺の線幅の CSS プロパティ名(kebab)。インラインの生値を読むときに使う */
110
+ export function borderSideWidthCss(side: BorderSide): string {
111
+ return `border-${side}-width`;
112
+ }
@@ -676,6 +676,11 @@ export function convertToAbsolutePositioning(iframeDoc: Document): number {
676
676
 
677
677
  // 1. まず全要素の現在の位置・サイズをキャプチャ(インライン要素は除外)
678
678
  const captures: ElementCapture[] = [];
679
+ // 変換しないインライン要素の、変換前の位置(自己検証で使う)。
680
+ // 変換しなかった要素は流れの中に残るので、ブロックの兄弟が絶対配置で流れから抜けると
681
+ // その分だけ上へ詰まる。例: <td><div>見出し</div><span class="mt-[10px] inline-block">ラベル</span></td>
682
+ // で見出しだけが抜け、ラベルが見出しに重なる
683
+ const skippedInline: { element: HTMLElement; rect: DOMRect }[] = [];
679
684
 
680
685
  editableElements.forEach((el) => {
681
686
  const element = el as HTMLElement;
@@ -694,6 +699,7 @@ export function convertToAbsolutePositioning(iframeDoc: Document): number {
694
699
 
695
700
  // インライン要素はスキップ(ただし flex/grid アイテムは除く)
696
701
  if (!isFlexOrGridItem && isInlineElement(element, computedStyle)) {
702
+ skippedInline.push({ element, rect: element.getBoundingClientRect() });
697
703
  return;
698
704
  }
699
705
 
@@ -900,11 +906,21 @@ export function convertToAbsolutePositioning(iframeDoc: Document): number {
900
906
  const overY = element.scrollHeight - element.clientHeight > 2;
901
907
  return (overX || overY) && element.getAttribute('data-gg-pre-overflow') !== 'true';
902
908
  });
903
- if (moved.length > 0) {
909
+ // 変換しなかったインライン要素も、流れの中で動いていないかを見る。
910
+ // 変換した要素だけを比べていたため、ブロックの兄弟が抜けて詰まったラベルを見逃し、
911
+ // 等倍で採寸が正確なキャンバス表示でだけ崩れた姿が確定していた
912
+ // (1 枚表示は縮小による端数で別の要素がずれ、たまたま巻き戻っていた)
913
+ const movedInline = skippedInline.filter(({ element, rect }) => {
914
+ const now = element.getBoundingClientRect();
915
+ return Math.abs(now.left - rect.left) > 1.5 || Math.abs(now.top - rect.top) > 1.5;
916
+ });
917
+ if (moved.length > 0 || movedInline.length > 0) {
904
918
  console.warn(
905
919
  '[convertToAbsolutePositioning] 変換で',
906
920
  moved.length,
907
- '要素がずれたため巻き戻します(採寸タイミングの問題の可能性)',
921
+ '要素がずれた・変換しないインライン要素が',
922
+ movedInline.length,
923
+ '個動いたため巻き戻します(採寸タイミング、または流れに残る要素が詰まった可能性)',
908
924
  );
909
925
  captures.forEach(({ element, prevStyle }) => {
910
926
  if (prevStyle == null) element.removeAttribute('style');
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ⌘ / Ctrl + クリックで、紙面の中のリンク(`<a href>`)の先へ移る。
2
+ * Alt(Mac は ⌥ Option)+ クリックで、紙面の中のリンク(`<a href>`)の先へ移る。
3
3
  *
4
4
  * - 同じサイトのページ(contentList[].href と照らし合わせる) … そのページを編集中にする
5
5
  * - 同じページのアンカー(`#id`) … その要素が見える位置へ紙面を移す
@@ -7,19 +7,27 @@
7
7
  * - `mailto:` / `tel:` / `javascript:` など … 何もしない
8
8
  * - ページ一覧に無い同じオリジンの URL … 短く知らせる
9
9
  *
10
- * [既存の ⌘ / Ctrl クリックとの両立]
11
- * useElementSelection の mousedown では ⌘ / Ctrl は「階層を無視して最深要素を選ぶ」、
12
- * ⌘ + Shift は「最深要素の追加・解除」、⌘ + ドラッグは自由配置(useDragResize)、
13
- * ⌘ + ダブルクリックはテキスト編集。押した瞬間にはどれになるか決められないので、
14
- * ここは mousedown で候補を覚えるだけにして選択には手を出さず、
10
+ * [⌘ / Ctrl ではなく Alt にした理由]
11
+ * 0.7.0 では ⌘ / Ctrl + クリックで移っていたが、⌘ / Ctrl + クリックはレイヤーパネルでは
12
+ * 「選択に足す・外す」(EditorLayerPanel)、紙面では「最深要素を選ぶ」で、複数選択のつもりの
13
+ * ⌘ クリックがリンクの上だとページ移動になった。紙面のクリックで Alt が空いている:
14
+ * Alt の既存の意味はホバーの距離メジャー(useAltMeasure、mousemove だけ)・中心からのリサイズ
15
+ * (ハンドルのドラッグ)・ズームツールの縮小(zoom-tool の間。下で止める)で、クリックには無い。
16
+ *
17
+ * [紙面のクリックとの両立]
18
+ * useElementSelection の mousedown は Alt を見ない(Alt + クリックは素のクリックと同じ選択)。
19
+ * Alt + ドラッグは移動、Alt + ダブルクリックは素のダブルクリック(潜る・テキスト編集)。
20
+ * 押した瞬間にはどれになるか決められないので、ここは mousedown で候補を覚えるだけにして
21
+ * 選択には手を出さず、
15
22
  * - 動かさずに離した(ドラッグではない)
16
- * - Shift / Alt を押していない
23
+ * - ⌘ / Ctrl / Shift を押していない(⌘ + Alt + クリックは最深要素を選ぶだけで移らない)
17
24
  * - 2 回目の mousedown(ダブルクリック)が LINK_NAV_DELAY_MS 以内に来なかった
18
25
  * - 押した点がテキスト編集中の要素の中ではない
19
- * ときだけ移る。リンクの中の深い要素を移らずに選びたいときは ⌘ + Alt + クリック。
26
+ * ときだけ移る。
20
27
  *
21
- * Mac は ⌘ だけ。Mac の Ctrl + クリックは右クリック扱い(contextmenu が出て click が来ない)で、
22
- * エディタのコンテキストメニューが開く。Windows などは Ctrl(と Meta)。
28
+ * Alt + クリックのリンクの既定の動作は、ブラウザによってはダウンロード(Chromium で実測)。
29
+ * 紙面のリンクの click は useElementSelection の handleClick が preventDefault するが、
30
+ * トリミング中は止めないので、Alt + クリックはここ(capture)でも必ず止める。
23
31
  */
24
32
  import { MARQUEE_DRAG_THRESHOLD } from '../constants';
25
33
 
@@ -175,17 +183,29 @@ export function clearPendingAnchor(id?: string): void {
175
183
 
176
184
  /**
177
185
  * 1 回目のクリックから移るまでの待ち。この間に 2 回目の mousedown が来たら
178
- * ダブルクリック(⌘ + ダブルクリック = テキスト編集)として移動を取り消す
186
+ * ダブルクリック(潜る・テキスト編集)として移動を取り消す
179
187
  */
180
188
  export const LINK_NAV_DELAY_MS = 350;
181
189
 
182
- /** ⌘ / Ctrl を押してリンクの上にいる間、紙面の <html> に付ける(カーソルを指にする) */
190
+ /** Alt を押してリンクの上にいる間、紙面の <html> に付ける(カーソルを指にする) */
183
191
  export const LINK_NAV_ATTR = 'data-gg-link-nav';
184
192
 
185
193
  const OVERLAY_SELECTOR =
186
194
  '.selection-box,.marquee-selection-box,[data-editor-overlay],#gg-smart-guides,#gg-measure-layer';
187
- /** 描画・テキスト・コメント・手のひら・トリミングの間は、クリックに別の意味がある */
188
- const BLOCKING_BODY_CLASSES = ['draw-mode', 'text-mode', 'comment-mode', 'move-mode', 'pan-mode', 'panning', 'gg-cropping'];
195
+ /**
196
+ * 描画・テキスト・コメント・手のひら・トリミング・ズームの間は、クリックに別の意味がある
197
+ * (ズームツールの Alt + クリックは縮小)
198
+ */
199
+ const BLOCKING_BODY_CLASSES = [
200
+ 'draw-mode',
201
+ 'text-mode',
202
+ 'comment-mode',
203
+ 'move-mode',
204
+ 'pan-mode',
205
+ 'panning',
206
+ 'gg-cropping',
207
+ 'zoom-tool',
208
+ ];
189
209
 
190
210
  const isMac = () => {
191
211
  if (typeof navigator === 'undefined') return false;
@@ -226,7 +246,7 @@ export interface LinkNavigationOptions {
226
246
  }
227
247
 
228
248
  /**
229
- * 紙面(iframe の文書)に ⌘ / Ctrl + クリックの移動を付ける。戻り値は後始末
249
+ * 紙面(iframe の文書)に Alt + クリックの移動を付ける。戻り値は後始末
230
250
  */
231
251
  export function attachLinkNavigation(iframeDoc: Document, options: LinkNavigationOptions): () => void {
232
252
  const win = iframeDoc.defaultView;
@@ -234,9 +254,9 @@ export function attachLinkNavigation(iframeDoc: Document, options: LinkNavigatio
234
254
  const hostDoc = frameEl?.ownerDocument ?? null;
235
255
  const hostWin = hostDoc?.defaultView ?? null;
236
256
  const mac = isMac();
237
- const mod = mac ? '⌘ ' : 'Ctrl + ';
238
- const navKey = (e: MouseEvent | KeyboardEvent) => (mac ? e.metaKey : e.ctrlKey || e.metaKey);
239
- const isNavKeyName = (key: string) => key === 'Meta' || (!mac && key === 'Control');
257
+ const mod = mac ? '⌥ ' : 'Alt + ';
258
+ /** Alt だけ。⌘ / Ctrl / Shift と一緒なら選択の操作(最深要素・追加)なので移らない */
259
+ const navKey = (e: MouseEvent | KeyboardEvent) => e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey;
240
260
 
241
261
  let pointer: { x: number; y: number } | null = null;
242
262
  let modifier = false;
@@ -330,7 +350,7 @@ export function attachLinkNavigation(iframeDoc: Document, options: LinkNavigatio
330
350
  if (hint) hint.hidden = true;
331
351
  };
332
352
 
333
- /** ⌘ / Ctrl を押してリンクの上にいれば、指のカーソルと行き先の案内を出す */
353
+ /** Alt を押してリンクの上にいれば、指のカーソルと行き先の案内を出す */
334
354
  const update = (buttons = 0) => {
335
355
  if (!modifier || !pointer || buttons !== 0 || blocked()) {
336
356
  disarm();
@@ -378,16 +398,26 @@ export function attachLinkNavigation(iframeDoc: Document, options: LinkNavigatio
378
398
  };
379
399
  const onKey = (e: KeyboardEvent) => {
380
400
  if (e.key === 'Escape') cancel();
381
- if (!isNavKeyName(e.key)) {
382
- // ⌘ を離したことに気づけなかった(別のウィンドウで離した等)ときの戻し
383
- if (modifier && !navKey(e)) {
384
- modifier = false;
385
- disarm();
401
+ if (e.key === 'Alt') {
402
+ // Alt の keydown で altKey が立たない実装があっても、押したことを優先する
403
+ modifier = e.type === 'keydown' ? !(e.metaKey || e.ctrlKey || e.shiftKey) : navKey(e);
404
+ update();
405
+ return;
406
+ }
407
+ if (e.key === 'Meta' || e.key === 'Control' || e.key === 'Shift') {
408
+ // Alt を押したまま ⌘ / Ctrl / Shift を足す(移らなくなる)・離す(移れるようになる)
409
+ const next = navKey(e);
410
+ if (next !== modifier) {
411
+ modifier = next;
412
+ update();
386
413
  }
387
414
  return;
388
415
  }
389
- modifier = e.type === 'keydown' ? true : navKey(e);
390
- update();
416
+ // Alt を離したことに気づけなかった(別のウィンドウで離した等)ときの戻し
417
+ if (modifier && !navKey(e)) {
418
+ modifier = false;
419
+ disarm();
420
+ }
391
421
  };
392
422
  const onBlur = () => {
393
423
  modifier = false;
@@ -397,7 +427,7 @@ export function attachLinkNavigation(iframeDoc: Document, options: LinkNavigatio
397
427
  const onMouseDown = (e: MouseEvent) => {
398
428
  // 2 回目の mousedown(ダブルクリック)・別のクリックが来たら、前の移動は取り消す
399
429
  cancel();
400
- if (e.button !== 0 || !navKey(e) || e.shiftKey || e.altKey) return;
430
+ if (e.button !== 0 || !navKey(e)) return;
401
431
  if (e.detail > 1 || blocked()) return;
402
432
  // 選択枠のハンドル(リサイズ・回転)を押したのはハンドルの操作。下にリンクがあっても移らない
403
433
  if ((e.target as Element | null)?.closest?.('[data-handle]')) return;
@@ -408,14 +438,16 @@ export function attachLinkNavigation(iframeDoc: Document, options: LinkNavigatio
408
438
  const moved = (e: MouseEvent, p: { x: number; y: number }) =>
409
439
  Math.abs(e.clientX - p.x) > MARQUEE_DRAG_THRESHOLD || Math.abs(e.clientY - p.y) > MARQUEE_DRAG_THRESHOLD;
410
440
  const onMouseUp = (e: MouseEvent) => {
411
- // ⌘ + ドラッグ(自由配置)だった
441
+ // Alt + ドラッグ(移動)だった
412
442
  if (pending && moved(e, pending)) pending = null;
413
443
  };
414
444
  const onClick = (e: MouseEvent) => {
445
+ // 移らないとき(ズームツール・トリミング中・⌘ と一緒など)も、リンクのダウンロードはさせない
446
+ if (e.altKey && (e.target as Element | null)?.closest?.('a[href]')) e.preventDefault();
415
447
  const p = pending;
416
448
  pending = null;
417
449
  if (!p || e.button !== 0 || e.detail > 1) return;
418
- if (!navKey(e) || e.shiftKey || e.altKey || moved(e, p)) return;
450
+ if (!navKey(e) || moved(e, p)) return;
419
451
  timer = setTimeout(() => {
420
452
  timer = null;
421
453
  // 待っている間にテキスト編集に入った・紙面が差し替わった・モードが変わった
@@ -5,6 +5,7 @@
5
5
  import type { SelectedElementInfo, ImageFillMode } from '../types';
6
6
  import { getArtboardScale } from './dom-utils';
7
7
  import { extractValueFromTailwindClass } from './tailwind-utils';
8
+ import { summarizeBorder, borderSideWidthCss } from './border-sides';
8
9
 
9
10
  /**
10
11
  * RGB/RGBA色をHEXに変換
@@ -525,7 +526,11 @@ export function extractElementInfo(
525
526
  const rawFontSize = extractRawStyleValue(element.style.fontSize, 'fontSize');
526
527
  const rawLineHeight = extractRawStyleValue(element.style.lineHeight, 'lineHeight');
527
528
  const rawLetterSpacing = extractRawStyleValue(element.style.letterSpacing, 'letterSpacing');
528
- const rawBorderWidth = extractRawStyleValue(element.style.borderWidth, 'borderWidth');
529
+ // 片側だけの線(border-l-2 など)は、見えている辺の値を出す(utils/border-sides.ts)
530
+ const border = summarizeBorder(style, element);
531
+ const rawBorderWidth = border.sides
532
+ ? extractRawStyleValue(element.style.getPropertyValue(borderSideWidthCss(border.sides[0])), 'borderWidth')
533
+ : extractRawStyleValue(element.style.borderWidth, 'borderWidth');
529
534
  const rawGap = extractRawStyleValue(element.style.gap, 'gap');
530
535
  // Position values (left/top)
531
536
  const rawLeft = extractRawStyleValue(element.style.left, 'left');
@@ -573,9 +578,10 @@ export function extractElementInfo(
573
578
  imagePositionX: parseBackgroundPositionX(style.backgroundPosition),
574
579
  imagePositionY: parseBackgroundPositionY(style.backgroundPosition),
575
580
  imageScale: parseBackgroundScale(style.backgroundSize),
576
- borderWidth: parseFloat(style.borderWidth) || 0,
577
- borderColor: style.borderColor,
578
- borderStyle: style.borderStyle,
581
+ borderWidth: border.width,
582
+ borderColor: border.color,
583
+ borderStyle: border.style,
584
+ borderSides: border.sides,
579
585
  borderRadius: parseFloat(style.borderRadius) || 0,
580
586
  borderRadiusTopLeft: parseFloat(style.borderTopLeftRadius) || 0,
581
587
  borderRadiusTopRight: parseFloat(style.borderTopRightRadius) || 0,
@@ -94,13 +94,8 @@ export const TAILWIND_CONFLICT_GROUPS: Record<string, string[]> = {
94
94
  borderTopRightRadius: ['rounded-tr-'],
95
95
  borderBottomRightRadius: ['rounded-br-'],
96
96
  borderBottomLeftRadius: ['rounded-bl-'],
97
- borderWidth: ['border-'],
98
- borderTopWidth: ['border-t-'],
99
- borderRightWidth: ['border-r-'],
100
- borderBottomWidth: ['border-b-'],
101
- borderLeftWidth: ['border-l-'],
102
- borderColor: ['border-'],
103
- borderStyle: ['border-solid', 'border-dashed', 'border-dotted', 'border-none'],
97
+ // borderWidth / border*Width / borderColor / borderStyle は tailwind-utils の removeConflictingClasses が
98
+ // クラスの形で見分けて消す(接頭辞 border- では太さ・色・スタイルが区別できない)
104
99
 
105
100
  // Typography
106
101
  // 注意: fontSize と color は両方 text- プレフィックスを使用するが、