diff --git a/package.json b/package.json index 7f4d97d..e17247e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "link-map", "displayName": "Link Map", - "version": "1.0.11", + "version": "1.0.12", "browserslist": "Chrome >= 96", "description": "Vertical Tabs Sidebar, But In Tree Structure", "author": "Garin", @@ -15,7 +15,7 @@ "url": "https://garin@linkmap.cc" }, "engines": { - "node": "^18.12.1" + "node": ">=18.12.1" }, "scripts": { "preinstall": "npx only-allow pnpm", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index f4d20aa..ffac67f 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -134,6 +134,18 @@ "themeDark": { "message": "Dark" }, + "displayMode": { + "message": "Open As" + }, + "displayModeSidebar": { + "message": "Chrome Sidebar" + }, + "displayModeFloatingModal": { + "message": "Floating Modal" + }, + "displayModePopup": { + "message": "Popup Window" + }, "commandTriggerLinkMap": { "message": "Open Link Map" }, diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index c9c44f9..b53419a 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -134,6 +134,18 @@ "themeDark": { "message": "黑暗模式" }, + "displayMode": { + "message": "打开方式" + }, + "displayModeSidebar": { + "message": "Chrome 侧边栏" + }, + "displayModeFloatingModal": { + "message": "悬浮弹窗" + }, + "displayModePopup": { + "message": "弹出窗口" + }, "commandTriggerLinkMap": { "message": "打开Link Map" }, diff --git a/src/background/event-bus.ts b/src/background/event-bus.ts index 324790b..94f5f61 100644 --- a/src/background/event-bus.ts +++ b/src/background/event-bus.ts @@ -25,5 +25,5 @@ export async function sendMessageToExt( } export function isContentScriptPage(url?: string) { - return url === browser.runtime.getURL(EXT_HOME_PAGE_PATH); + return url?.startsWith(browser.runtime.getURL(EXT_HOME_PAGE_PATH)) ?? false; } diff --git a/src/background/index.ts b/src/background/index.ts index ddbec66..a849ee4 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -18,6 +18,30 @@ import { isContentScriptPage, sendMessageToExt } from './event-bus'; try { setLogLevel(); + async function getPrimaryTabForWindow(windowId?: number) { + if (windowId == null) { + return null; + } + const tabs = await browser.tabs.query({ windowId }); + return tabs[0] ?? null; + } + + async function storeExtPageInfo(window: browser.Windows.Window) { + const extTab = window.tabs?.[0] ?? (await getPrimaryTabForWindow(window.id)); + if (!extTab?.id || !extTab.windowId) { + throw new Error('Failed to resolve Link Map tab for created window.'); + } + await setExtPageInfo({ + windowId: extTab.windowId, + tabId: extTab.id, + }); + } + + async function enableSidePanelByDefault() { + if (!chrome.sidePanel?.setPanelBehavior) return; + await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + } + async function syncTabsCountInBadge() { const allTabs = await browser.tabs.query({}); await browser.action.setBadgeBackgroundColor({ color: '#2b2d31' }); @@ -43,10 +67,15 @@ try { } const db = new TabMasterDB(); await db.initSetting(); + await enableSidePanelByDefault(); await syncTabsCountInBadge(); await removeExtPageInfo(); }); + browser.runtime.onStartup.addListener(async () => { + await enableSidePanelByDefault(); + }); + async function openNewExtWindow() { const displayInfos = await chrome.system.display.getInfo(); const primaryDisplayInfo = displayInfos.find((item) => item.isPrimary); @@ -62,11 +91,30 @@ try { left, focused: true, }); - const extTab = extWindow.tabs![0]; - await setExtPageInfo({ - windowId: extTab.windowId!, - tabId: extTab.id!, + await storeExtPageInfo(extWindow); + } + + async function openFloatingModalWindow() { + const displayInfos = await chrome.system.display.getInfo(); + const primaryDisplayInfo = displayInfos.find((item) => item.isPrimary); + const workAreaWidth = primaryDisplayInfo?.workArea.width ?? 1440; + const workAreaHeight = primaryDisplayInfo?.workArea.height ?? 960; + const workAreaLeft = primaryDisplayInfo?.workArea.left ?? 0; + const workAreaTop = primaryDisplayInfo?.workArea.top ?? 0; + const width = Math.min(1120, Math.floor(workAreaWidth * 0.7)); + const height = Math.min(820, Math.floor(workAreaHeight * 0.82)); + const left = workAreaLeft + Math.max(0, Math.floor((workAreaWidth - width) / 2)); + const top = workAreaTop + Math.max(0, Math.floor((workAreaHeight - height) / 2)); + const extWindow = await browser.windows.create({ + url: 'tree.html?display=floating-modal', + type: 'popup', + width, + height, + top, + left, + focused: true, }); + await storeExtPageInfo(extWindow); } onMessage('tree-ready', async (msg) => { @@ -74,21 +122,92 @@ try { await setExtPageInfo({ windowId, tabId }); }); - const focusOrCreateExtWindow = async () => { + const focusOrCreateExtWindow = async ( + createExtWindow = openNewExtWindow, + shouldReuseWindow: (url?: string) => boolean = (url) => + url === browser.runtime.getURL('tree.html'), + ) => { const extIdPair = await getExtPageInfo(); if (extIdPair == null) { - await openNewExtWindow(); + await createExtWindow(); } else { - // 页面已打开,则窗口focused try { + const extTab = await browser.tabs.get(extIdPair.tabId); + if (!shouldReuseWindow(extTab.url)) { + await createExtWindow(); + return; + } + // 页面已打开,则窗口focused await browser.windows.update(extIdPair.windowId, { focused: true }); } catch { // 防止localStorage数据未清除,但是页面已经关闭的情况 - await openNewExtWindow(); + await createExtWindow(); } } }; + const closeFloatingModalIfOpen = async () => { + const extIdPair = await getExtPageInfo(); + if (extIdPair == null) { + return false; + } + try { + const extTab = await browser.tabs.get(extIdPair.tabId); + const isFloatingModal = + extTab.url === browser.runtime.getURL('tree.html?display=floating-modal'); + if (!isFloatingModal) { + return false; + } + await browser.windows.remove(extIdPair.windowId); + await removeExtPageInfo(); + return true; + } catch { + await removeExtPageInfo(); + return false; + } + }; + + const closeFloatingModalOnBlur = async (focusedWindowId: number) => { + const extIdPair = await getExtPageInfo(); + if (!extIdPair || focusedWindowId === extIdPair.windowId) { + return; + } + await closeFloatingModalIfOpen(); + }; + + const openLinkMap = async (windowId?: number, shouldToggleFloatingModal = false) => { + const setting = await new TabMasterDB().getSetting(); + const shouldUseSidePanel = setting?.display === 'embedded-sidebar'; + const shouldUseFloatingModal = setting?.display === 'floating-modal'; + if (shouldUseSidePanel && chrome.sidePanel?.open) { + try { + if (windowId) { + await chrome.sidePanel.open({ windowId }); + } else { + const lastFocusedWindow = await browser.windows.getLastFocused(); + if (lastFocusedWindow.id) { + await chrome.sidePanel.open({ windowId: lastFocusedWindow.id }); + return; + } + } + return; + } catch (error) { + log.warn('Failed to open side panel, falling back to popup window.', error); + } + } + if (shouldUseFloatingModal) { + if (shouldToggleFloatingModal && (await closeFloatingModalIfOpen())) { + return; + } + await focusOrCreateExtWindow( + openFloatingModalWindow, + (url) => url === browser.runtime.getURL('tree.html?display=floating-modal'), + ); + return; + } + await focusOrCreateExtWindow(); + }; + /** * 点击插件按钮:打开一个TreeView页面 * 将extIdPair更新到localStorage中 @@ -96,7 +215,7 @@ try { */ browser.action.onClicked.addListener((tab) => { setPrevFocusWindowId(tab.windowId!); - focusOrCreateExtWindow(); + openLinkMap(tab.windowId); }); // #### 浏览器Fire的事件 @@ -138,6 +257,7 @@ try { browser.tabs.onActivated.addListener(({ tabId, windowId }) => { log.debug('[bg]: tab activated!'); + setPrevFocusWindowId(windowId); sendMessageToExt('activated-tab', { windowId, tabId }); }); /** @@ -180,8 +300,20 @@ try { sendMessageToExt('remove-window', { windowId }); }); - browser.windows.onFocusChanged.addListener((windowId) => { + browser.windows.onFocusChanged.addListener(async (windowId) => { log.debug('[bg]: window focus changed!'); + await closeFloatingModalOnBlur(windowId); + if (windowId !== browser.windows.WINDOW_ID_NONE) { + const [activeTab] = await browser.tabs.query({ active: true, windowId }); + if ( + !activeTab || + isContentScriptPage(activeTab.url) || + isContentScriptPage(activeTab.pendingUrl) + ) { + return; + } + await setPrevFocusWindowId(windowId); + } sendMessageToExt('window-focus', { windowId }); }); @@ -212,9 +344,13 @@ try { browser.commands.onCommand.addListener(async (command) => { if (command === 'openLinkMap') { - await focusOrCreateExtWindow(); + await openLinkMap(undefined, true); } }); + + enableSidePanelByDefault().catch((error) => { + log.warn('Failed to enable side panel action behavior.', error); + }); } catch (error) { log.error(error); } diff --git a/src/manifest.ts b/src/manifest.ts index e7c41b6..1cb0f30 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -7,12 +7,12 @@ export const commandKeyMap = { openLinkMap: 'openLinkMap', }; -const manifest: Manifest.WebExtensionManifest = { +const manifest: Manifest.WebExtensionManifest & { side_panel: { default_path: string } } = { name: pkg.displayName, version: pkg.version, description: '__MSG_extDesc__', manifest_version: 3, - minimum_chrome_version: pkg.browserslist.split(' ')[2], + minimum_chrome_version: '114', permissions: [ 'tabs', 'storage', @@ -20,6 +20,7 @@ const manifest: Manifest.WebExtensionManifest = { 'windows', 'downloads', 'system.display', + 'sidePanel' as Manifest.Permission, 'favicon', ], content_security_policy: { @@ -60,6 +61,9 @@ const manifest: Manifest.WebExtensionManifest = { '128': 'icons/x128.png', }, }, + side_panel: { + default_path: 'tree.html', + }, // 实现options页面后使用 // options_ui: { // page: 'options.html', diff --git a/src/storage/idb.ts b/src/storage/idb.ts index b7e4ec9..031d36a 100644 --- a/src/storage/idb.ts +++ b/src/storage/idb.ts @@ -13,7 +13,7 @@ export type ThemeType = 'light' | 'dark' | 'auto'; export interface Setting { id: number; theme: ThemeType; - display: 'popup' | 'tab' | 'embedded-sidebar'; + display: 'popup' | 'tab' | 'embedded-sidebar' | 'floating-modal'; autoScrollToActiveTab: boolean; createNewTabByLevel: boolean; } @@ -21,7 +21,7 @@ export interface Setting { export const DEFAULT_SETTING: Setting = { id: 1, theme: 'dark', - display: 'popup', + display: 'floating-modal', autoScrollToActiveTab: false, createNewTabByLevel: false, }; @@ -57,6 +57,10 @@ export class TabMasterDB extends Dexie { const currentSetting = await this.getSetting(); if (!currentSetting) { await this.setting.put(DEFAULT_SETTING); + return; + } + if (currentSetting.display === 'popup' || currentSetting.display === 'embedded-sidebar') { + await this.setting.update(1, { display: DEFAULT_SETTING.display }); } } diff --git a/src/styles/app.less b/src/styles/app.less index 5514d19..41efda7 100644 --- a/src/styles/app.less +++ b/src/styles/app.less @@ -4,7 +4,7 @@ html { button.ant-btn { - font-size: 12px; + font-size: var(--control-font-size); color: var(--btn-icon-color); background-color: var(--btn-bg-color); border: 1px solid var(--btn-border-color); @@ -51,7 +51,8 @@ html { } body { - --main-font-size: 11px; + --main-font-size: 13px; + --control-font-size: 14px; --font-family: inter, ui-sans-serif, system-ui, -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, 'Helvetica Neue', arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; diff --git a/src/tree/features/App.tsx b/src/tree/features/App.tsx index f5be0e5..ee53f09 100644 --- a/src/tree/features/App.tsx +++ b/src/tree/features/App.tsx @@ -10,7 +10,6 @@ import Feedback from './feedback/Feedback'; import Help from './help/Help'; import OperationBar from './operation-bar/OperationBar'; import { Search } from './search/Search'; -import Settings from './settings/Settings'; import store from './store'; import type { FancyTabMasterTree } from './tab-master-tree/fancy-tab-master-tree'; import { TabMasterTree } from './tab-master-tree/TabMasterTree'; @@ -23,6 +22,10 @@ import Welcome from './tutorial/Welcome'; import '../../styles/app.less'; +const closeFloatingModal = () => { + window.close(); +}; + const updateNotification = async (tmTree: FancyTabMasterTree) => { const isUpdate = await getIsUpdate(); if (!isUpdate) return; @@ -34,6 +37,8 @@ const updateNotification = async (tmTree: FancyTabMasterTree) => { const App: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [setting, setSetting] = useState(DEFAULT_SETTING); + const displayMode = new URLSearchParams(window.location.search).get('display'); + const isFloatingModal = displayMode === 'floating-modal'; // const matchMediaDark = window.matchMedia('(prefers-color-scheme: dark)'); // const isDarkMode = matchMediaDark.matches; @@ -62,6 +67,21 @@ const App: React.FC = () => { } }, [setting.theme]); + useEffect(() => { + if (!isFloatingModal) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + event.preventDefault(); + window.close(); + }; + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [isFloatingModal]); + const handleCancel = () => { setIsModalOpen(false); }; @@ -85,19 +105,28 @@ const App: React.FC = () => { return ( -
- - - - + +
+ +
+
{ } }; +const getVisibleNodes = (tree: Fancytree.Fancytree): Fancytree.FancytreeNode[] => { + const visibleNodes: Fancytree.FancytreeNode[] = []; + tree.visitRows((node) => { + visibleNodes.push(node); + return true; + }, {}); + return visibleNodes; +}; + +const getPreferredActiveBrowserTab = async () => { + const prevFocusWindowId = await getPrevFocusWindowId(); + if (prevFocusWindowId) { + const preferredTabs = await browser.tabs.query({ + active: true, + windowId: prevFocusWindowId, + }); + const preferredTab = preferredTabs[0]; + if ( + preferredTab && + !isContentScriptPage(preferredTab.url) && + !isContentScriptPage(preferredTab.pendingUrl) + ) { + return preferredTab; + } + } + const activeTabs = await browser.tabs.query({ active: true }); + return ( + activeTabs.find( + (tab) => !isContentScriptPage(tab.url) && !isContentScriptPage(tab.pendingUrl), + ) ?? null + ); +}; + +const ensureCurrentFocusedTabActive = async () => { + const tree = store.tree; + if (!tree) { + return null; + } + const activeTab = await getPreferredActiveBrowserTab(); + if (!activeTab?.id) { + return null; + } + const activeNode = tree.getNodeByKey(activeTab.id.toString()); + if (!activeNode) { + return null; + } + activeNode.makeVisible({ scrollIntoView: true }); + activeNode.setActive(true); + return activeNode; +}; + let inputRef: HTMLInputElement | null = null; Mousetrap.bind(ShortcutMap.search.key, (e) => { e.preventDefault(); @@ -63,16 +117,98 @@ Mousetrap.bind(ShortcutMap.search.key, (e) => { export const Search = () => { const [value, setValue] = useState(''); const [focus, setFocus] = useState(false); + const hasStartedKeyboardNavigation = useRef(false); + const shouldAutoFocus = + new URLSearchParams(window.location.search).get('display') === 'floating-modal'; + + useEffect(() => { + if (!shouldAutoFocus) { + return; + } + inputRef?.focus(); + }, [shouldAutoFocus]); const onKeyUp = (e: KeyboardEvent) => { if (e && e.keyCode === $.ui.keyCode.ESCAPE) { clearFilter(); setValue(''); + hasStartedKeyboardNavigation.current = false; inputRef!.blur(); } }; + const onKeyDown = async (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const activeNode = store.tree?.getActiveNode(); + if (!activeNode || activeNode.data.nodeType === 'note') { + return; + } + e.preventDefault(); + await FancyTabMasterTree.onDbClick(activeNode); + if (shouldAutoFocus) { + window.close(); + } + return; + } + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { + return; + } + e.preventDefault(); + const tree = store.tree; + if (!tree) { + return; + } + const isFiltered = value.trim() !== ''; + if (isFiltered) { + const visibleNodes = getVisibleNodes(tree); + if (visibleNodes.length === 0) { + return; + } + hasStartedKeyboardNavigation.current = true; + const activeNode = tree.getActiveNode(); + const direction = e.key === 'ArrowUp' ? -1 : 1; + const activeIndex = activeNode + ? visibleNodes.findIndex((node) => node.key === activeNode.key) + : -1; + const targetIndex = + activeIndex < 0 + ? direction > 0 + ? 0 + : visibleNodes.length - 1 + : (activeIndex + direction + visibleNodes.length) % visibleNodes.length; + const targetNode = visibleNodes[targetIndex]; + targetNode.makeVisible({ scrollIntoView: true }); + targetNode.setActive(true); + return; + } + if (!hasStartedKeyboardNavigation.current) { + hasStartedKeyboardNavigation.current = true; + await ensureCurrentFocusedTabActive(); + return; + } + const visibleNodes = getVisibleNodes(tree); + if (visibleNodes.length === 0) { + return; + } + const activeNode = tree.getActiveNode() ?? (await ensureCurrentFocusedTabActive()); + if (!activeNode) { + return; + } + const direction = e.key === 'ArrowUp' ? -1 : 1; + const activeIndex = visibleNodes.findIndex((node) => node.key === activeNode.key); + const targetIndex = + activeIndex < 0 + ? direction > 0 + ? 0 + : visibleNodes.length - 1 + : (activeIndex + direction + visibleNodes.length) % visibleNodes.length; + const targetNode = visibleNodes[targetIndex]; + targetNode.makeVisible({ scrollIntoView: true }); + targetNode.setActive(true); + }; + const onChange = (e: ChangeEvent) => { + hasStartedKeyboardNavigation.current = false; setValue(e.target.value); onSearch(e.target.value); }; @@ -87,6 +223,7 @@ export const Search = () => { className={'search'} name={'search'} autoComplete={'off'} + onKeyDown={onKeyDown} onKeyUp={onKeyUp} value={value} onChange={onChange} diff --git a/src/tree/features/search/search.less b/src/tree/features/search/search.less index bb21d03..eb9fd63 100644 --- a/src/tree/features/search/search.less +++ b/src/tree/features/search/search.less @@ -1,7 +1,9 @@ @import url('../../../styles/common.less'); .search-input { + box-sizing: border-box; display: flex; + width: 100%; height: 30px; padding: 0 8px; line-height: 30px; @@ -22,7 +24,7 @@ height: 30px; padding-left: 10px; margin: 0; - font-size: 12px; + font-size: var(--control-font-size); color: var(--search-input-font-color); background-color: transparent; border-style: none; @@ -42,6 +44,54 @@ } .search-shortcut-info { + font-size: var(--main-font-size); color: var(--search-input-placeholder-color); } } + +.app.floating-modal { + .search-input { + height: 40px; + padding: 0 12px; + border-radius: 999px; + + input.search { + height: 40px; + padding-left: 12px; + font-size: 14px; + } + + i.icon-search { + font-size: 14px; + } + } +} + +@media (max-width: 420px) { + .search-input { + height: 28px; + padding: 0 6px; + + input.search { + padding-left: 8px; + font-size: 11px; + } + + .search-shortcut-info { + display: none; + } + } + + .app.floating-modal { + .search-input { + height: 34px; + padding: 0 10px; + + input.search { + height: 34px; + padding-left: 10px; + font-size: 12px; + } + } + } +} diff --git a/src/tree/features/settings/Settings.tsx b/src/tree/features/settings/Settings.tsx index 92425f9..831edb7 100644 --- a/src/tree/features/settings/Settings.tsx +++ b/src/tree/features/settings/Settings.tsx @@ -5,7 +5,7 @@ import log from 'loglevel'; import { useContext, useState } from 'react'; import browser from 'webextension-polyfill'; -import type { ThemeType } from '../../../storage/idb'; +import type { Setting, ThemeType } from '../../../storage/idb'; import { downloadJsonWithExtensionAPI, getFormattedData } from '../../../utils'; import { SettingContext } from '../../context'; import Feedback from '../feedback/Feedback'; @@ -76,6 +76,11 @@ const Settings = () => { setSetting({ ...setting, theme: value }); }; + const handleDisplayChange = async (value: Setting['display']) => { + await store.db.updateSettingPartial({ display: value }); + setSetting({ ...setting, display: value }); + }; + return (
@@ -115,6 +120,32 @@ const Settings = () => { ]} />
+
+ + {browser.i18n.getMessage('displayMode')}: + +