lynx-console 0.0.1 → 0.1.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.
@@ -33,20 +33,113 @@ export const logHeader = style({
33
33
  flexDirection: "row",
34
34
  alignItems: "center",
35
35
  justifyContent: "space-between",
36
- marginBottom: 8,
37
- paddingBottom: 4,
36
+ paddingBottom: 3,
37
+ });
38
+
39
+ export const fadeTop = style({
40
+ height: 20,
41
+ marginBottom: -20,
42
+ zIndex: 1,
43
+ background: `linear-gradient(to bottom, ${vars.$color.bg.layerDefault}, #ffffff00)`,
44
+ });
45
+
46
+ export const filterWrapper = style({
47
+ position: "relative",
48
+ });
49
+
50
+ export const filterButton = style({
51
+ display: "flex",
52
+ flexDirection: "row",
53
+ alignItems: "center",
54
+ padding: "3px 6px",
55
+ backgroundColor: vars.$color.bg.neutralWeak,
56
+ borderRadius: 4,
57
+ });
58
+
59
+ export const filterButtonText = style({
60
+ ...typography("t3", "medium"),
61
+ color: vars.$color.fg.neutral,
62
+ });
63
+
64
+ export const filterDropdown = style({
65
+ position: "absolute",
66
+ top: "100%",
67
+ left: 0,
68
+ marginTop: 4,
69
+ backgroundColor: vars.$color.bg.layerDefault,
70
+ borderWidth: 1,
71
+ borderColor: vars.$color.stroke.neutralSubtle,
72
+ borderStyle: "solid",
73
+ borderRadius: 8,
74
+ padding: "4px 0",
75
+ zIndex: 100,
76
+ minWidth: 90,
77
+ });
78
+
79
+ export const filterOption = style({
80
+ display: "flex",
81
+ flexDirection: "row",
82
+ alignItems: "center",
83
+ gap: 4,
84
+ padding: "8px 12px",
85
+ });
86
+
87
+ export const filterCheckbox = recipe({
88
+ base: {
89
+ ...typography("t3", "medium"),
90
+ width: 16,
91
+ },
92
+ variants: {
93
+ level: {
94
+ log: { color: vars.$color.palette.green600 },
95
+ info: { color: vars.$color.palette.blue600 },
96
+ warn: { color: vars.$color.palette.yellow600 },
97
+ error: { color: vars.$color.palette.red600 },
98
+ },
99
+ },
100
+ });
101
+
102
+ export const filterLabel = recipe({
103
+ base: {
104
+ ...typography("t3", "medium"),
105
+ },
106
+ variants: {
107
+ level: {
108
+ log: { color: vars.$color.palette.green600 },
109
+ info: { color: vars.$color.palette.blue600 },
110
+ warn: { color: vars.$color.palette.yellow600 },
111
+ error: { color: vars.$color.palette.red600 },
112
+ },
113
+ },
114
+ });
115
+
116
+ export const searchWrapper = style({
117
+ display: "flex",
118
+ flexDirection: "row",
119
+ alignItems: "center",
120
+ flex: 1,
121
+ marginLeft: 8,
122
+ marginRight: 8,
38
123
  borderBottomWidth: 1,
39
124
  borderBottomColor: vars.$color.stroke.neutralSubtle,
40
125
  borderBottomStyle: "solid",
126
+ gap: 8,
127
+ });
128
+
129
+ export const searchPrompt = style({
130
+ ...typography("t6", "medium"),
131
+ color: vars.$color.fg.placeholder,
41
132
  });
42
133
 
43
- export const logCount = style({
134
+ export const searchInput = style({
135
+ flex: 1,
44
136
  ...typography("t3", "regular"),
45
- color: vars.$color.fg.neutralSubtle,
137
+ color: vars.$color.fg.neutral,
138
+ caretColor: vars.$color.palette.green600,
46
139
  });
47
140
 
48
141
  export const clearButton = style({
49
- padding: "6px 12px",
142
+ padding: "3px 6px",
50
143
  backgroundColor: vars.$color.bg.neutralWeak,
51
144
  borderRadius: 4,
52
145
  });
@@ -58,6 +151,8 @@ export const clearButtonText = style({
58
151
 
59
152
  export const logList = style({
60
153
  flex: 1,
154
+ paddingTop: 0,
155
+ paddingBottom: 0,
61
156
  });
62
157
 
63
158
  export const logItem = recipe({
@@ -216,22 +311,20 @@ export const argObjectJson = style({
216
311
  color: vars.$color.fg.neutral,
217
312
  });
218
313
 
314
+ export const fadeBottom = style({
315
+ height: 20,
316
+ marginTop: -20,
317
+ zIndex: 1,
318
+ background: `linear-gradient(to top, ${vars.$color.bg.layerDefault}, #ffffff00)`,
319
+ });
320
+
219
321
  export const replInputRow = style({
220
322
  display: "flex",
221
323
  flexDirection: "row",
222
324
  alignItems: "center",
223
325
  gap: 8,
224
- paddingTop: 8,
326
+ paddingTop: 0,
225
327
  paddingBottom: 8,
226
- marginTop: -1,
227
- borderTopWidth: 1,
228
- borderTopColor: vars.$color.stroke.neutralSubtle,
229
- borderTopStyle: "solid",
230
- backgroundImage: `linear-gradient(to bottom, transparent, ${vars.$color.bg.layerDefault})`,
231
- backgroundSize: "100% 32px",
232
- backgroundRepeat: "no-repeat",
233
- backgroundPosition: "top",
234
- backgroundColor: vars.$color.bg.layerDefault,
235
328
  });
236
329
 
237
330
  export const replPrompt = style({
@@ -1,6 +1,6 @@
1
1
  import { useConsole, useNetwork, usePerformance } from "../hooks";
2
2
  import * as css from "./ConsolePanel.css";
3
- import { LogPanel } from "./LogPanel";
3
+ import { LogPanel, dismissFilterDropdown } from "./LogPanel";
4
4
  import { NetworkPanel } from "./NetworkPanel";
5
5
  import { PerformancePanel } from "./PerformancePanel";
6
6
  import Tabs from "./Tabs";
@@ -13,6 +13,7 @@ export const ConsolePanel = () => {
13
13
  return (
14
14
  <view className={css.container}>
15
15
  <Tabs
16
+ onTabChange={dismissFilterDropdown}
16
17
  items={[
17
18
  {
18
19
  key: "log",
@@ -4,18 +4,18 @@ import { vars } from "../styles/vars";
4
4
 
5
5
  export const wrapper = style({
6
6
  position: "fixed",
7
- right: "16px",
8
- bottom: "84px",
9
7
  zIndex: 9999,
10
8
  display: "flex",
11
9
  flexDirection: "row",
12
10
  alignItems: "center",
13
11
  gap: "8px",
12
+ overflow: "visible",
13
+ transition: `transform ${vars.$duration.d4} cubic-bezier(0.4, 0, 0.2, 1)`,
14
14
  });
15
15
 
16
- export const container = style({});
17
-
18
16
  export const button = style({
17
+ position: "relative",
18
+ overflow: "hidden",
19
19
  paddingLeft: "8px",
20
20
  paddingRight: "8px",
21
21
  paddingTop: "4px",
@@ -30,6 +30,16 @@ export const button = style({
30
30
  boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
31
31
  });
32
32
 
33
+ export const shineOverlay = style({
34
+ position: "absolute",
35
+ top: "-50%",
36
+ left: "-25%",
37
+ width: "150%",
38
+ height: "200%",
39
+ backgroundColor: "rgba(255, 255, 255, 0.2)",
40
+ borderRadius: "9999px",
41
+ });
42
+
33
43
  export const title = style({
34
44
  ...typography("t4", "regular"),
35
45
  color: vars.$color.palette.staticWhite,
@@ -43,6 +53,7 @@ export const subtitle = style({
43
53
  });
44
54
 
45
55
  export const reloadButton = style({
56
+ overflow: "visible",
46
57
  width: "32px",
47
58
  height: "32px",
48
59
  borderRadius: "16px",
@@ -1,18 +1,36 @@
1
1
  import type { ReactNode } from "@lynx-js/react";
2
+ import { useLongPressDrag } from "../hooks/useLongPressDrag";
2
3
  import * as css from "./FloatingButton.css";
3
4
 
4
5
  interface FloatingButtonProps {
5
6
  bindtap: () => void;
6
- isVisible: boolean;
7
7
  children: ReactNode;
8
8
  }
9
9
 
10
+ const SHINE_STYLES = {
11
+ idle: {
12
+ transform: "scale(0)",
13
+ opacity: 0,
14
+ },
15
+ dragging: {
16
+ transform: "scale(1)",
17
+ opacity: 1,
18
+ transition: "transform 300ms cubic-bezier(0.4, 0, 0.2, 1)",
19
+ },
20
+ releasing: {
21
+ transform: "scale(1)",
22
+ opacity: 0,
23
+ transition: "opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)",
24
+ },
25
+ } as const;
26
+
10
27
  export const FloatingButton = ({
11
28
  bindtap,
12
- isVisible,
13
29
  children,
14
30
  }: FloatingButtonProps) => {
15
- if (!isVisible) return null;
31
+ const { phase, right, bottom, clearTimer, handlers } =
32
+ useLongPressDrag(bindtap);
33
+
16
34
 
17
35
  const handleReload = () => {
18
36
  try {
@@ -24,12 +42,27 @@ export const FloatingButton = ({
24
42
  }
25
43
  };
26
44
 
45
+ const isDragging = phase === "dragging";
46
+
27
47
  return (
28
- <view className={css.wrapper}>
29
- <view className={css.container} bindtap={bindtap}>
30
- <view className={css.button}>{children}</view>
48
+ <view
49
+ className={css.wrapper}
50
+ style={{
51
+ right: `${right}px`,
52
+ bottom: `${bottom}px`,
53
+ transform: isDragging ? "scale(1.05)" : "scale(1)",
54
+ }}
55
+ {...handlers}
56
+ >
57
+ <view className={css.button}>
58
+ {children}
59
+ <view className={css.shineOverlay} style={SHINE_STYLES[phase]} />
31
60
  </view>
32
- <view className={css.reloadButton} bindtap={handleReload}>
61
+ <view
62
+ className={css.reloadButton}
63
+ catchtouchstart={() => clearTimer()}
64
+ bindtap={handleReload}
65
+ >
33
66
  <text className={css.reloadIcon}>{"\u21BB"}</text>
34
67
  </view>
35
68
  </view>
@@ -1,9 +1,18 @@
1
- import { useEffect, useRef, useState } from "@lynx-js/react";
1
+ import { useEffect, useMemo, useRef, useState } from "@lynx-js/react";
2
2
  import type { BaseEvent, InputInputEvent, NodesRef } from "@lynx-js/types";
3
3
  import { stringify } from "javascript-stringify";
4
- import type { LogEntry } from "../types";
4
+ import type { LogEntry, LogLevel } from "../types";
5
+ import { vars } from "../styles/vars";
5
6
  import * as css from "./ConsolePanel.css";
6
7
 
8
+ const LOG_LEVELS: LogLevel[] = ["log", "info", "warn", "error"];
9
+
10
+ let savedEnabledLevels: Set<LogLevel> | null = null;
11
+ let savedSearchQuery = "";
12
+ let closeFilterDropdown: (() => void) | null = null;
13
+
14
+ export const dismissFilterDropdown = () => closeFilterDropdown?.();
15
+
7
16
  interface LogPanelProps {
8
17
  logs: LogEntry[];
9
18
  clearLogs: () => void;
@@ -26,10 +35,64 @@ const runCode = (code: string) => {
26
35
  export const LogPanel = ({ logs, clearLogs }: LogPanelProps) => {
27
36
  const [expandedArgs, setExpandedArgs] = useState(new Set());
28
37
  const [code, setCode] = useState("");
38
+ const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
39
+ () => savedEnabledLevels ?? new Set(LOG_LEVELS),
40
+ );
41
+ const [filterOpen, setFilterOpen] = useState(false);
42
+ const [searchQuery, setSearchQuery] = useState(savedSearchQuery);
43
+ const [fadeState, setFadeState] = useState({ atTop: true, atBottom: true });
44
+ const fadeRef = useRef({ atTop: true, atBottom: true });
29
45
  const inputRef = useRef<NodesRef>(null);
46
+ const searchInputRef = useRef<NodesRef>(null);
30
47
  const listRef = useRef<NodesRef>(null);
31
- const logsRef = useRef(logs);
32
- logsRef.current = logs;
48
+
49
+ useEffect(() => {
50
+ savedEnabledLevels = enabledLevels;
51
+ }, [enabledLevels]);
52
+
53
+ useEffect(() => {
54
+ savedSearchQuery = searchQuery;
55
+ }, [searchQuery]);
56
+
57
+ useEffect(() => {
58
+ if (savedSearchQuery) {
59
+ searchInputRef.current
60
+ ?.invoke({ method: "setValue", params: { value: savedSearchQuery } })
61
+ .exec();
62
+ }
63
+ }, []);
64
+
65
+ useEffect(() => {
66
+ closeFilterDropdown = () => setFilterOpen(false);
67
+ return () => { closeFilterDropdown = null; };
68
+ }, []);
69
+
70
+ const filteredLogs = useMemo(
71
+ () =>
72
+ logs.filter((log) => {
73
+ if (!enabledLevels.has(log.level)) return false;
74
+ if (searchQuery) {
75
+ const query = searchQuery.toLowerCase();
76
+ return log.args.some((arg) => String(arg).toLowerCase().includes(query));
77
+ }
78
+ return true;
79
+ }),
80
+ [logs, enabledLevels, searchQuery],
81
+ );
82
+ const logsRef = useRef(filteredLogs);
83
+ logsRef.current = filteredLogs;
84
+
85
+ const toggleLevel = (level: LogLevel) => {
86
+ setEnabledLevels((prev) => {
87
+ const next = new Set(prev);
88
+ if (next.has(level)) {
89
+ next.delete(level);
90
+ } else {
91
+ next.add(level);
92
+ }
93
+ return next;
94
+ });
95
+ };
33
96
 
34
97
  const scrollToBottom = (smooth: boolean) => {
35
98
  if (logsRef.current.length === 0) return;
@@ -43,7 +106,7 @@ export const LogPanel = ({ logs, clearLogs }: LogPanelProps) => {
43
106
 
44
107
  useEffect(() => {
45
108
  scrollToBottom(true);
46
- }, [logs]);
109
+ }, [filteredLogs]);
47
110
 
48
111
  const toggleArg = (key: string) => {
49
112
  setExpandedArgs((prev) => {
@@ -165,22 +228,81 @@ export const LogPanel = ({ logs, clearLogs }: LogPanelProps) => {
165
228
  };
166
229
 
167
230
  return (
168
- <view className={css.logContainer}>
231
+ <view
232
+ className={css.logContainer}
233
+ bindtap={() => { if (filterOpen) setFilterOpen(false); }}
234
+ >
169
235
  <view className={css.logHeader}>
170
- <text className={css.logCount}>Total {logs.length} logs</text>
236
+ <view className={css.filterWrapper}>
237
+ <view
238
+ className={css.filterButton}
239
+ catchtap={() => setFilterOpen((v) => !v)}
240
+ >
241
+ <text className={css.filterButtonText}>Filter ▼</text>
242
+ </view>
243
+ {filterOpen && (
244
+ <view className={css.filterDropdown} catchtap={() => {}}>
245
+ {LOG_LEVELS.map((level) => (
246
+ <view
247
+ key={level}
248
+ className={css.filterOption}
249
+ bindtap={() => toggleLevel(level)}
250
+ >
251
+ <text className={css.filterCheckbox({ level })}>
252
+ {enabledLevels.has(level) ? "✅" : "⬜"}
253
+ </text>
254
+ <text className={css.filterLabel({ level })}>
255
+ {level.toUpperCase()}
256
+ </text>
257
+ </view>
258
+ ))}
259
+ </view>
260
+ )}
261
+ </view>
262
+ <view className={css.searchWrapper}>
263
+ <text className={css.searchPrompt}>{"›"}</text>
264
+ <input
265
+ ref={searchInputRef}
266
+ className={css.searchInput}
267
+ placeholder="Search logs..."
268
+ bindinput={(e: BaseEvent<"bindinput", InputInputEvent>) =>
269
+ setSearchQuery(e.detail.value)
270
+ }
271
+ />
272
+ </view>
171
273
  <view style={{ display: "flex", flexDirection: "row", gap: 8 }}>
172
274
  <view className={css.clearButton} bindtap={clearLogs}>
173
275
  <text className={css.clearButtonText}>Clear</text>
174
276
  </view>
175
277
  </view>
176
278
  </view>
279
+ <view
280
+ className={css.fadeTop}
281
+ style={{
282
+ background: fadeState.atTop
283
+ ? `linear-gradient(to bottom, #ffffff00, #ffffff00)`
284
+ : `linear-gradient(to bottom, ${vars.$color.bg.layerDefault}, #ffffff00)`,
285
+ }}
286
+ />
177
287
  <list
178
288
  ref={listRef}
179
289
  scroll-orientation="vertical"
180
290
  className={css.logList}
181
- initial-scroll-index={Math.max(0, logs.length - 1)}
291
+ preload-buffer-count={10}
292
+ initial-scroll-index={Math.max(0, filteredLogs.length - 1)}
293
+ scroll-event-throttle={16}
294
+ bindscroll={(e: BaseEvent<"bindscroll", { scrollTop: number; scrollHeight: number; listHeight: number }>) => {
295
+ const { scrollTop, scrollHeight, listHeight } = e.detail;
296
+ const atTop = scrollTop <= 10;
297
+ const atBottom = scrollTop + listHeight >= scrollHeight - 10;
298
+ if (atTop !== fadeRef.current.atTop || atBottom !== fadeRef.current.atBottom) {
299
+ fadeRef.current.atTop = atTop;
300
+ fadeRef.current.atBottom = atBottom;
301
+ setFadeState({ atTop, atBottom });
302
+ }
303
+ }}
182
304
  >
183
- {logs.length === 0 ? (
305
+ {filteredLogs.length === 0 ? (
184
306
  <list-item item-key="empty-state">
185
307
  <view className={css.placeholder}>
186
308
  <text className={css.placeholderText}>
@@ -189,7 +311,7 @@ export const LogPanel = ({ logs, clearLogs }: LogPanelProps) => {
189
311
  </view>
190
312
  </list-item>
191
313
  ) : (
192
- logs.map((log) => {
314
+ filteredLogs.map((log) => {
193
315
  return (
194
316
  <list-item key={log.id} item-key={log.id}>
195
317
  <view className={css.logItem({ level: log.level })}>
@@ -221,6 +343,14 @@ export const LogPanel = ({ logs, clearLogs }: LogPanelProps) => {
221
343
  })
222
344
  )}
223
345
  </list>
346
+ <view
347
+ className={css.fadeBottom}
348
+ style={{
349
+ background: fadeState.atBottom
350
+ ? `linear-gradient(to top, #ffffff00, #ffffff00)`
351
+ : `linear-gradient(to top, ${vars.$color.bg.layerDefault}, #ffffff00)`,
352
+ }}
353
+ />
224
354
  <view className={css.replInputRow}>
225
355
  <text className={css.replPrompt}>{"›"}</text>
226
356
  <input
@@ -8,6 +8,7 @@ type TabsProps = {
8
8
  label: string;
9
9
  renderContent: () => ReactNode;
10
10
  }>;
11
+ onTabChange?: () => void;
11
12
  };
12
13
 
13
14
  export default function Tabs(props: TabsProps) {
@@ -23,6 +24,7 @@ export default function Tabs(props: TabsProps) {
23
24
  className={css.tabTriggerButton}
24
25
  bindtap={() => {
25
26
  setActiveIndex(i);
27
+ props.onTabChange?.();
26
28
 
27
29
  tabContentsRef.current
28
30
  ?.invoke({
@@ -59,6 +61,7 @@ export default function Tabs(props: TabsProps) {
59
61
  className={css.tabContents}
60
62
  scroll-orientation="horizontal"
61
63
  item-snap={{ factor: 0, offset: 0 }}
64
+ bindscroll={() => props.onTabChange?.()}
62
65
  bindsnap={(e: ListSnapEvent) => {
63
66
  setActiveIndex(e.detail.position);
64
67
  }}
@@ -0,0 +1,95 @@
1
+ import { useRef, useState } from "@lynx-js/react";
2
+ import type { BaseTouchEvent, Target } from "@lynx-js/types";
3
+
4
+ const LONG_PRESS_DURATION = 400;
5
+ const MOVE_THRESHOLD = 5;
6
+
7
+ const DEFAULT_RIGHT = 16;
8
+ const DEFAULT_BOTTOM = 84;
9
+
10
+ let savedRight = DEFAULT_RIGHT;
11
+ let savedBottom = DEFAULT_BOTTOM;
12
+
13
+ export function useLongPressDrag(onTap: () => void) {
14
+ const [right, setRight] = useState(savedRight);
15
+ const [bottom, setBottom] = useState(savedBottom);
16
+ const [phase, setPhase] = useState<"idle" | "dragging" | "releasing">("idle");
17
+ const [tempRight, setTempRight] = useState(savedRight);
18
+ const [tempBottom, setTempBottom] = useState(savedBottom);
19
+
20
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21
+ const draggingRef = useRef(false);
22
+ const startRef = useRef({ x: 0, y: 0, r: 0, b: 0 });
23
+
24
+ const clearTimer = () => {
25
+ if (timerRef.current) {
26
+ clearTimeout(timerRef.current);
27
+ timerRef.current = null;
28
+ }
29
+ };
30
+
31
+ const handleTouchStart = (e: BaseTouchEvent<Target>) => {
32
+ startRef.current = {
33
+ x: e.detail.x,
34
+ y: e.detail.y,
35
+ r: right,
36
+ b: bottom,
37
+ };
38
+ draggingRef.current = false;
39
+
40
+ timerRef.current = setTimeout(() => {
41
+ draggingRef.current = true;
42
+ setPhase("dragging");
43
+ setTempRight(right);
44
+ setTempBottom(bottom);
45
+ }, LONG_PRESS_DURATION);
46
+ };
47
+
48
+ const handleTouchMove = (e: BaseTouchEvent<Target>) => {
49
+ const dx = e.detail.x - startRef.current.x;
50
+ const dy = e.detail.y - startRef.current.y;
51
+
52
+ if (
53
+ !draggingRef.current &&
54
+ (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD)
55
+ ) {
56
+ clearTimer();
57
+ }
58
+
59
+ if (!draggingRef.current) return;
60
+
61
+ // right/bottom 기준이므로 방향 반전
62
+ setTempRight(startRef.current.r - dx);
63
+ setTempBottom(startRef.current.b - dy);
64
+ };
65
+
66
+ const handleTouchEnd = () => {
67
+ clearTimer();
68
+
69
+ if (draggingRef.current) {
70
+ setRight(tempRight);
71
+ setBottom(tempBottom);
72
+ savedRight = tempRight;
73
+ savedBottom = tempBottom;
74
+ setPhase("releasing");
75
+ draggingRef.current = false;
76
+ setTimeout(() => setPhase("idle"), 300);
77
+ } else {
78
+ onTap();
79
+ }
80
+ };
81
+
82
+ const isDragging = phase === "dragging";
83
+
84
+ return {
85
+ phase,
86
+ right: isDragging ? tempRight : right,
87
+ bottom: isDragging ? tempBottom : bottom,
88
+ clearTimer,
89
+ handlers: {
90
+ bindtouchstart: handleTouchStart,
91
+ bindtouchmove: handleTouchMove,
92
+ bindtouchend: handleTouchEnd,
93
+ },
94
+ };
95
+ }
package/src/index.tsx CHANGED
@@ -85,7 +85,7 @@ const LynxConsole = forwardRef<LynxConsoleHandle, LynxConsoleProps>(
85
85
 
86
86
  return (
87
87
  <view className={themeClass}>
88
- <FloatingButton bindtap={handleOpenBottomSheet} isVisible={!isOpen}>
88
+ <FloatingButton bindtap={handleOpenBottomSheet}>
89
89
  <text className={floatingButtonCss.title}>LynxConsole</text>
90
90
  <text className={floatingButtonCss.subtitle}>
91
91
  {`${latestFcp?.name ?? "FCP"}: ${latestFcp?.duration ? latestFcp.duration.toFixed(2) : "--"}ms`}