diff --git a/packages/playground/package.json b/packages/playground/package.json index a0c9d24..8e89f1e 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -21,13 +21,15 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.2.1", "@tanstack/react-query": "^5.45.1", "@tidbcloud/codemirror-extension-ai-widget": "workspace:^", - "@tidbcloud/codemirror-extension-sql-autocomplete": "workspace:^", "@tidbcloud/codemirror-extension-cur-sql": "workspace:^", "@tidbcloud/codemirror-extension-cur-sql-gutter": "workspace:^", + "@tidbcloud/codemirror-extension-events": "workspace:^", "@tidbcloud/codemirror-extension-linters": "workspace:^", "@tidbcloud/codemirror-extension-save-helper": "workspace:^", + "@tidbcloud/codemirror-extension-sql-autocomplete": "workspace:^", "@tidbcloud/codemirror-extension-sql-parser": "workspace:^", "@tidbcloud/codemirror-extension-themes": "workspace:^", "@tidbcloud/tisqleditor-react": "workspace:^", diff --git a/packages/playground/src/App.tsx b/packages/playground/src/App.tsx index 42d93bb..6b17e2b 100644 --- a/packages/playground/src/App.tsx +++ b/packages/playground/src/App.tsx @@ -59,7 +59,15 @@ function App() { ) } - return + return ( + + + + ) } return diff --git a/packages/playground/src/components/ui/toast.tsx b/packages/playground/src/components/ui/toast.tsx new file mode 100644 index 0000000..61cb6f8 --- /dev/null +++ b/packages/playground/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from 'react' +import { Cross2Icon } from '@radix-ui/react-icons' +import * as ToastPrimitives from '@radix-ui/react-toast' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + 'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', + { + variants: { + variant: { + default: 'border bg-background text-foreground', + destructive: + 'destructive group border-destructive bg-destructive text-destructive-foreground' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction +} diff --git a/packages/playground/src/components/ui/toaster.tsx b/packages/playground/src/components/ui/toaster.tsx new file mode 100644 index 0000000..dd3fe9a --- /dev/null +++ b/packages/playground/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport +} from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/packages/playground/src/components/ui/use-toast.ts b/packages/playground/src/components/ui/use-toast.ts new file mode 100644 index 0000000..1552e46 --- /dev/null +++ b/packages/playground/src/components/ui/use-toast.ts @@ -0,0 +1,189 @@ +// Inspired by react-hot-toast library +import * as React from 'react' + +import type { ToastActionElement, ToastProps } from '@/components/ui/toast' + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: 'ADD_TOAST', + UPDATE_TOAST: 'UPDATE_TOAST', + DISMISS_TOAST: 'DISMISS_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST' +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType['ADD_TOAST'] + toast: ToasterToast + } + | { + type: ActionType['UPDATE_TOAST'] + toast: Partial + } + | { + type: ActionType['DISMISS_TOAST'] + toastId?: ToasterToast['id'] + } + | { + type: ActionType['REMOVE_TOAST'] + toastId?: ToasterToast['id'] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: 'REMOVE_TOAST', + toastId: toastId + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) + } + + case 'UPDATE_TOAST': + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ) + } + + case 'DISMISS_TOAST': { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false + } + : t + ) + } + } + case 'REMOVE_TOAST': + if (action.toastId === undefined) { + return { + ...state, + toasts: [] + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId) + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: 'UPDATE_TOAST', + toast: { ...props, id } + }) + const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) + + dispatch({ + type: 'ADD_TOAST', + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + } + } + }) + + return { + id: id, + dismiss, + update + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }) + } +} + +export { useToast, toast } diff --git a/packages/playground/src/examples/editor-example-with-select.tsx b/packages/playground/src/examples/editor-example-with-select.tsx index 047b9a9..ef0132e 100644 --- a/packages/playground/src/examples/editor-example-with-select.tsx +++ b/packages/playground/src/examples/editor-example-with-select.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/select' import { Button } from '@/components/ui/button' import { useTheme } from '@/components/darkmode-toggle/theme-provider' +import { cn } from '@/lib/utils' import { EditorExample } from './editor-example' @@ -39,6 +40,7 @@ function ExampleSelect({ FullWidthChar Linter Save Helper + Events All @@ -88,6 +90,8 @@ export function EditorExampleWithSelect({ const [editorTheme, setEditorTheme] = useState(defTheme) const { setTheme: setAppTheme } = useTheme() + const showOutputBox = example === 'events' || example === 'all' + function onExampleChange(v: string) { setExample(v) updateUrlParam('example', v) @@ -141,8 +145,17 @@ export function EditorExampleWithSelect({ -
- +
+
diff --git a/packages/playground/src/examples/editor-example.tsx b/packages/playground/src/examples/editor-example.tsx index 2d0b0fd..f300d05 100644 --- a/packages/playground/src/examples/editor-example.tsx +++ b/packages/playground/src/examples/editor-example.tsx @@ -1,5 +1,6 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { EditorView } from '@codemirror/view' +import { Extension } from '@codemirror/state' import { SQLEditor } from '@tidbcloud/tisqleditor-react' import { saveHelper } from '@tidbcloud/codemirror-extension-save-helper' @@ -14,59 +15,44 @@ import { aiWidget, isUnifiedMergeViewActive } from '@tidbcloud/codemirror-extension-ai-widget' +import { + onDocChange, + onSelectionChange +} from '@tidbcloud/codemirror-extension-events' + +import { Toaster } from '@/components/ui/toaster' +import { useToast } from '@/components/ui/use-toast' +import { useTheme } from '@/components/darkmode-toggle/theme-provider' import { delay } from '@/lib/delay' -import { Extension } from '@codemirror/state' +import { setLocalStorageItem } from '@/lib/env-vars' +import { cn } from '@/lib/utils' -const DOC_1 = `USE sp500insight;` -const DOC_2 = `-- USE sp500insight;` -const DOC_3 = `-- USE sp500insight;` -const DOC_4 = ` -SELECT sector, industry, COUNT(*) AS companies -FROM companies c -WHERE c.stock_symbol IN (SELECT stock_symbol FROM index_compositions WHERE index_symbol = "SP500") -GROUP BY sector, industry -ORDER BY sector, companies DESC; +const DOC_1 = `USE game;` +const DOC_2 = `-- USE game;` +const DOC_3 = `-- USE game;` +const DOC_4 = `-- press cmd+s to save content` +const DOC_5 = ` +SELECT + name, + average_playtime_forever +FROM + games +ORDER BY + average_playtime_forever DESC +LIMIT + 10; ` const ALL_EXAMPLES = [ 'ai-widget', - 'save-helper', 'sql-autocomplete', 'cur-sql-gutter', 'use-db-linter', - 'full-width-char-linter' + 'full-width-char-linter', + 'save-helper', + 'events' ] -const EXAMPLE_EXTS: { [key: string]: Extension } = { - 'ai-widget': aiWidget({ - chat: async () => { - await delay(2000) - return { - status: 'success', - message: - 'select * from test;\n-- the data is mocked, replace by your own api when using' - } - }, - cancelChat: () => {}, - getDbList: () => { - return ['test1', 'test2'] - } - }), - 'save-helper': saveHelper({ - save: (view: EditorView) => { - console.log('save content:', view.state.doc.toString()) - } - }), - 'sql-autocomplete': sqlAutoCompletion(), - 'cur-sql-gutter': curSqlGutter({ - whenHide(view) { - return isUnifiedMergeViewActive(view.state) - } - }), - 'use-db-linter': useDbLinter(), - 'full-width-char-linter': fullWidthCharLinter() -} - const THEME_EXTS: { [key: string]: Extension } = { light: bbedit, bbedit: bbedit, @@ -76,16 +62,29 @@ const THEME_EXTS: { [key: string]: Extension } = { const EXAMPLE_DOCS: { [key: string]: string } = { 'use-db-linter': DOC_2, - 'full-width-char-linter': DOC_3 + 'full-width-char-linter': DOC_3, + 'save-helper': DOC_4 } export function EditorExample({ example = '', - theme = '' + theme = '', + withSelect = false }: { example?: string theme?: string + withSelect?: boolean }) { + const { setTheme: setAppTheme } = useTheme() + + useEffect(() => { + setAppTheme(theme === 'oneDark' || theme === 'dark' ? 'dark' : 'light') + }, [theme]) + + const [output, setOutput] = useState('') + + const { toast } = useToast() + const exampleArr = useMemo(() => { let exampleArr = example.split(',') if (exampleArr.includes('all')) { @@ -94,8 +93,66 @@ export function EditorExample({ return [...new Set(exampleArr)] }, [example]) + useEffect(() => { + setOutput('') + }, [exampleArr]) + + const showOutputBox = exampleArr.includes('events') + + const exts: { [key: string]: Extension } = useMemo( + () => ({ + 'ai-widget': aiWidget({ + chat: async () => { + await delay(2000) + return { + status: 'success', + message: + 'select * from test;\n-- the data is mocked, replace by your own api when using' + } + }, + cancelChat: () => {}, + getDbList: () => { + return ['test1', 'test2'] + } + }), + 'sql-autocomplete': sqlAutoCompletion(), + 'cur-sql-gutter': curSqlGutter({ + whenHide(view) { + return isUnifiedMergeViewActive(view.state) + } + }), + 'use-db-linter': useDbLinter(), + 'full-width-char-linter': fullWidthCharLinter(), + 'save-helper': saveHelper({ + save: (view: EditorView) => { + toast({ description: 'Doc has saved to local storage.' }) + setLocalStorageItem( + 'example.save_helper.doc', + view.state.doc.toString() + ) + } + }), + events: [ + onDocChange((_view, content) => { + const s = `Doc changes, current doc:\n\n${content}` + console.log(s) + setOutput(s) + }), + onSelectionChange((view, sels) => { + if (sels.length === 0 || sels[0].from === sels[0].to) { + return + } + const s = `Selection changes, select from ${sels[0].from} to ${sels[0].to}\nSelected content:\n${view.state.sliceDoc(sels[0].from, sels[0].to)}` + console.log(s) + setOutput(s) + }) + ] + }), + [] + ) + const extraExts = useMemo(() => { - return exampleArr.map((item) => EXAMPLE_EXTS[item]).filter((ex) => !!ex) + return exampleArr.map((item) => exts[item]).filter((ex) => !!ex) }, [exampleArr]) const doc = useMemo(() => { @@ -106,16 +163,33 @@ export function EditorExample({ if (str === '') { str = DOC_1 } - return [str, DOC_4].join('\n') + return [str, DOC_5].join('\n') }, [exampleArr]) return ( - +
+ + + {showOutputBox && ( +
+
+            

{output}

+
+
+ )} + + +
) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 169f400..f26d621 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toast': + specifier: ^1.2.1 + version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-query': specifier: ^5.45.1 version: 5.45.1(react@18.3.1) @@ -418,6 +421,9 @@ importers: '@tidbcloud/codemirror-extension-cur-sql-gutter': specifier: workspace:^ version: link:../extensions/cur-sql-gutter + '@tidbcloud/codemirror-extension-events': + specifier: workspace:^ + version: link:../extensions/events '@tidbcloud/codemirror-extension-linters': specifier: workspace:^ version: link:../extensions/linters @@ -2057,6 +2063,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toast@1.2.1': + resolution: {integrity: sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.0.1': resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} peerDependencies: @@ -6939,6 +6958,26 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-toast@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.3)(react@18.3.1)': dependencies: '@babel/runtime': 7.24.7