Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(devtools): paste and import storage preset from clipboard #5733

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/devtools/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"type-fest": "^4.1.0",
"typescript": "~5.0.4",
"ufo": "^1.3.0",
"@radix-ui/react-toast": "^1.1.5",
"valtio": "^1.11.1"
},
"dependencies": {
Expand Down
80 changes: 80 additions & 0 deletions packages/devtools/client/src/components/Toast.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
.container {
box-shadow: var(--shadow-3);
border-radius: var(--space-2);
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
gap: var(--space-2);
font-size: var(--font-size-2);
}
.container[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.container[data-state='closed'] {
animation: hide 100ms ease-in;
}
.container[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
.container[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
.container[data-swipe='end'] {
animation: swipeOut 100ms ease-out;
}

.viewport {
--viewport-padding: 25px;
position: fixed;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
padding: var(--viewport-padding);
gap: 10px;
width: 240px;
max-width: 100vw;
margin: 0;
list-style: none;
z-index: 2147483647;
outline: none;
}

.close {
cursor: pointer;
cursor: pointer;
width: var(--space-4);
height: var(--space-4);
border: none;
background: none;
padding: 0;
color: var(--gray-11);
}

@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

@keyframes slideIn {
from {
transform: translateX(calc(100% + var(--viewport-padding)));
}
to {
transform: translateX(0);
}
}

@keyframes swipeOut {
from {
transform: translateX(var(--radix-toast-swipe-end-x));
}
to {
transform: translateX(calc(100% + var(--viewport-padding)));
}
}
57 changes: 57 additions & 0 deletions packages/devtools/client/src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as ToastPrimitive from '@radix-ui/react-toast';
import { ToastProps as ToastRootProps } from '@radix-ui/react-toast';
import { FC, Fragment, useRef, useState } from 'react';
import { HiMiniXMark } from 'react-icons/hi2';
import { Box } from '@radix-ui/themes';
import styles from './Toast.module.scss';

export interface ToastProps extends ToastRootProps {
title?: string;
content: string;
}

export const Toast: FC<ToastProps> = props => {
const { title, content, children, ...rest } = props;
return (
<ToastPrimitive.Root {...rest} className={styles.container}>
{title && <ToastPrimitive.Title>{title}</ToastPrimitive.Title>}
<ToastPrimitive.Description>{content}</ToastPrimitive.Description>
{children && (
<ToastPrimitive.Action altText="" asChild>
{children}
</ToastPrimitive.Action>
)}
<Box grow="1" />
<ToastPrimitive.Close className={styles.close} aria-label="Close">
<HiMiniXMark />
</ToastPrimitive.Close>
</ToastPrimitive.Root>
);
};

export interface UseToastOptions extends ToastProps {
duration?: number;
}

export const useToast = (options: UseToastOptions) => {
const { duration = 5000, ...rest } = options;
const [open, setOpen] = useState(false);
const element = (
<Fragment>
<Toast {...rest} open={open} onOpenChange={setOpen} />
<ToastPrimitive.Viewport className={styles.viewport} />
</Fragment>
);

const timer = useRef<number | null>(null);
const handleOpen = () => {
setOpen(true);
timer.current && clearTimeout(timer.current);
timer.current = null;
if (duration) {
timer.current = window.setTimeout(() => setOpen(false), duration);
}
};

return { element, open: handleOpen };
};
33 changes: 18 additions & 15 deletions packages/devtools/client/src/entries/client/routes/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@/styles/theme.scss';
import React, { useEffect } from 'react';
import * as ToastPrimitive from '@radix-ui/react-toast';
import { NavLink, Outlet } from '@modern-js/runtime/router';
import {
Box,
Expand Down Expand Up @@ -100,22 +101,24 @@ const Layout = () => {
accentColor="blue"
panelBackground="solid"
>
<Navigator />
<Box width="100%" position="relative" pt="4">
<Box width="100%" height="100%" position="relative">
<Outlet />
<ToastPrimitive.Provider swipeDirection="up">
<Navigator />
<Box width="100%" position="relative" pt="4">
<Box width="100%" height="100%" position="relative">
<Outlet />
</Box>
<Breadcrumbs
className={styles.breadcrumbs}
height="7"
position="absolute"
top="0"
left="0"
right="0"
/>
</Box>
<Breadcrumbs
className={styles.breadcrumbs}
height="7"
position="absolute"
top="0"
left="0"
right="0"
/>
</Box>
<ThemePanel defaultOpen={false} style={{ display }} />
<Puller />
<ThemePanel defaultOpen={false} style={{ display }} />
<Puller />
</ToastPrimitive.Provider>
</Theme>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
/* eslint-disable max-lines */
import {
StoragePresetConfig,
StoragePresetContext,
} from '@modern-js/devtools-kit/runtime';
import { Badge, Box, Flex, IconButton, Text, Tooltip } from '@radix-ui/themes';
import { BadgeProps } from '@radix-ui/themes/dist/cjs/components/badge';
import { FlexProps } from '@radix-ui/themes/dist/cjs/components/flex';
import clsx from 'clsx';
import _ from 'lodash';
import { FC, useState } from 'react';
import {
HiPlus,
HiMiniFlag,
HiMiniClipboard,
HiMiniFolderOpen,
HiMiniClipboardDocumentList,
HiMiniFire,
HiMiniFlag,
HiMiniFolderOpen,
HiPlus,
} from 'react-icons/hi2';
import { StoragePresetContext } from '@modern-js/devtools-kit/runtime';
import { FC, useState } from 'react';
import { FlexProps } from '@radix-ui/themes/dist/cjs/components/flex';
import clsx from 'clsx';
import { BadgeProps } from '@radix-ui/themes/dist/cjs/components/badge';
import { useSnapshot } from 'valtio';
import { $server, $serverExported } from '../state';
import { $mountPoint, $server, $serverExported } from '../state';
import styles from './page.module.scss';
import { useToast } from '@/components/Toast';
import { useThrowable } from '@/utils';

const unwindRecord = <T extends string | void>(
Expand Down Expand Up @@ -125,6 +131,30 @@ const PresetCard: FC<PresetCardProps> = props => {
);
};

const applyPreset = async (preset: UnwindPreset | StoragePresetContext) => {
const mountPoint = await $mountPoint;
const storage: Record<StorageType, Record<string, string>> = {
cookie: {},
localStorage: {},
sessionStorage: {},
};
if ('items' in preset) {
for (const item of preset.items) {
storage[item.type][item.key] = item.value;
}
} else {
for (const type of STORAGE_TYPES) {
const records = preset[type];
records && Object.assign(storage[type], records);
}
}
await Promise.all([
mountPoint.remote.cookies(storage.cookie),
mountPoint.remote.localStorage(storage.localStorage),
mountPoint.remote.sessionStorage(storage.sessionStorage),
]);
};

const Page: FC = () => {
const { storagePresets } = useSnapshot($serverExported).context;
const server = useThrowable($server);
Expand All @@ -150,6 +180,7 @@ const Page: FC = () => {
.reverse()
.value(),
}));

const [select, setSelect] = useState<SelectUnwindPreset | null>();
const matchSelected = (preset: UnwindPreset) =>
Boolean(
Expand All @@ -158,10 +189,54 @@ const Page: FC = () => {
preset.name === select.name,
);
const selected = unwindPresets.find(preset => matchSelected(preset));

const handleCreatePreset = async () => {
setSelect(await server.remote.createTemporaryStoragePreset());
};

const applyActionToast = useToast({ content: '🔥 Fired' });
const copyActionToast = useToast({ content: '📋 Copied' });
const pasteActionToast = useToast({ content: '📋 Pasted' });

const handleApplyAction = async () => {
if (!selected) return;
await applyPreset(selected);
applyActionToast.open();
};

const handleCopyAction = async () => {
if (!selected) return;
const preset: StoragePresetConfig = {
name: selected.name,
};
for (const { type, key, value } of selected.items) {
const group = preset[type] || {};
group[key] = value;
preset[type] = group;
}
const stringified = JSON.stringify(preset);
const blob = new Blob([stringified], { type: 'application/json' });
const reader = new FileReader();
reader.readAsDataURL(blob);
const uri = await new Promise<string | null>(resolve => {
reader.onload = e => resolve(e.target?.result?.toString() ?? null);
});
if (uri) {
navigator.clipboard.writeText(uri);
copyActionToast.open();
} else {
console.error('Failed to copy preset as data URL');
}
};
const handlePasteAction = async () => {
if (!selected) return;
await server.remote.pasteStoragePreset({
filename: selected.filename,
name: selected.name,
});
pasteActionToast.open();
};

return (
<Flex width="100%" className={styles.container}>
<Flex direction="column" className={styles.sidePanel}>
Expand Down Expand Up @@ -214,12 +289,24 @@ const Page: FC = () => {
{selected.name}
</Text>
</Box>
<PresetToolbar shrink="0" grow="0" px="2" justify="end" />
<PresetToolbar
shrink="0"
grow="0"
px="2"
justify="end"
onCopyAction={handleCopyAction}
onPasteAction={handlePasteAction}
onOpenAction={() => server.remote.open(selected.filename)}
onApplyAction={handleApplyAction}
/>
</Flex>
)}
<Box grow="1" pb="2" pr="2" style={{ overflowY: 'scroll' }}>
{selected && <PresetDetails preset={selected} />}
</Box>
{applyActionToast.element}
{copyActionToast.element}
{pasteActionToast.element}
</Flex>
</Flex>
);
Expand Down Expand Up @@ -281,24 +368,32 @@ const PresetDetails: FC<PresetDetailsProps> = props => {

interface PresetToolbarProps extends FlexProps {
onCopyAction?: () => void;
onPasteAction?: () => void;
onOpenAction?: () => void;
onApplyAction?: () => void;
}

const PresetToolbar: FC<PresetToolbarProps> = props => {
const { onCopyAction, onOpenAction, onApplyAction, ...rest } = props;
const { onCopyAction, onOpenAction, onApplyAction, onPasteAction, ...rest } =
props;

return (
<Flex position="relative" gap="3" height="5" align="center" {...rest}>
<Tooltip content="Copy as Data URL">
<IconButton onClick={onCopyAction} variant="ghost" color="gray">
<HiMiniClipboard />
</IconButton>
</Tooltip>
<Tooltip content="Paste from Data URL">
<IconButton onClick={onPasteAction} variant="ghost" color="gray">
<HiMiniClipboardDocumentList />
</IconButton>
</Tooltip>
<Tooltip content="Open File">
<IconButton onClick={onOpenAction} variant="ghost" color="gray">
<HiMiniFolderOpen />
</IconButton>
</Tooltip>
<Tooltip content="Copy as Data URI">
<IconButton onClick={onCopyAction} variant="ghost" color="gray">
<HiMiniClipboard />
</IconButton>
</Tooltip>
<Tooltip content="Apply Preset">
<IconButton onClick={onApplyAction} variant="ghost" color="gray">
<HiMiniFire />
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/client/src/entries/mount/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const $client = $clientChannel.then(channel => {
async cookies(items) {
const cookiesReq = await fetch('/__devtools/api/cookies', {
method: 'POST',
body: JSON.stringify(items),
body: JSON.stringify({ setCookies: items }),
});
const { cookies } = await cookiesReq.json();
if (!cookies || typeof cookies !== 'object') {
Expand Down
Loading
Loading