diff --git a/CHANGELOG.md b/CHANGELOG.md
index 161bdc9fd..305ca0d4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,9 @@
### UI
+- Toast notifications via Reka UI Toast — top-center blue pill for info, red for errors
+- Global error handler (window.error + unhandledrejection) shows errors as toasts
+- Link copied toast on share and copy link actions
- HsvColorArea extracted as shared component (ColorPicker + FillPicker)
- Scrollable app menu without visible scrollbar
- Selection broadcasting to remote peers
diff --git a/components.d.ts b/components.d.ts
index 5345b737b..b6654b790 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -14,6 +14,7 @@ declare module 'vue' {
APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default']
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
AppMenu: typeof import('./src/components/AppMenu.vue')['default']
+ AppToast: typeof import('./src/components/AppToast.vue')['default']
CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default']
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
@@ -31,6 +32,7 @@ declare module 'vue' {
FontPicker: typeof import('./src/components/FontPicker.vue')['default']
HsvColorArea: typeof import('./src/components/HsvColorArea.vue')['default']
IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default']
+ IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideAlignCenter: typeof import('~icons/lucide/align-center')['default']
IconLucideAlignHorizontalJustifyCenter: typeof import('~icons/lucide/align-horizontal-justify-center')['default']
IconLucideAlignHorizontalJustifyEnd: typeof import('~icons/lucide/align-horizontal-justify-end')['default']
diff --git a/src/App.vue b/src/App.vue
index 7c2aa3f3e..db52a9dfb 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,3 +1,15 @@
+
+
+
diff --git a/src/components/AppToast.vue b/src/components/AppToast.vue
new file mode 100644
index 000000000..407e07a6f
--- /dev/null
+++ b/src/components/AppToast.vue
@@ -0,0 +1,29 @@
+
+
+
+
+ {
+ if (!open) toast.remove(t.id)
+ }
+ "
+ >
+
+
+ {{ t.message }}
+
+
+
+
+
diff --git a/src/components/CollabPanel.vue b/src/components/CollabPanel.vue
index 7984c7c48..e21a6855e 100644
--- a/src/components/CollabPanel.vue
+++ b/src/components/CollabPanel.vue
@@ -13,6 +13,7 @@ import {
} from 'reka-ui'
import type { CollabState, RemotePeer } from '@/composables/use-collab'
+import { toast } from '@/composables/use-toast'
import type { Color } from '@/types'
const props = defineProps<{
@@ -45,6 +46,7 @@ const isJoining = computed(() => !!props.pendingRoomId && !props.state.connected
function copyLink() {
if (!shareUrl.value) return
navigator.clipboard.writeText(shareUrl.value)
+ toast.show('Link copied to clipboard')
copied.value = true
setTimeout(() => {
copied.value = false
diff --git a/src/composables/use-toast.ts b/src/composables/use-toast.ts
new file mode 100644
index 000000000..e7163a2d5
--- /dev/null
+++ b/src/composables/use-toast.ts
@@ -0,0 +1,34 @@
+import { ref } from 'vue'
+
+export type ToastVariant = 'default' | 'error'
+
+export interface Toast {
+ id: number
+ message: string
+ variant: ToastVariant
+}
+
+const TOAST_DURATION = 3000
+
+const toasts = ref([])
+let nextId = 0
+
+function show(message: string, variant: ToastVariant = 'default') {
+ toasts.value.push({ id: ++nextId, message, variant })
+}
+
+function remove(id: number) {
+ toasts.value = toasts.value.filter((t) => t.id !== id)
+}
+
+function setupGlobalErrorHandler() {
+ window.addEventListener('error', (e) => {
+ show(e.message || 'An unexpected error occurred', 'error')
+ })
+ window.addEventListener('unhandledrejection', (e) => {
+ const msg = e.reason instanceof Error ? e.reason.message : String(e.reason)
+ show(msg || 'An unexpected error occurred', 'error')
+ })
+}
+
+export const toast = { show, remove, toasts, setupGlobalErrorHandler, TOAST_DURATION }
diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue
index 90a718de2..84d62b11e 100644
--- a/src/views/EditorView.vue
+++ b/src/views/EditorView.vue
@@ -7,6 +7,7 @@ import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { useKeyboard } from '@/composables/use-keyboard'
import { useMenu } from '@/composables/use-menu'
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
+import { toast } from '@/composables/use-toast'
import { createDemoShapes } from '@/demo'
import { provideEditorStore } from '@/stores/editor'
@@ -47,6 +48,8 @@ const pendingRoomId = (route.params.roomId as string) || null
function onShare() {
const roomId = collab.shareCurrentDoc()
router.push(`/share/${roomId}`)
+ navigator.clipboard.writeText(`${window.location.origin}/share/${roomId}`)
+ toast.show('Link copied to clipboard')
}
function onJoin(roomId: string) {