@growgroup/visual-editor 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/FrontendVisualEditor.tsx +173 -0
- package/src/editor/components/EditorCanvas.tsx +16 -0
- package/src/editor/components/EditorContextMenu.tsx +80 -0
- package/src/editor/components/EditorPropertyPanel.tsx +248 -80
- package/src/editor/components/EditorSidebar.tsx +2 -2
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +38 -19
- package/src/editor/components/ppt/PptChrome.tsx +46 -4
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
- package/src/editor/constants.ts +24 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +108 -6
- package/src/editor/hooks/index.ts +3 -0
- package/src/editor/hooks/useDragResize.ts +30 -5
- package/src/editor/hooks/useElementActions.ts +41 -9
- package/src/editor/hooks/useElementSelection.ts +4 -2
- package/src/editor/hooks/useIframeSetup.ts +8 -1
- package/src/editor/hooks/useInlineTextSelection.ts +16 -0
- package/src/editor/hooks/useKeepArtboardInPlace.ts +88 -0
- package/src/editor/hooks/useLinkNavigation.ts +160 -0
- package/src/editor/parts.ts +2 -0
- package/src/editor/types.ts +4 -0
- package/src/editor/utils/dom-utils.ts +45 -0
- package/src/editor/utils/free-layout-tree.ts +562 -0
- package/src/editor/utils/free-layout.ts +794 -0
- package/src/editor/utils/geometry.ts +41 -3
- package/src/editor/utils/html-utils.ts +2 -1
- package/src/editor/utils/inline-text-style.ts +426 -0
- package/src/editor/utils/link-navigation.ts +474 -0
- package/src/editor/utils/style-utils.ts +24 -0
- package/src/editor/utils/tailwind-utils.ts +12 -0
- package/src/editor/utils/text-line-break.ts +121 -0
- package/src/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
|
-
|
|
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 (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}, [isLinked, parsed.numericValue]);
|
|
224
|
+
if (isLinked || isEmpty) return;
|
|
225
|
+
// W / H は整数で打つ前提で丸める。最小・最大は入っている値をそのまま出す
|
|
226
|
+
setLocalValue(String(variant === 'limit' ? parsed.numericValue : Math.round(parsed.numericValue)));
|
|
227
|
+
}, [isLinked, isEmpty, variant, parsed.numericValue]);
|
|
201
228
|
|
|
202
229
|
// 現在の値から変数を作成
|
|
203
230
|
const handleCreateVariable = useCallback(() => {
|
|
@@ -291,24 +318,36 @@ export function VariableAwareSizeInput({
|
|
|
291
318
|
[disabled, isLinked, mode, parsed, min, max, currentUnit, onChangeValue]
|
|
292
319
|
);
|
|
293
320
|
|
|
321
|
+
// 単位の欄が付くのは固定値で変数に紐づいていないときだけ
|
|
322
|
+
const hasUnit = mode === 'fixed' && !isLinked;
|
|
323
|
+
|
|
294
324
|
return (
|
|
295
|
-
|
|
325
|
+
// [.group] 外の group はパネルの共通の入力の見た目(高さ 32px・左右 8px の余白の塗り)を中に当てないための印。
|
|
326
|
+
// 中の数値と単位は枠を持たず、箱(下の .group.h-5)が 1 つの入力として見える
|
|
327
|
+
<div
|
|
328
|
+
className="group flex items-center gap-1 min-w-0"
|
|
329
|
+
data-size-field={fieldId ?? (dimension === 'height' ? 'h' : 'w')}
|
|
330
|
+
>
|
|
296
331
|
{/* Label area (W/H) */}
|
|
297
332
|
{label && <div className="shrink-0">{label}</div>}
|
|
298
333
|
|
|
299
334
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
300
335
|
<PopoverTrigger asChild>
|
|
336
|
+
{/* [幅] 以前は固定値の箱を w-14(56px)に決め打ちし、その外に単位(40px)を置いていたため、
|
|
337
|
+
数値に使える幅が 26px しか残らず「1499」「1820」が切れていた。
|
|
338
|
+
箱はモードによらず列の残りをすべて使い、数値・単位・モード切替をこの中に並べる */}
|
|
301
339
|
<div
|
|
340
|
+
data-size-box
|
|
302
341
|
className={cn(
|
|
303
|
-
"flex items-center h-5 bg-[#383838] border border-[#4a4a4a] rounded
|
|
342
|
+
"group flex flex-1 min-w-0 items-center h-5 bg-[#383838] border border-[#4a4a4a] rounded cursor-pointer",
|
|
304
343
|
"hover:border-[#5d5d5d]",
|
|
344
|
+
variant === 'limit' && "pr-1",
|
|
305
345
|
disabled && "opacity-50 cursor-not-allowed",
|
|
306
|
-
isOpen && "ring-1 ring-[#0d99ff] border-[#0d99ff]"
|
|
307
|
-
mode === 'fixed' ? 'w-14' : 'flex-1'
|
|
346
|
+
isOpen && "ring-1 ring-[#0d99ff] border-[#0d99ff]"
|
|
308
347
|
)}
|
|
309
348
|
>
|
|
310
349
|
{/* コンテンツ */}
|
|
311
|
-
<div className="flex-1 min-w-0 h-full flex items-center
|
|
350
|
+
<div className="flex-1 min-w-0 h-full flex items-center pl-1.5">
|
|
312
351
|
{mode === 'fixed' ? (
|
|
313
352
|
isLinked ? (
|
|
314
353
|
<div className="flex items-center gap-0.5 flex-1 min-w-0">
|
|
@@ -335,13 +374,16 @@ export function VariableAwareSizeInput({
|
|
|
335
374
|
ref={inputRef}
|
|
336
375
|
type="number"
|
|
337
376
|
value={localValue || displayValue}
|
|
377
|
+
placeholder={placeholder}
|
|
338
378
|
onChange={handleInputChange}
|
|
339
379
|
onFocus={handleInputFocus}
|
|
380
|
+
// 打ちかけの文字は外れたら捨てる(次に選んだ要素の値の上に残さない)
|
|
381
|
+
onBlur={() => setLocalValue('')}
|
|
340
382
|
onMouseDown={handleMouseDown}
|
|
341
383
|
onClick={(e) => e.stopPropagation()}
|
|
342
384
|
disabled={disabled}
|
|
343
385
|
className={cn(
|
|
344
|
-
"w-full bg-transparent border-none outline-none text-gray-200 text-[10px]",
|
|
386
|
+
"w-full min-w-0 p-0 bg-transparent border-none outline-none text-gray-200 text-[10px] tabular-nums",
|
|
345
387
|
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
|
|
346
388
|
isDragging ? "cursor-ew-resize" : "cursor-ew-resize"
|
|
347
389
|
)}
|
|
@@ -358,14 +400,40 @@ export function VariableAwareSizeInput({
|
|
|
358
400
|
)}
|
|
359
401
|
</div>
|
|
360
402
|
|
|
361
|
-
{/*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
403
|
+
{/* 単位セレクター(fixed モードで変数リンクがない場合のみ)。
|
|
404
|
+
箱の中にあるので、押してもモード切替のメニューは開かない */}
|
|
405
|
+
{hasUnit && (
|
|
406
|
+
<select
|
|
407
|
+
value={currentUnit}
|
|
408
|
+
onChange={handleUnitChange}
|
|
409
|
+
onClick={(e) => e.stopPropagation()}
|
|
410
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
411
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
412
|
+
disabled={disabled}
|
|
413
|
+
title="単位"
|
|
414
|
+
data-size-unit
|
|
415
|
+
className={cn(
|
|
416
|
+
"h-full shrink-0 px-0.5 appearance-none bg-transparent border-none outline-none",
|
|
417
|
+
"text-[9px] text-gray-400 hover:text-white cursor-pointer",
|
|
418
|
+
"disabled:text-gray-500"
|
|
419
|
+
)}
|
|
420
|
+
>
|
|
421
|
+
{unitList.map((u) => (
|
|
422
|
+
<option key={u} value={u}>{u}</option>
|
|
423
|
+
))}
|
|
424
|
+
</select>
|
|
425
|
+
)}
|
|
426
|
+
|
|
427
|
+
{/* ドロップダウンアイコン(最小・最大の欄は幅を数値に回すので出さない。箱を押せばメニューは開く) */}
|
|
428
|
+
{variant === 'size' && (
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
className="h-full w-4 shrink-0 flex items-center justify-center text-gray-500 hover:text-white transition-colors"
|
|
432
|
+
disabled={disabled}
|
|
433
|
+
>
|
|
434
|
+
<ChevronDown className="h-2.5 w-2.5" />
|
|
435
|
+
</button>
|
|
436
|
+
)}
|
|
369
437
|
</div>
|
|
370
438
|
</PopoverTrigger>
|
|
371
439
|
|
|
@@ -391,7 +459,8 @@ export function VariableAwareSizeInput({
|
|
|
391
459
|
</div>
|
|
392
460
|
|
|
393
461
|
<ScrollArea className="max-h-56">
|
|
394
|
-
{/* リサイズモード */}
|
|
462
|
+
{/* リサイズモード(最小・最大の欄では出さない) */}
|
|
463
|
+
{variant === 'size' && (
|
|
395
464
|
<div className="p-1 border-b border-[#444444]">
|
|
396
465
|
<div className="px-2 py-0.5 text-[9px] text-gray-500 uppercase">
|
|
397
466
|
リサイズ
|
|
@@ -452,6 +521,7 @@ export function VariableAwareSizeInput({
|
|
|
452
521
|
{mode === 'hug' && <span>✓</span>}
|
|
453
522
|
</button>
|
|
454
523
|
</div>
|
|
524
|
+
)}
|
|
455
525
|
|
|
456
526
|
{/* スペーシング変数 */}
|
|
457
527
|
<div className="p-1">
|
|
@@ -557,32 +627,6 @@ export function VariableAwareSizeInput({
|
|
|
557
627
|
</PopoverContent>
|
|
558
628
|
</Popover>
|
|
559
629
|
|
|
560
|
-
{/* 単位セレクター(fixed モードで変数リンクがない場合のみ) */}
|
|
561
|
-
{mode === 'fixed' && !isLinked && (
|
|
562
|
-
<select
|
|
563
|
-
value={currentUnit}
|
|
564
|
-
onChange={handleUnitChange}
|
|
565
|
-
disabled={disabled}
|
|
566
|
-
className={cn(
|
|
567
|
-
"h-5 w-10 px-0 text-[8px] border border-l-0 rounded-r border-[#4a4a4a]",
|
|
568
|
-
"bg-[#4a4a4a] text-gray-300 focus:outline-none focus:border-[#0d99ff]",
|
|
569
|
-
"disabled:bg-[#383838] disabled:text-gray-500 cursor-pointer"
|
|
570
|
-
)}
|
|
571
|
-
>
|
|
572
|
-
{unitList.map((u) => (
|
|
573
|
-
<option key={u} value={u}>{u}</option>
|
|
574
|
-
))}
|
|
575
|
-
</select>
|
|
576
|
-
)}
|
|
577
|
-
|
|
578
|
-
{/* fill/hug モードまたはリンク時は角丸を調整 */}
|
|
579
|
-
{(mode !== 'fixed' || isLinked) && (
|
|
580
|
-
<style>{`
|
|
581
|
-
:global(.group\\/size-input > div:first-of-type > div:first-child) {
|
|
582
|
-
border-radius: 0.25rem !important;
|
|
583
|
-
}
|
|
584
|
-
`}</style>
|
|
585
|
-
)}
|
|
586
630
|
</div>
|
|
587
631
|
);
|
|
588
632
|
}
|
|
@@ -58,6 +58,8 @@ export interface VariableAwareUnitInputProps {
|
|
|
58
58
|
hideVariableLink?: boolean;
|
|
59
59
|
/** 単位変換コンテキスト */
|
|
60
60
|
conversionContext?: UnitConversionContext;
|
|
61
|
+
/** 選んだ文字の中で値が揃っていない。欄を空にして「混在」と出し、打ったときだけ値を返す */
|
|
62
|
+
mixed?: boolean;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// ============================================
|
|
@@ -80,6 +82,7 @@ export function VariableAwareUnitInput({
|
|
|
80
82
|
enableScrubbing = true,
|
|
81
83
|
hideVariableLink = false,
|
|
82
84
|
conversionContext,
|
|
85
|
+
mixed = false,
|
|
83
86
|
}: VariableAwareUnitInputProps) {
|
|
84
87
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
85
88
|
const [isOpen, setIsOpen] = useState(false);
|
|
@@ -165,6 +168,8 @@ export function VariableAwareUnitInput({
|
|
|
165
168
|
const inputValue = e.target.value;
|
|
166
169
|
|
|
167
170
|
if (inputValue === "" || inputValue === "-") {
|
|
171
|
+
// 混在のまま消しただけなら何も当てない(0 にしない)
|
|
172
|
+
if (mixed) return;
|
|
168
173
|
onChange(formatValueWithUnit(0, currentUnit));
|
|
169
174
|
return;
|
|
170
175
|
}
|
|
@@ -178,7 +183,7 @@ export function VariableAwareUnitInput({
|
|
|
178
183
|
|
|
179
184
|
onChange(formatValueWithUnit(clampedValue, currentUnit));
|
|
180
185
|
},
|
|
181
|
-
[onChange, currentUnit, min, max]
|
|
186
|
+
[onChange, currentUnit, min, max, mixed]
|
|
182
187
|
);
|
|
183
188
|
|
|
184
189
|
// 単位変更ハンドラ(値を変換)
|
|
@@ -272,7 +277,7 @@ export function VariableAwareUnitInput({
|
|
|
272
277
|
// 改善: クリックでフォーカスを許可し、ドラッグ閾値を超えた場合のみスクラブを開始
|
|
273
278
|
const handleMouseDown = useCallback(
|
|
274
279
|
(e: React.MouseEvent<HTMLInputElement>) => {
|
|
275
|
-
if (!enableScrubbing || disabled || isLinked) return;
|
|
280
|
+
if (!enableScrubbing || disabled || isLinked || mixed) return;
|
|
276
281
|
if (e.button !== 0) return;
|
|
277
282
|
// 既にフォーカスされている場合は通常の入力を許可
|
|
278
283
|
if (document.activeElement === inputRef.current) return;
|
|
@@ -325,13 +330,13 @@ export function VariableAwareUnitInput({
|
|
|
325
330
|
document.addEventListener("mousemove", handleMouseMove);
|
|
326
331
|
document.addEventListener("mouseup", handleMouseUp);
|
|
327
332
|
},
|
|
328
|
-
[enableScrubbing, disabled, isLinked, parsed, step, min, max, currentUnit, onChange]
|
|
333
|
+
[enableScrubbing, disabled, isLinked, parsed, step, min, max, currentUnit, onChange, mixed]
|
|
329
334
|
);
|
|
330
335
|
|
|
331
336
|
// キーボード操作
|
|
332
337
|
const handleKeyDown = useCallback(
|
|
333
338
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
334
|
-
if (disabled || isLinked) return;
|
|
339
|
+
if (disabled || isLinked || mixed) return;
|
|
335
340
|
|
|
336
341
|
const multiplier = e.shiftKey ? 10 : 1;
|
|
337
342
|
let newValue = parsed.numericValue;
|
|
@@ -351,7 +356,7 @@ export function VariableAwareUnitInput({
|
|
|
351
356
|
|
|
352
357
|
onChange(formatValueWithUnit(newValue, currentUnit));
|
|
353
358
|
},
|
|
354
|
-
[disabled, isLinked, parsed, step, min, max, currentUnit, onChange]
|
|
359
|
+
[disabled, isLinked, parsed, step, min, max, currentUnit, onChange, mixed]
|
|
355
360
|
);
|
|
356
361
|
|
|
357
362
|
const inputWidth = compact ? "w-10" : "w-12";
|
|
@@ -493,7 +498,8 @@ export function VariableAwareUnitInput({
|
|
|
493
498
|
<input
|
|
494
499
|
ref={inputRef}
|
|
495
500
|
type={isLinked ? "text" : "number"}
|
|
496
|
-
value={isLinked ? (linkedVariable?.name ?? extractVariableName(String(value)) ?? "") : parsed.numericValue}
|
|
501
|
+
value={isLinked ? (linkedVariable?.name ?? extractVariableName(String(value)) ?? "") : mixed ? "" : parsed.numericValue}
|
|
502
|
+
placeholder={mixed && !isLinked ? "混在" : undefined}
|
|
497
503
|
onChange={handleNumberChange}
|
|
498
504
|
onMouseDown={handleMouseDown}
|
|
499
505
|
onKeyDown={handleKeyDown}
|
package/src/editor/constants.ts
CHANGED
|
@@ -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)); }
|