diff --git a/README.md b/README.md index 1fd53ead3b3..d15afb19ab6 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad | `yarn app:server` | Launch GROWI app server | | `yarn start` | Invoke `yarn app:build` and `yarn app:server` | -For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup-v2/launch-system.html#list-of-npm-commands). +For more info, see [GROWI Docs: List of npm Scripts](https://docs.growi.org/en/dev/startup-v5/start-development.html#list-of-npm-scripts). # Documentation diff --git a/README_JP.md b/README_JP.md index 3abf0428ba7..478f3fd56a5 100644 --- a/README_JP.md +++ b/README_JP.md @@ -100,7 +100,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig | `yarn app:server` | GROWI app サーバーを起動します。 | | `yarn start` | `yarn app:build` と `yarn app:server` を呼び出します。 | -詳しくは [GROWI Docs: List of npm Commands](https://docs.growi.org/ja/dev/startup-v2/launch-system.html#npm-コマンドリスト)をご覧ください。 +詳しくは [GROWI Docs: npm スクリプトリスト](https://docs.growi.org/ja/dev/startup-v5/start-development.html#npm-%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%95%E3%82%9A%E3%83%88%E3%83%AA%E3%82%B9%E3%83%88)をご覧ください。 # ドキュメント diff --git a/apps/app/next.config.js b/apps/app/next.config.js index 120f0190ae8..5377d3b29e1 100644 --- a/apps/app/next.config.js +++ b/apps/app/next.config.js @@ -85,16 +85,25 @@ module.exports = async(phase, { defaultConfig }) => { /** @param config {import('next').NextConfig} */ webpack(config, options) { - // Avoid "Module not found: Can't resolve 'fs'" - // See: https://stackoverflow.com/a/68511591 if (!options.isServer) { + // Avoid "Module not found: Can't resolve 'fs'" + // See: https://stackoverflow.com/a/68511591 config.resolve.fallback.fs = false; - } - // See: https://webpack.js.org/configuration/externals/ - // This provides a way of excluding dependencies from the output bundles - config.externals.push('dtrace-provider'); - config.externals.push('mongoose'); + // exclude packages from the output bundles + config.module.rules.push( + ...[ + /dtrace-provider/, + /mongoose/, + /mathjax-full/, // required from marp + ].map((packageRegExp) => { + return { + test: packageRegExp, + use: 'null-loader', + }; + }), + ); + } // extract sourcemap if (options.dev) { diff --git a/apps/app/package.json b/apps/app/package.json index 3a8244e7ae9..0a07767c330 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -18,7 +18,6 @@ "//// for development": "", "dev": "yarn cross-env NODE_ENV=development yarn ts-node-dev --inspect --transpile-only src/server/app.ts", "dev:styles-prebuilt": "yarn styles-prebuilt --mode dev", - "dev:analyze": "yarn cross-env ANALYZE=true yarn dev", "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo", "dev:migrate": "yarn dev:migrate:status > tmp/cache/migration-status.out && yarn dev:migrate:up", "dev:migrate:create": "yarn dev:migrate-mongo create -f config/migrate-mongo-config.js", @@ -62,6 +61,7 @@ "@google-cloud/storage": "^5.8.5", "@growi/core": "^6.1.0-RC.0", "@growi/hackmd": "^6.1.0-RC.0", + "@growi/remark-attachment-refs": "^6.1.0-RC.0", "@growi/preset-themes": "^6.1.0-RC.0", "@growi/remark-drawio": "^6.1.0-RC.0", "@growi/remark-growi-directive": "^6.1.0-RC.0", @@ -144,6 +144,7 @@ "passport-local": "^1.0.0", "passport-saml": "^3.2.0", "prom-client": "^14.1.1", + "qs": "^6.11.1", "rate-limiter-flexible": "^2.3.7", "react": "^18.2.0", "react-bootstrap-typeahead": "^5.2.2", @@ -192,7 +193,7 @@ "usehooks-ts": "^2.6.0", "validator": "^13.7.0", "ws": "^8.3.0", - "xss": "^1.0.6" + "xss": "^1.0.14" }, "// comments for defDependencies": { "@handsontable/react": "v3 requires handsontable >= 7.0.0.", @@ -225,6 +226,7 @@ "load-css-file": "^1.0.0", "material-icons": "^1.11.3", "morgan": "^1.10.0", + "null-loader": "^4.0.1", "penpal": "^4.0.0", "plantuml-encoder": "^1.2.5", "prettier": "^1.19.1", diff --git a/apps/app/src/client/services/renderer/renderer.tsx b/apps/app/src/client/services/renderer/renderer.tsx index eb4bd0f5741..1fc7c240e03 100644 --- a/apps/app/src/client/services/renderer/renderer.tsx +++ b/apps/app/src/client/services/renderer/renderer.tsx @@ -1,6 +1,7 @@ import assert from 'assert'; import { isClient } from '@growi/core/dist/utils/browser-utils'; +import * as refsGrowiPlugin from '@growi/remark-attachment-refs/dist/client/index.mjs'; import * as drawioPlugin from '@growi/remark-drawio'; // eslint-disable-next-line import/extensions import * as lsxGrowiPlugin from '@growi/remark-lsx/dist/client/index.mjs'; @@ -33,6 +34,7 @@ import loggerFactory from '~/utils/logger'; // import EasyGrid from './PreProcessor/EasyGrid'; import '@growi/remark-lsx/dist/client/style.css'; +import '@growi/remark-attachment-refs/dist/client/style.css'; const logger = loggerFactory('growi:cli:services:renderer'); @@ -58,6 +60,7 @@ export const generateViewOptions = ( drawioPlugin.remarkPlugin, xsvToTable.remarkPlugin, lsxGrowiPlugin.remarkPlugin, + refsGrowiPlugin.remarkPlugin, ); if (config.isEnabledLinebreaks) { remarkPlugins.push(breaks); @@ -72,6 +75,7 @@ export const generateViewOptions = ( commonSanitizeOption, drawioPlugin.sanitizeOption, lsxGrowiPlugin.sanitizeOption, + refsGrowiPlugin.sanitizeOption, )] : () => {}; @@ -79,6 +83,7 @@ export const generateViewOptions = ( rehypePlugins.push( slug, [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }], + [refsGrowiPlugin.rehypePlugin, { pagePath }], rehypeSanitizePlugin, katex, [relocateToc.rehypePluginStore, { storeTocNode }], @@ -93,6 +98,11 @@ export const generateViewOptions = ( components.h5 = Header; components.h6 = Header; components.lsx = lsxGrowiPlugin.Lsx; + components.ref = refsGrowiPlugin.Ref; + components.refs = refsGrowiPlugin.Refs; + components.refimg = refsGrowiPlugin.RefImg; + components.refsimg = refsGrowiPlugin.RefsImg; + components.gallery = refsGrowiPlugin.Gallery; components.drawio = DrawioViewerWithEditButton; components.table = TableWithEditButton; } @@ -153,6 +163,7 @@ export const generateSimpleViewOptions = ( drawioPlugin.remarkPlugin, xsvToTable.remarkPlugin, lsxGrowiPlugin.remarkPlugin, + refsGrowiPlugin.remarkPlugin, ); const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks; @@ -171,12 +182,14 @@ export const generateSimpleViewOptions = ( commonSanitizeOption, drawioPlugin.sanitizeOption, lsxGrowiPlugin.sanitizeOption, + refsGrowiPlugin.sanitizeOption, )] : () => {}; // add rehype plugins rehypePlugins.push( [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }], + [refsGrowiPlugin.rehypePlugin, { pagePath }], [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }], rehypeSanitizePlugin, katex, @@ -185,6 +198,11 @@ export const generateSimpleViewOptions = ( // add components if (components != null) { components.lsx = lsxGrowiPlugin.LsxImmutable; + components.ref = refsGrowiPlugin.RefImmutable; + components.refs = refsGrowiPlugin.RefsImmutable; + components.refimg = refsGrowiPlugin.RefImgImmutable; + components.refsimg = refsGrowiPlugin.RefsImgImmutable; + components.gallery = refsGrowiPlugin.GalleryImmutable; components.drawio = drawioPlugin.DrawioViewer; } @@ -219,6 +237,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string) drawioPlugin.remarkPlugin, xsvToTable.remarkPlugin, lsxGrowiPlugin.remarkPlugin, + refsGrowiPlugin.remarkPlugin, ); if (config.isEnabledLinebreaks) { remarkPlugins.push(breaks); @@ -232,6 +251,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string) ? [sanitize, deepmerge( commonSanitizeOption, lsxGrowiPlugin.sanitizeOption, + refsGrowiPlugin.sanitizeOption, drawioPlugin.sanitizeOption, addLineNumberAttribute.sanitizeOption, )] @@ -240,6 +260,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string) // add rehype plugins rehypePlugins.push( [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }], + [refsGrowiPlugin.rehypePlugin, { pagePath }], addLineNumberAttribute.rehypePlugin, rehypeSanitizePlugin, katex, @@ -248,6 +269,11 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string) // add components if (components != null) { components.lsx = lsxGrowiPlugin.LsxImmutable; + components.ref = refsGrowiPlugin.RefImmutable; + components.refs = refsGrowiPlugin.RefsImmutable; + components.refimg = refsGrowiPlugin.RefImgImmutable; + components.refsimg = refsGrowiPlugin.RefsImgImmutable; + components.gallery = refsGrowiPlugin.GalleryImmutable; components.drawio = drawioPlugin.DrawioViewer; } diff --git a/apps/app/src/components/Common/Dropdown/PageItemControl.tsx b/apps/app/src/components/Common/Dropdown/PageItemControl.tsx index ded93d0b769..224a77210ed 100644 --- a/apps/app/src/components/Common/Dropdown/PageItemControl.tsx +++ b/apps/app/src/components/Common/Dropdown/PageItemControl.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useEffect } from 'react'; +import { modifiersForRightAlign } from '@growi/ui/dist/utils'; import { useTranslation } from 'next-i18next'; import { Dropdown, DropdownMenu, DropdownToggle, DropdownItem, @@ -248,9 +249,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E return ( {contents} diff --git a/apps/app/src/components/Me/InAppNotificationSettings.tsx b/apps/app/src/components/Me/InAppNotificationSettings.tsx index f6315a09a3d..5b170bf60ea 100644 --- a/apps/app/src/components/Me/InAppNotificationSettings.tsx +++ b/apps/app/src/components/Me/InAppNotificationSettings.tsx @@ -2,7 +2,7 @@ import React, { FC, useState, useEffect, useCallback, } from 'react'; -import { pullAllBy } from 'lodash'; +import pullAllBy from 'lodash/pullAllBy'; import { useTranslation } from 'next-i18next'; import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client'; diff --git a/apps/app/src/components/Page/CopyDropdown.jsx b/apps/app/src/components/Page/CopyDropdown.jsx index d1b917dadd2..5619b03f3ed 100644 --- a/apps/app/src/components/Page/CopyDropdown.jsx +++ b/apps/app/src/components/Page/CopyDropdown.jsx @@ -118,8 +118,11 @@ const CopyDropdown = (props) => { {children} - - +
{ t('copy_to_clipboard.Copy to clipboard') } diff --git a/apps/app/src/components/PageSideContents.tsx b/apps/app/src/components/PageSideContents.tsx index 15f49e5585d..8cf4b7d334c 100644 --- a/apps/app/src/components/PageSideContents.tsx +++ b/apps/app/src/components/PageSideContents.tsx @@ -14,7 +14,7 @@ import TableOfContents from './TableOfContents'; import styles from './PageSideContents.module.scss'; -const { isTopPage, isUsersHomePage } = pagePathUtils; +const { isTopPage, isUsersHomePage, isTrashPage } = pagePathUtils; export type PageSideContentsProps = { @@ -32,6 +32,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => { const pagePath = page.path; const isTopPagePath = isTopPage(pagePath); const isUsersHomePagePath = isUsersHomePage(pagePath); + const isTrash = isTrashPage(pagePath); return ( <> @@ -48,7 +49,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
{t('page_list')} - + + {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */} + { !isTrash ? :
} )} diff --git a/apps/app/src/components/SavePageControls.tsx b/apps/app/src/components/SavePageControls.tsx index 0f34cf7c779..151314e6b4c 100644 --- a/apps/app/src/components/SavePageControls.tsx +++ b/apps/app/src/components/SavePageControls.tsx @@ -80,17 +80,17 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
{isAclEnabled - && ( -
- -
- ) + && ( +
+ +
+ ) } @@ -101,9 +101,9 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu onClick={save} disabled={isWaitingSaveProcessing} > - { isWaitingSaveProcessing && ( + {isWaitingSaveProcessing && ( - ) } + )} {labelSubmitButton} diff --git a/apps/app/src/server/crowi/index.js b/apps/app/src/server/crowi/index.js index 244e5d14aa6..fb44c093620 100644 --- a/apps/app/src/server/crowi/index.js +++ b/apps/app/src/server/crowi/index.js @@ -3,6 +3,7 @@ import http from 'http'; import path from 'path'; import { createTerminus } from '@godaddy/terminus'; +import attachmentRoutes from '@growi/remark-attachment-refs/dist/server'; import lsxRoutes from '@growi/remark-lsx/dist/server'; import mongoose from 'mongoose'; import next from 'next'; @@ -531,6 +532,7 @@ Crowi.prototype.setupTerminus = function(server) { Crowi.prototype.setupRoutesForPlugins = function() { lsxRoutes(this, this.express); + attachmentRoutes(this, this.express); }; /** diff --git a/apps/app/src/server/models/page.ts b/apps/app/src/server/models/page.ts index 3abd3a0cd3a..aa64e004170 100644 --- a/apps/app/src/server/models/page.ts +++ b/apps/app/src/server/models/page.ts @@ -566,7 +566,7 @@ schema.statics.findByIdsAndViewer = async function( }; /* - * Find a page by path and viewer. Pass false to useFindOne to use findOne method. + * Find a page by path and viewer. Pass true to useFindOne to use findOne method. */ schema.statics.findByPathAndViewer = async function( path: string | null, user, userGroups = null, useFindOne = false, includeEmpty = false, diff --git a/apps/app/src/server/service/search.ts b/apps/app/src/server/service/search.ts index 3b2d10491db..094de92c8f2 100644 --- a/apps/app/src/server/service/search.ts +++ b/apps/app/src/server/service/search.ts @@ -1,5 +1,5 @@ import mongoose from 'mongoose'; -import xss from 'xss'; +import { FilterXSS } from 'xss'; import { SearchDelegatorName } from '~/interfaces/named-query'; import { IPageHasId } from '~/interfaces/page'; @@ -31,7 +31,7 @@ const filterXssOptions = { }, }; -const filterXss = new xss.FilterXSS(filterXssOptions); +const filterXss = new FilterXSS(filterXssOptions); const normalizeQueryString = (_queryString: string): string => { let queryString = _queryString.trim(); diff --git a/apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts b/apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts index b5ebd018ea5..d22106c3dad 100644 --- a/apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts +++ b/apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts @@ -263,11 +263,16 @@ context('Access to Template Editing Mode', () => { cy.visit('/Sandbox'); cy.waitUntilSkeletonDisappear(); - cy.get('#grw-subnav-container').within(() => { - cy.getByTestid('open-page-item-control-btn').click({force: true}); - cy.getByTestid('open-page-template-modal-btn').click({force: true}); + cy.waitUntil(() => { + // do + cy.get('#grw-subnav-container').within(() => { + cy.getByTestid('open-page-item-control-btn').find('button').click({force: true}); + }); + // wait until + return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible')) }); + cy.getByTestid('open-page-template-modal-btn').filter(':visible').click({force: true}); cy.getByTestid('page-template-modal').should('be.visible'); cy.screenshot(`${ssPrefix}-open-page-template-modal`); @@ -294,11 +299,16 @@ context('Access to Template Editing Mode', () => { cy.visit('/Sandbox'); cy.waitUntilSkeletonDisappear(); - cy.get('#grw-subnav-container').within(() => { - cy.getByTestid('open-page-item-control-btn').click({force: true}); - cy.getByTestid('open-page-template-modal-btn').click({force: true}); + cy.waitUntil(() => { + // do + cy.get('#grw-subnav-container').within(() => { + cy.getByTestid('open-page-item-control-btn').find('button').click({force: true}); + }); + // Wait until + return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible')) }); + cy.getByTestid('open-page-template-modal-btn').filter(':visible').click({force: true}); cy.getByTestid('page-template-modal').should('be.visible'); cy.getByTestid('template-button-decendants').click(({force: true})) @@ -323,10 +333,18 @@ context('Access to Template Editing Mode', () => { it('Template is applied to pages created from PageTree (template for descendants)', () => { // delete /Sandbox/_template cy.visit('/Sandbox/_template'); - cy.get('#grw-subnav-container').within(() => { - cy.getByTestid('open-page-item-control-btn').click({force: true}); - cy.getByTestid('open-page-delete-modal-btn').click({force: true}); + + cy.waitUntil(() => { + //do + cy.get('#grw-subnav-container').within(() => { + cy.getByTestid('open-page-item-control-btn').find('button').click({force: true}); + }); + // wait until + return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible')) }); + + cy.getByTestid('open-page-delete-modal-btn').filter(':visible').click({force: true}); + cy.getByTestid('page-delete-modal').should('be.visible').within(() => { cy.intercept('POST', '/_api/pages.remove').as('remove'); cy.getByTestid('delete-page-button').click(); diff --git a/apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts b/apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts index 5cd38a4d463..a97d19e3108 100644 --- a/apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts +++ b/apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts @@ -136,11 +136,17 @@ context('Modal for page operation', () => { it('Page Deletion and PutBack is executed successfully', { scrollBehavior: false }, () => { cy.visit('/Sandbox/Bootstrap4'); - cy.get('#grw-subnav-container').within(() => { - cy.getByTestid('open-page-item-control-btn').click({force: true}); - cy.getByTestid('open-page-delete-modal-btn').click({force: true}); + cy.waitUntil(() => { + // do + cy.get('#grw-subnav-container').within(() => { + cy.getByTestid('open-page-item-control-btn').find('button').click({force: true}); + }); + //wait until + return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible')) }); + cy.getByTestid('open-page-delete-modal-btn').filter(':visible').click({force: true}); + cy.getByTestid('page-delete-modal').should('be.visible').within(() => { cy.screenshot(`${ssPrefix}-delete-modal`); cy.getByTestid('delete-page-button').click(); @@ -163,11 +169,17 @@ context('Modal for page operation', () => { cy.visit('/Sandbox/Bootstrap4'); cy.waitUntilSkeletonDisappear(); - cy.get('#grw-subnav-container').within(() => { - cy.getByTestid('open-page-item-control-btn').click({force: true}); - cy.getByTestid('open-page-duplicate-modal-btn').click({force: true}); + cy.waitUntil(() => { + // do + cy.get('#grw-subnav-container').within(() => { + cy.getByTestid('open-page-item-control-btn').find('button').click({force: true}); + }); + // wait until + return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible')) }); + cy.getByTestid('open-page-duplicate-modal-btn').filter(':visible').click({force: true}); + cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}-duplicate-bootstrap4`); }); @@ -175,11 +187,16 @@ context('Modal for page operation', () => { cy.visit('/Sandbox/Bootstrap4'); cy.waitUntilSkeletonDisappear(); - cy.get('#grw-subnav-container').within(() => { - cy.getByTestid('open-page-item-control-btn').click({force: true}); - cy.getByTestid('open-page-move-rename-modal-btn').click({force: true}); + cy.waitUntil(() => { + // do + cy.get('#grw-subnav-container').within(() => { + cy.getByTestid('open-page-item-control-btn').find('button').click({force: true}); + }); + // wait until + return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible')) }); + cy.getByTestid('open-page-move-rename-modal-btn').filter(':visible').click({force: true}); cy.getByTestid('grw-page-rename-button').should('be.disabled'); cy.getByTestid('page-rename-modal').should('be.visible').screenshot(`${ssPrefix}-rename-bootstrap4`); diff --git a/package.json b/package.json index 999a8453363..a32612525da 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "shipjs": "^0.24.1", "stylelint": "^14.2.0", "stylelint-config-recess-order": "^3.0.0", - "tsconfig-paths": "^3.9.0", "ts-node-dev": "^2.0.0", + "tsconfig-paths": "^3.9.0", "typescript": "~4.9", "unplugin-swc": "^1.3.2", "vite": "^4.2.2", diff --git a/packages-obsolete/plugin-attachment-refs/package.json b/packages-obsolete/plugin-attachment-refs/package.json deleted file mode 100644 index 0c3ac3c3704..00000000000 --- a/packages-obsolete/plugin-attachment-refs/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@growi/plugin-attachment-refs", - "version": "6.0.0-RC.9", - "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags", - "license": "MIT", - "keywords": [ - "growi", - "growi-plugin" - ], - "main": "dist/cjs/index.js", - "module": "dist/esm/index.js", - "files": [ - "dist" - ], - "scripts": { - "build": "run-p build:*", - "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json", - "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json", - "clean": "npx -y shx rm -rf dist", - "lint:js": "yarn eslint **/*.{js,jsx,ts,tsx}", - "lint:styles": "stylelint src/**/*.scss src/**/*.css", - "lint": "run-p lint:*", - "test": "" - }, - "dependencies": { - "browser-bunyan": "^1.6.3", - "bunyan": "^1.8.15", - "http-errors": "^2.0.0", - "react-images": "~1.0.0", - "react-motion": "^0.5.2", - "universal-bunyan": "^0.9.2" - }, - "devDependencies": { - "eslint-plugin-regex": "^1.8.0", - "npm-run-all": "^4.1.5", - "react": "^18.2.0", - "react-dom": "^18.2.0" - } -} diff --git a/packages-obsolete/plugin-attachment-refs/src/client-entry.js b/packages-obsolete/plugin-attachment-refs/src/client-entry.js deleted file mode 100644 index f3ac2b2c93a..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client-entry.js +++ /dev/null @@ -1,10 +0,0 @@ -import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor'; -import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor'; - -export default () => { - // add interceptors - global.interceptorManager.addInterceptors([ - new RefsPreRenderInterceptor(), - new RefsPostRenderInterceptor(), - ]); -}; diff --git a/packages-obsolete/plugin-attachment-refs/src/client/css/index.css b/packages-obsolete/plugin-attachment-refs/src/client/css/index.css deleted file mode 100644 index 4d670a107bf..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/css/index.css +++ /dev/null @@ -1,28 +0,0 @@ -@keyframes attachement-refs-fadeIn { - 0% {opacity: .2} - 100% {opacity: .9} -} - -.attachment-refs .attachement-refs-blink { - animation: attachement-refs-fadeIn 1s ease 0s infinite alternate; -} - - -.attachment-refs li.attachment { - list-style: none; -} - -.attachment-refs .attachment-userpicture { - line-height: 1.7em; - vertical-align: bottom; -} - -.attachment-refs .page-meta { - font-size: 0.95em; -} - -.attachment-refs .attachment-filetype { - padding: 1px 5px; - margin: 0 0 0 4px; - font-weight: normal; -} diff --git a/packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx b/packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx deleted file mode 100644 index ec9f7f8fa6d..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx +++ /dev/null @@ -1,199 +0,0 @@ -import React from 'react'; - -import { Attachment } from '@growi/ui/dist/components/Attachment'; -import axios from 'axios'; // import axios from growi dependencies -import PropTypes from 'prop-types'; - -// eslint-disable-next-line import/no-unresolved - -import RefsContext from '../util/RefsContext'; -import TagCacheManagerFactory from '../util/TagCacheManagerFactory'; - -// eslint-disable-next-line no-unused-vars - -import ExtractedAttachments from './ExtractedAttachments'; - -import styles from '../../css/index.css'; - -const AttachmentLink = Attachment; - -export default class AttachmentList extends React.Component { - - constructor(props) { - super(props); - - this.state = { - isLoading: false, - isLoaded: false, - isError: false, - errorMessage: null, - - attachments: [], - }; - - this.tagCacheManager = TagCacheManagerFactory.getInstance(); - } - - async UNSAFE_componentWillMount() { - const { refsContext } = this.props; - - // get state object cache - const stateCache = this.tagCacheManager.getStateCache(refsContext); - - // check cache exists - if (stateCache != null) { - // restore state - this.setState({ - ...stateCache, - isLoading: false, - }); - // parse with no effect - try { - refsContext.parse(); - } - catch (err) { - // do nothing - } - - return; // go to render() - } - - // parse - try { - refsContext.parse(); - } - catch (err) { - this.setState({ - isError: true, - errorMessage: err.toString(), - }); - - // store to sessionStorage - this.tagCacheManager.cacheState(refsContext, this.state); - - return; - } - - this.loadContents(); - } - - async loadContents() { - const { refsContext } = this.props; - - let res; - try { - this.setState({ isLoading: true }); - - if (refsContext.isSingle) { - res = await axios.get('/_api/plugin/ref', { - params: { - pagePath: refsContext.pagePath, - fileNameOrId: refsContext.fileNameOrId, - options: refsContext.options, - }, - }); - this.setState({ - attachments: [res.data.attachment], - }); - } - else { - res = await axios.get('/_api/plugin/refs', { - params: { - prefix: refsContext.prefix, - pagePath: refsContext.pagePath, - options: refsContext.options, - }, - }); - this.setState({ - attachments: res.data.attachments, - }); - } - - this.setState({ - isLoaded: true, - }); - } - catch (err) { - this.setState({ - isError: true, - errorMessage: err.response.data, - }); - - return; - } - finally { - this.setState({ isLoading: false }); - - // store to sessionStorage - this.tagCacheManager.cacheState(refsContext, this.state); - } - - } - - renderNoAttachmentsMessage() { - const { refsContext } = this.props; - - let message; - - if (refsContext.prefix != null) { - message = `${refsContext.prefix} and descendant pages have no attachments`; - } - else { - message = `${refsContext.pagePath} has no attachments`; - } - - return ( -
- - - {message} - -
- ); - } - - renderContents() { - const { refsContext } = this.props; - - if (this.state.isLoading) { - return ( -
- - {refsContext.tagExpression} -
- ); - } - if (this.state.errorMessage != null) { - return ( -
- - {refsContext.tagExpression} (-> {this.state.errorMessage}) -
- ); - } - - if (this.state.isLoaded) { - const { attachments } = this.state; - - // no attachments - if (attachments.length === 0) { - return this.renderNoAttachmentsMessage(); - } - - return (refsContext.isExtractImg) - ? - : attachments.map((attachment) => { - return ; - }); - } - } - - render() { - return
{this.renderContents()}
; - } - -} - -AttachmentList.propTypes = { - refsContext: PropTypes.instanceOf(RefsContext).isRequired, -}; diff --git a/packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx b/packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx deleted file mode 100644 index 8fdc024d7f2..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx +++ /dev/null @@ -1,214 +0,0 @@ -import React from 'react'; - -import PropTypes from 'prop-types'; -import Carousel, { Modal, ModalGateway } from 'react-images'; - -import RefsContext from '../util/RefsContext'; - -/** - * 1. when 'fileFormat' is image, render Attachment as an image - * 2. when 'fileFormat' is not image, render Attachment as an Attachment component - */ -export default class ExtractedAttachments extends React.PureComponent { - - constructor(props) { - super(props); - - this.state = { - showCarousel: false, - currentIndex: null, - }; - } - - imageClickedHandler(index) { - this.setState({ - showCarousel: true, - currentIndex: index, - }); - } - - getAttachmentsFilteredByFormat() { - return this.props.attachments - .filter(attachment => attachment.fileFormat.startsWith('image/')); - } - - getClassesAndStylesForNonGrid() { - const { refsContext } = this.props; - const { options } = refsContext; - - const { - width, - height, - 'max-width': maxWidth, - 'max-height': maxHeight, - display = 'block', - } = options; - - const containerStyles = { - width, height, maxWidth, maxHeight, display, - }; - - const imageClasses = []; - const imageStyles = { - width, height, maxWidth, maxHeight, - }; - - return { - containerStyles, - imageClasses, - imageStyles, - }; - } - - getClassesAndStylesForGrid() { - const { refsContext } = this.props; - const { options } = refsContext; - - const { - 'max-width': maxWidth, - 'max-height': maxHeight, - } = options; - - const containerStyles = { - width: refsContext.getOptGridWidth(), - height: refsContext.getOptGridHeight(), - maxWidth, - maxHeight, - }; - - const imageClasses = ['w-100', 'h-100']; - const imageStyles = { - objectFit: 'cover', - maxWidth, - maxHeight, - }; - - return { - containerStyles, - imageClasses, - imageStyles, - }; - } - - /** - * wrapper method for getClassesAndStylesForGrid/getClassesAndStylesForNonGrid - */ - getClassesAndStyles() { - const { refsContext } = this.props; - const { options } = refsContext; - - return (options.grid != null) - ? this.getClassesAndStylesForGrid() - : this.getClassesAndStylesForNonGrid(); - } - - renderExtractedImage(attachment, index) { - const { refsContext } = this.props; - const { options } = refsContext; - - // determine alt - let alt = refsContext.isSingle ? options.alt : undefined; // use only when single mode - alt = alt || attachment.originalName; // use 'originalName' if options.alt is not specified - - // get styles - const { - containerStyles, imageClasses, imageStyles, - } = this.getClassesAndStyles(); - - // carousel settings - let onClick; - if (options['no-carousel'] == null) { - // pointer cursor - Object.assign(containerStyles, { cursor: 'pointer' }); - // set click handler - onClick = () => { - this.imageClickedHandler(index); - }; - } - - return ( -
- {alt} -
- ); - } - - renderCarousel() { - const { options } = this.props.refsContext; - const withCarousel = options['no-carousel'] == null; - - const { showCarousel, currentIndex } = this.state; - - const images = this.getAttachmentsFilteredByFormat() - .map((attachment) => { - return { src: attachment.filePathProxied }; - }); - - // overwrite react-images modal styles - const zIndex = 1030; // > grw-navbar - const modalStyles = { - blanket: (styleObj) => { - return Object.assign(styleObj, { zIndex }); - }, - positioner: (styleObj) => { - return Object.assign(styleObj, { zIndex }); - }, - }; - - return ( - - { withCarousel && showCarousel && ( - { this.setState({ showCarousel: false }) }}> - - - ) } - - ); - } - - render() { - const { refsContext } = this.props; - const { options } = refsContext; - const { - grid, - 'grid-gap': gridGap, - } = options; - - const styles = {}; - - // Grid mode - if (grid != null) { - - const gridTemplateColumns = (refsContext.isOptGridColumnEnabled()) - ? `repeat(${refsContext.getOptGridColumnsNum()}, 1fr)` - : `repeat(auto-fill, ${refsContext.getOptGridWidth()})`; - - Object.assign(styles, { - display: 'grid', - gridTemplateColumns, - gridAutoRows: '1fr', - gridGap, - }); - - } - - const contents = this.getAttachmentsFilteredByFormat() - .map((attachment, index) => this.renderExtractedImage(attachment, index)); - - return ( - -
- {contents} -
- - { this.renderCarousel() } -
- ); - } - -} - -ExtractedAttachments.propTypes = { - attachments: PropTypes.arrayOf(PropTypes.object).isRequired, - refsContext: PropTypes.instanceOf(RefsContext).isRequired, -}; diff --git a/packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js b/packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js deleted file mode 100644 index 7dace7e3719..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js +++ /dev/null @@ -1,24 +0,0 @@ -import RefsContext from './RefsContext'; - -/** - * Context Object class for $gallery() - */ -export default class GalleryContext extends RefsContext { - - /** - * @param {object|TagContext|RefsContext} initArgs - */ - constructor(initArgs, fromPagePath) { - super(initArgs); - - this.options = { - grid: 'col-4', - 'grid-gap': '1px', - }; - } - - get isExtractImg() { - return true; - } - -} diff --git a/packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js b/packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js deleted file mode 100644 index a2c349a6d00..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; - -import { BasicInterceptor } from '@growi/core'; -import ReactDOM from 'react-dom'; - - -import AttachmentList from '../../components/AttachmentList'; -import GalleryContext from '../GalleryContext'; -import RefsContext from '../RefsContext'; - - -/** - * The interceptor for refs - * - * render React DOM - */ -export default class RefsPostRenderInterceptor extends BasicInterceptor { - - /** - * @inheritdoc - */ - isInterceptWhen(contextName) { - return ( - contextName === 'postRenderHtml' - || contextName === 'postRenderPreviewHtml' - ); - } - - /** - * @inheritdoc - */ - async process(contextName, ...args) { - const context = Object.assign(args[0]); // clone - - // forEach keys of tagContextMap - Object.keys(context.tagContextMap).forEach((domId) => { - const elem = document.getElementById(domId); - - if (elem) { - const tagContext = context.tagContextMap[domId]; - - // instanciate RefsContext from context - const refsContext = (tagContext.method === 'gallery') - ? new GalleryContext(tagContext || {}) - : new RefsContext(tagContext || {}); - refsContext.fromPagePath = context.pagePath ?? context.currentPathname; - - this.renderReactDom(refsContext, elem); - } - }); - - return context; - } - - renderReactDom(refsContext, elem) { - ReactDOM.render( - , - elem, - ); - } - -} diff --git a/packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js b/packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js deleted file mode 100644 index 8c19a4456e7..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js +++ /dev/null @@ -1,59 +0,0 @@ -import { customTagUtils, BasicInterceptor } from '@growi/core'; - -import TagCacheManagerFactory from '../TagCacheManagerFactory'; - -/** - * The interceptor for refs - * - * replace refs tag to a React target element - */ -export default class RefsPreRenderInterceptor extends BasicInterceptor { - - /** - * @inheritdoc - */ - isInterceptWhen(contextName) { - return ( - contextName === 'preRenderHtml' - || contextName === 'preRenderPreviewHtml' - ); - } - - /** - * @inheritdoc - */ - isProcessableParallel() { - return false; - } - - /** - * @inheritdoc - */ - async process(contextName, ...args) { - const context = Object.assign(args[0]); // clone - const parsedHTML = context.parsedHTML; - this.initializeCache(contextName); - - const tagPattern = /ref|refs|refimg|refsimg|gallery/; - const result = customTagUtils.findTagAndReplace(tagPattern, parsedHTML); - - context.parsedHTML = result.html; - context.tagContextMap = result.tagContextMap; - - return context; - } - - /** - * initialize cache - * when contextName is 'preRenderHtml' -> clear cache - * when contextName is 'preRenderPreviewHtml' -> doesn't clear cache - * - * @param {string} contextName - */ - initializeCache(contextName) { - if (contextName === 'preRenderHtml') { - TagCacheManagerFactory.getInstance().clearAllStateCaches(); - } - } - -} diff --git a/packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js b/packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js deleted file mode 100644 index 671d1e24d9e..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js +++ /dev/null @@ -1,249 +0,0 @@ -import * as url from 'url'; - -import { customTagUtils, pathUtils } from '@growi/core'; - -const { TagContext, ArgsParser, OptionParser } = customTagUtils; - -const GRID_DEFAULT_TRACK_WIDTH = 64; -const GRID_AVAILABLE_OPTIONS_LIST = [ - 'autofill', - 'autofill-xs', - 'autofill-sm', - 'autofill-md', - 'autofill-lg', - 'autofill-xl', - 'col-2', - 'col-3', - 'col-4', - 'col-5', - 'col-6', -]; - -/** - * Context Object class for $refs() and $refsimg() - */ -export default class RefsContext extends TagContext { - - /** - * @param {object|TagContext|RefsContext} initArgs - */ - constructor(initArgs, fromPagePath) { - super(initArgs); - - this.fromPagePath = fromPagePath; - - // initialized after parse() - this.pagePath = null; - this.isParsed = null; - this.options = {}; - } - - get isSingle() { - return this.method.match(/^(ref|refimg)$/); - } - - get isExtractImg() { - return this.method.match(/^(refimg|refsimg)$/); - } - - parse() { - if (this.isParsed) { - return; - } - - const parsedArgs = ArgsParser.parse(this.args); - this.options = Object.assign(this.options, parsedArgs.options); - - if (this.isSingle) { - this.parseForSingle(parsedArgs); - } - else { - this.parseForMulti(parsedArgs); - } - - this.isParsed = true; - } - - /** - * parse method for 'ref' and 'refimg' - */ - parseForSingle(parsedArgs) { - // determine fileNameOrId - // order: - // 1: ref(file=..., ...) - // 2: ref(id=..., ...) - // 2: refs(firstArgs, ...) - this.fileNameOrId = this.options.file || this.options.id - || ((parsedArgs.firstArgsValue === true) ? parsedArgs.firstArgsKey : undefined); - - if (this.fileNameOrId == null) { - throw new Error('\'file\' or \'id\' is not specified. Set first argument or specify \'file\' or \'id\' option'); - } - - // determine pagePath - // order: - // 1: ref(page=..., ...) - // 2: constructor argument - const specifiedPath = this.options.page || this.fromPagePath; - this.pagePath = this.getAbsolutePathFor(specifiedPath); - } - - /** - * parse method for 'refs' and 'refsimg' - */ - parseForMulti(parsedArgs) { - if (this.options.prefix) { - this.prefix = this.resolvePath(this.options.prefix); - } - else { - // determine pagePath - // order: - // 1: ref(page=..., ...) - // 2: refs(firstArgs, ...) - // 3: constructor argument - const specifiedPath = this.options.page - || ((parsedArgs.firstArgsValue === true) ? parsedArgs.firstArgsKey : undefined) - || this.fromPagePath; - - this.pagePath = this.getAbsolutePathFor(specifiedPath); - } - - if (this.options.grid != null && this.getOptGrid() == null) { - throw new Error('\'grid\' option is invalid. ' - + 'Available value is: \'autofill-( xs | sm | md | lg | xl )\' or \'col-( 2 | 3 | 4 | 5 | 6 )\''); - } - } - - // resolve pagePath - // when `fromPagePath`=/hoge and `specifiedPath`=./fuga, - // `pagePath` to be /hoge/fuga - // when `fromPagePath`=/hoge and `specifiedPath`=/fuga, - // `pagePath` to be /fuga - // when `fromPagePath`=/hoge and `specifiedPath`=undefined, - // `pagePath` to be /hoge - resolvePath(pagePath) { - return decodeURIComponent(url.resolve(pathUtils.addTrailingSlash(this.fromPagePath), pagePath)); - } - - getOptDepth() { - if (this.options.depth === undefined) { - return undefined; - } - return OptionParser.parseRange(this.options.depth); - } - - getOptGrid() { - const { grid } = this.options; - return GRID_AVAILABLE_OPTIONS_LIST.find(item => item === grid); - } - - isOptGridColumnEnabled() { - const optGrid = this.getOptGrid(); - return (optGrid != null) && optGrid.startsWith('col-'); - } - - getOptGridColumnsNum() { - const { grid } = this.options; - - let columnsNum = null; - - switch (grid) { - case 'col-2': - columnsNum = 2; - break; - case 'col-3': - columnsNum = 3; - break; - case 'col-4': - columnsNum = 4; - break; - case 'col-5': - columnsNum = 5; - break; - case 'col-6': - columnsNum = 6; - break; - } - - return columnsNum; - } - - /** - * return auto-calculated grid width - * rules: - * 1. when column mode (e.g. col-6, col5, ...), the width specification is disabled - * 2. when width option is set, return it - * 3. otherwise, the mode should be autofill and the width will be calculated according to the size - */ - getOptGridWidth() { - const grid = this.getOptGrid(); - const { width } = this.options; - - // when Grid column mode - if (this.isOptGridColumnEnabled()) { - // not specify and ignore width - return undefined; - } - - // when width is specified - if (width != null) { - return width; - } - - // when Grid autofill mode - let autofillMagnification = 1; - - switch (grid) { - case 'autofill-xl': - autofillMagnification = 3; - break; - case 'autofill-lg': - autofillMagnification = 2; - break; - case 'autofill-sm': - autofillMagnification = 0.75; - break; - case 'autofill-xs': - autofillMagnification = 0.5; - break; - case 'autofill': - case 'autofill-md': - break; - } - - return `${GRID_DEFAULT_TRACK_WIDTH * autofillMagnification}px`; - } - - /** - * return auto-calculated grid height - * rules: - * 1. when height option is set, return it - * 2. otherwise, the same value to the width will be returned - */ - - getOptGridHeight() { - const { height } = this.options; - - // when height is specified - if (height != null) { - return height; - } - - // return the value which is same to width - return this.getOptGridWidth(); - } - - /** - * return absolute path for the specified path - * - * @param {string} relativePath relative path from `this.fromPagePath` - */ - getAbsolutePathFor(relativePath) { - return decodeURIComponent( - pathUtils.normalizePath( // normalize like /foo/bar - url.resolve(pathUtils.addTrailingSlash(this.fromPagePath), relativePath), - ), - ); - } - -} diff --git a/packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js b/packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js deleted file mode 100644 index f98a146fe6f..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js +++ /dev/null @@ -1,21 +0,0 @@ -import { TagCacheManager } from '@growi/core'; - -const STATE_CACHE_NS = 'refs-state-cache'; - -let _instance; -export default class TagCacheManagerFactory { - - static getInstance() { - if (_instance == null) { - // create generateCacheKey implementation - const generateCacheKey = (refsContext) => { - return `${refsContext.method}__${refsContext.fromPagePath}__${refsContext.args}`; - }; - - _instance = new TagCacheManager(STATE_CACHE_NS, generateCacheKey); - } - - return _instance; - } - -} diff --git a/packages-obsolete/plugin-attachment-refs/src/index.js b/packages-obsolete/plugin-attachment-refs/src/index.js deleted file mode 100644 index 23474c5b5fe..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/index.js +++ /dev/null @@ -1,11 +0,0 @@ -const isProd = process.env.NODE_ENV === 'production'; - -module.exports = { - pluginSchemaVersion: 4, - serverEntries: [ - isProd ? 'dist/cjs/server-entry.js' : 'src/server-entry.js', - ], - clientEntries: [ - 'src/client-entry.js', - ], -}; diff --git a/packages-obsolete/plugin-attachment-refs/src/server-entry.js b/packages-obsolete/plugin-attachment-refs/src/server-entry.js deleted file mode 100644 index c69955a53b2..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/server-entry.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = (crowi, app) => { - // add routes - require('./server/routes')(crowi, app); -}; diff --git a/packages-obsolete/plugin-attachment-refs/src/server/routes/index.js b/packages-obsolete/plugin-attachment-refs/src/server/routes/index.js deleted file mode 100644 index 94186146f3d..00000000000 --- a/packages-obsolete/plugin-attachment-refs/src/server/routes/index.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = (crowi, app) => { - // add routes - app.use('/_api/plugin', require('./refs')(crowi, app)); -}; diff --git a/packages-obsolete/plugin-attachment-refs/tsconfig.base.json b/packages-obsolete/plugin-attachment-refs/tsconfig.base.json deleted file mode 100644 index 47af4497f13..00000000000 --- a/packages-obsolete/plugin-attachment-refs/tsconfig.base.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - }, - "include": [ - "src" - ], - "exclude": [ - "src/test" - ] -} diff --git a/packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json b/packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json deleted file mode 100644 index 5bd05a77e7e..00000000000 --- a/packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "dist/cjs", - "declaration": true, - "noResolve": false, - "preserveConstEnums": true, - "sourceMap": false, - "noEmit": false, - - "baseUrl": ".", - "paths": { - } - } -} diff --git a/packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json b/packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json deleted file mode 100644 index 37b23114edb..00000000000 --- a/packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "module": "esnext", - - "rootDir": "./src", - "outDir": "dist/esm", - "declaration": true, - "noResolve": false, - "preserveConstEnums": true, - "sourceMap": false, - "noEmit": false, - - "baseUrl": ".", - "paths": { - } - } -} diff --git a/packages-obsolete/plugin-attachment-refs/tsconfig.json b/packages-obsolete/plugin-attachment-refs/tsconfig.json deleted file mode 100644 index 5e250e5a5d5..00000000000 --- a/packages-obsolete/plugin-attachment-refs/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "~/*": ["./src/*"], - "@growi/*": ["../*/src"] - } - } -} diff --git a/packages/core/src/interfaces/attachment.ts b/packages/core/src/interfaces/attachment.ts index 2f84489c74a..c6113db50b9 100644 --- a/packages/core/src/interfaces/attachment.ts +++ b/packages/core/src/interfaces/attachment.ts @@ -1,4 +1,5 @@ import type { Ref } from './common'; +import { HasObjectId } from './has-object-id'; import type { IPage } from './page'; import type { IUser } from './user'; @@ -13,3 +14,5 @@ export type IAttachment = { downloadPathProxied: string, originalName: string, }; + +export type IAttachmentHasId = IAttachment & HasObjectId; diff --git a/packages/presentation/package.json b/packages/presentation/package.json index a8d159afa5f..e5124f76f92 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -21,7 +21,7 @@ "@growi/core": "^6.1.0-RC.0" }, "devDependencies": { - "@marp-team/marp-core": "^3.4.2", + "@marp-team/marp-core": "^3.6.0", "@types/reveal.js": "^4.4.1", "eslint-plugin-regex": "^1.8.0", "reveal.js": "^4.4.0" diff --git a/packages-obsolete/plugin-attachment-refs/.eslintignore b/packages/remark-attachment-refs/.eslintignore similarity index 100% rename from packages-obsolete/plugin-attachment-refs/.eslintignore rename to packages/remark-attachment-refs/.eslintignore diff --git a/packages-obsolete/plugin-attachment-refs/.gitignore b/packages/remark-attachment-refs/.gitignore similarity index 100% rename from packages-obsolete/plugin-attachment-refs/.gitignore rename to packages/remark-attachment-refs/.gitignore diff --git a/packages-obsolete/plugin-attachment-refs/README.md b/packages/remark-attachment-refs/README.md similarity index 95% rename from packages-obsolete/plugin-attachment-refs/README.md rename to packages/remark-attachment-refs/README.md index 5e88f9f9717..86f1d165f04 100644 --- a/packages-obsolete/plugin-attachment-refs/README.md +++ b/packages/remark-attachment-refs/README.md @@ -1,4 +1,4 @@ -# growi-plugin-attachment-refs +# remark-attachment-refs Examples ------- @@ -11,9 +11,9 @@ Examples ![refsimg](https://user-images.githubusercontent.com/1638767/64986528-1c54eb00-d902-11e9-95dc-2784fa15746c.gif) -### Image Carousel + Usage @@ -113,7 +113,7 @@ $refsimg(prefix=/somewhere, grid=autofill, grid-gap=1px) - `col-6` : Grid layout with 6 columns - *`grid-gap`* : Grid gap - e.g. `grid-gap=1px` -- *`no-carousel`* : Omit carousel function and just show images + ### `$gallery` tag diff --git a/packages/remark-attachment-refs/package.json b/packages/remark-attachment-refs/package.json new file mode 100644 index 00000000000..16140de57b7 --- /dev/null +++ b/packages/remark-attachment-refs/package.json @@ -0,0 +1,44 @@ +{ + "name": "@growi/remark-attachment-refs", + "version": "6.1.0-RC.0", + "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags", + "license": "MIT", + "keywords": [ + "growi", + "growi-plugin" + ], + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "run-p build:*", + "build:server": "vite build -c vite.server.config.ts", + "build:client": "vite build -c vite.client.config.ts", + "clean": "npx -y shx rm -rf dist", + "dev": "run-p dev:*", + "dev:server": "vite build -c vite.server.config.ts --mode dev", + "dev:client": "vite build -c vite.client.config.ts --mode dev", + "watch": "yarn dev -w --emptyOutDir=false", + "lint:js": "yarn eslint **/*.{js,jsx,ts,tsx}", + "lint:styles": "stylelint src/**/*.scss src/**/*.css", + "lint:typecheck": "tsc", + "lint": "run-p lint:*", + "test": "" + }, + "dependencies": { + "bunyan": "^1.8.15", + "universal-bunyan": "^0.9.2", + "@growi/core": "^6.1.0-RC.0", + "@growi/remark-growi-directive": "^6.1.0-RC.0", + "@growi/ui": "^6.1.0-RC.0" + }, + "devDependencies": { + "eslint-plugin-regex": "^1.8.0", + "npm-run-all": "^4.1.5", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/packages/remark-attachment-refs/src/client/components/AttachmentList.module.scss b/packages/remark-attachment-refs/src/client/components/AttachmentList.module.scss new file mode 100644 index 00000000000..c2f6d29ae33 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/AttachmentList.module.scss @@ -0,0 +1,29 @@ +@keyframes attachement-refs-fadeIn { + 0% {opacity: .2} + 100% {opacity: .9} +} + +.attachment-refs :global { + .attachement-refs-blink { + animation: attachement-refs-fadeIn 1s ease 0s infinite alternate; + } + + li.attachment { + list-style: none; + } + + .attachment-userpicture { + line-height: 1.7em; + vertical-align: bottom; + } + + .page-meta { + font-size: 0.95em; + } + + .attachment-filetype { + padding: 1px 5px; + margin: 0 0 0 4px; + font-weight: normal; + } +} diff --git a/packages/remark-attachment-refs/src/client/components/AttachmentList.tsx b/packages/remark-attachment-refs/src/client/components/AttachmentList.tsx new file mode 100644 index 00000000000..a2fe58f41b5 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/AttachmentList.tsx @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; + +import { IAttachmentHasId } from '@growi/core'; +import { Attachment } from '@growi/ui/dist/components/Attachment'; + +import { ExtractedAttachments } from './ExtractedAttachments'; +import { RefsContext } from './util/refs-context'; + + +import styles from './AttachmentList.module.scss'; + +const AttachmentLink = Attachment; + +type Props = { + refsContext: RefsContext + isLoading: boolean + error?: Error + attachments: IAttachmentHasId[] +}; + +export const AttachmentList = ({ + refsContext, + isLoading, + error, + attachments, +}: Props): JSX.Element => { + const renderNoAttachmentsMessage = useCallback(() => { + return ( +
+ + + { + refsContext.options?.prefix != null + ? `${refsContext.options.prefix} and descendant pages have no attachments` + : `${refsContext.pagePath} has no attachments` + } + +
+ ); + }, [refsContext]); + + const renderContents = useCallback(() => { + if (isLoading) { + return ( +
+ + {refsContext.toString()} +
+ ); + } + if (error != null) { + return ( +
+ + {refsContext.toString()} (-> {error.message}) +
+ ); + } + + // no attachments + if (attachments.length === 0) { + return renderNoAttachmentsMessage(); + } + + return (refsContext.isExtractImage) + ? + : attachments.map((attachment) => { + return ; + }); + }, [refsContext, isLoading, attachments]); + + return
{renderContents()}
; + +}; diff --git a/packages/remark-attachment-refs/src/client/components/ExtractedAttachments.tsx b/packages/remark-attachment-refs/src/client/components/ExtractedAttachments.tsx new file mode 100644 index 00000000000..ea0e425d5c8 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/ExtractedAttachments.tsx @@ -0,0 +1,197 @@ +import React, { useCallback } from 'react'; + +import { IAttachmentHasId } from '@growi/core'; +import { Property } from 'csstype'; +// import Carousel, { Modal, ModalGateway } from 'react-images'; + +import { RefsContext } from './util/refs-context'; + +type Props = { + attachments: IAttachmentHasId[], + refsContext: RefsContext, +}; + +/** + * 1. when 'fileFormat' is image, render Attachment as an image + * 2. when 'fileFormat' is not image, render Attachment as an Attachment component + */ +// TODO https://redmine.weseek.co.jp/issues/121095: implement image carousel modal without using react-images +export const ExtractedAttachments = React.memo(({ + attachments, + refsContext, +}: Props): JSX.Element => { + + // const [showCarousel, setShowCarousel] = useState(false); + // const [currentIndex, setCurrentIndex] = useState(null); + + // const imageClickedHandler = useCallback((index: number) => { + // setShowCarousel(true); + // setCurrentIndex(index); + // }, []); + + const getAttachmentsFilteredByFormat = useCallback(() => { + return attachments + .filter(attachment => attachment.fileFormat.startsWith('image/')); + }, []); + + const getClassesAndStylesForNonGrid = useCallback(() => { + const { options } = refsContext; + + const width = options?.width; + const height = options?.height; + const maxWidth = options?.maxWidth; + const maxHeight = options?.maxHeight; + const display = options?.display || 'block'; + + const containerStyles = { + width, height, maxWidth, maxHeight, display, + }; + + const imageClasses = []; + const imageStyles = { + width, height, maxWidth, maxHeight, + }; + + return { + containerStyles, + imageClasses, + imageStyles, + }; + }, [refsContext]); + + const getClassesAndStylesForGrid = useCallback(() => { + const { options } = refsContext; + + const maxWidth = options?.maxWidth; + const maxHeight = options?.maxHeight; + + const containerStyles = { + width: refsContext.getOptGridWidth(), + height: refsContext.getOptGridHeight(), + maxWidth, + maxHeight, + }; + + const imageClasses = ['w-100', 'h-100']; + const imageStyles = { + objectFit: 'cover' as Property.ObjectFit, + maxWidth, + maxHeight, + }; + + return { + containerStyles, + imageClasses, + imageStyles, + }; + }, [refsContext]); + + /** + * wrapper method for getClassesAndStylesForGrid/getClassesAndStylesForNonGrid + */ + const getClassesAndStyles = useCallback(() => { + const { options } = refsContext; + + return (options?.grid != null) + ? getClassesAndStylesForGrid() + : getClassesAndStylesForNonGrid(); + }, []); + + const renderExtractedImage = useCallback((attachment: IAttachmentHasId, index: number) => { + const { options } = refsContext; + + // determine alt + let alt = refsContext.isSingle ? options?.alt : undefined; // use only when single mode + alt = alt || attachment.originalName; // use 'originalName' if options.alt is not specified + + // get styles + const { + containerStyles, imageClasses, imageStyles, + } = getClassesAndStyles(); + + // carousel settings + // let onClick; + // if (options?.noCarousel == null) { + // // pointer cursor + // Object.assign(containerStyles, { cursor: 'pointer' }); + // // set click handler + // onClick = () => { + // imageClickedHandler(index); + // }; + // } + + return ( +
+ {alt} +
+ ); + }, [refsContext]); + + // const renderCarousel = useCallback(() => { + // const { options } = refsContext; + // const withCarousel = options?.noCarousel == null; + + // const images = getAttachmentsFilteredByFormat() + // .map((attachment) => { + // return { src: attachment.filePathProxied }; + // }); + + // // overwrite react-images modal styles + // const zIndex = 1030; // > grw-navbar + // const modalStyles = { + // blanket: (styleObj) => { + // return Object.assign(styleObj, { zIndex }); + // }, + // positioner: (styleObj) => { + // return Object.assign(styleObj, { zIndex }); + // }, + // }; + + // return ( + // + // { withCarousel && showCarousel && ( + // { setShowCarousel(false) }}> + // + // + // ) } + // + // ); + // }, [refsContext]); + + const { options } = refsContext; + const grid = options?.grid; + const gridGap = options?.gridGap; + + const styles = {}; + + // Grid mode + if (grid != null) { + + const gridTemplateColumns = (refsContext.isOptGridColumnEnabled()) + ? `repeat(${refsContext.getOptGridColumnsNum()}, 1fr)` + : `repeat(auto-fill, ${refsContext.getOptGridWidth()})`; + + Object.assign(styles, { + display: 'grid', + gridTemplateColumns, + gridAutoRows: '1fr', + gridGap, + }); + + } + + const contents = getAttachmentsFilteredByFormat() + .map((attachment, index) => renderExtractedImage(attachment, index)); + + return ( + +
+ {contents} +
+ + {/* { renderCarousel() } */} +
+ ); +}); diff --git a/packages/remark-attachment-refs/src/client/components/Gallery.tsx b/packages/remark-attachment-refs/src/client/components/Gallery.tsx new file mode 100644 index 00000000000..a9c6519ceb4 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/Gallery.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { RefsImgSubstance, Props } from './RefsImg'; + +const gridDefault = 'col-4'; +const gridGapDefault = '1px'; + +export const Gallery = React.memo((props: Props): JSX.Element => { + const grid = props.grid || gridDefault; + const gridGap = props.gridGap || gridGapDefault; + return ; +}); + +export const GalleryImmutable = React.memo((props: Omit): JSX.Element => { + const grid = props.grid || gridDefault; + const gridGap = props.gridGap || gridGapDefault; + return ; +}); + +Gallery.displayName = 'Gallery'; diff --git a/packages/remark-attachment-refs/src/client/components/Ref.tsx b/packages/remark-attachment-refs/src/client/components/Ref.tsx new file mode 100644 index 00000000000..de185a9a3fb --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/Ref.tsx @@ -0,0 +1,43 @@ +import React, { useMemo } from 'react'; + +import { useSWRxRef } from '../stores/refs'; + +import { AttachmentList } from './AttachmentList'; +import { RefsContext } from './util/refs-context'; + + +type Props = { + fileNameOrId: string, + pagePath: string, + isImmutable?: boolean, +}; + +const RefSubstance = React.memo(({ + fileNameOrId, + pagePath, + isImmutable, +}: Props): JSX.Element => { + const refsContext = useMemo(() => { + return new RefsContext('ref', pagePath, { fileNameOrId }); + }, [fileNameOrId, pagePath]); + + const { data, error, isLoading } = useSWRxRef(pagePath, fileNameOrId, isImmutable); + const attachments = data != null ? [data] : []; + + return ; +}); + +export const Ref = React.memo((props: Props): JSX.Element => { + return ; +}); + +export const RefImmutable = React.memo((props: Omit): JSX.Element => { + return ; +}); + +Ref.displayName = 'Ref'; diff --git a/packages/remark-attachment-refs/src/client/components/RefImg.tsx b/packages/remark-attachment-refs/src/client/components/RefImg.tsx new file mode 100644 index 00000000000..6aac3ba69a4 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/RefImg.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; + +import { useSWRxRef } from '../stores/refs'; + +import { AttachmentList } from './AttachmentList'; +import { RefsContext } from './util/refs-context'; + + +type Props = { + fileNameOrId: string + pagePath: string + width?: string + height?: string + maxWidth?: string + maxHeight?: string + alt?: string + + isImmutable?: boolean +}; + +const RefImgSubstance = React.memo(({ + fileNameOrId, + pagePath, + width, + height, + maxWidth, + maxHeight, + alt, + isImmutable, +}: Props): JSX.Element => { + const refsContext = useMemo(() => { + const options = { + fileNameOrId, width, height, maxWidth, maxHeight, alt, + }; + return new RefsContext('refimg', pagePath, options); + }, [fileNameOrId, pagePath, width, height, maxWidth, maxHeight, alt]); + + const { data, error, isLoading } = useSWRxRef(pagePath, fileNameOrId, isImmutable); + const attachments = data != null ? [data] : []; + + return ; +}); + +export const RefImg = React.memo((props: Props): JSX.Element => { + return ; +}); + +export const RefImgImmutable = React.memo((props: Omit): JSX.Element => { + return ; +}); + +RefImg.displayName = 'RefImg'; diff --git a/packages/remark-attachment-refs/src/client/components/Refs.tsx b/packages/remark-attachment-refs/src/client/components/Refs.tsx new file mode 100644 index 00000000000..75a2478d578 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/Refs.tsx @@ -0,0 +1,52 @@ +import React, { useMemo } from 'react'; + +import { useSWRxRefs } from '../stores/refs'; + +import { AttachmentList } from './AttachmentList'; +import { RefsContext } from './util/refs-context'; + + +type Props = { + pagePath: string, + prefix?: string, + depth?: string, + regexp?: string, + + isImmutable?: boolean, +}; + +const RefsSubstance = React.memo(({ + pagePath, + prefix, + depth, + regexp, + + isImmutable, +}: Props): JSX.Element => { + const refsContext = useMemo(() => { + const options = { + prefix, depth, regexp, + }; + return new RefsContext('refs', pagePath, options); + }, [pagePath, prefix, depth, regexp]); + + const { data, error, isLoading } = useSWRxRefs(pagePath, prefix, { depth, regexp }, isImmutable); + const attachments = data != null ? data : []; + + return ; +}); + +export const Refs = React.memo((props: Props): JSX.Element => { + return ; +}); + +export const RefsImmutable = React.memo((props: Omit): JSX.Element => { + return ; +}); + +Refs.displayName = 'Refs'; diff --git a/packages/remark-attachment-refs/src/client/components/RefsImg.tsx b/packages/remark-attachment-refs/src/client/components/RefsImg.tsx new file mode 100644 index 00000000000..ff01890831a --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/RefsImg.tsx @@ -0,0 +1,83 @@ +import React, { useMemo } from 'react'; + +import { useSWRxRefs } from '../stores/refs'; + +import { AttachmentList } from './AttachmentList'; +import { RefsContext } from './util/refs-context'; + + +export type Props = { + pagePath: string + prefix?: string + depth?: string + regexp?: string + width?: string + height?: string + maxWidth?: string + maxHeight?: string + display?: string + grid?: string + gridGap?: string + noCarousel?: string + + isImmutable?: boolean, +}; + +export const RefsImgSubstance = React.memo(({ + pagePath, prefix, depth, regexp, + width, height, maxWidth, maxHeight, + display, grid, gridGap, noCarousel, + + isImmutable, +}: Props): JSX.Element => { + const refsContext = useMemo(() => { + const options = { + pagePath, + prefix, + depth, + regexp, + width, + height, + maxWidth, + maxHeight, + display, + grid, + gridGap, + noCarousel, + }; + return new RefsContext('refsimg', pagePath, options); + }, [pagePath, prefix, depth, regexp, + width, height, maxWidth, maxHeight, + display, grid, gridGap, noCarousel]); + + const { data, error, isLoading } = useSWRxRefs(pagePath, prefix, { + depth, + regexp, + width, + height, + maxWidth, + maxHeight, + display, + grid, + gridGap, + noCarousel, + }, isImmutable); + const attachments = data != null ? data : []; + + return ; +}); + +export const RefsImg = React.memo((props: Props): JSX.Element => { + return ; +}); + +export const RefsImgImmutable = React.memo((props: Omit): JSX.Element => { + return ; +}); + +RefsImg.displayName = 'RefsImg'; diff --git a/packages/remark-attachment-refs/src/client/components/index.ts b/packages/remark-attachment-refs/src/client/components/index.ts new file mode 100644 index 00000000000..e9005712d94 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/index.ts @@ -0,0 +1,5 @@ +export { Ref, RefImmutable } from './Ref'; +export { RefImg, RefImgImmutable } from './RefImg'; +export { Refs, RefsImmutable } from './Refs'; +export { RefsImg, RefsImgImmutable } from './RefsImg'; +export { Gallery, GalleryImmutable } from './Gallery'; diff --git a/packages/remark-attachment-refs/src/client/components/util/refs-context.ts b/packages/remark-attachment-refs/src/client/components/util/refs-context.ts new file mode 100644 index 00000000000..584d32ea89d --- /dev/null +++ b/packages/remark-attachment-refs/src/client/components/util/refs-context.ts @@ -0,0 +1,166 @@ +const GRID_DEFAULT_TRACK_WIDTH = 64; +const GRID_AVAILABLE_OPTIONS_LIST = [ + 'autofill', + 'autofill-xs', + 'autofill-sm', + 'autofill-md', + 'autofill-lg', + 'autofill-xl', + 'col-2', + 'col-3', + 'col-4', + 'col-5', + 'col-6', +]; + +type tags = 'ref' | 'refs' | 'refimg' | 'refsimg' + +/** + * Context Object class for $ref() and $refimg() + */ +export class RefsContext { + + tag: tags; + + pagePath: string; + + options?: Record; + + constructor(tag: tags, pagePath: string, options: Record) { + this.tag = tag; + + this.pagePath = pagePath; + + // remove undefined keys + Object.keys(options).forEach(key => options[key] === undefined && delete options[key]); + + this.options = options; + } + + getStringifiedAttributes(separator = ', '): string { + const attributeStrs = [`page=${this.pagePath}`]; + if (this.options != null) { + const optionEntries = Object.entries(this.options).sort(); + attributeStrs.push( + ...optionEntries.map(([key, val]) => `${key}=${val || 'true'}`), + ); + } + + return attributeStrs.join(separator); + } + + /** + * for printing errors + * @returns + */ + toString(): string { + return `$${this.tag}(${this.getStringifiedAttributes()})`; + } + + get isSingle(): boolean { + return this.tag === 'ref' || this.tag === 'refimg'; + } + + get isExtractImage(): boolean { + return this.tag === 'refimg' || this.tag === 'refsimg'; + } + + getOptGrid(): string | undefined { + return GRID_AVAILABLE_OPTIONS_LIST.find(item => item === this.options?.grid); + } + + isOptGridColumnEnabled(): boolean { + const optGrid = this.getOptGrid(); + return (optGrid != null) && optGrid.startsWith('col-'); + } + + /** + * return auto-calculated grid width + * rules: + * 1. when column mode (e.g. col-6, col5, ...), the width specification is disabled + * 2. when width option is set, return it + * 3. otherwise, the mode should be autofill and the width will be calculated according to the size + */ + getOptGridWidth(): string | undefined { + const grid = this.getOptGrid(); + const width = this.options?.width; + + // when Grid column mode + if (this.isOptGridColumnEnabled()) { + // not specify and ignore width + return undefined; + } + + // when width is specified + if (width != null) { + return width; + } + + // when Grid autofill mode + let autofillMagnification = 1; + + switch (grid) { + case 'autofill-xl': + autofillMagnification = 3; + break; + case 'autofill-lg': + autofillMagnification = 2; + break; + case 'autofill-sm': + autofillMagnification = 0.75; + break; + case 'autofill-xs': + autofillMagnification = 0.5; + break; + case 'autofill': + case 'autofill-md': + break; + } + + return `${GRID_DEFAULT_TRACK_WIDTH * autofillMagnification}px`; + } + + /** + * return auto-calculated grid height + * rules: + * 1. when height option is set, return it + * 2. otherwise, the same value to the width will be returned + */ + + getOptGridHeight(): string | undefined { + const height = this.options?.height; + + // when height is specified + if (height != null) { + return height; + } + + // return the value which is same to width + return this.getOptGridWidth(); + } + + getOptGridColumnsNum(): number | null { + let columnsNum: number | null = null; + + switch (this.options?.grid) { + case 'col-2': + columnsNum = 2; + break; + case 'col-3': + columnsNum = 3; + break; + case 'col-4': + columnsNum = 4; + break; + case 'col-5': + columnsNum = 5; + break; + case 'col-6': + columnsNum = 6; + break; + } + + return columnsNum; + } + +} diff --git a/packages/remark-attachment-refs/src/client/index.ts b/packages/remark-attachment-refs/src/client/index.ts new file mode 100644 index 00000000000..2389ec598e6 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/index.ts @@ -0,0 +1,2 @@ +export * from './services/renderer/refs'; +export * from './components'; diff --git a/packages/remark-attachment-refs/src/client/services/renderer/refs.ts b/packages/remark-attachment-refs/src/client/services/renderer/refs.ts new file mode 100644 index 00000000000..7ac44910d1b --- /dev/null +++ b/packages/remark-attachment-refs/src/client/services/renderer/refs.ts @@ -0,0 +1,157 @@ +import { pathUtils } from '@growi/core'; +import { remarkGrowiDirectivePluginType } from '@growi/remark-growi-directive'; +import { Schema as SanitizeOption } from 'hast-util-sanitize'; +import { selectAll, HastNode } from 'hast-util-select'; +import { Plugin } from 'unified'; +import { visit } from 'unist-util-visit'; + +const REF_SINGLE_NAME_PATTERN = new RegExp(/refimg|ref/); +const REF_MULTI_NAME_PATTERN = new RegExp(/refsimg|refs|gallery/); + +const REF_SUPPORTED_ATTRIBUTES = ['fileNameOrId', 'pagePath']; +const REF_IMG_SUPPORTED_ATTRIBUTES = ['fileNameOrId', 'pagePath', 'width', 'height', 'maxWidth', 'maxHeight', 'alt']; +const REFS_SUPPORTED_ATTRIBUTES = ['pagePath', 'prefix', 'depth', 'regexp']; +const REFS_IMG_SUPPORTED_ATTRIBUTES = [ + 'pagePath', 'prefix', 'depth', 'regexp', 'width', 'height', 'maxWidth', 'maxHeight', 'display', 'grid', 'gridGap', 'noCarousel', +]; + +type DirectiveAttributes = Record + +export const remarkPlugin: Plugin = function() { + return (tree) => { + visit(tree, (node) => { + if (node.type === remarkGrowiDirectivePluginType.Text || node.type === remarkGrowiDirectivePluginType.Leaf) { + if (typeof node.name !== 'string') { + return; + } + const data = node.data ?? (node.data = {}); + const attributes = node.attributes as DirectiveAttributes || {}; + const attrEntries = Object.entries(attributes); + + if (REF_SINGLE_NAME_PATTERN.test(node.name)) { + // determine fileNameOrId + // order: + // 1: ref(file=..., ...) + // 2: ref(id=..., ...) + // 3: refs(firstArgs, ...) + let fileNameOrId: string = attributes.file || attributes.id; + if (fileNameOrId == null && attrEntries.length > 0) { + const [firstAttrKey, firstAttrValue] = attrEntries[0]; + fileNameOrId = (firstAttrValue === '' && !REF_SUPPORTED_ATTRIBUTES.concat(REF_IMG_SUPPORTED_ATTRIBUTES).includes(firstAttrValue)) + ? firstAttrKey : ''; + } + attributes.fileNameOrId = fileNameOrId; + } + else if (REF_MULTI_NAME_PATTERN.test(node.name)) { + // set 'page' attribute if the first attribute is only value + // e.g. + // case 1: refs(page=/path..., ...) => page="/path" + // case 2: refs(/path, ...) => page="/path" + // case 3: refs(/foo, page=/bar ...) => page="/bar" + if (attributes.page == null && attrEntries.length > 0) { + const [firstAttrKey, firstAttrValue] = attrEntries[0]; + + if (firstAttrValue === '' && !REFS_SUPPORTED_ATTRIBUTES.concat(REFS_IMG_SUPPORTED_ATTRIBUTES).includes(firstAttrValue)) { + attributes.page = firstAttrKey; + } + } + } + else { + return; + } + + // kebab case to camel case + attributes.maxWidth = attributes['max-width']; + attributes.maxHeight = attributes['max-height']; + attributes.gridGap = attributes['grid-gap']; + attributes.noCarousel = attributes['no-carousel']; + + data.hName = node.name; + data.hProperties = attributes; + } + }); + }; +}; + +// return absolute path for the specified path +const getAbsolutePathFor = (relativePath: string, basePath: string) => { + const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com'); + const absoluteUrl = new URL(relativePath, baseUrl); + return decodeURIComponent( + pathUtils.normalizePath( // normalize like /foo/bar + absoluteUrl.pathname, + ), + ); +}; + +// resolve pagePath +// when `fromPagePath`=/hoge and `specifiedPath`=./fuga, +// `pagePath` to be /hoge/fuga +// when `fromPagePath`=/hoge and `specifiedPath`=/fuga, +// `pagePath` to be /fuga +// when `fromPagePath`=/hoge and `specifiedPath`=undefined, +// `pagePath` to be /hoge +const resolvePath = (pagePath:string, basePath: string) => { + const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com'); + const absoluteUrl = new URL(pagePath, baseUrl); + return decodeURIComponent(absoluteUrl.pathname); +}; + +type RefRehypePluginParams = { + pagePath?: string, +} + +export const rehypePlugin: Plugin<[RefRehypePluginParams]> = (options = {}) => { + if (options.pagePath == null) { + throw new Error('refs rehype plugin requires \'pagePath\' option'); + } + + return (tree) => { + if (options.pagePath == null) { + return; + } + + const basePagePath = options.pagePath; + const elements = selectAll('ref, refimg, refs, refsimg, gallery', tree as HastNode); + + elements.forEach((refElem) => { + if (refElem.properties == null) { + return; + } + + const prefix = refElem.properties.prefix; + // set basePagePath when prefix is undefined or invalid + if (prefix != null && typeof prefix === 'string') { + refElem.properties.prefix = resolvePath(prefix, basePagePath); + } + + refElem.properties.pagePath = refElem.properties.page; + const pagePath = refElem.properties.pagePath; + + // set basePagePath when pagePath is undefined or invalid + if (pagePath == null || typeof pagePath !== 'string') { + refElem.properties.pagePath = basePagePath; + return; + } + + // return when page is already determined and aboslute path + if (pathUtils.hasHeadingSlash(pagePath)) { + return; + } + + // resolve relative path + refElem.properties.pagePath = getAbsolutePathFor(pagePath, basePagePath); + }); + }; +}; + +export const sanitizeOption: SanitizeOption = { + tagNames: ['ref', 'refimg', 'refs', 'refsimg', 'gallery'], + attributes: { + ref: REF_SUPPORTED_ATTRIBUTES, + refimg: REF_IMG_SUPPORTED_ATTRIBUTES, + refs: REFS_SUPPORTED_ATTRIBUTES, + refsimg: REFS_IMG_SUPPORTED_ATTRIBUTES, + gallery: REFS_IMG_SUPPORTED_ATTRIBUTES, + }, +}; diff --git a/packages/remark-attachment-refs/src/client/stores/refs.tsx b/packages/remark-attachment-refs/src/client/stores/refs.tsx new file mode 100644 index 00000000000..aa8d8c562e7 --- /dev/null +++ b/packages/remark-attachment-refs/src/client/stores/refs.tsx @@ -0,0 +1,49 @@ +import { IAttachmentHasId } from '@growi/core'; +import axios from 'axios'; +import useSWR, { SWRResponse } from 'swr'; + +export const useSWRxRef = ( + pagePath: string, fileNameOrId: string, isImmutable?: boolean, +): SWRResponse => { + return useSWR( + ['/_api/attachment-refs/ref', pagePath, fileNameOrId, isImmutable], + ([endpoint, pagePath, fileNameOrId]) => { + return axios.get(endpoint, { + params: { + pagePath, + fileNameOrId, + }, + }).then(result => result.data.attachment) + .catch(() => null); + }, + { + keepPreviousData: true, + revalidateIfStale: !isImmutable, + revalidateOnFocus: !isImmutable, + revalidateOnReconnect: !isImmutable, + }, + ); +}; + +export const useSWRxRefs = ( + pagePath: string, prefix?: string, options?: Record, isImmutable?: boolean, +): SWRResponse => { + return useSWR( + ['/_api/attachment-refs/refs', pagePath, prefix, options, isImmutable], + ([endpoint, pagePath, prefix, options]) => { + return axios.get(endpoint, { + params: { + pagePath, + prefix, + options, + }, + }).then(result => result.data.attachments); + }, + { + keepPreviousData: true, + revalidateIfStale: !isImmutable, + revalidateOnFocus: !isImmutable, + revalidateOnReconnect: !isImmutable, + }, + ); +}; diff --git a/packages/remark-attachment-refs/src/server/index.ts b/packages/remark-attachment-refs/src/server/index.ts new file mode 100644 index 00000000000..f93f913d6b7 --- /dev/null +++ b/packages/remark-attachment-refs/src/server/index.ts @@ -0,0 +1,10 @@ +import { routesFactory } from './routes/refs'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any +const middleware = (crowi: any, app: any): void => { + const refs = routesFactory(crowi); + + app.use('/_api/attachment-refs', refs); +}; + +export default middleware; diff --git a/packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js b/packages/remark-attachment-refs/src/server/routes/refs.ts similarity index 91% rename from packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js rename to packages/remark-attachment-refs/src/server/routes/refs.ts index d67489c68bd..a0ce0b9593d 100644 --- a/packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js +++ b/packages/remark-attachment-refs/src/server/routes/refs.ts @@ -1,8 +1,6 @@ -import loggerFactory from '../../utils/logger'; - -const { customTagUtils } = require('@growi/core'); +import { OptionParser } from '@growi/core'; -const { OptionParser } = customTagUtils; +import loggerFactory from '../../utils/logger'; const logger = loggerFactory('growi-plugin:attachment-refs:routes:refs'); @@ -11,8 +9,8 @@ const loginRequiredFallback = (req, res) => { return res.status(403).send('login required'); }; - -module.exports = (crowi) => { +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const routesFactory = (crowi): any => { const express = crowi.require('express'); const mongoose = crowi.require('mongoose'); @@ -30,12 +28,7 @@ module.exports = (crowi) => { const { PageQueryBuilder } = Page; - /** - * generate RegExp instance by the 'expression' arg - * @param {string} expression - * @return {RegExp} - */ - function generateRegexp(expression) { + function generateRegexp(expression: string): RegExp { // https://regex101.com/r/uOrwqt/2 const matches = expression.match(/^\/(.+)\/(.*)?$/); @@ -59,6 +52,11 @@ module.exports = (crowi) => { } const range = OptionParser.parseRange(optionsDepth); + + if (range == null) { + return query; + } + const start = range.start; const end = range.end; @@ -82,15 +80,13 @@ module.exports = (crowi) => { router.get('/ref', accessTokenParser, loginRequired, async(req, res) => { const user = req.user; const { pagePath, fileNameOrId } = req.query; - // eslint-disable-next-line no-unused-vars - const options = JSON.parse(req.query.options); if (pagePath == null) { res.status(400).send('the param \'pagePath\' must be set.'); return; } - const page = await Page.findByPathAndViewer(pagePath, user); + const page = await Page.findByPathAndViewer(pagePath, user, undefined, true); // not found if (page == null) { @@ -99,7 +95,7 @@ module.exports = (crowi) => { } // convert ObjectId - const orConditions = [{ originalName: fileNameOrId }]; + const orConditions: any[] = [{ originalName: fileNameOrId }]; if (ObjectId.isValid(fileNameOrId)) { orConditions.push({ _id: ObjectId(fileNameOrId) }); } diff --git a/packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts b/packages/remark-attachment-refs/src/utils/logger/index.ts similarity index 100% rename from packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts rename to packages/remark-attachment-refs/src/utils/logger/index.ts diff --git a/packages/remark-attachment-refs/tsconfig.json b/packages/remark-attachment-refs/tsconfig.json new file mode 100644 index 00000000000..e5764dbb213 --- /dev/null +++ b/packages/remark-attachment-refs/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsxdev", + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": [ + "src" + ] +} diff --git a/packages/remark-attachment-refs/vite.client.config.ts b/packages/remark-attachment-refs/vite.client.config.ts new file mode 100644 index 00000000000..62b6017dffd --- /dev/null +++ b/packages/remark-attachment-refs/vite.client.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + dts(), + ], + build: { + outDir: 'dist/client', + sourcemap: true, + lib: { + entry: { + index: 'src/client/index.ts', + }, + name: 'remark-attachment-refs-libs', + formats: ['es'], + }, + rollupOptions: { + external: [ + 'bunyan', + 'universal-bunyan', + 'react', + 'react-dom', + /^hast-.*/, + /^unist-.*/, + /^@growi\/.*/, + ], + }, + }, +}); diff --git a/packages/remark-attachment-refs/vite.server.config.ts b/packages/remark-attachment-refs/vite.server.config.ts new file mode 100644 index 00000000000..6a738a9c70b --- /dev/null +++ b/packages/remark-attachment-refs/vite.server.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + dts(), + ], + build: { + outDir: 'dist/server', + sourcemap: true, + lib: { + entry: [ + 'src/server/index.ts', + ], + name: 'remark-attachment-refs-libs', + formats: ['cjs'], + }, + rollupOptions: { + output: { + preserveModules: true, + preserveModulesRoot: 'src/server', + }, + external: [ + 'bunyan', + 'universal-bunyan', + 'react', + 'react-dom', + /^hast-.*/, + /^unist-.*/, + /^@growi\/.*/, + ], + }, + }, +}); diff --git a/packages/ui/src/interfaces/popper-data.ts b/packages/ui/src/interfaces/popper-data.ts new file mode 100644 index 00000000000..47a31716e9e --- /dev/null +++ b/packages/ui/src/interfaces/popper-data.ts @@ -0,0 +1,28 @@ +interface Rect { + top: number + left: number + width: number + height: number +} + +export interface PopperData { + styles: Partial; + offsets: { + popper: Rect; + reference: Rect; + arrow: { top: number; left: number }; + }; +} + +export interface Modifiers { + applyStyle: { + enabled: boolean + } + computeStyle: { + enabled: boolean, + fn: (data: PopperData) => PopperData + } + preventOverflow: { + boundariesElement: string + } +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 90ee46cfa2a..42adfe73430 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './browser-utils'; export * from './use-fullscreen'; +export * from './reactstrap-modifiers'; diff --git a/packages/ui/src/utils/reactstrap-modifiers.ts b/packages/ui/src/utils/reactstrap-modifiers.ts new file mode 100644 index 00000000000..4a7ebe5f9de --- /dev/null +++ b/packages/ui/src/utils/reactstrap-modifiers.ts @@ -0,0 +1,26 @@ +import { PopperData, Modifiers } from '~/interfaces/popper-data'; + +// Conditional modifiers +// To prevent flickering. only happened when `right` is true and persist props should be enabled +export const modifiersForRightAlign: Modifiers = { + applyStyle: { + enabled: true, + }, + computeStyle: { + enabled: true, + fn: (data: PopperData): PopperData => { + const popperRect = data.offsets.popper; + // Calculate transform styles + const newTransform = `translate3d(${popperRect.left - window.innerWidth + popperRect.width}px, ${popperRect.top}px, 0px)`; + const styles = { + top: '0px', + right: '0px', + willChange: 'transform', + transform: newTransform, + }; + data.styles = { ...data.styles, ...styles }; + return data; + }, + }, + preventOverflow: { boundariesElement: 'viewport' }, +}; diff --git a/tools/replacer/.eslintrc.cjs b/tools/replacer/.eslintrc.cjs new file mode 100644 index 00000000000..4020bcbf409 --- /dev/null +++ b/tools/replacer/.eslintrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': 'warn', + }, +} diff --git a/tools/replacer/.gitignore b/tools/replacer/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/tools/replacer/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/tools/replacer/index.html b/tools/replacer/index.html new file mode 100644 index 00000000000..e0d1c840806 --- /dev/null +++ b/tools/replacer/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/tools/replacer/package.json b/tools/replacer/package.json new file mode 100644 index 00000000000..ff7927d0c3b --- /dev/null +++ b/tools/replacer/package.json @@ -0,0 +1,28 @@ +{ + "name": "replacer", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@typescript-eslint/eslint-plugin": "^5.57.1", + "@typescript-eslint/parser": "^5.57.1", + "@vitejs/plugin-react-swc": "^3.0.0", + "eslint": "^8.38.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.3.4", + "typescript": "^5.0.2", + "vite": "^4.3.0" + } +} diff --git a/tools/replacer/src/App.css b/tools/replacer/src/App.css new file mode 100644 index 00000000000..ffb225533cb --- /dev/null +++ b/tools/replacer/src/App.css @@ -0,0 +1,49 @@ +#root { + max-width: 1280px; + padding: 2rem; + margin: 0 auto; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} + +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} + +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.card textarea { + width: 480px; +} + + +.read-the-docs { + color: #888; +} diff --git a/tools/replacer/src/App.tsx b/tools/replacer/src/App.tsx new file mode 100644 index 00000000000..36a9722697e --- /dev/null +++ b/tools/replacer/src/App.tsx @@ -0,0 +1,47 @@ +import { ChangeEventHandler, useState } from 'react' +import './App.css' + + + +function replaceImport(str: string): string { + const regex = /import {[\s\n]*([^}]+)[\s\n]*} from 'reactstrap';/; + + return str.replace(regex, (_match, group: string) => { + const modules = group + .split(',') + .map(mod => mod.trim()) + .filter(mod => mod.length > 0) + + return modules.map((mod) => { + return `import ${mod} from 'reactstrap/es/${mod}';` + }).join('\n') + }); +} + +function App() { + + const [output, setOutput] = useState(''); + + const changeHandler: ChangeEventHandler = (e): void => { + const { value } = e.target; + + const replacedValue = replaceImport(value); + + setOutput(replacedValue); + } + return ( + <> +

Input

+
+