diff --git a/.env.e2e.example b/.env.e2e.example index b9b8e75f9..43ae5b781 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -8,7 +8,7 @@ VERDACCIO_PORT=10104 ################# TACHYBASE APPLICATION ################# -# !!! When `APP_ENV=production`, opening http://localhost:13000/ will show "Not Found". +# !!! When `APP_ENV=production`, opening http://localhost:3000/ will show "Not Found". # !!! It is recommended to use nginx to proxy static files. For example https://github.com/tachybase/tachybase/blob/main/docker/tachybase/tachybase.conf APP_ENV=development APP_PORT=20000 diff --git a/.env.test.example b/.env.test.example index ae891e731..094fe2566 100644 --- a/.env.test.example +++ b/.env.test.example @@ -1,7 +1,7 @@ ################# TACHYBASE APPLICATION ################# APP_ENV=development -APP_PORT=13000 +APP_PORT=3000 APP_KEY=test-key API_BASE_PATH=/api/ diff --git a/docker/tachybase/tachybase.conf b/docker/tachybase/tachybase.conf index 9af627bf9..4725bbd76 100644 --- a/docker/tachybase/tachybase.conf +++ b/docker/tachybase/tachybase.conf @@ -54,7 +54,7 @@ server { } location ^~ /api/ { - proxy_pass http://127.0.0.1:13000/api/; + proxy_pass http://127.0.0.1:3000/api/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -67,7 +67,7 @@ server { } location ^~ /adapters/ { - proxy_pass http://127.0.0.1:13000/adapters/; + proxy_pass http://127.0.0.1:3000/adapters/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -80,7 +80,7 @@ server { } location ^~ /static/plugins/ { - proxy_pass http://127.0.0.1:13000/static/plugins/; + proxy_pass http://127.0.0.1:3000/static/plugins/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -93,7 +93,7 @@ server { } location /ws { - proxy_pass http://127.0.0.1:13000/ws; + proxy_pass http://127.0.0.1:3000/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; diff --git a/packages/core/app/src/__tests__/commands.test.ts b/packages/core/app/src/__tests__/commands.test.ts index ac7ae7cb1..efec85591 100644 --- a/packages/core/app/src/__tests__/commands.test.ts +++ b/packages/core/app/src/__tests__/commands.test.ts @@ -67,7 +67,7 @@ describe.skip('cli', () => { test('install', async () => { const database = await createDatabase(); const port = await getPortPromise({ - port: 13000, + port: 3000, }); console.log(process.env.DB_DIALECT, port); const dbFile = `storage/tests/db/tachybase-${uid()}.sqlite`; @@ -97,7 +97,7 @@ describe.skip('cli', () => { console.log(process.env.DB_DIALECT); const database = await createDatabase(); const port = await getPortPromise({ - port: 13000, + port: 3000, }); const dbFile = `storage/tests/db/tachybase-${uid()}.sqlite`; const env = { @@ -127,7 +127,7 @@ describe.skip('cli', () => { console.log(process.env.DB_DIALECT); const database = await createDatabase(); const port = await getPortPromise({ - port: 13000, + port: 3000, }); const dbFile = `storage/tests/db/tachybase-${uid()}.sqlite`; const env = { @@ -153,7 +153,7 @@ describe.skip('cli', () => { console.log(process.env.DB_DIALECT); const database = await createDatabase(); const port = await getPortPromise({ - port: 13000, + port: 3000, }); const dbFile = `storage/tests/db/tachybase-${uid()}.sqlite`; const env = { @@ -183,7 +183,7 @@ describe.skip('cli', () => { console.log(process.env.DB_DIALECT); const database = await createDatabase(); const port = await getPortPromise({ - port: 13000, + port: 3000, }); const dbFile = `storage/tests/db/tachybase-${uid()}.sqlite`; const env = { @@ -208,7 +208,7 @@ describe.skip('cli', () => { console.log(process.env.DB_DIALECT); const database = await createDatabase(); const port = await getPortPromise({ - port: 13000, + port: 3000, }); const dbFile = `storage/tests/db/tachybase-${uid()}.sqlite`; const env = { diff --git a/packages/core/cli/src/util.ts b/packages/core/cli/src/util.ts index 849c34304..f547bc63e 100644 --- a/packages/core/cli/src/util.ts +++ b/packages/core/cli/src/util.ts @@ -320,7 +320,7 @@ export function initEnv() { const env = { APP_ENV: 'development', APP_KEY: 'test-jwt-secret', - APP_PORT: 13000, + APP_PORT: 3000, API_BASE_PATH: '/api/', DB_DIALECT: 'sqlite', DB_STORAGE: 'storage/db/tachybase.sqlite', diff --git a/packages/core/client/package.json b/packages/core/client/package.json index b47bd0044..d4d601864 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -54,7 +54,6 @@ "react-hotkeys-hook": "^3.4.7", "react-i18next": "^14.1.2", "react-iframe": "~1.8.5", - "react-image-lightbox": "^5.1.4", "react-intersection-observer": "^9.10.3", "react-js-cron": "^3.1.0", "react-quill": "^2.0.0", diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index 3535f8153..f3f8d92d6 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -28,6 +28,7 @@ import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterMa import { SchemaInitializer, SchemaInitializerManager } from './schema-initializer'; import * as schemaInitializerComponents from './schema-initializer/components'; import { SchemaSettings, SchemaSettingsManager } from './schema-settings'; +import { UserSettingOptions, UserSettingsManager } from './UserSettingsManager'; import { compose, normalizeContainer } from './utils'; import { defineGlobalDeps } from './utils/globalDeps'; import { getRequireJs, type RequireJS } from './utils/requirejs'; @@ -36,6 +37,7 @@ import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient'; declare global { interface Window { define: RequireJS['define']; + require: RequireJS['require']; } } @@ -53,6 +55,7 @@ export interface ApplicationOptions { scopes?: Record; router?: RouterOptions; pluginSettings?: Record; + userSettings?: Record; schemaSettings?: SchemaSettings[]; schemaInitializers?: SchemaInitializer[]; designable?: boolean; @@ -77,6 +80,7 @@ export class Application { }; public pluginManager: PluginManager; public pluginSettingsManager: PluginSettingsManager; + public userSettingsManager: UserSettingsManager; public devDynamicImport: DevDynamicImport; public requirejs: RequireJS; public notification; @@ -129,6 +133,7 @@ export class Application { this.addProviders(options.providers || []); this.ws = new WebSocketClient(options.ws, this); this.pluginSettingsManager = new PluginSettingsManager(options.pluginSettings, this); + this.userSettingsManager = new UserSettingsManager(options.userSettings, this); this.addRoutes(); this.name = this.options.name || getSubAppName(options.publicPath) || 'main'; this.pluginContextMenu = new PluginContextMenu(options.pluginMenuItems, this); @@ -138,7 +143,7 @@ export class Application { this.requirejs = getRequireJs(); defineGlobalDeps(this.requirejs); window.define = this.requirejs.define; - window.require = this.requirejs.require; + window.require = this.requirejs.require as RequireJS['require'] & NodeRequire; } private addDefaultProviders() { diff --git a/packages/core/client/src/application/Plugin.ts b/packages/core/client/src/application/Plugin.ts index 23149cf0f..102d0c0cb 100644 --- a/packages/core/client/src/application/Plugin.ts +++ b/packages/core/client/src/application/Plugin.ts @@ -27,6 +27,10 @@ export class Plugin { return this.app.pluginSettingsManager; } + get userSettingsManager() { + return this.app.userSettingsManager; + } + get schemaInitializerManager() { return this.app.schemaInitializerManager; } diff --git a/packages/core/client/src/application/UserSettingsManager.ts b/packages/core/client/src/application/UserSettingsManager.ts new file mode 100644 index 000000000..e3c24cbf0 --- /dev/null +++ b/packages/core/client/src/application/UserSettingsManager.ts @@ -0,0 +1,161 @@ +import React, { createElement } from 'react'; + +import { set } from 'lodash'; +import { Outlet } from 'react-router-dom'; + +import { Icon } from '../icon'; +import type { Application } from './Application'; +import type { RouteType } from './RouterManager'; + +export const USER_SETTINGS_KEY = 'admin.profilers.'; +export const USER_SETTINGS_PATH = '/admin/profilers/'; +export const SNIPPET_PREFIX = 'pm.'; + +export interface UserSettingOptions { + title: any; + /** + * @default Outlet + */ + Component?: RouteType['Component']; + icon?: string; + /** + * sort, the smaller the number, the higher the priority + * @default 0 + */ + sort?: number; + aclSnippet?: string; + [index: string]: any; +} + +export interface UserSettingsPageType { + label?: string | React.ReactElement; + title: string | React.ReactElement; + key: string; + icon: any; + path: string; + sort?: number; + name?: string; + isAllow?: boolean; + topLevelName?: string; + aclSnippet: string; + children?: UserSettingsPageType[]; + [index: string]: any; +} + +export class UserSettingsManager { + protected settings: Record = {}; + protected aclSnippets: string[] = []; + public app: Application; + private cachedList = {}; + + constructor(_userSettings: Record, app: Application) { + this.app = app; + Object.entries(_userSettings || {}).forEach(([name, userSettingOptions]) => { + this.add(name, userSettingOptions); + }); + } + + setAclSnippets(aclSnippets: string[]) { + this.aclSnippets = aclSnippets; + } + + getAclSnippet(name: string) { + const setting = this.settings[name]; + return setting?.aclSnippet ? setting.aclSnippet : `${SNIPPET_PREFIX}${name}`; + } + + getRouteName(name: string) { + return `${USER_SETTINGS_KEY}${name}`; + } + + getRoutePath(name: string) { + return `${USER_SETTINGS_PATH}${name.replaceAll('.', '/')}`; + } + + add(name: string, options: UserSettingOptions) { + const nameArr = name.split('.'); + const topLevelName = nameArr[0]; + this.settings[name] = { + ...this.settings[name], + Component: Outlet, + ...options, + name, + topLevelName: options.topLevelName || topLevelName, + }; + // add children + if (nameArr.length > 1) { + set(this.settings, nameArr.join('.children.'), this.settings[name]); + } + + // add route + this.app.router.add(this.getRouteName(name), { + path: this.getRoutePath(name), + Component: this.settings[name].Component, + }); + } + + remove(name: string) { + // delete self and children + Object.keys(this.settings).forEach((key) => { + if (key.startsWith(name)) { + delete this.settings[key]; + this.app.router.remove(`${ADMIN_SETTINGS_KEY}${key}`); + } + }); + } + + hasAuth(name: string) { + if (this.aclSnippets.includes(`!${this.getAclSnippet('*')}`)) return false; + return this.aclSnippets.includes(`!${this.getAclSnippet(name)}`) === false; + } + + getSetting(name: string) { + return this.settings[name]; + } + + has(name: string) { + const hasAuth = this.hasAuth(name); + if (!hasAuth) return false; + return !!this.getSetting(name); + } + + get(name: string, filterAuth = true): UserSettingsPageType { + const isAllow = this.hasAuth(name); + const userSetting = this.getSetting(name); + if ((filterAuth && !isAllow) || !userSetting) return null; + const children = Object.keys(userSetting.children || {}) + .sort((a, b) => a.localeCompare(b)) // sort by name + .map((key) => this.get(userSetting.children[key].name, filterAuth)) + .filter(Boolean) + .sort((a, b) => (a.sort || 0) - (b.sort || 0)); + const { title, icon, aclSnippet, ...others } = userSetting; + return { + ...others, + aclSnippet: this.getAclSnippet(name), + title, + isAllow, + label: title, + icon: typeof icon === 'string' ? createElement(Icon, { type: icon }) : icon, + path: this.getRoutePath(name), + key: name, + children: children.length ? children : undefined, + }; + } + + getList(filterAuth = true): UserSettingsPageType[] { + const cacheKey = JSON.stringify(filterAuth); + if (this.cachedList[cacheKey]) return this.cachedList[cacheKey]; + + return (this.cachedList[cacheKey] = Array.from( + new Set(Object.values(this.settings).map((item) => item.topLevelName)), + ) + .sort((a, b) => a.localeCompare(b)) // sort by name + .map((name) => this.get(name, filterAuth)) + .filter(Boolean) + .sort((a, b) => (a.sort || 0) - (b.sort || 0))); + } + + getAclSnippets() { + return Object.keys(this.settings).map((name) => this.getAclSnippet(name)); + } +} diff --git a/packages/core/client/src/built-in/index.tsx b/packages/core/client/src/built-in/index.tsx index 83a829d2b..16b13cc76 100644 --- a/packages/core/client/src/built-in/index.tsx +++ b/packages/core/client/src/built-in/index.tsx @@ -30,6 +30,8 @@ import { LocalePlugin } from './locale/LocalePlugin'; import { PinnedListPlugin } from './pinned-list'; import { PMPlugin } from './pm'; import { SystemSettingsPlugin } from './system-settings'; +import { UserSettingsPlugin } from './user-settings'; +import { PluginWebNotification } from './web-notification/PluginWebNotification'; export { AdminProvider, NoticeArea } from './admin-layout'; export * from './context-menu'; @@ -352,7 +354,8 @@ export class BuiltInPlugin extends Plugin { await this.app.pm.add(RemoteDocumentTitlePlugin, { name: 'remote-document-title' }); await this.app.pm.add(PMPlugin, { name: 'builtin-pm' }); await this.app.pm.add(CollectionPlugin, { name: 'builtin-collection' }); - await this.app.pm.add(PluginContextMenu); - await this.app.pm.add(PluginAssistant); + await this.app.pm.add(PluginContextMenu, { name: 'context-menu' }); + await this.app.pm.add(PluginAssistant, { name: 'drag-assistant' }); + await this.app.pm.add(UserSettingsPlugin, { name: 'user-settings' }); } } diff --git a/packages/core/client/src/built-in/pm/index.tsx b/packages/core/client/src/built-in/pm/index.tsx index 265aa99fc..1ac8405a9 100644 --- a/packages/core/client/src/built-in/pm/index.tsx +++ b/packages/core/client/src/built-in/pm/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { ADMIN_SETTINGS_PATH } from '../../application'; import { Plugin } from '../../application/Plugin'; +import { USER_SETTINGS_PATH } from '../../application/UserSettingsManager'; import { BlockTemplatesPane } from '../../schema-templates'; import { SystemSettingsPane } from '../system-settings'; import { PluginManager } from './PluginManager'; diff --git a/packages/core/client/src/built-in/user-settings/UserSettingsLayout.tsx b/packages/core/client/src/built-in/user-settings/UserSettingsLayout.tsx new file mode 100644 index 000000000..f77b1125f --- /dev/null +++ b/packages/core/client/src/built-in/user-settings/UserSettingsLayout.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import { Outlet } from 'react-router'; + +export const UserSettingsLayout = () => { + return ; +}; diff --git a/packages/core/client/src/built-in/user-settings/index.tsx b/packages/core/client/src/built-in/user-settings/index.tsx new file mode 100644 index 000000000..2385ab7a8 --- /dev/null +++ b/packages/core/client/src/built-in/user-settings/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { Plugin } from '../../application/Plugin'; +import { USER_SETTINGS_PATH } from '../../application/UserSettingsManager'; +import { UserSettingsLayout } from './UserSettingsLayout'; + +export class UserSettingsPlugin extends Plugin { + async load() { + this.app.router.add('admin.profilers', { + path: USER_SETTINGS_PATH, + element: , + }); + } +} diff --git a/packages/core/client/src/built-in/web-notification/PluginWebNotification.tsx b/packages/core/client/src/built-in/web-notification/PluginWebNotification.tsx new file mode 100644 index 000000000..08fa15e7b --- /dev/null +++ b/packages/core/client/src/built-in/web-notification/PluginWebNotification.tsx @@ -0,0 +1,17 @@ +import { Plugin } from '../../application/Plugin'; + +export class PluginWebNotification extends Plugin { + async afterLoad() { + // ‌请求用户授予权限 + if (Notification.permission !== 'denied') { + await Notification.requestPermission(); + } + + setTimeout(() => { + const notification = new Notification('通知标题:', { + body: '通知内容', + icon: 'https://www.example.com/icon.png', // 通知的图标 URL + }); + }, 2000); + } +} diff --git a/packages/core/client/src/schema-component/antd/preview/Preview.tsx b/packages/core/client/src/schema-component/antd/preview/Preview.tsx index 4b42d8ac8..9f42a8e9b 100644 --- a/packages/core/client/src/schema-component/antd/preview/Preview.tsx +++ b/packages/core/client/src/schema-component/antd/preview/Preview.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { Lightbox } from '@tachybase/components'; import { connect, mapReadPretty } from '@tachybase/schema'; import { DeleteOutlined, DownloadOutlined, PlusOutlined } from '@ant-design/icons'; @@ -6,9 +7,6 @@ import { Upload as AntdUpload, Button, Progress, Space, UploadFile } from 'antd' import cls from 'classnames'; import { saveAs } from 'file-saver'; import { useTranslation } from 'react-i18next'; -import Lightbox from 'react-image-lightbox'; - -import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app import { ReadPretty } from '../upload/ReadPretty'; import { isImage, toFileList, useUploadProps } from '../upload/shared'; diff --git a/packages/core/client/src/schema-component/antd/upload/ReadPretty.tsx b/packages/core/client/src/schema-component/antd/upload/ReadPretty.tsx index 1957911ea..1b7a9b967 100644 --- a/packages/core/client/src/schema-component/antd/upload/ReadPretty.tsx +++ b/packages/core/client/src/schema-component/antd/upload/ReadPretty.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { Lightbox } from '@tachybase/components'; import { Field, useField } from '@tachybase/schema'; import { isString } from '@tachybase/utils/client'; @@ -8,7 +9,6 @@ import useUploadStyle from 'antd/es/upload/style'; import cls from 'classnames'; import { saveAs } from 'file-saver'; import { useTranslation } from 'react-i18next'; -import Lightbox from 'react-image-lightbox'; import { useRecord } from '../../../record-provider'; import { isImage, isPdf, toArr, toImages } from './shared'; diff --git a/packages/core/client/src/schema-component/antd/upload/Upload.tsx b/packages/core/client/src/schema-component/antd/upload/Upload.tsx index a55e33ca2..7c127d738 100644 --- a/packages/core/client/src/schema-component/antd/upload/Upload.tsx +++ b/packages/core/client/src/schema-component/antd/upload/Upload.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { Lightbox } from '@tachybase/components'; import { connect, mapProps, mapReadPretty } from '@tachybase/schema'; import { DeleteOutlined, DownloadOutlined, InboxOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'; @@ -6,9 +7,6 @@ import { Upload as AntdUpload, Button, Modal, Progress, Space, UploadFile } from import cls from 'classnames'; import { saveAs } from 'file-saver'; import { useTranslation } from 'react-i18next'; -import LightBox from 'react-image-lightbox'; - -import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps'; import { useProps } from '../../hooks/useProps'; @@ -201,7 +199,7 @@ Upload.Attachment = connect((props: UploadProps) => { {/* 预览图片的弹框 */} {visible && fileType === 'image' && ( - windowWidth) { + nextX += (windowWidth - width) / 2; + } + const scaleFactor = zoom * (targetWidth / width); + + return { + transform: `translate3d(${nextX}px,${y}px,0) scale3d(${scaleFactor},${scaleFactor},1)`, + }; + } + + constructor(props) { + super(props); + + this.state = { + //----------------------------- + // Animation + //----------------------------- + + // Lightbox is closing + // When Lightbox is mounted, if animation is enabled it will open with the reverse of the closing animation + isClosing: !props.animationDisabled, + + // Component parts should animate (e.g., when images are moving, or image is being zoomed) + shouldAnimate: false, + + //----------------------------- + // Zoom settings + //----------------------------- + // Zoom level of image + zoomLevel: MIN_ZOOM_LEVEL, + + //----------------------------- + // Image position settings + //----------------------------- + // Horizontal offset from center + offsetX: 0, + + // Vertical offset from center + offsetY: 0, + + // image load error for srcType + loadErrorStatus: {}, + }; + + // Refs + this.outerEl = React.createRef(); + this.zoomInBtn = React.createRef(); + this.zoomOutBtn = React.createRef(); + this.caption = React.createRef(); + + this.closeIfClickInner = this.closeIfClickInner.bind(this); + this.handleImageDoubleClick = this.handleImageDoubleClick.bind(this); + this.handleImageMouseWheel = this.handleImageMouseWheel.bind(this); + this.handleKeyInput = this.handleKeyInput.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleOuterMousewheel = this.handleOuterMousewheel.bind(this); + this.handleTouchStart = this.handleTouchStart.bind(this); + this.handleTouchMove = this.handleTouchMove.bind(this); + this.handleTouchEnd = this.handleTouchEnd.bind(this); + this.handlePointerEvent = this.handlePointerEvent.bind(this); + this.handleCaptionMousewheel = this.handleCaptionMousewheel.bind(this); + this.handleWindowResize = this.handleWindowResize.bind(this); + this.handleZoomInButtonClick = this.handleZoomInButtonClick.bind(this); + this.handleZoomOutButtonClick = this.handleZoomOutButtonClick.bind(this); + this.requestClose = this.requestClose.bind(this); + this.requestMoveNext = this.requestMoveNext.bind(this); + this.requestMovePrev = this.requestMovePrev.bind(this); + + // Timeouts - always clear it before umount + this.timeouts = []; + + // Current action + this.currentAction = ACTION_NONE; + + // Events source + this.eventsSource = SOURCE_ANY; + + // Empty pointers list + this.pointerList = []; + + // Prevent inner close + this.preventInnerClose = false; + this.preventInnerCloseTimeout = null; + + // Used to disable animation when changing props.mainSrc|nextSrc|prevSrc + this.keyPressed = false; + + // Used to store load state / dimensions of images + this.imageCache = {}; + + // Time the last keydown event was called (used in keyboard action rate limiting) + this.lastKeyDownTime = 0; + + // Used for debouncing window resize event + this.resizeTimeout = null; + + // Used to determine when actions are triggered by the scroll wheel + this.wheelActionTimeout = null; + this.resetScrollTimeout = null; + this.scrollX = 0; + this.scrollY = 0; + + // Used in panning zoomed images + this.moveStartX = 0; + this.moveStartY = 0; + this.moveStartOffsetX = 0; + this.moveStartOffsetY = 0; + + // Used to swipe + this.swipeStartX = 0; + this.swipeStartY = 0; + this.swipeEndX = 0; + this.swipeEndY = 0; + + // Used to pinch + this.pinchTouchList = null; + this.pinchDistance = 0; + + // Used to differentiate between images with identical src + this.keyCounter = 0; + + // Used to detect a move when all src's remain unchanged (four or more of the same image in a row) + this.moveRequested = false; + } + + componentDidMount() { + if (!this.props.animationDisabled) { + // Make opening animation play + this.setState({ isClosing: false }); + } + + // Prevents cross-origin errors when using a cross-origin iframe + this.windowContext = getHighestSafeWindowContext(); + + this.listeners = { + resize: this.handleWindowResize, + mouseup: this.handleMouseUp, + touchend: this.handleTouchEnd, + touchcancel: this.handleTouchEnd, + pointerdown: this.handlePointerEvent, + pointermove: this.handlePointerEvent, + pointerup: this.handlePointerEvent, + pointercancel: this.handlePointerEvent, + }; + Object.keys(this.listeners).forEach((type) => { + this.windowContext.addEventListener(type, this.listeners[type]); + }); + + this.loadAllImages(); + } + + shouldComponentUpdate(nextProps) { + this.getSrcTypes().forEach((srcType) => { + if (this.props[srcType.name] !== nextProps[srcType.name]) { + this.moveRequested = false; + } + }); + + // Wait for move... + return !this.moveRequested; + } + + componentDidUpdate(prevProps) { + let sourcesChanged = false; + const prevSrcDict = {}; + const nextSrcDict = {}; + this.getSrcTypes().forEach((srcType) => { + if (prevProps[srcType.name] !== this.props[srcType.name]) { + sourcesChanged = true; + + prevSrcDict[prevProps[srcType.name]] = true; + nextSrcDict[this.props[srcType.name]] = true; + } + }); + + if (sourcesChanged || this.moveRequested) { + // Reset the loaded state for images not rendered next + Object.keys(prevSrcDict).forEach((prevSrc) => { + if (!(prevSrc in nextSrcDict) && prevSrc in this.imageCache) { + this.imageCache[prevSrc].loaded = false; + } + }); + + this.moveRequested = false; + + // Load any new images + this.loadAllImages(this.props); + } + } + + componentWillUnmount() { + this.didUnmount = true; + Object.keys(this.listeners).forEach((type) => { + this.windowContext.removeEventListener(type, this.listeners[type]); + }); + this.timeouts.forEach((tid) => clearTimeout(tid)); + } + + setTimeout(func, time) { + const id = setTimeout(() => { + this.timeouts = this.timeouts.filter((tid) => tid !== id); + func(); + }, time); + this.timeouts.push(id); + return id; + } + + setPreventInnerClose() { + if (this.preventInnerCloseTimeout) { + this.clearTimeout(this.preventInnerCloseTimeout); + } + this.preventInnerClose = true; + this.preventInnerCloseTimeout = this.setTimeout(() => { + this.preventInnerClose = false; + this.preventInnerCloseTimeout = null; + }, 100); + } + + // Get info for the best suited image to display with the given srcType + getBestImageForType(srcType) { + let imageSrc = this.props[srcType]; + let fitSizes = {}; + + if (this.isImageLoaded(imageSrc)) { + // Use full-size image if available + fitSizes = this.getFitSizes(this.imageCache[imageSrc].width, this.imageCache[imageSrc].height); + } else if (this.isImageLoaded(this.props[`${srcType}Thumbnail`])) { + // Fall back to using thumbnail if the image has not been loaded + imageSrc = this.props[`${srcType}Thumbnail`]; + fitSizes = this.getFitSizes(this.imageCache[imageSrc].width, this.imageCache[imageSrc].height, true); + } else { + return null; + } + + return { + src: imageSrc, + height: this.imageCache[imageSrc].height, + width: this.imageCache[imageSrc].width, + targetHeight: fitSizes.height, + targetWidth: fitSizes.width, + }; + } + + // Get sizing for when an image is larger than the window + getFitSizes(width, height, stretch) { + const boxSize = this.getLightboxRect(); + let maxHeight = boxSize.height - this.props.imagePadding * 2; + let maxWidth = boxSize.width - this.props.imagePadding * 2; + + if (!stretch) { + maxHeight = Math.min(maxHeight, height); + maxWidth = Math.min(maxWidth, width); + } + + const maxRatio = maxWidth / maxHeight; + const srcRatio = width / height; + + if (maxRatio > srcRatio) { + // height is the constraining dimension of the photo + return { + width: (width * maxHeight) / height, + height: maxHeight, + }; + } + + return { + width: maxWidth, + height: (height * maxWidth) / width, + }; + } + + getMaxOffsets(zoomLevel = this.state.zoomLevel) { + const currentImageInfo = this.getBestImageForType('mainSrc'); + if (currentImageInfo === null) { + return { maxX: 0, minX: 0, maxY: 0, minY: 0 }; + } + + const boxSize = this.getLightboxRect(); + const zoomMultiplier = this.getZoomMultiplier(zoomLevel); + + let maxX = 0; + if (zoomMultiplier * currentImageInfo.width - boxSize.width < 0) { + // if there is still blank space in the X dimension, don't limit except to the opposite edge + maxX = (boxSize.width - zoomMultiplier * currentImageInfo.width) / 2; + } else { + maxX = (zoomMultiplier * currentImageInfo.width - boxSize.width) / 2; + } + + let maxY = 0; + if (zoomMultiplier * currentImageInfo.height - boxSize.height < 0) { + // if there is still blank space in the Y dimension, don't limit except to the opposite edge + maxY = (boxSize.height - zoomMultiplier * currentImageInfo.height) / 2; + } else { + maxY = (zoomMultiplier * currentImageInfo.height - boxSize.height) / 2; + } + + return { + maxX, + maxY, + minX: -1 * maxX, + minY: -1 * maxY, + }; + } + + // Get image src types + getSrcTypes() { + return [ + { + name: 'mainSrc', + keyEnding: `i${this.keyCounter}`, + }, + { + name: 'mainSrcThumbnail', + keyEnding: `t${this.keyCounter}`, + }, + { + name: 'nextSrc', + keyEnding: `i${this.keyCounter + 1}`, + }, + { + name: 'nextSrcThumbnail', + keyEnding: `t${this.keyCounter + 1}`, + }, + { + name: 'prevSrc', + keyEnding: `i${this.keyCounter - 1}`, + }, + { + name: 'prevSrcThumbnail', + keyEnding: `t${this.keyCounter - 1}`, + }, + ]; + } + + /** + * Get sizing when the image is scaled + */ + getZoomMultiplier(zoomLevel = this.state.zoomLevel) { + return ZOOM_RATIO ** zoomLevel; + } + + /** + * Get the size of the lightbox in pixels + */ + getLightboxRect() { + if (this.outerEl.current) { + return this.outerEl.current.getBoundingClientRect(); + } + + return { + width: getWindowWidth(), + height: getWindowHeight(), + top: 0, + right: 0, + bottom: 0, + left: 0, + }; + } + + clearTimeout(id) { + this.timeouts = this.timeouts.filter((tid) => tid !== id); + clearTimeout(id); + } + + // Change zoom level + changeZoom(zoomLevel, clientX, clientY) { + // Ignore if zoom disabled + if (!this.props.enableZoom) { + return; + } + + // Constrain zoom level to the set bounds + const nextZoomLevel = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, zoomLevel)); + + // Ignore requests that don't change the zoom level + if (nextZoomLevel === this.state.zoomLevel) { + return; + } + + if (nextZoomLevel === MIN_ZOOM_LEVEL) { + // Snap back to center if zoomed all the way out + this.setState({ + zoomLevel: nextZoomLevel, + offsetX: 0, + offsetY: 0, + }); + + return; + } + + const imageBaseSize = this.getBestImageForType('mainSrc'); + if (imageBaseSize === null) { + return; + } + + const currentZoomMultiplier = this.getZoomMultiplier(); + const nextZoomMultiplier = this.getZoomMultiplier(nextZoomLevel); + + // Default to the center of the image to zoom when no mouse position specified + const boxRect = this.getLightboxRect(); + const pointerX = typeof clientX !== 'undefined' ? clientX - boxRect.left : boxRect.width / 2; + const pointerY = typeof clientY !== 'undefined' ? clientY - boxRect.top : boxRect.height / 2; + + const currentImageOffsetX = (boxRect.width - imageBaseSize.width * currentZoomMultiplier) / 2; + const currentImageOffsetY = (boxRect.height - imageBaseSize.height * currentZoomMultiplier) / 2; + + const currentImageRealOffsetX = currentImageOffsetX - this.state.offsetX; + const currentImageRealOffsetY = currentImageOffsetY - this.state.offsetY; + + const currentPointerXRelativeToImage = (pointerX - currentImageRealOffsetX) / currentZoomMultiplier; + const currentPointerYRelativeToImage = (pointerY - currentImageRealOffsetY) / currentZoomMultiplier; + + const nextImageRealOffsetX = pointerX - currentPointerXRelativeToImage * nextZoomMultiplier; + const nextImageRealOffsetY = pointerY - currentPointerYRelativeToImage * nextZoomMultiplier; + + const nextImageOffsetX = (boxRect.width - imageBaseSize.width * nextZoomMultiplier) / 2; + const nextImageOffsetY = (boxRect.height - imageBaseSize.height * nextZoomMultiplier) / 2; + + let nextOffsetX = nextImageOffsetX - nextImageRealOffsetX; + let nextOffsetY = nextImageOffsetY - nextImageRealOffsetY; + + // When zooming out, limit the offset so things don't get left askew + if (this.currentAction !== ACTION_PINCH) { + const maxOffsets = this.getMaxOffsets(); + if (this.state.zoomLevel > nextZoomLevel) { + nextOffsetX = Math.max(maxOffsets.minX, Math.min(maxOffsets.maxX, nextOffsetX)); + nextOffsetY = Math.max(maxOffsets.minY, Math.min(maxOffsets.maxY, nextOffsetY)); + } + } + + this.setState({ + zoomLevel: nextZoomLevel, + offsetX: nextOffsetX, + offsetY: nextOffsetY, + }); + } + + closeIfClickInner(event) { + if (!this.preventInnerClose && event.target.className.search(/\bril-inner\b/) > -1) { + this.requestClose(event); + } + } + + /** + * Handle user keyboard actions + */ + handleKeyInput(event) { + event.stopPropagation(); + + // Ignore key input during animations + if (this.isAnimating()) { + return; + } + + // Allow slightly faster navigation through the images when user presses keys repeatedly + if (event.type === 'keyup') { + this.lastKeyDownTime -= this.props.keyRepeatKeyupBonus; + return; + } + + const keyCode = event.which || event.keyCode; + + // Ignore key presses that happen too close to each other (when rapid fire key pressing or holding down the key) + // But allow it if it's a lightbox closing action + const currentTime = new Date(); + if (currentTime.getTime() - this.lastKeyDownTime < this.props.keyRepeatLimit && keyCode !== KEYS.ESC) { + return; + } + this.lastKeyDownTime = currentTime.getTime(); + + switch (keyCode) { + // ESC key closes the lightbox + case KEYS.ESC: + event.preventDefault(); + this.requestClose(event); + break; + + // Left arrow key moves to previous image + case KEYS.LEFT_ARROW: + if (!this.props.prevSrc) { + return; + } + + event.preventDefault(); + this.keyPressed = true; + this.requestMovePrev(event); + break; + + // Right arrow key moves to next image + case KEYS.RIGHT_ARROW: + if (!this.props.nextSrc) { + return; + } + + event.preventDefault(); + this.keyPressed = true; + this.requestMoveNext(event); + break; + + default: + } + } + + /** + * Handle a mouse wheel event over the lightbox container + */ + handleOuterMousewheel(event) { + // Prevent scrolling of the background + event.stopPropagation(); + + const xThreshold = WHEEL_MOVE_X_THRESHOLD; + let actionDelay = 0; + const imageMoveDelay = 500; + + this.clearTimeout(this.resetScrollTimeout); + this.resetScrollTimeout = this.setTimeout(() => { + this.scrollX = 0; + this.scrollY = 0; + }, 300); + + // Prevent rapid-fire zoom behavior + if (this.wheelActionTimeout !== null || this.isAnimating()) { + return; + } + + if (Math.abs(event.deltaY) < Math.abs(event.deltaX)) { + // handle horizontal scrolls with image moves + this.scrollY = 0; + this.scrollX += event.deltaX; + + const bigLeapX = xThreshold / 2; + // If the scroll amount has accumulated sufficiently, or a large leap was taken + if (this.scrollX >= xThreshold || event.deltaX >= bigLeapX) { + // Scroll right moves to next + this.requestMoveNext(event); + actionDelay = imageMoveDelay; + this.scrollX = 0; + } else if (this.scrollX <= -1 * xThreshold || event.deltaX <= -1 * bigLeapX) { + // Scroll left moves to previous + this.requestMovePrev(event); + actionDelay = imageMoveDelay; + this.scrollX = 0; + } + } + + // Allow successive actions after the set delay + if (actionDelay !== 0) { + this.wheelActionTimeout = this.setTimeout(() => { + this.wheelActionTimeout = null; + }, actionDelay); + } + } + + handleImageMouseWheel(event) { + const yThreshold = WHEEL_MOVE_Y_THRESHOLD; + + if (Math.abs(event.deltaY) >= Math.abs(event.deltaX)) { + event.stopPropagation(); + // If the vertical scroll amount was large enough, perform a zoom + if (Math.abs(event.deltaY) < yThreshold) { + return; + } + + this.scrollX = 0; + this.scrollY += event.deltaY; + + this.changeZoom(this.state.zoomLevel - event.deltaY, event.clientX, event.clientY); + } + } + + /** + * Handle a double click on the current image + */ + handleImageDoubleClick(event) { + if (this.state.zoomLevel > MIN_ZOOM_LEVEL) { + // A double click when zoomed in zooms all the way out + this.changeZoom(MIN_ZOOM_LEVEL, event.clientX, event.clientY); + } else { + // A double click when zoomed all the way out zooms in + this.changeZoom(this.state.zoomLevel + ZOOM_BUTTON_INCREMENT_SIZE, event.clientX, event.clientY); + } + } + + shouldHandleEvent(source) { + if (this.eventsSource === source) { + return true; + } + if (this.eventsSource === SOURCE_ANY) { + this.eventsSource = source; + return true; + } + switch (source) { + case SOURCE_MOUSE: + return false; + case SOURCE_TOUCH: + this.eventsSource = SOURCE_TOUCH; + this.filterPointersBySource(); + return true; + case SOURCE_POINTER: + if (this.eventsSource === SOURCE_MOUSE) { + this.eventsSource = SOURCE_POINTER; + this.filterPointersBySource(); + return true; + } + return false; + default: + return false; + } + } + + addPointer(pointer) { + this.pointerList.push(pointer); + } + + removePointer(pointer) { + this.pointerList = this.pointerList.filter(({ id }) => id !== pointer.id); + } + + filterPointersBySource() { + this.pointerList = this.pointerList.filter(({ source }) => source === this.eventsSource); + } + + handleMouseDown(event) { + if (this.shouldHandleEvent(SOURCE_MOUSE) && ReactImageLightbox.isTargetMatchImage(event.target)) { + this.addPointer(ReactImageLightbox.parseMouseEvent(event)); + this.multiPointerStart(event); + } + } + + handleMouseMove(event) { + if (this.shouldHandleEvent(SOURCE_MOUSE)) { + this.multiPointerMove(event, [ReactImageLightbox.parseMouseEvent(event)]); + } + } + + handleMouseUp(event) { + if (this.shouldHandleEvent(SOURCE_MOUSE)) { + this.removePointer(ReactImageLightbox.parseMouseEvent(event)); + this.multiPointerEnd(event); + } + } + + handlePointerEvent(event) { + if (this.shouldHandleEvent(SOURCE_POINTER)) { + switch (event.type) { + case 'pointerdown': + if (ReactImageLightbox.isTargetMatchImage(event.target)) { + this.addPointer(ReactImageLightbox.parsePointerEvent(event)); + this.multiPointerStart(event); + } + break; + case 'pointermove': + this.multiPointerMove(event, [ReactImageLightbox.parsePointerEvent(event)]); + break; + case 'pointerup': + case 'pointercancel': + this.removePointer(ReactImageLightbox.parsePointerEvent(event)); + this.multiPointerEnd(event); + break; + default: + break; + } + } + } + + handleTouchStart(event) { + if (this.shouldHandleEvent(SOURCE_TOUCH) && ReactImageLightbox.isTargetMatchImage(event.target)) { + [].forEach.call(event.changedTouches, (eventTouch) => + this.addPointer(ReactImageLightbox.parseTouchPointer(eventTouch)), + ); + this.multiPointerStart(event); + } + } + + handleTouchMove(event) { + if (this.shouldHandleEvent(SOURCE_TOUCH)) { + this.multiPointerMove( + event, + [].map.call(event.changedTouches, (eventTouch) => ReactImageLightbox.parseTouchPointer(eventTouch)), + ); + } + } + + handleTouchEnd(event) { + if (this.shouldHandleEvent(SOURCE_TOUCH)) { + [].map.call(event.changedTouches, (touch) => this.removePointer(ReactImageLightbox.parseTouchPointer(touch))); + this.multiPointerEnd(event); + } + } + + decideMoveOrSwipe(pointer) { + if (this.state.zoomLevel <= MIN_ZOOM_LEVEL) { + this.handleSwipeStart(pointer); + } else { + this.handleMoveStart(pointer); + } + } + + multiPointerStart(event) { + this.handleEnd(null); + switch (this.pointerList.length) { + case 1: { + event.preventDefault(); + this.decideMoveOrSwipe(this.pointerList[0]); + break; + } + case 2: { + event.preventDefault(); + this.handlePinchStart(this.pointerList); + break; + } + default: + break; + } + } + + multiPointerMove(event, pointerList) { + switch (this.currentAction) { + case ACTION_MOVE: { + event.preventDefault(); + this.handleMove(pointerList[0]); + break; + } + case ACTION_SWIPE: { + event.preventDefault(); + this.handleSwipe(pointerList[0]); + break; + } + case ACTION_PINCH: { + event.preventDefault(); + this.handlePinch(pointerList); + break; + } + default: + break; + } + } + + multiPointerEnd(event) { + if (this.currentAction !== ACTION_NONE) { + this.setPreventInnerClose(); + this.handleEnd(event); + } + switch (this.pointerList.length) { + case 0: { + this.eventsSource = SOURCE_ANY; + break; + } + case 1: { + event.preventDefault(); + this.decideMoveOrSwipe(this.pointerList[0]); + break; + } + case 2: { + event.preventDefault(); + this.handlePinchStart(this.pointerList); + break; + } + default: + break; + } + } + + handleEnd(event) { + switch (this.currentAction) { + case ACTION_MOVE: + this.handleMoveEnd(event); + break; + case ACTION_SWIPE: + this.handleSwipeEnd(event); + break; + case ACTION_PINCH: + this.handlePinchEnd(event); + break; + default: + break; + } + } + + // Handle move start over the lightbox container + // This happens: + // - On a mouseDown event + // - On a touchstart event + handleMoveStart({ x: clientX, y: clientY }) { + if (!this.props.enableZoom) { + return; + } + this.currentAction = ACTION_MOVE; + this.moveStartX = clientX; + this.moveStartY = clientY; + this.moveStartOffsetX = this.state.offsetX; + this.moveStartOffsetY = this.state.offsetY; + } + + // Handle dragging over the lightbox container + // This happens: + // - After a mouseDown and before a mouseUp event + // - After a touchstart and before a touchend event + handleMove({ x: clientX, y: clientY }) { + const newOffsetX = this.moveStartX - clientX + this.moveStartOffsetX; + const newOffsetY = this.moveStartY - clientY + this.moveStartOffsetY; + if (this.state.offsetX !== newOffsetX || this.state.offsetY !== newOffsetY) { + this.setState({ + offsetX: newOffsetX, + offsetY: newOffsetY, + }); + } + } + + handleMoveEnd() { + this.currentAction = ACTION_NONE; + this.moveStartX = 0; + this.moveStartY = 0; + this.moveStartOffsetX = 0; + this.moveStartOffsetY = 0; + // Snap image back into frame if outside max offset range + const maxOffsets = this.getMaxOffsets(); + const nextOffsetX = Math.max(maxOffsets.minX, Math.min(maxOffsets.maxX, this.state.offsetX)); + const nextOffsetY = Math.max(maxOffsets.minY, Math.min(maxOffsets.maxY, this.state.offsetY)); + if (nextOffsetX !== this.state.offsetX || nextOffsetY !== this.state.offsetY) { + this.setState({ + offsetX: nextOffsetX, + offsetY: nextOffsetY, + shouldAnimate: true, + }); + this.setTimeout(() => { + this.setState({ shouldAnimate: false }); + }, this.props.animationDuration); + } + } + + handleSwipeStart({ x: clientX, y: clientY }) { + this.currentAction = ACTION_SWIPE; + this.swipeStartX = clientX; + this.swipeStartY = clientY; + this.swipeEndX = clientX; + this.swipeEndY = clientY; + } + + handleSwipe({ x: clientX, y: clientY }) { + this.swipeEndX = clientX; + this.swipeEndY = clientY; + } + + handleSwipeEnd(event) { + const xDiff = this.swipeEndX - this.swipeStartX; + const xDiffAbs = Math.abs(xDiff); + const yDiffAbs = Math.abs(this.swipeEndY - this.swipeStartY); + + this.currentAction = ACTION_NONE; + this.swipeStartX = 0; + this.swipeStartY = 0; + this.swipeEndX = 0; + this.swipeEndY = 0; + + if (!event || this.isAnimating() || xDiffAbs < yDiffAbs * 1.5) { + return; + } + + if (xDiffAbs < MIN_SWIPE_DISTANCE) { + const boxRect = this.getLightboxRect(); + if (xDiffAbs < boxRect.width / 4) { + return; + } + } + + if (xDiff > 0 && this.props.prevSrc) { + event.preventDefault(); + this.requestMovePrev(); + } else if (xDiff < 0 && this.props.nextSrc) { + event.preventDefault(); + this.requestMoveNext(); + } + } + + calculatePinchDistance([a, b] = this.pinchTouchList) { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); + } + + calculatePinchCenter([a, b] = this.pinchTouchList) { + return { + x: a.x - (a.x - b.x) / 2, + y: a.y - (a.y - b.y) / 2, + }; + } + + handlePinchStart(pointerList) { + if (!this.props.enableZoom) { + return; + } + this.currentAction = ACTION_PINCH; + this.pinchTouchList = pointerList.map(({ id, x, y }) => ({ id, x, y })); + this.pinchDistance = this.calculatePinchDistance(); + } + + handlePinch(pointerList) { + this.pinchTouchList = this.pinchTouchList.map((oldPointer) => { + for (let i = 0; i < pointerList.length; i += 1) { + if (pointerList[i].id === oldPointer.id) { + return pointerList[i]; + } + } + + return oldPointer; + }); + + const newDistance = this.calculatePinchDistance(); + + const zoomLevel = this.state.zoomLevel + newDistance - this.pinchDistance; + + this.pinchDistance = newDistance; + const { x: clientX, y: clientY } = this.calculatePinchCenter(this.pinchTouchList); + this.changeZoom(zoomLevel, clientX, clientY); + } + + handlePinchEnd() { + this.currentAction = ACTION_NONE; + this.pinchTouchList = null; + this.pinchDistance = 0; + } + + // Handle the window resize event + handleWindowResize() { + this.clearTimeout(this.resizeTimeout); + this.resizeTimeout = this.setTimeout(this.forceUpdate.bind(this), 100); + } + + handleZoomInButtonClick() { + const nextZoomLevel = this.state.zoomLevel + ZOOM_BUTTON_INCREMENT_SIZE; + this.changeZoom(nextZoomLevel); + if (nextZoomLevel === MAX_ZOOM_LEVEL) { + this.zoomOutBtn.current.focus(); + } + } + + handleZoomOutButtonClick() { + const nextZoomLevel = this.state.zoomLevel - ZOOM_BUTTON_INCREMENT_SIZE; + this.changeZoom(nextZoomLevel); + if (nextZoomLevel === MIN_ZOOM_LEVEL) { + this.zoomInBtn.current.focus(); + } + } + + handleCaptionMousewheel(event) { + event.stopPropagation(); + + if (!this.caption.current) { + return; + } + + const { height } = this.caption.current.getBoundingClientRect(); + const { scrollHeight, scrollTop } = this.caption.current; + if ((event.deltaY > 0 && height + scrollTop >= scrollHeight) || (event.deltaY < 0 && scrollTop <= 0)) { + event.preventDefault(); + } + } + + // Detach key and mouse input events + isAnimating() { + return this.state.shouldAnimate || this.state.isClosing; + } + + // Check if image is loaded + isImageLoaded(imageSrc) { + return imageSrc && imageSrc in this.imageCache && this.imageCache[imageSrc].loaded; + } + + // Load image from src and call callback with image width and height on load + loadImage(srcType, imageSrc, done) { + // Return the image info if it is already cached + if (this.isImageLoaded(imageSrc)) { + this.setTimeout(() => { + done(); + }, 1); + return; + } + + const inMemoryImage = new global.Image(); + + if (this.props.imageCrossOrigin) { + inMemoryImage.crossOrigin = this.props.imageCrossOrigin; + } + + inMemoryImage.onerror = (errorEvent) => { + this.props.onImageLoadError(imageSrc, srcType, errorEvent); + + // failed to load so set the state loadErrorStatus + this.setState((prevState) => ({ + loadErrorStatus: { ...prevState.loadErrorStatus, [srcType]: true }, + })); + + done(errorEvent); + }; + + inMemoryImage.onload = () => { + this.props.onImageLoad(imageSrc, srcType, inMemoryImage); + + this.imageCache[imageSrc] = { + loaded: true, + width: inMemoryImage.width, + height: inMemoryImage.height, + }; + + done(); + }; + + inMemoryImage.src = imageSrc; + } + + // Load all images and their thumbnails + loadAllImages(props = this.props) { + const generateLoadDoneCallback = (srcType, imageSrc) => (err) => { + // Give up showing image on error + if (err) { + return; + } + + // Don't rerender if the src is not the same as when the load started + // or if the component has unmounted + if (this.props[srcType] !== imageSrc || this.didUnmount) { + return; + } + + // Force rerender with the new image + this.forceUpdate(); + }; + + // Load the images + this.getSrcTypes().forEach((srcType) => { + const type = srcType.name; + + // there is no error when we try to load it initially + if (props[type] && this.state.loadErrorStatus[type]) { + this.setState((prevState) => ({ + loadErrorStatus: { ...prevState.loadErrorStatus, [type]: false }, + })); + } + + // Load unloaded images + if (props[type] && !this.isImageLoaded(props[type])) { + this.loadImage(type, props[type], generateLoadDoneCallback(type, props[type])); + } + }); + } + + // Request that the lightbox be closed + requestClose(event) { + // Call the parent close request + const closeLightbox = () => this.props.onCloseRequest(event); + + if (this.props.animationDisabled || (event.type === 'keydown' && !this.props.animationOnKeyInput)) { + // No animation + closeLightbox(); + return; + } + + // With animation + // Start closing animation + this.setState({ isClosing: true }); + + // Perform the actual closing at the end of the animation + this.setTimeout(closeLightbox, this.props.animationDuration); + } + + requestMove(direction, event) { + // Reset the zoom level on image move + const nextState = { + zoomLevel: MIN_ZOOM_LEVEL, + offsetX: 0, + offsetY: 0, + }; + + // Enable animated states + if (!this.props.animationDisabled && (!this.keyPressed || this.props.animationOnKeyInput)) { + nextState.shouldAnimate = true; + this.setTimeout(() => this.setState({ shouldAnimate: false }), this.props.animationDuration); + } + this.keyPressed = false; + + this.moveRequested = true; + + if (direction === 'prev') { + this.keyCounter -= 1; + this.setState(nextState); + this.props.onMovePrevRequest(event); + } else { + this.keyCounter += 1; + this.setState(nextState); + this.props.onMoveNextRequest(event); + } + } + + // Request to transition to the next image + requestMoveNext(event) { + this.requestMove('next', event); + } + + // Request to transition to the previous image + requestMovePrev(event) { + this.requestMove('prev', event); + } + + render() { + const { + animationDisabled, + animationDuration, + clickOutsideToClose, + discourageDownloads, + enableZoom, + imageTitle, + nextSrc, + prevSrc, + toolbarButtons, + reactModalStyle, + onAfterOpen, + imageCrossOrigin, + reactModalProps, + loader, + } = this.props; + const { zoomLevel, offsetX, offsetY, isClosing, loadErrorStatus } = this.state; + + const boxSize = this.getLightboxRect(); + let transitionStyle = {}; + + // Transition settings for sliding animations + if (!animationDisabled && this.isAnimating()) { + transitionStyle = { + ...transitionStyle, + transition: `transform ${animationDuration}ms`, + }; + } + + // Key endings to differentiate between images with the same src + const keyEndings = {}; + this.getSrcTypes().forEach(({ name, keyEnding }) => { + keyEndings[name] = keyEnding; + }); + + // Images to be displayed + const images = []; + const addImage = (srcType, imageClass, transforms) => { + // Ignore types that have no source defined for their full size image + if (!this.props[srcType]) { + return; + } + const bestImageInfo = this.getBestImageForType(srcType); + + const imageStyle = { + ...transitionStyle, + ...ReactImageLightbox.getTransform({ + ...transforms, + ...bestImageInfo, + }), + }; + + if (zoomLevel > MIN_ZOOM_LEVEL) { + imageStyle.cursor = 'move'; + } + + // support IE 9 and 11 + const hasTrueValue = (object) => Object.keys(object).some((key) => object[key]); + + // when error on one of the loads then push custom error stuff + if (bestImageInfo === null && hasTrueValue(loadErrorStatus)) { + images.push( +
+
{this.props.imageLoadErrorMessage}
+
, + ); + + return; + } + if (bestImageInfo === null) { + const loadingIcon = + loader !== undefined ? ( + loader + ) : ( +
+ {[...new Array(12)].map((_, index) => ( +
+ ))} +
+ ); + + // Fall back to loading icon if the thumbnail has not been loaded + images.push( +
+
{loadingIcon}
+
, + ); + + return; + } + + const imageSrc = bestImageInfo.src; + if (discourageDownloads) { + imageStyle.backgroundImage = `url('${imageSrc}')`; + images.push( +
+
+
, + ); + } else { + images.push( + e.preventDefault()} + style={imageStyle} + src={imageSrc} + key={imageSrc + keyEndings[srcType]} + alt={typeof imageTitle === 'string' ? imageTitle : translate('Image')} + draggable={false} + />, + ); + } + }; + + const zoomMultiplier = this.getZoomMultiplier(); + // Next Image (displayed on the right) + addImage('nextSrc', 'ril-image-next ril__imageNext', { + x: boxSize.width, + }); + // Main Image + addImage('mainSrc', 'ril-image-current', { + x: -1 * offsetX, + y: -1 * offsetY, + zoom: zoomMultiplier, + }); + // Previous Image (displayed on the left) + addImage('prevSrc', 'ril-image-prev ril__imagePrev', { + x: -1 * boxSize.width, + }); + + const modalStyle = { + overlay: { + zIndex: 1000, + backgroundColor: 'transparent', + ...reactModalStyle.overlay, // Allow style overrides via props + }, + content: { + backgroundColor: 'transparent', + overflow: 'hidden', // Needed, otherwise keyboard shortcuts scroll the page + border: 'none', + borderRadius: 0, + padding: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + ...reactModalStyle.content, // Allow style overrides via props + }, + }; + + return ( + { + // Focus on the div with key handlers + if (this.outerEl.current) { + this.outerEl.current.focus(); + } + + onAfterOpen(); + }} + style={modalStyle} + contentLabel={translate('Lightbox')} + appElement={typeof global.window !== 'undefined' ? global.window.document.body : undefined} + {...reactModalProps} + > +
+
+ {images} +
+ + {prevSrc && ( +
+ + {this.props.imageCaption && ( +
event.stopPropagation()} + className="ril-caption ril__caption" + ref={this.caption} + > +
{this.props.imageCaption}
+
+ )} +
+ + ); + } +} + +ReactImageLightbox.propTypes = { + //----------------------------- + // Image sources + //----------------------------- + + // Main display image url + mainSrc: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types + + // Previous display image url (displayed to the left) + // If left undefined, movePrev actions will not be performed, and the button not displayed + prevSrc: PropTypes.string, + + // Next display image url (displayed to the right) + // If left undefined, moveNext actions will not be performed, and the button not displayed + nextSrc: PropTypes.string, + + //----------------------------- + // Image thumbnail sources + //----------------------------- + + // Thumbnail image url corresponding to props.mainSrc + mainSrcThumbnail: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + + // Thumbnail image url corresponding to props.prevSrc + prevSrcThumbnail: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + + // Thumbnail image url corresponding to props.nextSrc + nextSrcThumbnail: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + + //----------------------------- + // Event Handlers + //----------------------------- + + // Close window event + // Should change the parent state such that the lightbox is not rendered + onCloseRequest: PropTypes.func.isRequired, + + // Move to previous image event + // Should change the parent state such that props.prevSrc becomes props.mainSrc, + // props.mainSrc becomes props.nextSrc, etc. + onMovePrevRequest: PropTypes.func, + + // Move to next image event + // Should change the parent state such that props.nextSrc becomes props.mainSrc, + // props.mainSrc becomes props.prevSrc, etc. + onMoveNextRequest: PropTypes.func, + + // Called when an image fails to load + // (imageSrc: string, srcType: string, errorEvent: object): void + onImageLoadError: PropTypes.func, + + // Called when image successfully loads + onImageLoad: PropTypes.func, + + // Open window event + onAfterOpen: PropTypes.func, + + //----------------------------- + // Download discouragement settings + //----------------------------- + + // Enable download discouragement (prevents [right-click -> Save Image As...]) + discourageDownloads: PropTypes.bool, + + //----------------------------- + // Animation settings + //----------------------------- + + // Disable all animation + animationDisabled: PropTypes.bool, + + // Disable animation on actions performed with keyboard shortcuts + animationOnKeyInput: PropTypes.bool, + + // Animation duration (ms) + animationDuration: PropTypes.number, + + //----------------------------- + // Keyboard shortcut settings + //----------------------------- + + // Required interval of time (ms) between key actions + // (prevents excessively fast navigation of images) + keyRepeatLimit: PropTypes.number, + + // Amount of time (ms) restored after each keyup + // (makes rapid key presses slightly faster than holding down the key to navigate images) + keyRepeatKeyupBonus: PropTypes.number, + + //----------------------------- + // Image info + //----------------------------- + + // Image title + imageTitle: PropTypes.node, + + // Image caption + imageCaption: PropTypes.node, + + // Optional crossOrigin attribute + imageCrossOrigin: PropTypes.string, + + //----------------------------- + // Lightbox style + //----------------------------- + + // Set z-index style, etc., for the parent react-modal (format: https://github.com/reactjs/react-modal#styles ) + reactModalStyle: PropTypes.shape({}), + + // Padding (px) between the edge of the window and the lightbox + imagePadding: PropTypes.number, + + wrapperClassName: PropTypes.string, + + //----------------------------- + // Other + //----------------------------- + + // Array of custom toolbar buttons + toolbarButtons: PropTypes.arrayOf(PropTypes.node), + + // When true, clicks outside of the image close the lightbox + clickOutsideToClose: PropTypes.bool, + + // Set to false to disable zoom functionality and hide zoom buttons + enableZoom: PropTypes.bool, + + // Override props set on react-modal (https://github.com/reactjs/react-modal) + reactModalProps: PropTypes.shape({}), + + // Aria-labels + nextLabel: PropTypes.string, + prevLabel: PropTypes.string, + zoomInLabel: PropTypes.string, + zoomOutLabel: PropTypes.string, + closeLabel: PropTypes.string, + + imageLoadErrorMessage: PropTypes.node, + + // custom loader + loader: PropTypes.node, +}; + +ReactImageLightbox.defaultProps = { + imageTitle: null, + imageCaption: null, + toolbarButtons: null, + reactModalProps: {}, + animationDisabled: false, + animationDuration: 300, + animationOnKeyInput: false, + clickOutsideToClose: true, + closeLabel: 'Close lightbox', + discourageDownloads: false, + enableZoom: true, + imagePadding: 10, + imageCrossOrigin: null, + keyRepeatKeyupBonus: 40, + keyRepeatLimit: 180, + mainSrcThumbnail: null, + nextLabel: 'Next image', + nextSrc: null, + nextSrcThumbnail: null, + onAfterOpen: () => {}, + onImageLoadError: () => {}, + onImageLoad: () => {}, + onMoveNextRequest: () => {}, + onMovePrevRequest: () => {}, + prevLabel: 'Previous image', + prevSrc: null, + prevSrcThumbnail: null, + reactModalStyle: {}, + wrapperClassName: '', + zoomInLabel: 'Zoom in', + zoomOutLabel: 'Zoom out', + imageLoadErrorMessage: 'This image failed to load', + loader: undefined, +}; + +export default ReactImageLightbox; diff --git a/packages/core/components/src/lightbox/style.css b/packages/core/components/src/lightbox/style.css new file mode 100644 index 000000000..9c4dfa3cc --- /dev/null +++ b/packages/core/components/src/lightbox/style.css @@ -0,0 +1,344 @@ +@keyframes closeWindow { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.ril__outer { + background-color: rgba(0, 0, 0, 0.85); + outline: none; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + width: 100%; + height: 100%; + -ms-content-zooming: none; + -ms-user-select: none; + -ms-touch-select: none; + touch-action: none; +} + +.ril__outerClosing { + opacity: 0; +} + +.ril__inner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.ril__image, +.ril__imagePrev, +.ril__imageNext { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + max-width: none; + -ms-content-zooming: none; + -ms-user-select: none; + -ms-touch-select: none; + touch-action: none; +} + +.ril__imageDiscourager { + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} + +.ril__navButtons { + border: none; + position: absolute; + top: 0; + bottom: 0; + width: 20px; + height: 34px; + padding: 40px 30px; + margin: auto; + cursor: pointer; + opacity: 0.7; +} +.ril__navButtons:hover { + opacity: 1; +} +.ril__navButtons:active { + opacity: 0.7; +} + +.ril__navButtonPrev { + left: 0; + background: rgba(0, 0, 0, 0.2) + url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDE5LDMgLTIsLTIgLTE2LDE2IDE2LDE2IDEsLTEgLTE1LC0xNSAxNSwtMTUgeiIgZmlsbD0iI0ZGRiIvPjwvc3ZnPg==') + no-repeat center; +} + +.ril__navButtonNext { + right: 0; + background: rgba(0, 0, 0, 0.2) + url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDEsMyAyLC0yIDE2LDE2IC0xNiwxNiAtMSwtMSAxNSwtMTUgLTE1LC0xNSB6IiBmaWxsPSIjRkZGIi8+PC9zdmc+') + no-repeat center; +} + +.ril__downloadBlocker { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); + background-size: cover; +} + +.ril__caption, +.ril__toolbar { + background-color: rgba(0, 0, 0, 0.5); + position: absolute; + left: 0; + right: 0; + display: flex; + justify-content: space-between; +} + +.ril__caption { + bottom: 0; + max-height: 150px; + overflow: auto; +} + +.ril__captionContent { + padding: 10px 20px; + color: #fff; +} + +.ril__toolbar { + top: 0; + height: 50px; +} + +.ril__toolbarSide { + height: 50px; + margin: 0; +} + +.ril__toolbarLeftSide { + padding-left: 20px; + padding-right: 0; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; +} + +.ril__toolbarRightSide { + padding-left: 0; + padding-right: 20px; + flex: 0 0 auto; +} + +.ril__toolbarItem { + display: inline-block; + line-height: 50px; + padding: 0; + color: #fff; + font-size: 120%; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ril__toolbarItemChild { + vertical-align: middle; +} + +.ril__builtinButton { + width: 40px; + height: 35px; + cursor: pointer; + border: none; + opacity: 0.7; +} +.ril__builtinButton:hover { + opacity: 1; +} +.ril__builtinButton:active { + outline: none; +} + +.ril__builtinButtonDisabled { + cursor: default; + opacity: 0.5; +} +.ril__builtinButtonDisabled:hover { + opacity: 0.5; +} + +.ril__closeButton { + background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj48cGF0aCBkPSJtIDEsMyAxLjI1LC0xLjI1IDcuNSw3LjUgNy41LC03LjUgMS4yNSwxLjI1IC03LjUsNy41IDcuNSw3LjUgLTEuMjUsMS4yNSAtNy41LC03LjUgLTcuNSw3LjUgLTEuMjUsLTEuMjUgNy41LC03LjUgLTcuNSwtNy41IHoiIGZpbGw9IiNGRkYiLz48L3N2Zz4=') + no-repeat center; +} + +.ril__zoomInButton { + background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PHBhdGggZD0iTTEyIDV2NiIvPjwvZz48Y2lyY2xlIGN4PSIxMiIgY3k9IjgiIHI9IjciIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+') + no-repeat center; +} + +.ril__zoomOutButton { + background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PC9nPjxjaXJjbGUgY3g9IjEyIiBjeT0iOCIgcj0iNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4=') + no-repeat center; +} + +.ril__outerAnimating { + animation-name: closeWindow; +} + +@keyframes pointFade { + 0%, + 19.999%, + 100% { + opacity: 0; + } + 20% { + opacity: 1; + } +} + +.ril__loadingCircle { + width: 60px; + height: 60px; + position: relative; +} + +.ril__loadingCirclePoint { + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; +} +.ril__loadingCirclePoint::before { + content: ''; + display: block; + margin: 0 auto; + width: 11%; + height: 30%; + background-color: #fff; + border-radius: 30%; + animation: pointFade 800ms infinite ease-in-out both; +} +.ril__loadingCirclePoint:nth-of-type(1) { + transform: rotate(0deg); +} +.ril__loadingCirclePoint:nth-of-type(7) { + transform: rotate(180deg); +} +.ril__loadingCirclePoint:nth-of-type(1)::before, +.ril__loadingCirclePoint:nth-of-type(7)::before { + animation-delay: -800ms; +} +.ril__loadingCirclePoint:nth-of-type(2) { + transform: rotate(30deg); +} +.ril__loadingCirclePoint:nth-of-type(8) { + transform: rotate(210deg); +} +.ril__loadingCirclePoint:nth-of-type(2)::before, +.ril__loadingCirclePoint:nth-of-type(8)::before { + animation-delay: -666ms; +} +.ril__loadingCirclePoint:nth-of-type(3) { + transform: rotate(60deg); +} +.ril__loadingCirclePoint:nth-of-type(9) { + transform: rotate(240deg); +} +.ril__loadingCirclePoint:nth-of-type(3)::before, +.ril__loadingCirclePoint:nth-of-type(9)::before { + animation-delay: -533ms; +} +.ril__loadingCirclePoint:nth-of-type(4) { + transform: rotate(90deg); +} +.ril__loadingCirclePoint:nth-of-type(10) { + transform: rotate(270deg); +} +.ril__loadingCirclePoint:nth-of-type(4)::before, +.ril__loadingCirclePoint:nth-of-type(10)::before { + animation-delay: -400ms; +} +.ril__loadingCirclePoint:nth-of-type(5) { + transform: rotate(120deg); +} +.ril__loadingCirclePoint:nth-of-type(11) { + transform: rotate(300deg); +} +.ril__loadingCirclePoint:nth-of-type(5)::before, +.ril__loadingCirclePoint:nth-of-type(11)::before { + animation-delay: -266ms; +} +.ril__loadingCirclePoint:nth-of-type(6) { + transform: rotate(150deg); +} +.ril__loadingCirclePoint:nth-of-type(12) { + transform: rotate(330deg); +} +.ril__loadingCirclePoint:nth-of-type(6)::before, +.ril__loadingCirclePoint:nth-of-type(12)::before { + animation-delay: -133ms; +} +.ril__loadingCirclePoint:nth-of-type(7) { + transform: rotate(180deg); +} +.ril__loadingCirclePoint:nth-of-type(13) { + transform: rotate(360deg); +} +.ril__loadingCirclePoint:nth-of-type(7)::before, +.ril__loadingCirclePoint:nth-of-type(13)::before { + animation-delay: 0ms; +} + +.ril__loadingContainer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} +.ril__imagePrev .ril__loadingContainer, +.ril__imageNext .ril__loadingContainer { + display: none; +} + +.ril__errorContainer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; +} +.ril__imagePrev .ril__errorContainer, +.ril__imageNext .ril__errorContainer { + display: none; +} + +.ril__loadingContainer__icon { + color: #fff; + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); +} diff --git a/packages/core/components/src/lightbox/util.ts b/packages/core/components/src/lightbox/util.ts new file mode 100644 index 000000000..a3e8e4dad --- /dev/null +++ b/packages/core/components/src/lightbox/util.ts @@ -0,0 +1,52 @@ +/** + * Placeholder for future translate functionality + */ +export function translate(str, replaceStrings = null) { + if (!str) { + return ''; + } + + let translated = str; + if (replaceStrings) { + Object.keys(replaceStrings).forEach((placeholder) => { + translated = translated.replace(placeholder, replaceStrings[placeholder]); + }); + } + + return translated; +} + +export function getWindowWidth() { + return typeof global.window !== 'undefined' ? global.window.innerWidth : 0; +} + +export function getWindowHeight() { + return typeof global.window !== 'undefined' ? global.window.innerHeight : 0; +} + +const isCrossOriginFrame = () => { + try { + return global.window.location.hostname !== global.window.parent.location.hostname; + } catch (e) { + return true; + } +}; + +// Get the highest window context that isn't cross-origin +// (When in an iframe) +export function getHighestSafeWindowContext(self = global.window.self) { + // If we reached the top level, return self + if (self === global.window.top) { + return self; + } + + // If parent is the same origin, we can move up one context + // Reference: https://stackoverflow.com/a/21965342/1601953 + if (!isCrossOriginFrame()) { + return getHighestSafeWindowContext(self.parent); + } + + // If a different origin, we consider the current level + // as the top reachable one + return self; +} diff --git a/packages/core/create-tachybase-app/src/generator.js b/packages/core/create-tachybase-app/src/generator.js index 149eedce4..4595d645e 100644 --- a/packages/core/create-tachybase-app/src/generator.js +++ b/packages/core/create-tachybase-app/src/generator.js @@ -130,7 +130,7 @@ class AppGenerator extends Generator { dependencies: dependencies.join(`,\n `), envs: envs.join(`\n`), env: { - APP_PORT: 13000, + APP_PORT: 3000, APP_ENV: 'development', DB_DIALECT: dbDialect, APP_KEY: crypto.randomBytes(256).toString('base64'), diff --git a/packages/core/create-tachybase-app/templates/app/.env.e2e.tpl b/packages/core/create-tachybase-app/templates/app/.env.e2e.tpl index fb58d51e3..bf5092c24 100644 --- a/packages/core/create-tachybase-app/templates/app/.env.e2e.tpl +++ b/packages/core/create-tachybase-app/templates/app/.env.e2e.tpl @@ -1,6 +1,6 @@ ################# TACHYBASE APPLICATION ################# -# !!! When `APP_ENV=production`, opening http://localhost:13000/ will show "Not Found". +# !!! When `APP_ENV=production`, opening http://localhost:3000/ will show "Not Found". # !!! It is recommended to use nginx to proxy static files. For example https://github.com/tachybase/tachybase/blob/main/docker/tachybase/tachybase.conf APP_ENV=development APP_PORT=20000 diff --git a/packages/core/create-tachybase-app/templates/app/.env.test.tpl b/packages/core/create-tachybase-app/templates/app/.env.test.tpl index 7cbedd9f0..0ba2ef99c 100644 --- a/packages/core/create-tachybase-app/templates/app/.env.test.tpl +++ b/packages/core/create-tachybase-app/templates/app/.env.test.tpl @@ -1,5 +1,5 @@ APP_ENV=development -APP_PORT=13000 +APP_PORT=3000 APP_KEY=test-key API_BASE_PATH=/api/ diff --git a/packages/core/server/src/gateway/index.ts b/packages/core/server/src/gateway/index.ts index f4fe93fd8..4ee68f06e 100644 --- a/packages/core/server/src/gateway/index.ts +++ b/packages/core/server/src/gateway/index.ts @@ -304,7 +304,7 @@ export class Gateway extends EventEmitter { await this.watch(); const startOptions = this.getStartOptions(); - const port = startOptions.port || process.env.APP_PORT || 13000; + const port = startOptions.port || process.env.APP_PORT || 3000; const host = startOptions.host || process.env.APP_HOST || '0.0.0.0'; this.start({ diff --git a/packages/plugins/@tachybase/plugin-api-keys/src/client/index.tsx b/packages/plugins/@tachybase/plugin-api-keys/src/client/index.tsx index 37b7c0c10..145b9c29d 100644 --- a/packages/plugins/@tachybase/plugin-api-keys/src/client/index.tsx +++ b/packages/plugins/@tachybase/plugin-api-keys/src/client/index.tsx @@ -5,7 +5,7 @@ import { Configuration } from './Configuration'; export class PluginAPIKeysClient extends Plugin { async load() { - this.pluginSettingsManager.add(NAMESPACE, { + this.userSettingsManager.add(NAMESPACE, { icon: 'KeyOutlined', title: this.t('API keys'), Component: Configuration, diff --git a/packages/plugins/@tachybase/plugin-backup-restore/package.json b/packages/plugins/@tachybase/plugin-backup-restore/package.json index 2cb5c890d..d4b96e0e8 100644 --- a/packages/plugins/@tachybase/plugin-backup-restore/package.json +++ b/packages/plugins/@tachybase/plugin-backup-restore/package.json @@ -11,7 +11,6 @@ "devDependencies": { "@ant-design/icons": "^5.3.7", "@hapi/topo": "^6.0.0", - "@koa/multer": "^3.0.2", "@tachybase/components": "workspace:*", "@types/archiver": "^5.3.1", "@types/file-saver": "^2.0.7", diff --git a/packages/plugins/@tachybase/plugin-cas/src/client/Options.tsx b/packages/plugins/@tachybase/plugin-cas/src/client/Options.tsx index fd2d57bba..8dc58d65e 100644 --- a/packages/plugins/@tachybase/plugin-cas/src/client/Options.tsx +++ b/packages/plugins/@tachybase/plugin-cas/src/client/Options.tsx @@ -32,7 +32,7 @@ export const Options = () => { 'x-decorator': 'FormItem', 'x-decorator-props': { tooltip: generateNTemplate( - 'The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:13000', + 'The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:3000', ), }, required: true, diff --git a/packages/plugins/@tachybase/plugin-cas/src/locale/en-US.json b/packages/plugins/@tachybase/plugin-cas/src/locale/en-US.json index 17d37a8e1..2b8be36b9 100644 --- a/packages/plugins/@tachybase/plugin-cas/src/locale/en-US.json +++ b/packages/plugins/@tachybase/plugin-cas/src/locale/en-US.json @@ -3,5 +3,5 @@ "User will be registered automatically if not exists.": "User will be registered automatically if not exists.", "Sign up automatically when the user does not exist": "Sign up automatically when the user does not exist", "Service domain": "Service domain", - "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:13000": "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:13000" + "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:3000": "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:3000" } diff --git a/packages/plugins/@tachybase/plugin-cas/src/locale/ko_KR.json b/packages/plugins/@tachybase/plugin-cas/src/locale/ko_KR.json index 4e79ab8ff..31ebfe981 100644 --- a/packages/plugins/@tachybase/plugin-cas/src/locale/ko_KR.json +++ b/packages/plugins/@tachybase/plugin-cas/src/locale/ko_KR.json @@ -3,6 +3,6 @@ "User will be registered automatically if not exists.": "사용자가 존재하지 않으면 자동으로 등록됩니다.", "Sign up automatically when the user does not exist": "사용자가 존재하지 않을 경우 자동으로 가입", "Service domain": "서비스 도메인", - "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:13000": - "도메인은 일반적으로 서버의 주소입니다. 로컬 개발에서는 로컬 머신의 주소를 사용할 수 있습니다. 예: http://localhost:13000" + "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:3000": + "도메인은 일반적으로 서버의 주소입니다. 로컬 개발에서는 로컬 머신의 주소를 사용할 수 있습니다. 예: http://localhost:3000" } diff --git a/packages/plugins/@tachybase/plugin-cas/src/locale/zh-CN.json b/packages/plugins/@tachybase/plugin-cas/src/locale/zh-CN.json index ccae2ee99..f2aec85bc 100644 --- a/packages/plugins/@tachybase/plugin-cas/src/locale/zh-CN.json +++ b/packages/plugins/@tachybase/plugin-cas/src/locale/zh-CN.json @@ -3,5 +3,5 @@ "User will be registered automatically if not exists.": "用户不存在时将自动注册。", "Sign up automatically when the user does not exist": "用户不存在时自动注册", "Service domain": "服务域名", - "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:13000": "域名通常是你的服务器地址,在本地开发时,可以使用你本机的地址,例如:http://localhost:13000" + "The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:3000": "域名通常是你的服务器地址,在本地开发时,可以使用你本机的地址,例如:http://localhost:3000" } diff --git a/packages/plugins/@tachybase/plugin-file-manager/package.json b/packages/plugins/@tachybase/plugin-file-manager/package.json index 541ea391f..e96e7cb31 100644 --- a/packages/plugins/@tachybase/plugin-file-manager/package.json +++ b/packages/plugins/@tachybase/plugin-file-manager/package.json @@ -12,7 +12,6 @@ "devDependencies": { "@ant-design/icons": "^5.3.7", "@aws-sdk/client-s3": "^3.245.0", - "@koa/multer": "^3.0.0", "@tachybase/components": "workspace:*", "@tachybase/schema": "workspace:*", "@types/koa-multer": "^1.0.1", diff --git a/packages/plugins/@tachybase/plugin-file-manager/src/server/__tests__/action.test.ts b/packages/plugins/@tachybase/plugin-file-manager/src/server/__tests__/action.test.ts index 5a56c5a9c..2f42848e2 100644 --- a/packages/plugins/@tachybase/plugin-file-manager/src/server/__tests__/action.test.ts +++ b/packages/plugins/@tachybase/plugin-file-manager/src/server/__tests__/action.test.ts @@ -4,7 +4,7 @@ import path from 'path'; import { getApp } from '.'; import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants'; -const { LOCAL_STORAGE_BASE_URL, LOCAL_STORAGE_DEST = 'storage/uploads', APP_PORT = '13000' } = process.env; +const { LOCAL_STORAGE_BASE_URL, LOCAL_STORAGE_DEST = 'storage/uploads', APP_PORT = '3000' } = process.env; const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `/storage/uploads`; diff --git a/packages/plugins/@tachybase/plugin-file-manager/src/server/migrations/20231120142523-fix-storage.ts b/packages/plugins/@tachybase/plugin-file-manager/src/server/migrations/20231120142523-fix-storage.ts index 440673c6c..c20187411 100644 --- a/packages/plugins/@tachybase/plugin-file-manager/src/server/migrations/20231120142523-fix-storage.ts +++ b/packages/plugins/@tachybase/plugin-file-manager/src/server/migrations/20231120142523-fix-storage.ts @@ -40,6 +40,6 @@ export default class extends Migration { } const c = this.db.getCollection('attachments'); const table = c.getTableNameWithSchemaAsString(); - await this.db.sequelize.query(`update ${table} set url = replace(url, 'http://localhost:13000', '')`); + await this.db.sequelize.query(`update ${table} set url = replace(url, 'http://localhost:3000', '')`); } } diff --git a/packages/plugins/@tachybase/plugin-import/package.json b/packages/plugins/@tachybase/plugin-import/package.json index 2b844e6d6..ad5a0cfe9 100644 --- a/packages/plugins/@tachybase/plugin-import/package.json +++ b/packages/plugins/@tachybase/plugin-import/package.json @@ -10,7 +10,6 @@ "main": "./dist/server/index.js", "devDependencies": { "@ant-design/icons": "~5.3.7", - "@koa/multer": "^3.0.2", "@tachybase/components": "workspace:*", "@tachybase/schema": "workspace:*", "@types/node-xlsx": "^0.15.1", diff --git a/packages/plugins/@tachybase/plugin-messages/.npmignore b/packages/plugins/@tachybase/plugin-messages/.npmignore new file mode 100644 index 000000000..65f5e8779 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@tachybase/plugin-messages/README.md b/packages/plugins/@tachybase/plugin-messages/README.md new file mode 100644 index 000000000..aedccd591 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/README.md @@ -0,0 +1 @@ +# @tachybase/plugin-messages diff --git a/packages/plugins/@tachybase/plugin-messages/client.d.ts b/packages/plugins/@tachybase/plugin-messages/client.d.ts new file mode 100644 index 000000000..6c459cbac --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@tachybase/plugin-messages/client.js b/packages/plugins/@tachybase/plugin-messages/client.js new file mode 100644 index 000000000..b6e3be70e --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@tachybase/plugin-messages/package.json b/packages/plugins/@tachybase/plugin-messages/package.json new file mode 100644 index 000000000..67e105422 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/package.json @@ -0,0 +1,12 @@ +{ + "name": "@tachybase/plugin-messages", + "version": "0.21.82", + "main": "dist/server/index.js", + "dependencies": {}, + "peerDependencies": { + "@tachybase/client": "workspace:*", + "@tachybase/database": "workspace:*", + "@tachybase/server": "workspace:*", + "@tachybase/test": "workspace:*" + } +} diff --git a/packages/plugins/@tachybase/plugin-messages/server.d.ts b/packages/plugins/@tachybase/plugin-messages/server.d.ts new file mode 100644 index 000000000..c41081ddc --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@tachybase/plugin-messages/server.js b/packages/plugins/@tachybase/plugin-messages/server.js new file mode 100644 index 000000000..972842039 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@tachybase/plugin-messages/src/client/index.tsx b/packages/plugins/@tachybase/plugin-messages/src/client/index.tsx new file mode 100644 index 000000000..9a5a5ba74 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/src/client/index.tsx @@ -0,0 +1,21 @@ +import { Plugin } from '@tachybase/client'; + +export class PluginMessagesClient extends Plugin { + async afterAdd() { + // await this.app.pm.add() + } + + async beforeLoad() {} + + // You can get and modify the app instance here + async load() { + console.log(this.app); + // this.app.addComponents({}) + // this.app.addScopes({}) + // this.app.addProvider() + // this.app.addProviders() + // this.app.router.add() + } +} + +export default PluginMessagesClient; diff --git a/packages/plugins/@tachybase/plugin-messages/src/index.ts b/packages/plugins/@tachybase/plugin-messages/src/index.ts new file mode 100644 index 000000000..7e74612df --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/src/index.ts @@ -0,0 +1,2 @@ +export * from './server'; +export { default } from './server'; diff --git a/packages/plugins/@tachybase/plugin-messages/src/server/collections/messages.ts b/packages/plugins/@tachybase/plugin-messages/src/server/collections/messages.ts new file mode 100644 index 000000000..07005df70 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/src/server/collections/messages.ts @@ -0,0 +1,79 @@ +import { defineCollection } from '@tachybase/database'; + +export default defineCollection({ + dumpRules: 'required', + name: 'messages', + sortable: true, + createdBy: true, + updatedBy: true, + logging: true, + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + interface: 'id', + }, + { + interface: 'input', + type: 'string', + name: 'title', + allowNull: false, + unique: false, + uiSchema: { + type: 'string', + title: '{{t("Title")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'content', + allowNull: false, + unique: false, + uiSchema: { + type: 'string', + title: '{{t("Content")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + name: 'read', + type: 'boolean', + interface: 'checkbox', + description: '{{ t("Read") }}', + uiSchema: { + 'x-component-props': { showUnchecked: true }, + type: 'boolean', + 'x-component': 'Checkbox', + title: '{{ t("Read") }}', + }, + }, + { + name: 'userId', + type: 'bigInt', + interface: 'integer', + isForeignKey: true, + uiSchema: { type: 'number', title: 'userId', 'x-component': 'InputNumber', 'x-read-pretty': true }, + }, + { + foreignKey: 'userId', + onDelete: 'CASCADE', + name: 'user', + type: 'belongsTo', + interface: 'm2o', + uiSchema: { + 'x-component': 'AssociationField', + 'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } }, + title: '用户', + }, + target: 'users', + targetKey: 'id', + }, + ], +}); diff --git a/packages/plugins/@tachybase/plugin-messages/src/server/collections/users.ts b/packages/plugins/@tachybase/plugin-messages/src/server/collections/users.ts new file mode 100644 index 000000000..8b1ed012e --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/src/server/collections/users.ts @@ -0,0 +1,14 @@ +import { extendCollection } from '@tachybase/database'; + +export default extendCollection({ + name: 'users', + fields: [ + { + type: 'json', + // 订阅偏好设置 + name: 'subPrefs', + allowNull: false, + defaultValue: {}, + }, + ], +}); diff --git a/packages/plugins/@tachybase/plugin-messages/src/server/index.ts b/packages/plugins/@tachybase/plugin-messages/src/server/index.ts new file mode 100644 index 000000000..b68aea57f --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/src/server/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/packages/plugins/@tachybase/plugin-messages/src/server/plugin.ts b/packages/plugins/@tachybase/plugin-messages/src/server/plugin.ts new file mode 100644 index 000000000..d9a446f63 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-messages/src/server/plugin.ts @@ -0,0 +1,19 @@ +import { Plugin } from '@tachybase/server'; + +export class PluginMessagesServer extends Plugin { + async afterAdd() {} + + async beforeLoad() {} + + async load() {} + + async install() {} + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default PluginMessagesServer; diff --git a/packages/plugins/@tachybase/plugin-theme-editor/README.md b/packages/plugins/@tachybase/plugin-theme-editor/README.md index 6e2acfb4e..5ae66a324 100644 --- a/packages/plugins/@tachybase/plugin-theme-editor/README.md +++ b/packages/plugins/@tachybase/plugin-theme-editor/README.md @@ -10,7 +10,7 @@ English | [中文](./README.zh-CN.md) ### 1、启用主题插件 -首先把 TachyBase 更新到最新版本,然后会在[插件管理页面](http://localhost:13000/admin/pm/list/local/)看到 `theme-editor` 的卡片,点击卡片右下角的 `启用` 按钮等待页面刷新。 +首先把 TachyBase 更新到最新版本,然后会在[插件管理页面](http://localhost:3000/admin/pm/list/local/)看到 `theme-editor` 的卡片,点击卡片右下角的 `启用` 按钮等待页面刷新。 diff --git a/packages/plugins/@tachybase/plugin-theme-editor/README.zh-CN.md b/packages/plugins/@tachybase/plugin-theme-editor/README.zh-CN.md index 0a9130297..965edbc25 100644 --- a/packages/plugins/@tachybase/plugin-theme-editor/README.zh-CN.md +++ b/packages/plugins/@tachybase/plugin-theme-editor/README.zh-CN.md @@ -10,7 +10,7 @@ ### 1、启用主题插件 -首先把 TachyBase 更新到最新版本,然后会在[插件管理页面](http://localhost:13000/admin/pm/list/local/)看到 `theme-editor` 的卡片,点击卡片右下角的 `启用` 按钮等待页面刷新。 +首先把 TachyBase 更新到最新版本,然后会在[插件管理页面](http://localhost:3000/admin/pm/list/local/)看到 `theme-editor` 的卡片,点击卡片右下角的 `启用` 按钮等待页面刷新。 diff --git a/packages/presets/base/package.json b/packages/presets/base/package.json index 9a56138eb..a42682c5e 100644 --- a/packages/presets/base/package.json +++ b/packages/presets/base/package.json @@ -37,6 +37,7 @@ "@tachybase/plugin-localization-management": "workspace:*", "@tachybase/plugin-logger": "workspace:*", "@tachybase/plugin-map": "workspace:*", + "@tachybase/plugin-messages": "workspace:*", "@tachybase/plugin-mobile-client": "workspace:*", "@tachybase/plugin-mock-collections": "workspace:*", "@tachybase/plugin-multi-app-manager": "workspace:*", diff --git a/packages/presets/base/src/server/index.ts b/packages/presets/base/src/server/index.ts index 55cfb608b..c054adcf4 100644 --- a/packages/presets/base/src/server/index.ts +++ b/packages/presets/base/src/server/index.ts @@ -16,6 +16,7 @@ export class PresetTachyBase extends Plugin { 'verification', 'users', 'acl', + 'messages', // optional plugins, default enabled 'action-bulk-edit', 'action-bulk-update', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e3d73e18..339df0955 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -614,9 +614,6 @@ importers: react-iframe: specifier: ~1.8.5 version: 1.8.5(react@18.3.1) - react-image-lightbox: - specifier: ^5.1.4 - version: 5.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-intersection-observer: specifier: ^9.10.3 version: 9.10.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -726,6 +723,12 @@ importers: dayjs: specifier: 1.11.10 version: 1.11.10 + prop-types: + specifier: ^15.8.1 + version: 15.8.1 + react-modal: + specifier: ^3.16.1 + version: 3.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-sticky-box: specifier: ^1.0.2 version: 1.0.2(react@18.3.1) @@ -2171,9 +2174,6 @@ importers: '@hapi/topo': specifier: ^6.0.0 version: 6.0.2 - '@koa/multer': - specifier: ^3.0.2 - version: 3.0.2(multer@1.4.5-lts.1) '@tachybase/components': specifier: workspace:* version: link:../../../core/components @@ -2873,9 +2873,6 @@ importers: '@aws-sdk/client-s3': specifier: ^3.245.0 version: 3.474.0 - '@koa/multer': - specifier: ^3.0.0 - version: 3.0.2(multer@1.4.5-lts.1) '@tachybase/components': specifier: workspace:* version: link:../../../core/components @@ -3129,9 +3126,6 @@ importers: '@ant-design/icons': specifier: ~5.3.7 version: 5.3.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@koa/multer': - specifier: ^3.0.2 - version: 3.0.2(multer@1.4.5-lts.1) '@tachybase/components': specifier: workspace:* version: link:../../../core/components @@ -3356,6 +3350,21 @@ importers: specifier: ^6.25.1 version: 6.25.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + packages/plugins/@tachybase/plugin-messages: + dependencies: + '@tachybase/client': + specifier: workspace:* + version: link:../../../core/client + '@tachybase/database': + specifier: workspace:* + version: link:../../../core/database + '@tachybase/server': + specifier: workspace:* + version: link:../../../core/server + '@tachybase/test': + specifier: workspace:* + version: link:../../../core/test + packages/plugins/@tachybase/plugin-mobile-client: dependencies: '@tachybase/client': @@ -4464,6 +4473,9 @@ importers: '@tachybase/plugin-map': specifier: workspace:* version: link:../../plugins/@tachybase/plugin-map + '@tachybase/plugin-messages': + specifier: workspace:* + version: link:../../plugins/@tachybase/plugin-messages '@tachybase/plugin-mobile-client': specifier: workspace:* version: link:../../plugins/@tachybase/plugin-mobile-client @@ -17408,13 +17420,6 @@ packages: peerDependencies: react: '>=16.x.x' - react-image-lightbox@5.1.4: - resolution: {integrity: sha512-kTiAODz091bgT7SlWNHab0LSMZAPJtlNWDGKv7pLlLY1krmf7FuG1zxE0wyPpeA8gPdwfr3cu6sPwZRqWsc3Eg==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - peerDependencies: - react: 16.x || 17.x - react-dom: 16.x || 17.x - react-intersection-observer@9.10.3: resolution: {integrity: sha512-9NYfKwPZRovB6QJee7fDg0zz/SyYrqXtn5xTZU0vwLtLVBtfu9aZt1pVmr825REE49VPDZ7Lm5SNHjJBOTZHpA==} peerDependencies: @@ -36110,13 +36115,6 @@ snapshots: object-assign: 4.1.1 react: 18.3.1 - react-image-lightbox@5.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-modal: 3.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-intersection-observer@9.10.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1