diff --git a/package.json b/package.json index 314edb8..dcd212d 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,10 @@ "license": "MIT", "dependencies": { "@logseq/libs": "^0.0.1-alpha.26", - "@use-gesture/react": "^10.0.0-beta.22", "eslint-plugin-react-hooks": "^4.2.0", + "keyboardjs": "^2.6.4", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-spring": "^9.2.4", "react-use": "^17.2.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09cb5da..6bf71f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,15 +9,14 @@ specifiers: '@types/react-dom': 17.0.9 '@typescript-eslint/eslint-plugin': ^4.29.3 '@typescript-eslint/parser': ^4.29.3 - '@use-gesture/react': ^10.0.0-beta.22 '@vitejs/plugin-react-refresh': 1.3.6 conventional-changelog-conventionalcommits: 4.6.0 eslint: ^7.32.0 eslint-plugin-react: ^7.24.0 eslint-plugin-react-hooks: ^4.2.0 + keyboardjs: ^2.6.4 react: ^17.0.2 react-dom: ^17.0.2 - react-spring: ^9.2.4 react-use: ^17.2.4 typescript: 4.4.2 vite: 2.5.1 @@ -26,11 +25,10 @@ specifiers: dependencies: '@logseq/libs': 0.0.1-alpha.26 - '@use-gesture/react': 10.0.0-beta.22_react@17.0.2 eslint-plugin-react-hooks: 4.2.0_eslint@7.32.0 + keyboardjs: 2.6.4 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - react-spring: 9.2.4_react-dom@17.0.2+react@17.0.2 react-use: 17.2.4_react-dom@17.0.2+react@17.0.2 devDependencies: @@ -376,117 +374,6 @@ packages: fastq: 1.12.0 dev: true - /@react-spring/animated/9.2.4_react@17.0.2: - resolution: {integrity: sha512-AfV6ZM8pCCAT29GY5C8/1bOPjZrv/7kD0vedjiE/tEYvNDwg9GlscrvsTViWR2XykJoYrDfdkYArrldWpsCJ5g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 - dependencies: - '@react-spring/shared': 9.2.4_react@17.0.2 - '@react-spring/types': 9.2.4 - react: 17.0.2 - dev: false - - /@react-spring/core/9.2.4_react@17.0.2: - resolution: {integrity: sha512-R+PwyfsjiuYCWqaTTfCpYpRmsP0h87RNm7uxC1Uxy7QAHUfHEm2sAHn+AdHPwq/MbVwDssVT8C5yf2WGcqiXGg==} - requiresBuild: true - peerDependencies: - react: ^16.8.0 || ^17.0.0 - dependencies: - '@react-spring/animated': 9.2.4_react@17.0.2 - '@react-spring/shared': 9.2.4_react@17.0.2 - '@react-spring/types': 9.2.4 - react: 17.0.2 - dev: false - - /@react-spring/konva/9.2.4_react@17.0.2: - resolution: {integrity: sha512-19anDOIkfjcydDTfGgVIuZ3lruZxKubYGs9oHCswaP8SRLj7c1kkopJHUr/S4LXGxiIdqdF0XucWm0iTEPEq4w==} - peerDependencies: - konva: '>=2.6' - react: ^16.8.0 || ^17.0.0 - react-konva: ^16.8.0 || ^17.0.0 - dependencies: - '@react-spring/animated': 9.2.4_react@17.0.2 - '@react-spring/core': 9.2.4_react@17.0.2 - '@react-spring/shared': 9.2.4_react@17.0.2 - '@react-spring/types': 9.2.4 - react: 17.0.2 - dev: false - - /@react-spring/native/9.2.4_react@17.0.2: - resolution: {integrity: sha512-xKJWKh5qOhSclpL3iuGwJRLoZzTNvlBEnIrMs8yh8xvX6z9Lmnu4uGu5DpfrnM1GzBvRoktoCoLEx/VcEYFSng==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 - react-native: '>=0.58' - dependencies: - '@react-spring/animated': 9.2.4_react@17.0.2 - '@react-spring/core': 9.2.4_react@17.0.2 - '@react-spring/shared': 9.2.4_react@17.0.2 - '@react-spring/types': 9.2.4 - react: 17.0.2 - dev: false - - /@react-spring/rafz/9.2.4: - resolution: {integrity: sha512-SOKf9eue+vAX+DGo7kWYNl9i9J3gPUlQjifIcV9Bzw9h3i30wPOOP0TjS7iMG/kLp2cdHQYDNFte6nt23VAZkQ==} - dev: false - - /@react-spring/shared/9.2.4_react@17.0.2: - resolution: {integrity: sha512-ZEr4l2BxmyFRUvRA2VCkPfCJii4E7cGkwbjmTBx1EmcGrOnde/V2eF5dxqCTY3k35QuCegkrWe0coRJVkh8q2Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 - dependencies: - '@react-spring/rafz': 9.2.4 - '@react-spring/types': 9.2.4 - react: 17.0.2 - dev: false - - /@react-spring/three/9.2.4_react@17.0.2: - resolution: {integrity: sha512-ljFig7XW099VWwRPKPUf+4yYLivp/sSWXN3oO5SJOF/9BSoV1quS/9chZ5Myl5J14od3CsHf89Tv4FdlX5kHlA==} - peerDependencies: - '@react-three/fiber': '>=6.0' - react: '>=16.11' - three: '>=0.126' - dependencies: - '@react-spring/animated': 9.2.4_react@17.0.2 - '@react-spring/core': 9.2.4_react@17.0.2 - '@react-spring/shared': 9.2.4_react@17.0.2 - '@react-spring/types': 9.2.4 - react: 17.0.2 - dev: false - - /@react-spring/types/9.2.4: - resolution: {integrity: sha512-zHUXrWO8nweUN/ISjrjqU7GgXXvoEbFca1CgiE0TY0H/dqJb3l+Rhx8ecPVNYimzFg3ZZ1/T0egpLop8SOv4aA==} - dev: false - - /@react-spring/web/9.2.4_react-dom@17.0.2+react@17.0.2: - resolution: {integrity: sha512-vtPvOalLFvuju/MDBtoSnCyt0xXSL6Amyv82fljOuWPl1yGd4M1WteijnYL9Zlriljl0a3oXcPunAVYTD9dbDQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 - react-dom: ^16.8.0 || ^17.0.0 - dependencies: - '@react-spring/animated': 9.2.4_react@17.0.2 - '@react-spring/core': 9.2.4_react@17.0.2 - '@react-spring/shared': 9.2.4_react@17.0.2 - '@react-spring/types': 9.2.4 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - - /@react-spring/zdog/9.2.4_react-dom@17.0.2+react@17.0.2: - resolution: {integrity: sha512-rv7ptedS37SHr6yuCbRkUErAzAhebdgt8f4KUtZWzseC+7qLNkaZWf+uujgsb881qAuX9b9yz8rre9UKeYepgw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 - react-dom: ^16.8.0 || ^17.0.0 - react-zdog: '>=1.0' - zdog: '>=1.0' - dependencies: - '@react-spring/animated': 9.2.4_react@17.0.2 - '@react-spring/core': 9.2.4_react@17.0.2 - '@react-spring/shared': 9.2.4_react@17.0.2 - '@react-spring/types': 9.2.4 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - /@rollup/pluginutils/4.1.1: resolution: {integrity: sha512-clDjivHqWGXi7u+0d2r2sBi4Ie6VLEAzWMIkvJLnDmxoOhBYOTfzGbOQBA32THHm11/LiJbd01tJUpJsbshSWQ==} engines: {node: '>= 8.0.0'} @@ -693,19 +580,6 @@ packages: eslint-visitor-keys: 2.1.0 dev: true - /@use-gesture/core/10.0.0-beta.22: - resolution: {integrity: sha512-nFPsMiHii99huDpeKjC9e5Ggagn4M/UAb3DFWw1/dEZl1fJSfFobrNdtB+5sPOTwyDBBsKpk4oS7Nde4coGDzA==} - dev: false - - /@use-gesture/react/10.0.0-beta.22_react@17.0.2: - resolution: {integrity: sha512-6ZTpyK1XwL6xgzV0LArxE/rMBNueOiIp0LJoKHdX5/LSf33unMd/E7v9YGOf55NM7vl0tOevpa9f5gSlw4Pvhw==} - peerDependencies: - react: '>= 16.8.0' - dependencies: - '@use-gesture/core': 10.0.0-beta.22 - react: 17.0.2 - dev: false - /@vitejs/plugin-react-refresh/1.3.6: resolution: {integrity: sha512-iNR/UqhUOmFFxiezt0em9CgmiJBdWR+5jGxB2FihaoJfqGt76kiwaKoVOJVU5NYcDWMdN06LbyN2VIGIoYdsEA==} engines: {node: '>=12.0.0'} @@ -1921,6 +1795,10 @@ packages: object.assign: 4.1.2 dev: true + /keyboardjs/2.6.4: + resolution: {integrity: sha512-xDiNwiwH3KUqap++RFJiLAXzbvRB5Yw08xliuceOgLhM1o7g1puKKR9vWy6wp9H/Bi4VP0+SQMpiWXMWWmR6rA==} + dev: false + /levn/0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2419,27 +2297,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /react-spring/9.2.4_react-dom@17.0.2+react@17.0.2: - resolution: {integrity: sha512-bMjbyTW0ZGd+/h9cjtohLqCwOGqX2OuaTvalOVfLCGmhzEg/u3GgopI3LAm4UD2Br3MNdVdGgNVoESg4MGqKFQ==} - dependencies: - '@react-spring/core': 9.2.4_react@17.0.2 - '@react-spring/konva': 9.2.4_react@17.0.2 - '@react-spring/native': 9.2.4_react@17.0.2 - '@react-spring/three': 9.2.4_react@17.0.2 - '@react-spring/web': 9.2.4_react-dom@17.0.2+react@17.0.2 - '@react-spring/zdog': 9.2.4_react-dom@17.0.2+react@17.0.2 - transitivePeerDependencies: - - '@react-three/fiber' - - konva - - react - - react-dom - - react-konva - - react-native - - react-zdog - - three - - zdog - dev: false - /react-universal-interface/0.6.2_react@17.0.2+tslib@2.3.1: resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==} peerDependencies: diff --git a/readme.md b/readme.md index cf55208..3c80608 100644 --- a/readme.md +++ b/readme.md @@ -7,9 +7,9 @@ UX is mainly brought from morden browsers: - if you click a page link while holding CTRL(or CMD on Mac) key, a new tab will be created (not visied yet) - you can remove the tab by click the remove button in each tab - you can double-click a tab to pin it. A pined tab will not be replaced or be removed. +- you can drag & drop to reorder tabs ## TODO -- [ ] Drag and drop tabs -- [ ] Transitions +- [ ] keyboard shortcuts. Eg., CTRL+TAB to switch to next tab, CTRL+SHIFT+TAB to switch to previous tab, CTRL+W to close current tab ![](./demo.gif) diff --git a/src/PageTabs.css b/src/PageTabs.css index bedd200..8dd2e95 100644 --- a/src/PageTabs.css +++ b/src/PageTabs.css @@ -1,5 +1,5 @@ .logseq-tab { - @apply cursor-pointer font-sans select-none text-xs h-6 transition + @apply cursor-pointer font-sans select-none text-xs h-6 transition-all flex items-center rounded mx-0.5 px-2 light:text-black dark:text-white; } @@ -13,6 +13,10 @@ @apply light:bg-cool-gray-300 dark:bg-cool-gray-900; } +.logseq-tab[data-dragging="true"] { + @apply ring-1 ring-red-500 mx-6; +} + .logseq-tab-title { @apply overflow-ellipsis max-w-80 px-1 overflow-hidden whitespace-nowrap inline transition-all duration-200 ease-in-out; } diff --git a/src/PageTabs.tsx b/src/PageTabs.tsx index ab98877..40cfad0 100644 --- a/src/PageTabs.tsx +++ b/src/PageTabs.tsx @@ -1,12 +1,17 @@ import type { BlockEntity } from "@logseq/libs/dist/LSPlugin"; +// @ts-expect-error no types +import keyboardjs from "keyboardjs"; +// @ts-expect-error no types +import { us } from "keyboardjs/locales/us"; import React from "react"; import { useDeepCompareEffect, useLatest } from "react-use"; - import "./PageTabs.css"; import { ITabInfo } from "./types"; import { getSourcePage, + isMac, useAdpatMainUIStyle, + useEventCallback, useOpeningPageTabs, } from "./utils"; @@ -58,11 +63,13 @@ function Tabs({ tabs, onCloseTab, onPinTab, + onSwapTab, }: { tabs: ITabInfo[]; activePage: ITabInfo | null; onCloseTab: (tab: ITabInfo, tabIdx: number) => void; onPinTab: (tab: ITabInfo) => void; + onSwapTab: (tab: ITabInfo, anotherTab: ITabInfo) => void; }) { const ref = React.useRef(null); React.useEffect(() => { @@ -75,6 +82,18 @@ function Tabs({ } }, [activePage]); + const [draggingTab, setDraggingTab] = React.useState(); + + React.useEffect(() => { + const dragEndListener = () => { + setDraggingTab(undefined); + }; + document.addEventListener("dragend", dragEndListener); + return () => { + document.removeEventListener("dragend", dragEndListener); + }; + }, []); + return (
{ + if (draggingTab) { + // Prevent fly back animation + e.preventDefault(); + onSwapTab(tab, draggingTab); + } + }; return (
setDraggingTab(tab)} className="logseq-tab" > {tab.originalName} @@ -118,10 +148,7 @@ function useAddPageTab(cb: (e: ITabInfo) => void) { const listener = async (e: MouseEvent) => { const target = e.composedPath()[0] as HTMLAnchorElement; // If CtrlKey is pressed, always open a new tab - const ctrlKey = - navigator.platform.toUpperCase().indexOf("MAC") >= 0 - ? e.metaKey - : e.ctrlKey; + const ctrlKey = isMac() ? e.metaKey : e.ctrlKey; if ( target.tagName === "A" && target.hasAttribute("data-ref") && @@ -190,7 +217,13 @@ export function PageTabs(): JSX.Element { } }, [activePage, tabs]); - const onCloseTab = (tab: ITabInfo, idx: number) => { + const onCloseTab = useEventCallback((tab: ITabInfo, idx?: number) => { + if (tabs.length <= 1) { + return; + } + if (idx == null) { + idx = tabs.findIndex((t) => isTabEqual(t, tab)); + } const newTabs = [...tabs]; newTabs.splice(idx, 1); setTabs(newTabs); @@ -199,7 +232,7 @@ export function PageTabs(): JSX.Element { name: newTabs[Math.min(newTabs.length - 1, idx)].originalName, }); } - }; + }); const onNewTab = React.useCallback( (t: ITabInfo | null) => { @@ -220,20 +253,17 @@ export function PageTabs(): JSX.Element { useAddPageTab(onNewTab); - const prevActivePageRef = React.useRef(); + const currActivePageRef = React.useRef(); const latestTabsRef = useLatest(tabs); useDeepCompareEffect(() => { let newTabs = latestTabsRef.current; // If a new ActivePage is set, we will need to replace or insert the tab if (activePage) { - // if new active page is NOT in the tabs - // - if current active page is pined, insert new tab at the end - // - if there is no if (tabs.every((t) => !isTabEqual(t, activePage))) { newTabs = [...tabs]; const currentIndex = tabs.findIndex((t) => - isTabEqual(t, prevActivePageRef.current) + isTabEqual(t, currActivePageRef.current) ); const currentPinned = tabs[currentIndex]?.pined; if (currentIndex === -1 || currentPinned) { @@ -243,7 +273,7 @@ export function PageTabs(): JSX.Element { } } } - prevActivePageRef.current = activePage; + currActivePageRef.current = activePage; setTabs(newTabs); }, [activePage, setTabs]); @@ -260,10 +290,43 @@ export function PageTabs(): JSX.Element { [setTabs] ); + const onSwapTab = (t0: ITabInfo, t1: ITabInfo) => { + setTabs((_tabs) => { + const newTabs = [..._tabs]; + const i0 = _tabs.findIndex((t) => isTabEqual(t, t0)); + const i1 = _tabs.findIndex((t) => isTabEqual(t, t1)); + newTabs[i0] = t1; + newTabs[i1] = t0; + return sortTabs(newTabs); + }); + }; + + React.useEffect(() => { + const topKb = new keyboardjs.Keyboard(top); + const currKb = new keyboardjs.Keyboard(window); + topKb.setLocale('us', us); + currKb.setLocale('us', us); + const closeCurrentTab = (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + if (currActivePageRef.current) { + onCloseTab(currActivePageRef.current); + } + }; + const ctrlW = isMac() ? "command + w" : "ctrl + w"; + topKb.bind(ctrlW, closeCurrentTab); + currKb.bind(ctrlW, closeCurrentTab); + return () => { + topKb.unbind(ctrlW, closeCurrentTab); + currKb.unbind(ctrlW, closeCurrentTab); + }; + }, [onCloseTab]); + return ( diff --git a/src/utils.ts b/src/utils.ts index 36c49af..e13c21f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useState } from "react"; import type { PageEntity } from "@logseq/libs/dist/LSPlugin"; import { useMountedState } from "react-use"; @@ -139,3 +140,21 @@ export function useAdpatMainUIStyle() { }; }, []); } + +export const isMac = () => { + return navigator.platform.toUpperCase().includes("MAC"); +} + +export function useEventCallback any>(fn: T): T { + const ref: any = React.useRef(); + + // we copy a ref to the callback scoped to the current state/props on each render + React.useLayoutEffect(() => { + ref.current = fn; + }); + + return React.useCallback( + (...args: any[]) => ref.current.apply(void 0, args), + [] + ) as T; +} \ No newline at end of file