@mkbabb/glass-ui 0.2.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.
- package/README.md +172 -0
- package/dist/glass-ui.css +1 -0
- package/dist/glass-ui.js +10019 -0
- package/dist/index.d.ts +6619 -0
- package/package.json +65 -0
- package/src/components/custom/aurora/Aurora.vue +34 -0
- package/src/components/custom/aurora/composables/color.ts +122 -0
- package/src/components/custom/aurora/composables/useAurora.ts +355 -0
- package/src/components/custom/aurora/index.ts +8 -0
- package/src/components/custom/confirm-dialog/ConfirmDialog.vue +88 -0
- package/src/components/custom/confirm-dialog/index.ts +1 -0
- package/src/components/custom/controls/DarkModeToggle.vue +96 -0
- package/src/components/custom/controls/index.ts +1 -0
- package/src/components/custom/dock/DockLayerGroup.vue +21 -0
- package/src/components/custom/dock/DockPopover.vue +263 -0
- package/src/components/custom/dock/GlassDock.vue +276 -0
- package/src/components/custom/dock/composables/index.ts +16 -0
- package/src/components/custom/dock/composables/isTeleportedTarget.ts +19 -0
- package/src/components/custom/dock/composables/useDockActionBar.ts +33 -0
- package/src/components/custom/dock/composables/useDockState.ts +301 -0
- package/src/components/custom/dock/composables/useDockTransition.ts +146 -0
- package/src/components/custom/dock/composables/useLayerTransition.ts +135 -0
- package/src/components/custom/dock/composables/usePopupMutex.ts +83 -0
- package/src/components/custom/dock/index.ts +9 -0
- package/src/components/custom/expandable-container/ExpandableContainer.vue +64 -0
- package/src/components/custom/expandable-container/index.ts +1 -0
- package/src/components/custom/glass-panel/GlassPanel.vue +98 -0
- package/src/components/custom/glass-panel/index.ts +2 -0
- package/src/components/custom/icon-tooltip/IconTooltip.vue +20 -0
- package/src/components/custom/icon-tooltip/index.ts +1 -0
- package/src/components/custom/index.ts +15 -0
- package/src/components/custom/infinite-scroll/InfiniteScroll.vue +55 -0
- package/src/components/custom/infinite-scroll/composables/index.ts +2 -0
- package/src/components/custom/infinite-scroll/composables/types.ts +23 -0
- package/src/components/custom/infinite-scroll/composables/useInfiniteScroll.ts +73 -0
- package/src/components/custom/infinite-scroll/index.ts +1 -0
- package/src/components/custom/labeled-field/LabeledInput.vue +29 -0
- package/src/components/custom/labeled-field/LabeledSelect.vue +59 -0
- package/src/components/custom/labeled-field/LabeledSlider.vue +32 -0
- package/src/components/custom/labeled-field/LabeledSwitch.vue +27 -0
- package/src/components/custom/labeled-field/index.ts +4 -0
- package/src/components/custom/metaballs/MetaballCanvas.vue +23 -0
- package/src/components/custom/metaballs/index.ts +4 -0
- package/src/components/custom/metaballs/shaders.ts +63 -0
- package/src/components/custom/metaballs/types.ts +29 -0
- package/src/components/custom/metaballs/useMetaballs.ts +252 -0
- package/src/components/custom/search/FuzzySearch.vue +589 -0
- package/src/components/custom/search/SearchBar.vue +44 -0
- package/src/components/custom/search/composables/fuzzySearchIndex.ts +224 -0
- package/src/components/custom/search/composables/index.ts +5 -0
- package/src/components/custom/search/composables/types.ts +34 -0
- package/src/components/custom/search/composables/useFuzzySearch.ts +115 -0
- package/src/components/custom/search/index.ts +7 -0
- package/src/components/custom/sidebar/ProgressiveSidebar.vue +256 -0
- package/src/components/custom/sidebar/composables/index.ts +6 -0
- package/src/components/custom/sidebar/composables/useScrollTracker.ts +242 -0
- package/src/components/custom/sidebar/composables/useSidebarFollow.ts +247 -0
- package/src/components/custom/sidebar/composables/useSidebarState.ts +72 -0
- package/src/components/custom/sidebar/composables/useTreeIndex.ts +152 -0
- package/src/components/custom/sidebar/index.ts +15 -0
- package/src/components/custom/sidebar/types.ts +50 -0
- package/src/components/custom/tabs/BouncyTabs.vue +39 -0
- package/src/components/custom/tabs/BouncyToggle.vue +352 -0
- package/src/components/custom/tabs/UnderlineTabs.vue +115 -0
- package/src/components/custom/tabs/index.ts +5 -0
- package/src/components/custom/timeline/GlassTimeline.vue +174 -0
- package/src/components/custom/timeline/index.ts +1 -0
- package/src/components/custom/typewriter/TypewriterText.vue +239 -0
- package/src/components/custom/typewriter/composables/index.ts +1 -0
- package/src/components/custom/typewriter/composables/useTypewriter.ts +413 -0
- package/src/components/custom/typewriter/index.ts +7 -0
- package/src/components/custom/typewriter/types.ts +159 -0
- package/src/components/custom/typewriter/utils/keyboard.ts +213 -0
- package/src/components/custom/typewriter/utils/pausePatterns.ts +55 -0
- package/src/components/custom/typewriter/utils/timing.ts +104 -0
- package/src/components/custom/typewriter/utils/typoStateMachine.ts +197 -0
- package/src/components/index.ts +2 -0
- package/src/components/ui/accordion/Accordion.vue +19 -0
- package/src/components/ui/accordion/AccordionContent.vue +24 -0
- package/src/components/ui/accordion/AccordionItem.vue +24 -0
- package/src/components/ui/accordion/AccordionTrigger.vue +39 -0
- package/src/components/ui/accordion/index.ts +4 -0
- package/src/components/ui/alert/Alert.vue +20 -0
- package/src/components/ui/alert/AlertDescription.vue +17 -0
- package/src/components/ui/alert/AlertTitle.vue +17 -0
- package/src/components/ui/alert/index.ts +23 -0
- package/src/components/ui/avatar/Avatar.vue +21 -0
- package/src/components/ui/avatar/AvatarFallback.vue +11 -0
- package/src/components/ui/avatar/AvatarImage.vue +9 -0
- package/src/components/ui/avatar/index.ts +24 -0
- package/src/components/ui/badge/Badge.vue +16 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +26 -0
- package/src/components/ui/button/index.ts +43 -0
- package/src/components/ui/card/Card.vue +28 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +21 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/carousel/Carousel.vue +53 -0
- package/src/components/ui/carousel/CarouselContent.vue +35 -0
- package/src/components/ui/carousel/CarouselItem.vue +24 -0
- package/src/components/ui/carousel/CarouselNext.vue +40 -0
- package/src/components/ui/carousel/CarouselPrevious.vue +40 -0
- package/src/components/ui/carousel/index.ts +10 -0
- package/src/components/ui/carousel/interface.ts +26 -0
- package/src/components/ui/carousel/useCarousel.ts +56 -0
- package/src/components/ui/checkbox/Checkbox.vue +33 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/collapsible/Collapsible.vue +15 -0
- package/src/components/ui/collapsible/CollapsibleContent.vue +11 -0
- package/src/components/ui/collapsible/CollapsibleTrigger.vue +11 -0
- package/src/components/ui/collapsible/index.ts +3 -0
- package/src/components/ui/combobox/Combobox.vue +17 -0
- package/src/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/src/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/src/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/src/components/ui/combobox/ComboboxInput.vue +41 -0
- package/src/components/ui/combobox/ComboboxItem.vue +24 -0
- package/src/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/src/components/ui/combobox/ComboboxList.vue +29 -0
- package/src/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/src/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/src/components/ui/combobox/index.ts +12 -0
- package/src/components/ui/command/Command.vue +30 -0
- package/src/components/ui/command/CommandDialog.vue +21 -0
- package/src/components/ui/command/CommandEmpty.vue +20 -0
- package/src/components/ui/command/CommandGroup.vue +29 -0
- package/src/components/ui/command/CommandInput.vue +33 -0
- package/src/components/ui/command/CommandItem.vue +26 -0
- package/src/components/ui/command/CommandList.vue +27 -0
- package/src/components/ui/command/CommandSeparator.vue +23 -0
- package/src/components/ui/command/CommandShortcut.vue +14 -0
- package/src/components/ui/command/index.ts +9 -0
- package/src/components/ui/context-menu/ContextMenu.vue +15 -0
- package/src/components/ui/context-menu/ContextMenuCheckboxItem.vue +40 -0
- package/src/components/ui/context-menu/ContextMenuContent.vue +36 -0
- package/src/components/ui/context-menu/ContextMenuGroup.vue +11 -0
- package/src/components/ui/context-menu/ContextMenuItem.vue +34 -0
- package/src/components/ui/context-menu/ContextMenuLabel.vue +25 -0
- package/src/components/ui/context-menu/ContextMenuPortal.vue +11 -0
- package/src/components/ui/context-menu/ContextMenuRadioGroup.vue +19 -0
- package/src/components/ui/context-menu/ContextMenuRadioItem.vue +40 -0
- package/src/components/ui/context-menu/ContextMenuSeparator.vue +20 -0
- package/src/components/ui/context-menu/ContextMenuShortcut.vue +14 -0
- package/src/components/ui/context-menu/ContextMenuSub.vue +19 -0
- package/src/components/ui/context-menu/ContextMenuSubContent.vue +35 -0
- package/src/components/ui/context-menu/ContextMenuSubTrigger.vue +34 -0
- package/src/components/ui/context-menu/ContextMenuTrigger.vue +13 -0
- package/src/components/ui/context-menu/index.ts +14 -0
- package/src/components/ui/data-table/DataTable.vue +167 -0
- package/src/components/ui/data-table/DataTablePagination.vue +112 -0
- package/src/components/ui/data-table/index.ts +3 -0
- package/src/components/ui/data-table/types.ts +48 -0
- package/src/components/ui/dialog/Dialog.vue +14 -0
- package/src/components/ui/dialog/DialogClose.vue +11 -0
- package/src/components/ui/dialog/DialogContent.vue +61 -0
- package/src/components/ui/dialog/DialogDescription.vue +24 -0
- package/src/components/ui/dialog/DialogFooter.vue +19 -0
- package/src/components/ui/dialog/DialogHeader.vue +16 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +65 -0
- package/src/components/ui/dialog/DialogTitle.vue +29 -0
- package/src/components/ui/dialog/DialogTrigger.vue +11 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/drawer/Drawer.vue +19 -0
- package/src/components/ui/drawer/DrawerContent.vue +28 -0
- package/src/components/ui/drawer/DrawerDescription.vue +20 -0
- package/src/components/ui/drawer/DrawerFooter.vue +14 -0
- package/src/components/ui/drawer/DrawerHeader.vue +14 -0
- package/src/components/ui/drawer/DrawerOverlay.vue +18 -0
- package/src/components/ui/drawer/DrawerTitle.vue +20 -0
- package/src/components/ui/drawer/index.ts +8 -0
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +40 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +44 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +11 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +28 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +24 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +19 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +40 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +36 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +33 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +13 -0
- package/src/components/ui/dropdown-menu/index.ts +16 -0
- package/src/components/ui/hover-card/HoverCard.vue +14 -0
- package/src/components/ui/hover-card/HoverCardContent.vue +41 -0
- package/src/components/ui/hover-card/HoverCardTrigger.vue +11 -0
- package/src/components/ui/hover-card/index.ts +3 -0
- package/src/components/ui/index.ts +41 -0
- package/src/components/ui/input/Input.vue +24 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/label/Label.vue +27 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/multi-select/MultiSelect.vue +141 -0
- package/src/components/ui/multi-select/index.ts +7 -0
- package/src/components/ui/notification/Notification.vue +85 -0
- package/src/components/ui/notification/index.ts +1 -0
- package/src/components/ui/number-field/NumberField.vue +23 -0
- package/src/components/ui/number-field/NumberFieldContent.vue +14 -0
- package/src/components/ui/number-field/NumberFieldDecrement.vue +25 -0
- package/src/components/ui/number-field/NumberFieldIncrement.vue +25 -0
- package/src/components/ui/number-field/NumberFieldInput.vue +8 -0
- package/src/components/ui/number-field/index.ts +5 -0
- package/src/components/ui/popover/Popover.vue +15 -0
- package/src/components/ui/popover/PopoverContent.vue +61 -0
- package/src/components/ui/popover/PopoverTrigger.vue +11 -0
- package/src/components/ui/popover/index.ts +3 -0
- package/src/components/ui/progress/Progress.vue +39 -0
- package/src/components/ui/progress/index.ts +1 -0
- package/src/components/ui/radio-group/RadioGroup.vue +25 -0
- package/src/components/ui/radio-group/RadioGroupItem.vue +39 -0
- package/src/components/ui/radio-group/index.ts +2 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +29 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +30 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/scroll-pane/ScrollPane.vue +25 -0
- package/src/components/ui/scroll-pane/ScrollPaneHeader.vue +75 -0
- package/src/components/ui/scroll-pane/index.ts +2 -0
- package/src/components/ui/select/Select.vue +15 -0
- package/src/components/ui/select/SelectContent.vue +57 -0
- package/src/components/ui/select/SelectGroup.vue +19 -0
- package/src/components/ui/select/SelectItem.vue +47 -0
- package/src/components/ui/select/SelectItemText.vue +11 -0
- package/src/components/ui/select/SelectLabel.vue +13 -0
- package/src/components/ui/select/SelectScrollDownButton.vue +24 -0
- package/src/components/ui/select/SelectScrollUpButton.vue +24 -0
- package/src/components/ui/select/SelectSeparator.vue +17 -0
- package/src/components/ui/select/SelectTrigger.vue +45 -0
- package/src/components/ui/select/SelectValue.vue +11 -0
- package/src/components/ui/select/index.ts +11 -0
- package/src/components/ui/separator/Separator.vue +35 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/sheet/Sheet.vue +14 -0
- package/src/components/ui/sheet/SheetClose.vue +11 -0
- package/src/components/ui/sheet/SheetContent.vue +56 -0
- package/src/components/ui/sheet/SheetDescription.vue +22 -0
- package/src/components/ui/sheet/SheetFooter.vue +19 -0
- package/src/components/ui/sheet/SheetHeader.vue +16 -0
- package/src/components/ui/sheet/SheetTitle.vue +22 -0
- package/src/components/ui/sheet/SheetTrigger.vue +11 -0
- package/src/components/ui/sheet/index.ts +31 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/slider/Slider.vue +66 -0
- package/src/components/ui/slider/index.ts +1 -0
- package/src/components/ui/switch/Switch.vue +37 -0
- package/src/components/ui/switch/index.ts +1 -0
- package/src/components/ui/table/Table.vue +16 -0
- package/src/components/ui/table/TableBody.vue +14 -0
- package/src/components/ui/table/TableCaption.vue +14 -0
- package/src/components/ui/table/TableCell.vue +14 -0
- package/src/components/ui/table/TableEmpty.vue +39 -0
- package/src/components/ui/table/TableFooter.vue +16 -0
- package/src/components/ui/table/TableHead.vue +21 -0
- package/src/components/ui/table/TableHeader.vue +14 -0
- package/src/components/ui/table/TableRow.vue +21 -0
- package/src/components/ui/table/index.ts +9 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +22 -0
- package/src/components/ui/tabs/TabsIndicator.vue +22 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +5 -0
- package/src/components/ui/tags-input/TagsInput.vue +22 -0
- package/src/components/ui/tags-input/TagsInputInput.vue +19 -0
- package/src/components/ui/tags-input/TagsInputItem.vue +22 -0
- package/src/components/ui/tags-input/TagsInputItemDelete.vue +24 -0
- package/src/components/ui/tags-input/TagsInputItemText.vue +19 -0
- package/src/components/ui/tags-input/index.ts +5 -0
- package/src/components/ui/textarea/Textarea.vue +24 -0
- package/src/components/ui/textarea/index.ts +1 -0
- package/src/components/ui/toast/Toast.vue +57 -0
- package/src/components/ui/toast/ToastAction.vue +30 -0
- package/src/components/ui/toast/ToastClose.vue +31 -0
- package/src/components/ui/toast/ToastDescription.vue +25 -0
- package/src/components/ui/toast/ToastTitle.vue +25 -0
- package/src/components/ui/toast/Toaster.vue +31 -0
- package/src/components/ui/toast/index.ts +8 -0
- package/src/components/ui/toast/use-toast.ts +136 -0
- package/src/components/ui/toggle/Toggle.vue +35 -0
- package/src/components/ui/toggle/index.ts +27 -0
- package/src/components/ui/toggle-group/ToggleGroup.vue +34 -0
- package/src/components/ui/toggle-group/ToggleGroupItem.vue +35 -0
- package/src/components/ui/toggle-group/index.ts +2 -0
- package/src/components/ui/tooltip/Tooltip.vue +14 -0
- package/src/components/ui/tooltip/TooltipContent.vue +31 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +11 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +11 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/glass/index.ts +8 -0
- package/src/composables/glass/useGlassRenderer.ts +252 -0
- package/src/composables/glass/webgl/frostShader.ts +221 -0
- package/src/composables/glass/webgpu/glassShader.wgsl +173 -0
- package/src/composables/index.ts +32 -0
- package/src/composables/infinite-scroll/index.ts +2 -0
- package/src/composables/infinite-scroll/types.ts +25 -0
- package/src/composables/infinite-scroll/useInfiniteScroll.ts +101 -0
- package/src/composables/interaction/index.ts +5 -0
- package/src/composables/interaction/useHeightTransition.ts +82 -0
- package/src/composables/interaction/useHoverPopover.ts +64 -0
- package/src/composables/interaction/useHoverToggle.ts +103 -0
- package/src/composables/interaction/useLeaveTimer.ts +17 -0
- package/src/composables/interaction/useTouchGate.ts +207 -0
- package/src/composables/pagination/index.ts +2 -0
- package/src/composables/pagination/useOffsetPagination.ts +70 -0
- package/src/composables/prng.ts +32 -0
- package/src/composables/useCharSplit.ts +31 -0
- package/src/composables/useClipboard.ts +46 -0
- package/src/composables/useGlobalDark.ts +61 -0
- package/src/composables/useKeyboardShortcuts.ts +205 -0
- package/src/composables/useWatercolorBlob.ts +136 -0
- package/src/composables/virtual/index.ts +22 -0
- package/src/composables/virtual/useVirtualSectionWindow.ts +338 -0
- package/src/composables/virtual/useWindowedStore.ts +86 -0
- package/src/composables/virtual/virtualSectionLayout.ts +212 -0
- package/src/index.ts +9 -0
- package/src/styles/animations.css +233 -0
- package/src/styles/cards.css +66 -0
- package/src/styles/dock.css +221 -0
- package/src/styles/floating-panel.css +49 -0
- package/src/styles/glass.css +266 -0
- package/src/styles/index.css +26 -0
- package/src/styles/scroll-pane.css +10 -0
- package/src/styles/theme.css +138 -0
- package/src/styles/tokens.css +333 -0
- package/src/styles/transitions.css +226 -0
- package/src/styles/typography.css +277 -0
- package/src/styles/utilities.css +697 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { ref, onUnmounted } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared global touchstart listener registry.
|
|
5
|
+
* Instead of each useTouchGate() instance adding its own global listener,
|
|
6
|
+
* all instances register a proxy with a single shared listener.
|
|
7
|
+
*/
|
|
8
|
+
interface GateProxy {
|
|
9
|
+
isActive: () => boolean;
|
|
10
|
+
controlEl: () => HTMLElement | null;
|
|
11
|
+
deactivate: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const gateRegistry = new Set<GateProxy>();
|
|
15
|
+
let sharedListenerInstalled = false;
|
|
16
|
+
|
|
17
|
+
function onGlobalTouchStartShared(e: TouchEvent) {
|
|
18
|
+
for (const proxy of gateRegistry) {
|
|
19
|
+
if (!proxy.isActive()) continue;
|
|
20
|
+
const el = proxy.controlEl();
|
|
21
|
+
if (el && !el.contains(e.target as Node)) {
|
|
22
|
+
proxy.deactivate();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function installSharedListener() {
|
|
28
|
+
if (sharedListenerInstalled) return;
|
|
29
|
+
document.addEventListener("touchstart", onGlobalTouchStartShared, { passive: true });
|
|
30
|
+
sharedListenerInstalled = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function uninstallSharedListener() {
|
|
34
|
+
if (gateRegistry.size > 0 || !sharedListenerInstalled) return;
|
|
35
|
+
document.removeEventListener("touchstart", onGlobalTouchStartShared);
|
|
36
|
+
sharedListenerInstalled = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-control tap-to-activate pattern for mobile touch.
|
|
41
|
+
*
|
|
42
|
+
* - Desktop (no `ontouchstart`): always active — no gating.
|
|
43
|
+
* - Mobile first touch: starts a 150ms pending window. If the user scrolls
|
|
44
|
+
* (vertical delta > 10px) during that window, activation is cancelled.
|
|
45
|
+
* If no scroll is detected, activates after 150ms.
|
|
46
|
+
* - Mobile subsequent touch while active: interaction passes through.
|
|
47
|
+
* - After `deactivateDelayMs` of no touch, deactivates.
|
|
48
|
+
* - Tapping outside the control deactivates (shared global `touchstart` listener).
|
|
49
|
+
*/
|
|
50
|
+
export function useTouchGate(deactivateDelayMs = 3000) {
|
|
51
|
+
const isActive = ref(false);
|
|
52
|
+
const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
|
|
53
|
+
|
|
54
|
+
let deactivateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
+
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
56
|
+
let controlEl: HTMLElement | null = null;
|
|
57
|
+
let initialTouchY: number | null = null;
|
|
58
|
+
let isPending = false;
|
|
59
|
+
let suppressDeactivation = false;
|
|
60
|
+
|
|
61
|
+
function clearDeactivateTimer() {
|
|
62
|
+
if (deactivateTimer !== null) {
|
|
63
|
+
clearTimeout(deactivateTimer);
|
|
64
|
+
deactivateTimer = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearPendingTimer() {
|
|
69
|
+
if (pendingTimer !== null) {
|
|
70
|
+
clearTimeout(pendingTimer);
|
|
71
|
+
pendingTimer = null;
|
|
72
|
+
}
|
|
73
|
+
isPending = false;
|
|
74
|
+
initialTouchY = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resetTimer() {
|
|
78
|
+
clearDeactivateTimer();
|
|
79
|
+
deactivateTimer = setTimeout(() => {
|
|
80
|
+
deactivate();
|
|
81
|
+
}, deactivateDelayMs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function activate(el: HTMLElement) {
|
|
85
|
+
isActive.value = true;
|
|
86
|
+
el.style.touchAction = "none";
|
|
87
|
+
resetTimer();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function suppressDeactivate(suppress: boolean) {
|
|
91
|
+
suppressDeactivation = suppress;
|
|
92
|
+
if (suppress) {
|
|
93
|
+
clearDeactivateTimer();
|
|
94
|
+
} else {
|
|
95
|
+
resetTimer();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function deactivate() {
|
|
100
|
+
if (suppressDeactivation) return;
|
|
101
|
+
clearDeactivateTimer();
|
|
102
|
+
clearPendingTimer();
|
|
103
|
+
isActive.value = false;
|
|
104
|
+
if (controlEl) {
|
|
105
|
+
controlEl.style.touchAction = "";
|
|
106
|
+
controlEl = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Call on touchstart / pointerdown of the gated control.
|
|
112
|
+
* On mobile, the first touch starts a 150ms pending window.
|
|
113
|
+
* Returns `true` if the control is already active and interaction should proceed.
|
|
114
|
+
*/
|
|
115
|
+
function handleTouchStart(el: HTMLElement, clientY: number): boolean {
|
|
116
|
+
if (!isTouchDevice) return true;
|
|
117
|
+
|
|
118
|
+
controlEl = el;
|
|
119
|
+
|
|
120
|
+
if (isActive.value) {
|
|
121
|
+
resetTimer();
|
|
122
|
+
return true; // already active — process the interaction
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Clear any previous pending timer, then start new pending window
|
|
126
|
+
clearPendingTimer();
|
|
127
|
+
isPending = true;
|
|
128
|
+
initialTouchY = clientY;
|
|
129
|
+
|
|
130
|
+
pendingTimer = setTimeout(() => {
|
|
131
|
+
// No scroll detected within the window — activate
|
|
132
|
+
if (isPending) {
|
|
133
|
+
isPending = false;
|
|
134
|
+
initialTouchY = null;
|
|
135
|
+
activate(el);
|
|
136
|
+
}
|
|
137
|
+
}, 150);
|
|
138
|
+
|
|
139
|
+
return false; // activation pending — don't process yet
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Call on touchmove. If still in the pending window and vertical delta
|
|
144
|
+
* exceeds 10px, cancel activation (user is scrolling).
|
|
145
|
+
*/
|
|
146
|
+
function handleScrollCheck(event: TouchEvent): void {
|
|
147
|
+
if (!isTouchDevice || !isPending || initialTouchY === null) return;
|
|
148
|
+
|
|
149
|
+
const currentY = event.touches[0]?.clientY ?? 0;
|
|
150
|
+
const delta = Math.abs(currentY - initialTouchY);
|
|
151
|
+
|
|
152
|
+
if (delta > 10) {
|
|
153
|
+
// User is scrolling — cancel activation
|
|
154
|
+
clearPendingTimer();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handleTouchEnd(): void {
|
|
159
|
+
if (!isTouchDevice) return;
|
|
160
|
+
if (isPending && controlEl) {
|
|
161
|
+
// Finger lifted during pending window without scrolling —
|
|
162
|
+
// this is a genuine tap. Activate immediately.
|
|
163
|
+
clearPendingTimer();
|
|
164
|
+
activate(controlEl);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (isActive.value) {
|
|
168
|
+
resetTimer();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Register with shared global listener instead of adding our own
|
|
173
|
+
const proxy: GateProxy = {
|
|
174
|
+
isActive: () => isActive.value,
|
|
175
|
+
controlEl: () => controlEl,
|
|
176
|
+
deactivate,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (isTouchDevice) {
|
|
180
|
+
gateRegistry.add(proxy);
|
|
181
|
+
installSharedListener();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
onUnmounted(() => {
|
|
185
|
+
clearDeactivateTimer();
|
|
186
|
+
clearPendingTimer();
|
|
187
|
+
if (isTouchDevice) {
|
|
188
|
+
gateRegistry.delete(proxy);
|
|
189
|
+
uninstallSharedListener();
|
|
190
|
+
}
|
|
191
|
+
if (controlEl) {
|
|
192
|
+
controlEl.style.touchAction = "";
|
|
193
|
+
controlEl = null;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
isActive,
|
|
199
|
+
isTouchDevice,
|
|
200
|
+
handleTouchStart,
|
|
201
|
+
handleScrollCheck,
|
|
202
|
+
handleTouchEnd,
|
|
203
|
+
resetTimer,
|
|
204
|
+
deactivate,
|
|
205
|
+
suppressDeactivate,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ref, computed, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
export interface OffsetPaginationOptions<T> {
|
|
4
|
+
fetchFn: (limit: number, offset: number) => Promise<{ data: T[]; total: number }>;
|
|
5
|
+
pageSize?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Composable for offset-based pagination with page navigation.
|
|
10
|
+
* Suitable for admin views that need jump-to-page and stable positions.
|
|
11
|
+
*/
|
|
12
|
+
export function useOffsetPagination<T>(options: OffsetPaginationOptions<T>) {
|
|
13
|
+
const items = ref<T[]>([]) as Ref<T[]>;
|
|
14
|
+
const total = ref(0);
|
|
15
|
+
const page = ref(1);
|
|
16
|
+
const pageSize = ref(options.pageSize ?? 20);
|
|
17
|
+
const loading = ref(false);
|
|
18
|
+
const error = ref<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
|
21
|
+
const offset = computed(() => (page.value - 1) * pageSize.value);
|
|
22
|
+
const hasNext = computed(() => page.value < pageCount.value);
|
|
23
|
+
const hasPrev = computed(() => page.value > 1);
|
|
24
|
+
|
|
25
|
+
async function loadPage(p?: number) {
|
|
26
|
+
if (p != null) page.value = Math.max(1, Math.min(p, pageCount.value || 1));
|
|
27
|
+
loading.value = true;
|
|
28
|
+
error.value = null;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const res = await options.fetchFn(pageSize.value, offset.value);
|
|
32
|
+
items.value = res.data;
|
|
33
|
+
total.value = res.total;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
error.value = e instanceof Error ? e.message : "Failed to load";
|
|
36
|
+
} finally {
|
|
37
|
+
loading.value = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function nextPage() {
|
|
42
|
+
if (hasNext.value) loadPage(page.value + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function prevPage() {
|
|
46
|
+
if (hasPrev.value) loadPage(page.value - 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function reset() {
|
|
50
|
+
page.value = 1;
|
|
51
|
+
items.value = [];
|
|
52
|
+
total.value = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
items,
|
|
57
|
+
total,
|
|
58
|
+
page,
|
|
59
|
+
pageSize,
|
|
60
|
+
pageCount,
|
|
61
|
+
loading,
|
|
62
|
+
error,
|
|
63
|
+
hasNext,
|
|
64
|
+
hasPrev,
|
|
65
|
+
loadPage,
|
|
66
|
+
nextPage,
|
|
67
|
+
prevPage,
|
|
68
|
+
reset,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Mulberry32 — fast 32-bit seeded PRNG */
|
|
2
|
+
export function mulberry32(seed: number) {
|
|
3
|
+
return () => {
|
|
4
|
+
seed |= 0;
|
|
5
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
6
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
7
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
8
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Simple string->u32 hash (djb2) */
|
|
13
|
+
export function hashString(str: string): number {
|
|
14
|
+
let hash = 5381;
|
|
15
|
+
for (let i = 0; i < str.length; i++) {
|
|
16
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
17
|
+
}
|
|
18
|
+
return hash >>> 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Generate 8 random border-radius values in [lo, hi] using the given PRNG */
|
|
22
|
+
export function randomRadii(rng: () => number, lo: number, hi: number): number[] {
|
|
23
|
+
const out: number[] = [];
|
|
24
|
+
for (let i = 0; i < 8; i++) {
|
|
25
|
+
out.push(lo + rng() * (hi - lo));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function radiiToCSS(r: number[]): string {
|
|
31
|
+
return `${r[0]}% ${r[1]}% ${r[2]}% ${r[3]}% / ${r[4]}% ${r[5]}% ${r[6]}% ${r[7]}%`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { computed, type MaybeRef, toValue } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Splits text into per-character `<span>` elements with `--char-index` CSS custom
|
|
5
|
+
* properties for staggered animation. Use with the `.char-stagger` CSS class.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```vue
|
|
9
|
+
* <template>
|
|
10
|
+
* <h1 class="char-stagger" v-html="chars" />
|
|
11
|
+
* </template>
|
|
12
|
+
* <script setup>
|
|
13
|
+
* const chars = useCharSplit('Hello World');
|
|
14
|
+
* </script>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function useCharSplit(text: MaybeRef<string>) {
|
|
18
|
+
return computed(() => {
|
|
19
|
+
const str = toValue(text);
|
|
20
|
+
return Array.from(str)
|
|
21
|
+
.map((char, i) => {
|
|
22
|
+
if (char === " ") return " ";
|
|
23
|
+
const escaped = char
|
|
24
|
+
.replace(/&/g, "&")
|
|
25
|
+
.replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">");
|
|
27
|
+
return `<span class="char" style="--char-index:${i}">${escaped}</span>`;
|
|
28
|
+
})
|
|
29
|
+
.join("");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback clipboard copy using a temporary textarea.
|
|
3
|
+
* Works on iOS Safari where navigator.clipboard may not be available.
|
|
4
|
+
*/
|
|
5
|
+
function copyViaTextarea(text: string): boolean {
|
|
6
|
+
const textarea = document.createElement("textarea");
|
|
7
|
+
textarea.value = text;
|
|
8
|
+
textarea.style.position = "fixed";
|
|
9
|
+
textarea.style.left = "-9999px";
|
|
10
|
+
textarea.style.top = "-9999px";
|
|
11
|
+
textarea.style.opacity = "0";
|
|
12
|
+
document.body.appendChild(textarea);
|
|
13
|
+
textarea.focus();
|
|
14
|
+
textarea.setSelectionRange(0, text.length);
|
|
15
|
+
let ok = false;
|
|
16
|
+
try {
|
|
17
|
+
ok = document.execCommand("copy");
|
|
18
|
+
} catch {
|
|
19
|
+
ok = false;
|
|
20
|
+
}
|
|
21
|
+
document.body.removeChild(textarea);
|
|
22
|
+
return ok;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Copy text to clipboard.
|
|
27
|
+
* Uses navigator.clipboard.writeText with textarea fallback for mobile.
|
|
28
|
+
* Returns true on success, false on failure.
|
|
29
|
+
*/
|
|
30
|
+
export async function copyToClipboard(text: string, _label?: string): Promise<boolean> {
|
|
31
|
+
if (!text) return false;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
if (navigator.clipboard?.writeText) {
|
|
35
|
+
await navigator.clipboard.writeText(text);
|
|
36
|
+
} else if (!copyViaTextarea(text)) {
|
|
37
|
+
throw new Error("execCommand failed");
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
if (copyViaTextarea(text)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createGlobalState, useDark, useToggle } from "@vueuse/core";
|
|
2
|
+
import { ref, watch } from "vue";
|
|
3
|
+
|
|
4
|
+
export interface UseGlobalDarkOptions {
|
|
5
|
+
/**
|
|
6
|
+
* When true, temporarily adds `no-transition` class to `<html>` during
|
|
7
|
+
* dark mode toggle to prevent CSS transition jank on pages with many
|
|
8
|
+
* transitioned elements.
|
|
9
|
+
*
|
|
10
|
+
* Requires the `.no-transition` utility rule in glass-ui's utilities.css.
|
|
11
|
+
* @default false
|
|
12
|
+
*/
|
|
13
|
+
disableTransitions?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Single shared dark mode instance — avoids multiple useDark() watchers racing on classList. */
|
|
17
|
+
export const useGlobalDark = createGlobalState(() => {
|
|
18
|
+
const isDark = useDark({ disableTransition: false });
|
|
19
|
+
const _toggle = useToggle(isDark);
|
|
20
|
+
const disableTransitions = ref(false);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Toggle dark mode. When `disableTransitions` is enabled (via
|
|
24
|
+
* `setDisableTransitions`), CSS transitions are suppressed for the
|
|
25
|
+
* duration of the toggle to avoid visual jank.
|
|
26
|
+
*/
|
|
27
|
+
function toggleDark() {
|
|
28
|
+
if (disableTransitions.value) {
|
|
29
|
+
document.documentElement.classList.add("no-transition");
|
|
30
|
+
// Force reflow so the class takes effect before the toggle
|
|
31
|
+
void document.documentElement.offsetHeight;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_toggle();
|
|
35
|
+
|
|
36
|
+
if (disableTransitions.value) {
|
|
37
|
+
requestAnimationFrame(() => {
|
|
38
|
+
document.documentElement.classList.remove("no-transition");
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Configure whether transitions are suppressed during dark mode toggle. */
|
|
44
|
+
function setDisableTransitions(value: boolean) {
|
|
45
|
+
disableTransitions.value = value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Safari: force style recalculation after .dark class toggle.
|
|
49
|
+
// WebKit doesn't always invalidate CSS custom properties when an ancestor
|
|
50
|
+
// class changes. Mirroring color-scheme as an inline style on <html> forces
|
|
51
|
+
// a full cascade recalculation.
|
|
52
|
+
watch(
|
|
53
|
+
isDark,
|
|
54
|
+
(dark) => {
|
|
55
|
+
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
|
56
|
+
},
|
|
57
|
+
{ immediate: true },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return { isDark, toggleDark, disableTransitions, setDisableTransitions };
|
|
61
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createGlobalState, useEventListener } from "@vueuse/core";
|
|
2
|
+
import { computed, onScopeDispose, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
export interface ShortcutOptions {
|
|
5
|
+
/** Fire even when focus is in input/textarea/contenteditable. Default: false */
|
|
6
|
+
allowInInput?: boolean;
|
|
7
|
+
/** Call preventDefault on the event. Default: false */
|
|
8
|
+
preventDefault?: boolean;
|
|
9
|
+
/** Human-readable label for the shortcuts modal */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Group name for display (e.g. "Playback", "Navigation") */
|
|
12
|
+
group?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RegisteredShortcut {
|
|
16
|
+
combo: ParsedCombo;
|
|
17
|
+
raw: string;
|
|
18
|
+
handler: (e: KeyboardEvent) => void;
|
|
19
|
+
options: ShortcutOptions;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ParsedCombo {
|
|
23
|
+
key: string;
|
|
24
|
+
ctrl: boolean;
|
|
25
|
+
meta: boolean;
|
|
26
|
+
shift: boolean;
|
|
27
|
+
alt: boolean;
|
|
28
|
+
mod: boolean; // Mod = Meta on mac, Ctrl elsewhere
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const isMac =
|
|
32
|
+
typeof navigator !== "undefined" &&
|
|
33
|
+
/Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
34
|
+
|
|
35
|
+
function parseCombo(combo: string): ParsedCombo {
|
|
36
|
+
const parts = combo.split("+").map((p) => p.trim());
|
|
37
|
+
const parsed: ParsedCombo = {
|
|
38
|
+
key: "",
|
|
39
|
+
ctrl: false,
|
|
40
|
+
meta: false,
|
|
41
|
+
shift: false,
|
|
42
|
+
alt: false,
|
|
43
|
+
mod: false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
const lower = part.toLowerCase();
|
|
48
|
+
if (lower === "mod") parsed.mod = true;
|
|
49
|
+
else if (lower === "ctrl" || lower === "control") parsed.ctrl = true;
|
|
50
|
+
else if (lower === "meta" || lower === "cmd" || lower === "command")
|
|
51
|
+
parsed.meta = true;
|
|
52
|
+
else if (lower === "shift") parsed.shift = true;
|
|
53
|
+
else if (lower === "alt" || lower === "option") parsed.alt = true;
|
|
54
|
+
else parsed.key = part; // Preserve original case for e.key matching
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Normalize key aliases so registrations like "Space" or "Delete" work cross-platform. */
|
|
61
|
+
const KEY_ALIASES: Record<string, string[]> = {
|
|
62
|
+
space: [" "],
|
|
63
|
+
delete: ["backspace", "delete"],
|
|
64
|
+
enter: ["enter", "return"],
|
|
65
|
+
escape: ["escape", "esc"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function matchesCombo(e: KeyboardEvent, combo: ParsedCombo): boolean {
|
|
69
|
+
// Check modifiers
|
|
70
|
+
const wantCtrl = combo.ctrl || (combo.mod && !isMac);
|
|
71
|
+
const wantMeta = combo.meta || (combo.mod && isMac);
|
|
72
|
+
|
|
73
|
+
if (e.ctrlKey !== wantCtrl) return false;
|
|
74
|
+
if (e.metaKey !== wantMeta) return false;
|
|
75
|
+
if (e.altKey !== combo.alt) return false;
|
|
76
|
+
|
|
77
|
+
// For printable chars that inherently require shift (e.g. "?", "!", "+"),
|
|
78
|
+
// don't enforce shift match unless shift was explicitly in the combo.
|
|
79
|
+
const isPrintableShifted = e.key.length === 1 && e.shiftKey && !combo.shift;
|
|
80
|
+
if (!isPrintableShifted && e.shiftKey !== combo.shift) return false;
|
|
81
|
+
|
|
82
|
+
const comboKeyLower = combo.key.toLowerCase();
|
|
83
|
+
const eventKeyLower = e.key.toLowerCase();
|
|
84
|
+
|
|
85
|
+
// Direct match
|
|
86
|
+
if (eventKeyLower === comboKeyLower) return true;
|
|
87
|
+
|
|
88
|
+
// Alias match (e.g. combo "Space" matches e.key " ")
|
|
89
|
+
const aliases = KEY_ALIASES[comboKeyLower];
|
|
90
|
+
if (aliases && aliases.some((a) => a.toLowerCase() === eventKeyLower)) return true;
|
|
91
|
+
|
|
92
|
+
// Also match against e.code (e.g. "Space", "Delete", "Backspace")
|
|
93
|
+
if (e.code.toLowerCase() === comboKeyLower) return true;
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isEditableTarget(el: Element | null): boolean {
|
|
99
|
+
if (!el) return false;
|
|
100
|
+
const tag = (el as HTMLElement).tagName;
|
|
101
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
102
|
+
if ((el as HTMLElement).isContentEditable) return true;
|
|
103
|
+
if (el.closest(".monaco-editor")) return true;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Format a single combo part to its display symbol. */
|
|
108
|
+
function formatPart(p: string): string {
|
|
109
|
+
const lower = p.trim().toLowerCase();
|
|
110
|
+
if (lower === "mod") return isMac ? "\u2318" : "Ctrl";
|
|
111
|
+
if (lower === "shift") return isMac ? "\u21E7" : "Shift";
|
|
112
|
+
if (lower === "alt" || lower === "option") return isMac ? "\u2325" : "Alt";
|
|
113
|
+
if (lower === "ctrl" || lower === "control") return isMac ? "\u2303" : "Ctrl";
|
|
114
|
+
if (lower === "meta" || lower === "cmd") return "\u2318";
|
|
115
|
+
if (lower === "space") return isMac ? "\u2423" : "Space";
|
|
116
|
+
if (lower === "arrowleft") return "\u2190";
|
|
117
|
+
if (lower === "arrowright") return "\u2192";
|
|
118
|
+
if (lower === "arrowup") return "\u2191";
|
|
119
|
+
if (lower === "arrowdown") return "\u2193";
|
|
120
|
+
if (lower === "delete") return isMac ? "\u232B" : "Del";
|
|
121
|
+
if (lower === "escape") return "Esc";
|
|
122
|
+
if (lower === "enter") return "\u23CE";
|
|
123
|
+
if (lower === "home") return "Home";
|
|
124
|
+
if (lower === "end") return "End";
|
|
125
|
+
return p.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Format a combo string into individual display parts (for rendering as separate <kbd> elements). */
|
|
129
|
+
export function formatComboParts(raw: string): string[] {
|
|
130
|
+
return raw.split("+").map(formatPart);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Format a combo string for display (resolve Mod to platform symbol). */
|
|
134
|
+
export function formatCombo(raw: string): string {
|
|
135
|
+
return formatComboParts(raw).join(isMac ? "" : "+");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const useShortcutRegistry = createGlobalState(() => {
|
|
139
|
+
const shortcuts = new Set<RegisteredShortcut>();
|
|
140
|
+
const version = ref(0);
|
|
141
|
+
|
|
142
|
+
useEventListener(window, "keydown", (e: KeyboardEvent) => {
|
|
143
|
+
for (const shortcut of shortcuts) {
|
|
144
|
+
if (!matchesCombo(e, shortcut.combo)) continue;
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
!shortcut.options.allowInInput &&
|
|
148
|
+
isEditableTarget(e.target as Element)
|
|
149
|
+
) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (shortcut.options.preventDefault) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
shortcut.handler(e);
|
|
158
|
+
return; // First match wins
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const labeled = computed(() => {
|
|
163
|
+
version.value;
|
|
164
|
+
return [...shortcuts].filter((s) => s.options.label);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return { shortcuts, version, labeled };
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Register a keyboard shortcut. Returns cleanup function.
|
|
172
|
+
* Auto-disposed when the current effect scope is disposed.
|
|
173
|
+
*/
|
|
174
|
+
export function registerShortcut(
|
|
175
|
+
combo: string,
|
|
176
|
+
handler: (e: KeyboardEvent) => void,
|
|
177
|
+
options: ShortcutOptions = {},
|
|
178
|
+
): () => void {
|
|
179
|
+
const { shortcuts, version } = useShortcutRegistry();
|
|
180
|
+
|
|
181
|
+
const entry: RegisteredShortcut = {
|
|
182
|
+
combo: parseCombo(combo),
|
|
183
|
+
raw: combo,
|
|
184
|
+
handler,
|
|
185
|
+
options,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
shortcuts.add(entry);
|
|
189
|
+
version.value++;
|
|
190
|
+
|
|
191
|
+
const cleanup = () => {
|
|
192
|
+
shortcuts.delete(entry);
|
|
193
|
+
version.value++;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
onScopeDispose(cleanup);
|
|
197
|
+
|
|
198
|
+
return cleanup;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Get all labeled shortcuts (reactive). */
|
|
202
|
+
export function useRegisteredShortcuts() {
|
|
203
|
+
const { labeled } = useShortcutRegistry();
|
|
204
|
+
return labeled;
|
|
205
|
+
}
|