fix(desktop): 네이티브 위젯 picker를 창 전체 뷰로 전환

portal/overlay 대신 widget-main에서 picker 전용 화면으로 전환해
macOS·Windows WebView 모두에서 위젯 선택 팝업이 보이도록 합니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-15 16:49:25 +09:00
parent 1804328b9b
commit 36b4679c9a
4 changed files with 130 additions and 73 deletions
+57 -15
View File
@@ -6,6 +6,7 @@ import { LogicalSize } from '@tauri-apps/api/dpi';
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared'; import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
import { ensureDesktopApiBase } from './ensureDesktopApiBase'; import { ensureDesktopApiBase } from './ensureDesktopApiBase';
import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow'; import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow';
import WidgetPickerModal from '@frontend/components/widgetDashboard/WidgetPickerModal';
import { import {
defaultGridFr, defaultGridFr,
NATIVE_WIDGET_PICKER_WINDOW, NATIVE_WIDGET_PICKER_WINDOW,
@@ -131,6 +132,8 @@ const WidgetApp: React.FC = () => {
return () => { unlisten?.(); }; return () => { unlisten?.(); };
}, [instance?.id]); }, [instance?.id]);
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
void getCurrentWindow().close(); void getCurrentWindow().close();
}, []); }, []);
@@ -174,6 +177,34 @@ const WidgetApp: React.FC = () => {
} }
}, []); }, []);
const handleNativePickRequest = useCallback((slotId: string) => {
setPickSlotId(slotId);
void Promise.resolve(handlePickerOpenChange(true)).catch(err => {
console.warn('[widget-main] picker open resize failed', err);
});
}, [handlePickerOpenChange]);
const handleNativePickClose = useCallback(() => {
setPickSlotId(null);
void Promise.resolve(handlePickerOpenChange(false)).catch(err => {
console.warn('[widget-main] picker close resize failed', err);
});
}, [handlePickerOpenChange]);
const handleNativePickSelect = useCallback((widgetType: string) => {
if (!pickSlotId) return;
setInstance(prev => {
if (!prev) return prev;
return {
...prev,
slots: prev.slots.map(s => (
s.id === pickSlotId ? { ...s, widgetType, config: {} } : s
)),
};
});
handleNativePickClose();
}, [pickSlotId, handleNativePickClose]);
if (!instance) { if (!instance) {
return <p className="wd-widget-empty"> .</p>; return <p className="wd-widget-empty"> .</p>;
} }
@@ -194,21 +225,32 @@ const WidgetApp: React.FC = () => {
paperAutoTradeEnabled={false} paperAutoTradeEnabled={false}
chartRealtimeSource="BACKEND_STOMP" chartRealtimeSource="BACKEND_STOMP"
> >
<div className="fw-widget-native-root"> <div className={`fw-widget-native-root${pickSlotId ? ' fw-widget-native-root--picker' : ''}`}>
<FloatingWidgetWindow {pickSlotId != null ? (
instance={instance} <WidgetPickerModal
focused open
nativeShell category="all"
onClose={handleClose} nativeCompact
onFocus={() => {}} nativeFullRoot
onPickerOpenChange={handlePickerOpenChange} onClose={handleNativePickClose}
onUpdateSlots={slots => setInstance(prev => (prev ? { ...prev, slots } : prev))} onSelect={handleNativePickSelect}
onUpdateSize={(width, height) => { />
setInstance(prev => (prev ? { ...prev, width, height } : prev)); ) : (
void getCurrentWindow().setSize(new LogicalSize(width, height)); <FloatingWidgetWindow
}} instance={instance}
onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))} focused
/> nativeShell
onClose={handleClose}
onFocus={() => {}}
onNativePickRequest={handleNativePickRequest}
onUpdateSlots={slots => setInstance(prev => (prev ? { ...prev, slots } : prev))}
onUpdateSize={(width, height) => {
setInstance(prev => (prev ? { ...prev, width, height } : prev));
void getCurrentWindow().setSize(new LogicalSize(width, height));
}}
onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))}
/>
)}
</div> </div>
</WidgetDashboardProvider> </WidgetDashboardProvider>
); );
@@ -24,7 +24,9 @@ interface Props {
onUpdateGridFr: (rowFr: number[], colFr: number[]) => void; onUpdateGridFr: (rowFr: number[], colFr: number[]) => void;
/** Tauri OS 창 — 네이티브 타이틀·리사이즈 사용, 내부 플로팅 크롬 숨김 */ /** Tauri OS 창 — 네이티브 타이틀·리사이즈 사용, 내부 플로팅 크롬 숨김 */
nativeShell?: boolean; nativeShell?: boolean;
/** 위젯 선택 팝업 열림/닫힘 (네이티브 창 크기 조절 — 완료 후 picker 표시) */ /** 네이티브 위젯 창 — picker는 부모(widget-main)에서 전환 렌더 */
onNativePickRequest?: (slotId: string) => void;
/** 위젯 선택 팝업 열림/닫힘 (웹 플로팅 레이어용) */
onPickerOpenChange?: (open: boolean) => void | Promise<void>; onPickerOpenChange?: (open: boolean) => void | Promise<void>;
} }
@@ -37,27 +39,22 @@ const FloatingWidgetWindow: React.FC<Props> = ({
onUpdateSize, onUpdateSize,
onUpdateGridFr, onUpdateGridFr,
nativeShell = false, nativeShell = false,
onNativePickRequest,
onPickerOpenChange, onPickerOpenChange,
}) => { }) => {
const [pickSlotId, setPickSlotId] = useState<string | null>(null); const [pickSlotId, setPickSlotId] = useState<string | null>(null);
const nativePickerExternal = nativeShell && Boolean(onNativePickRequest);
const openPicker = useCallback((slotId: string) => { const openPicker = useCallback((slotId: string) => {
if (nativePickerExternal) {
onNativePickRequest!(slotId);
return;
}
setPickSlotId(slotId); setPickSlotId(slotId);
if (nativeShell) { }, [nativePickerExternal, onNativePickRequest]);
document.documentElement.classList.add('fw-native-picker-open');
}
if (nativeShell && onPickerOpenChange) {
void Promise.resolve(onPickerOpenChange(true)).catch(err => {
console.warn('[FloatingWidgetWindow] picker window resize failed', err);
});
}
}, [nativeShell, onPickerOpenChange]);
const closePicker = useCallback(async () => { const closePicker = useCallback(async () => {
setPickSlotId(null); setPickSlotId(null);
if (nativeShell) {
document.documentElement.classList.remove('fw-native-picker-open');
}
if (nativeShell && onPickerOpenChange) { if (nativeShell && onPickerOpenChange) {
try { try {
await onPickerOpenChange(false); await onPickerOpenChange(false);
@@ -66,9 +63,6 @@ const FloatingWidgetWindow: React.FC<Props> = ({
} }
} }
}, [nativeShell, onPickerOpenChange]); }, [nativeShell, onPickerOpenChange]);
useEffect(() => () => {
document.documentElement.classList.remove('fw-native-picker-open');
}, []);
const bodyWrapRef = useRef<HTMLDivElement>(null); const bodyWrapRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
@@ -265,7 +259,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
<WidgetSlotView <WidgetSlotView
slot={slot} slot={slot}
zoneId="center" zoneId="center"
onPickWidget={id => { void openPicker(id); }} onPickWidget={id => { openPicker(id); }}
onClearWidget={id => clearWidget(id)} onClearWidget={id => clearWidget(id)}
/> />
</div> </div>
@@ -322,7 +316,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
</> </>
); );
const pickerModal = pickSlotId != null ? ( const pickerModal = !nativePickerExternal && pickSlotId != null ? (
<WidgetPickerModal <WidgetPickerModal
open open
category="all" category="all"
@@ -333,12 +327,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
) : null; ) : null;
if (nativeShell) { if (nativeShell) {
return ( return content;
<>
{content}
{pickerModal && createPortal(pickerModal, document.body)}
</>
);
} }
return createPortal( return createPortal(
@@ -12,6 +12,8 @@ interface Props {
onSelect: (widgetType: string) => void; onSelect: (widgetType: string) => void;
/** Tauri 네이티브 위젯 창 — OS 창 전체를 쓰는 고정형 picker */ /** Tauri 네이티브 위젯 창 — OS 창 전체를 쓰는 고정형 picker */
nativeCompact?: boolean; nativeCompact?: boolean;
/** 네이티브 위젯 창 루트를 picker 전용으로 쓸 때 (overlay 없음) */
nativeFullRoot?: boolean;
} }
function pickerMeta(category: WidgetPickerCategory) { function pickerMeta(category: WidgetPickerCategory) {
@@ -69,36 +71,45 @@ const NativeWidgetPicker: React.FC<Props> = ({
category, category,
onClose, onClose,
onSelect, onSelect,
nativeFullRoot = false,
}) => { }) => {
if (!open) return null; if (!open) return null;
const { title } = pickerMeta(category); const { title } = pickerMeta(category);
const shell = (
<div
className="wd-picker-native-shell"
role="dialog"
aria-modal="true"
aria-labelledby="wd-picker-native-title"
>
<div className="wd-picker-native-header">
<h2 id="wd-picker-native-title" className="wd-picker-native-title">
{title}
<span className="wd-picker-native-title-ko"> (Widget)</span>
</h2>
<button
type="button"
className="wd-picker-native-close"
onClick={onClose}
aria-label="닫기"
>
</button>
</div>
<div className="wd-picker-native-body wd-picker-popup-body">
<WidgetPickerContent category={category} onSelect={onSelect} onClose={onClose} />
</div>
</div>
);
if (nativeFullRoot) {
return <div className="wd-picker-native-root">{shell}</div>;
}
return ( return (
<div className="wd-picker-native-overlay" role="presentation"> <div className="wd-picker-native-overlay" role="presentation">
<div {shell}
className="wd-picker-native-shell"
role="dialog"
aria-modal="true"
aria-labelledby="wd-picker-native-title"
>
<div className="wd-picker-native-header">
<h2 id="wd-picker-native-title" className="wd-picker-native-title">
{title}
<span className="wd-picker-native-title-ko"> (Widget)</span>
</h2>
<button
type="button"
className="wd-picker-native-close"
onClick={onClose}
aria-label="닫기"
>
</button>
</div>
<div className="wd-picker-native-body wd-picker-popup-body">
<WidgetPickerContent category={category} onSelect={onSelect} onClose={onClose} />
</div>
</div>
</div> </div>
); );
}; };
@@ -109,6 +120,7 @@ const WidgetPickerModal: React.FC<Props> = ({
onClose, onClose,
onSelect, onSelect,
nativeCompact = false, nativeCompact = false,
nativeFullRoot = false,
}) => { }) => {
if (!open) return null; if (!open) return null;
@@ -119,6 +131,7 @@ const WidgetPickerModal: React.FC<Props> = ({
category={category} category={category}
onClose={onClose} onClose={onClose}
onSelect={onSelect} onSelect={onSelect}
nativeFullRoot={nativeFullRoot}
/> />
); );
} }
+23 -10
View File
@@ -1317,6 +1317,29 @@ html.fw-native-widget .wd-picker-native-body {
overflow-y: auto; overflow-y: auto;
} }
/* 네이티브 위젯 창 — picker가 창 전체를 차지 (overlay/portal 미사용) */
html.fw-native-widget .fw-widget-native-root--picker {
overflow: hidden;
}
html.fw-native-widget .wd-picker-native-root {
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg, #1a1b26);
}
html.fw-native-widget .wd-picker-native-root .wd-picker-native-shell {
flex: 1;
min-height: 0;
border-radius: 0;
border: none;
box-shadow: none;
}
html.fw-native-widget .wd-picker-native-loading { html.fw-native-widget .wd-picker-native-loading {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1329,16 +1352,6 @@ html.fw-native-widget .wd-picker-native-loading {
font-size: 13px; font-size: 13px;
} }
html.fw-native-widget.fw-native-picker-open .fw-widget-native-root .fw-window {
visibility: hidden;
pointer-events: none;
}
html.fw-native-widget.fw-native-picker-open .wd-picker-native-overlay {
padding: 0;
background: var(--bg, #1a1b26);
}
.bps-page--wd-no-left .bps-side-wrap--left, .bps-page--wd-no-left .bps-side-wrap--left,
.bps-page--wd-no-left .bps-side-wrap--left + .bps-splitter { .bps-page--wd-no-left .bps-side-wrap--left + .bps-splitter {
display: none !important; display: none !important;