From 6da6b855307f1a9c962386ae68b9e8afd9deb241 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 17 Jul 2025 16:21:37 +0800 Subject: [PATCH] feat: support multiple reader windows on desktop, closes #259 (#1596) --- .gitignore | 2 + .../public/locales/ar/translation.json | 3 +- .../public/locales/de/translation.json | 3 +- .../public/locales/el/translation.json | 3 +- .../public/locales/es/translation.json | 3 +- .../public/locales/fr/translation.json | 3 +- .../public/locales/hi/translation.json | 3 +- .../public/locales/id/translation.json | 3 +- .../public/locales/it/translation.json | 3 +- .../public/locales/ja/translation.json | 3 +- .../public/locales/ko/translation.json | 3 +- .../public/locales/nl/translation.json | 3 +- .../public/locales/pl/translation.json | 3 +- .../public/locales/pt/translation.json | 3 +- .../public/locales/ru/translation.json | 3 +- .../public/locales/th/translation.json | 3 +- .../public/locales/tr/translation.json | 3 +- .../public/locales/uk/translation.json | 3 +- .../public/locales/vi/translation.json | 3 +- .../public/locales/zh-CN/translation.json | 3 +- .../public/locales/zh-TW/translation.json | 3 +- .../src-tauri/capabilities/default.json | 3 +- .../src-tauri/src/macos/traffic_light.rs | 6 ++- .../src/app/library/components/Bookshelf.tsx | 11 +++-- .../app/library/components/BookshelfItem.tsx | 11 +++-- .../app/library/components/SettingsMenu.tsx | 14 +++++++ .../src/app/library/hooks/useBooksSync.ts | 2 +- apps/readest-app/src/app/library/page.tsx | 26 +++++++++++- .../src/app/reader/components/HeaderBar.tsx | 7 ++-- .../app/reader/components/ReaderContent.tsx | 25 +++++++---- .../app/reader/components/sidebar/Header.tsx | 2 +- apps/readest-app/src/helpers/openWith.ts | 5 ++- .../readest-app/src/hooks/useOpenWithBooks.ts | 22 ++++++---- apps/readest-app/src/services/appService.ts | 7 ++++ apps/readest-app/src/services/constants.ts | 1 + apps/readest-app/src/types/book.ts | 1 + apps/readest-app/src/types/settings.ts | 1 + apps/readest-app/src/utils/nav.ts | 42 +++++++++++++++++++ apps/readest-app/src/utils/window.ts | 10 +++-- 39 files changed, 203 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index 378f22da..0ffd8f53 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ next-env.d.ts # Rust build target + +fastlane/report.xml diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 22839be8..cbfb011f 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -465,5 +465,6 @@ "Google Books": "كتب جوجل", "Open Library": "المكتبة المفتوحة", "Fiction, Science, History": "خيال، علوم، تاريخ", - "Select Cover Image": "اختر صورة الغلاف" + "Select Cover Image": "اختر صورة الغلاف", + "Open Book in New Window": "فتح الكتاب في نافذة جديدة" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 7482a73c..de581a93 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -449,5 +449,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte", - "Select Cover Image": "Coverbild auswählen" + "Select Cover Image": "Coverbild auswählen", + "Open Book in New Window": "Buch in neuem Fenster öffnen" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 44bbc252..18b78273 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -449,5 +449,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία", - "Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου" + "Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου", + "Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 50354024..67f01997 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -453,5 +453,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Ficción, Ciencia, Historia", - "Select Cover Image": "Seleccionar imagen de portada" + "Select Cover Image": "Seleccionar imagen de portada", + "Open Book in New Window": "Abre el libro en una nueva ventana" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index a7026ab6..9da11321 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -453,5 +453,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Fiction, Science, Histoire", - "Select Cover Image": "Sélectionner l'image de couverture" + "Select Cover Image": "Sélectionner l'image de couverture", + "Open Book in New Window": "Ouvrir dans une nouvelle fenêtre" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 516ae2f1..24644f13 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -449,5 +449,6 @@ "Google Books": "गूगल बुक्स", "Open Library": "ओपन लाइब्रेरी", "Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास", - "Select Cover Image": "कवर इमेज चुनें" + "Select Cover Image": "कवर इमेज चुनें", + "Open Book in New Window": "नई विंडो में पुस्तक खोलें" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 0128ebbc..98678962 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -445,5 +445,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Fiksi, Sains, Sejarah", - "Select Cover Image": "Pilihan Gambar Sampul" + "Select Cover Image": "Pilihan Gambar Sampul", + "Open Book in New Window": "Buka Buku di Jendela Baru" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index b90a71f2..281e34b8 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -453,5 +453,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Narrativa, Scienza, Storia", - "Select Cover Image": "Seleziona immagine di copertina" + "Select Cover Image": "Seleziona immagine di copertina", + "Open Book in New Window": "Apri libro in una nuova finestra" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 80a1716c..c59102a1 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -445,5 +445,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "小説、科学、歴史", - "Select Cover Image": "表紙画像を選択" + "Select Cover Image": "表紙画像を選択", + "Open Book in New Window": "新しいウィンドウで本を開く" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index c5802914..6cbae69b 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -445,5 +445,6 @@ "Google Books": "구글 북스", "Open Library": "오픈 라이브러리", "Fiction, Science, History": "소설, 과학, 역사", - "Select Cover Image": "표지 이미지 선택" + "Select Cover Image": "표지 이미지 선택", + "Open Book in New Window": "새 창에서 책 열기" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index e9703796..53edb8c5 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -449,5 +449,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis", - "Select Cover Image": "Selecteer omslagafbeelding" + "Select Cover Image": "Selecteer omslagafbeelding", + "Open Book in New Window": "Open boek in nieuw venster" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 7decf527..f1b6e93f 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -457,5 +457,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Beletrystyka, Nauka, Historia", - "Select Cover Image": "Zaznacz obraz okładki" + "Select Cover Image": "Zaznacz obraz okładki", + "Open Book in New Window": "Otwórz książkę w nowym oknie" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index f608d9ac..28c544cc 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -453,5 +453,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Ficção, Ciência, História", - "Select Cover Image": "Selecionar imagem da capa" + "Select Cover Image": "Selecionar imagem da capa", + "Open Book in New Window": "Abrir livro em nova janela" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 9652b3d6..90d32819 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -457,5 +457,6 @@ "Google Books": "Google Книги", "Open Library": "Open Library", "Fiction, Science, History": "Художественная литература, Наука, История", - "Select Cover Image": "Выбрать изображение обложки" + "Select Cover Image": "Выбрать изображение обложки", + "Open Book in New Window": "Открыть книгу в новом окне" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 8c7b9516..031401ed 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -445,5 +445,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์", - "Select Cover Image": "เลือกภาพปก" + "Select Cover Image": "เลือกภาพปก", + "Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 9bc3bcdf..1c72f584 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -449,5 +449,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Kurgu, Bilim, Tarih", - "Select Cover Image": "Kapak Resmi Seç" + "Select Cover Image": "Kapak Resmi Seç", + "Open Book in New Window": "Kitabı Yeni Pencerede Aç" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 59d71715..6169ce37 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -457,5 +457,6 @@ "Google Books": "Google Книги", "Open Library": "Open Library", "Fiction, Science, History": "Художня література, Наукова фантастика, Історія", - "Select Cover Image": "Оберіть зображення обкладинки" + "Select Cover Image": "Оберіть зображення обкладинки", + "Open Book in New Window": "Відкрити книгу в новому вікні" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index b7fe57cc..b4455a66 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -445,5 +445,6 @@ "Google Books": "Google Books", "Open Library": "Open Library", "Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử", - "Select Cover Image": "Chọn ảnh bìa" + "Select Cover Image": "Chọn ảnh bìa", + "Open Book in New Window": "Open Sách trong Cửa sổ Mới" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 2f1786aa..6d1feb1c 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -445,5 +445,6 @@ "Google Books": "谷歌图书", "Open Library": "开放图书馆", "Fiction, Science, History": "小说,科学,历史", - "Select Cover Image": "选择封面图片" + "Select Cover Image": "选择封面图片", + "Open Book in New Window": "在新窗口中打开书籍" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 7d879157..3f06d73a 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -445,5 +445,6 @@ "Google Books": "Google 圖書", "Open Library": "開放圖書館", "Fiction, Science, History": "小說,科學,歷史", - "Select Cover Image": "選擇封面圖片" + "Select Cover Image": "選擇封面圖片", + "Open Book in New Window": "在新視窗中打開書籍" } diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 794f32eb..f88a0c9a 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main", "updater"], + "windows": ["main", "updater", "reader-*"], "permissions": [ "core:default", "fs:default", @@ -91,6 +91,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-set-fullscreen", "core:window:allow-set-always-on-top", + "core:window:allow-destroy", "core:webview:allow-create-webview-window", "core:path:allow-resolve-directory", "log:default", diff --git a/apps/readest-app/src-tauri/src/macos/traffic_light.rs b/apps/readest-app/src-tauri/src/macos/traffic_light.rs index 5db6a64d..d542c1bb 100644 --- a/apps/readest-app/src-tauri/src/macos/traffic_light.rs +++ b/apps/readest-app/src-tauri/src/macos/traffic_light.rs @@ -144,7 +144,8 @@ pub fn setup_traffic_light_positioner(window: Window) { .expect("NS window should exist on state to handle resize") as id; - if state.window.label() == "main" { + if state.window.label() == "main" || state.window.label().starts_with("reader") + { position_traffic_lights( UnsafeWindowHandle(id as *mut std::ffi::c_void), TRAFFIC_LIGHTS_VISIBLE, @@ -278,7 +279,8 @@ pub fn setup_traffic_light_positioner(window: Window) { .expect("Failed to emit event"); let id = state.window.ns_window().expect("Failed to emit event") as id; - if state.window.label() == "main" { + if state.window.label() == "main" || state.window.label().starts_with("reader") + { position_traffic_lights( UnsafeWindowHandle(id as *mut std::ffi::c_void), TRAFFIC_LIGHTS_VISIBLE, diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 1cfc4f6d..cc128570 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -11,7 +11,7 @@ import { useEnv } from '@/context/EnvContext'; import { useSettingsStore } from '@/store/settingsStore'; import { useLibraryStore } from '@/store/libraryStore'; import { useTranslation } from '@/hooks/useTranslation'; -import { navigateToLibrary, navigateToReader } from '@/utils/nav'; +import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav'; import { formatAuthors, formatTitle } from '@/utils/book'; import { isMd5 } from '@/utils/md5'; @@ -169,8 +169,13 @@ const Bookshelf: React.FC = ({ }; const openSelectedBooks = () => { - setTimeout(() => setLoading(true), 200); - navigateToReader(router, selectedBooks); + handleSetSelectMode(false); + if (appService?.hasWindow && settings.openBookInNewWindow) { + showReaderWindow(appService, selectedBooks); + } else { + setTimeout(() => setLoading(true), 200); + navigateToReader(router, selectedBooks); + } }; const openBookDetails = () => { diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index a6112e41..6db07be0 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { useRouter, useSearchParams } from 'next/navigation'; -import { navigateToLibrary, navigateToReader } from '@/utils/nav'; +import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav'; import { useEnv } from '@/context/EnvContext'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; @@ -117,8 +117,9 @@ const BookshelfItem: React.FC = ({ const makeBookAvailable = async (book: Book) => { if (book.uploadedAt && !book.downloadedAt) { if (await appService?.isBookAvailable(book)) { - if (!book.downloadedAt) { + if (!book.downloadedAt || !book.coverDownloadedAt) { book.downloadedAt = Date.now(); + book.coverDownloadedAt = Date.now(); updateBook(envConfig, book); } return true; @@ -142,7 +143,11 @@ const BookshelfItem: React.FC = ({ toggleSelection(book.hash); } else { if (!(await makeBookAvailable(book))) return; - navigateToReader(router, [book.hash]); + if (appService?.hasWindow && settings.openBookInNewWindow) { + showReaderWindow(appService, [book.hash]); + } else { + navigateToReader(router, [book.hash]); + } } }; diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 9141ca82..240378b9 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -83,6 +83,13 @@ const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { setIsDropdownOpen?.(false); }; + const toggleOpenInNewWindow = () => { + settings.openBookInNewWindow = !settings.openBookInNewWindow; + setSettings(settings); + saveSettings(envConfig, settings); + setIsDropdownOpen?.(false); + }; + const toggleAlwaysOnTop = () => { settings.alwaysOnTop = !settings.alwaysOnTop; setSettings(settings); @@ -219,6 +226,13 @@ const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { /> )}
+ {appService?.hasWindow && ( + + )} {appService?.hasWindow && } {appService?.hasWindow && ( { const processOldBook = async (oldBook: Book) => { const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash); if (matchingBook) { - if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.downloadedAt) { + if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.coverDownloadedAt) { await appService?.downloadBook(oldBook, true); } const mergedBook = diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 881ea075..99a4fcf3 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import * as React from 'react'; -import { useState, useRef, useEffect, Suspense } from 'react'; +import { useState, useRef, useEffect, Suspense, useCallback } from 'react'; import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation'; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; import 'overlayscrollbars/overlayscrollbars.css'; @@ -194,6 +194,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP } }; + const handleRefreshLibrary = useCallback(async () => { + const appService = await envConfig.getAppService(); + const settings = await appService.loadSettings(); + const library = await appService.loadLibraryBooks(); + setSettings(settings); + setLibrary(library); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [envConfig, appService]); + + useEffect(() => { + if (appService?.hasWindow) { + const currentWebview = getCurrentWebview(); + const unlisten = currentWebview.listen('close-reader-window', async () => { + handleRefreshLibrary(); + }); + return () => { + unlisten.then((fn) => fn()); + }; + } + return; + }, [appService, handleRefreshLibrary]); + useEffect(() => { const libraryPage = document.querySelector('.library-page'); if (!appService?.isMobile) { @@ -294,7 +316,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP navigateToReader(router, bookIds); } } - }, [pendingNavigationBookIds, router]); + }, [pendingNavigationBookIds, appService, router]); useEffect(() => { if (isInitiating.current) return; diff --git a/apps/readest-app/src/app/reader/components/HeaderBar.tsx b/apps/readest-app/src/app/reader/components/HeaderBar.tsx index 644368dd..879653f9 100644 --- a/apps/readest-app/src/app/reader/components/HeaderBar.tsx +++ b/apps/readest-app/src/app/reader/components/HeaderBar.tsx @@ -62,19 +62,18 @@ const HeaderBar: React.FC = ({ initializeTrafficLightStore(appService); initializeTrafficLightListeners(); - setTrafficLightVisibility(true); return () => { cleanupTrafficLightListeners(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [appService]); useEffect(() => { if (!appService?.hasTrafficLight) return; setTrafficLightVisibility(isSideBarVisible); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSideBarVisible]); + }, [appService, isSideBarVisible]); const isVisible = hoveredBookKey === bookKey || isDropdownOpen; @@ -86,7 +85,7 @@ const HeaderBar: React.FC = ({ }} >