diff --git a/apps/app/config/i18next.config.js b/apps/app/config/i18next.config.js new file mode 100644 index 00000000000..6f7d65c306a --- /dev/null +++ b/apps/app/config/i18next.config.js @@ -0,0 +1,16 @@ +const { Lang, AllLang } = require('@growi/core'); + +/** @type {Lang} */ +const defaultLang = Lang.en_US; + +/** @type {import('i18next').InitOptions} */ +const initOptions = { + fallbackLng: defaultLang.toString(), + supportedLngs: AllLang, + defaultNS: 'translation', +}; + +module.exports = { + defaultLang, + initOptions, +}; diff --git a/apps/app/config/next-i18next.config.js b/apps/app/config/next-i18next.config.js index eef6aac4941..8ae662f7eaa 100644 --- a/apps/app/config/next-i18next.config.js +++ b/apps/app/config/next-i18next.config.js @@ -2,31 +2,41 @@ const isDev = process.env.NODE_ENV === 'development'; const path = require('path'); -const { AllLang, Lang } = require('@growi/core'); +const { AllLang } = require('@growi/core'); const { isServer } = require('@growi/core/dist/utils'); -const I18nextChainedBackend = isDev ? require('i18next-chained-backend').default : undefined; -const I18NextHttpBackend = require('i18next-http-backend').default; -const I18NextLocalStorageBackend = require('i18next-localstorage-backend').default; + +const { defaultLang } = require('./i18next.config'); const HMRPlugin = isDev ? require('i18next-hmr/plugin').HMRPlugin : undefined; +/** @type {import('next-i18next').UserConfig} */ module.exports = { - defaultLang: Lang.en_US, + ...require('./i18next.config').initOptions, + i18n: { - defaultLocale: Lang.en_US, + defaultLocale: defaultLang.toString(), locales: AllLang, }, - defaultNS: 'translation', + localePath: path.resolve('./public/static/locales'), serializeConfig: false, + // eslint-disable-next-line no-nested-ternary use: isDev ? isServer() ? [new HMRPlugin({ webpack: { server: true } })] - : [I18nextChainedBackend, new HMRPlugin({ webpack: { client: true } })] + : [ + require('i18next-chained-backend').default, + new HMRPlugin({ webpack: { client: true } }), + ] : [], backend: { - backends: isServer() ? [] : [I18NextLocalStorageBackend, I18NextHttpBackend], + backends: isServer() + ? [] + : [ + require('i18next-localstorage-backend').default, + require('i18next-http-backend').default, + ], backendOptions: [ // options for i18next-localstorage-backend { expirationTime: isDev ? 0 : 24 * 60 * 60 * 1000 }, // 1 day in production @@ -34,4 +44,5 @@ module.exports = { { loadPath: '/static/locales/{{lng}}/{{ns}}.json' }, ], }, + }; diff --git a/apps/app/package.json b/apps/app/package.json index ab74d0ab635..caa2da2cfcb 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,6 +1,6 @@ { "name": "@growi/app", - "version": "7.0.3", + "version": "7.0.4-RC.0", "license": "MIT", "scripts": { "//// for production": "", @@ -108,7 +108,7 @@ "detect-indent": "^7.0.0", "diff": "^5.0.0", "diff_match_patch": "^0.1.1", - "ejs": "^3.1.8", + "ejs": "^3.1.10", "esa-node": "^0.2.2", "escape-string-regexp": "^4.0.0", "eslint-plugin-regex": "^1.8.0", @@ -125,9 +125,7 @@ "helmet": "^4.6.0", "http-errors": "^2.0.0", "i18next": "^23.10.1", - "i18next-chained-backend": "^4.6.2", - "i18next-http-backend": "^2.5.0", - "i18next-localstorage-backend": "^4.2.0", + "i18next-resources-to-backend": "^1.2.1", "is-absolute-url": "^4.0.1", "is-iso-date": "^0.0.1", "ldapjs": "^3.0.2", @@ -233,6 +231,7 @@ "@testing-library/user-event": "^14.5.2", "@types/express": "^4.17.11", "@types/jest": "^29.5.2", + "@types/react-input-autosize": "^2.2.4", "@types/react-scroll": "^1.8.4", "@types/react-stickynode": "^4.0.3", "@types/throttle-debounce": "^5.0.1", @@ -244,6 +243,7 @@ "babel-loader": "^8.2.5", "bootstrap": "^5.3.1", "connect-browser-sync": "^2.1.0", + "cypress-real-events": "^1.12.0", "diff2html": "^3.4.47", "downshift": "^8.2.3", "eazy-logger": "^3.1.0", @@ -253,7 +253,10 @@ "fslightbox-react": "^1.7.6", "handsontable": "=6.2.2", "happy-dom": "^13.2.0", + "i18next-chained-backend": "^4.6.2", "i18next-hmr": "^3.0.4", + "i18next-http-backend": "^2.5.0", + "i18next-localstorage-backend": "^4.2.0", "jest": "^29.5.0", "jest-date-mock": "^1.0.8", "jest-localstorage-mock": "^2.4.14", diff --git a/apps/app/public/static/locales/en_US/commons.json b/apps/app/public/static/locales/en_US/commons.json index 29a9623ae14..eb6c0c50080 100644 --- a/apps/app/public/static/locales/en_US/commons.json +++ b/apps/app/public/static/locales/en_US/commons.json @@ -77,6 +77,7 @@ "create_page_dropdown": { "new_page": "Create New Page", + "open_page_create_modal": "Open new page create modal", "todays": { "desc": "Create today's memo", "memo": "memo" diff --git a/apps/app/public/static/locales/en_US/translation.json b/apps/app/public/static/locales/en_US/translation.json index 6ee63b6bf99..5437e2da66c 100644 --- a/apps/app/public/static/locales/en_US/translation.json +++ b/apps/app/public/static/locales/en_US/translation.json @@ -160,16 +160,20 @@ "not_allowed_to_see_this_page": "You cannot see this page", "Confirm": "Confirm", "Successfully requested": "Successfully requested.", - "form_validation": { - "error_message": "Some values ​​are incorrect", - "required": "%s is required", - "invalid_syntax": "The syntax of %s is invalid.", - "title_required": "Title is required.", - "field_required": "{{target}} is required" - }, - "page_name": "Page name", - "folder_name": "Folder name", - "field": "field", + "input_validation": { + "target": { + "page_name": "Page name", + "folder_name": "Folder name", + "field": "field" + }, + "message": { + "error_message": "Some values ​​are incorrect", + "required": "%s is required", + "invalid_syntax": "The syntax of %s is invalid.", + "title_required": "Title is required.", + "field_required": "{{target}} is required" + } + }, "not_creatable_page": { "message": "Page contents cannot be created in this path." }, @@ -863,5 +867,8 @@ "show_wip_page": "Show WIP", "size_s": "Size: S", "size_l": "Size: L" + }, + "create_page": { + "untitled": "Untitled" } } diff --git a/apps/app/public/static/locales/fr_FR/commons.json b/apps/app/public/static/locales/fr_FR/commons.json index 5765424e1c0..3771c037ee5 100644 --- a/apps/app/public/static/locales/fr_FR/commons.json +++ b/apps/app/public/static/locales/fr_FR/commons.json @@ -77,6 +77,7 @@ "create_page_dropdown": { "new_page": "Créer nouvelle page", + "open_page_create_modal": "Ouvrir une nouvelle page créer une fenêtre modale", "todays": { "desc": "Créer le mémo du jour", "memo": "mémo" diff --git a/apps/app/public/static/locales/fr_FR/translation.json b/apps/app/public/static/locales/fr_FR/translation.json index 3d3e5011141..0c1c6ff4c6d 100644 --- a/apps/app/public/static/locales/fr_FR/translation.json +++ b/apps/app/public/static/locales/fr_FR/translation.json @@ -160,16 +160,20 @@ "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page", "Confirm": "Confirmer", "Successfully requested": "Demande envoyée.", - "form_validation": { - "error_message": "Des champs sont invalides", - "required": "%s est requis", - "invalid_syntax": "La syntaxe de %s est invalide.", - "title_required": "Titre requis.", - "field_required": "{{target}} est requis" - }, - "page_name": "Nom de la page", - "folder_name": "Nom du dossier", - "field": "champ", + "input_validation": { + "target": { + "page_name": "Nom de la page", + "folder_name": "Nom du dossier", + "field": "champ" + }, + "message": { + "error_message": "Des champs sont invalides", + "required": "%s est requis", + "invalid_syntax": "La syntaxe de %s est invalide.", + "title_required": "Titre requis.", + "field_required": "{{target}} est requis" + } + }, "not_creatable_page": { "message": "Vous ne pouvez pas créer cette page dans ce chemin." }, diff --git a/apps/app/public/static/locales/ja_JP/commons.json b/apps/app/public/static/locales/ja_JP/commons.json index 87103b76983..f85a86db4f9 100644 --- a/apps/app/public/static/locales/ja_JP/commons.json +++ b/apps/app/public/static/locales/ja_JP/commons.json @@ -79,6 +79,7 @@ "create_page_dropdown": { "new_page": "新規ページ作成", + "open_page_create_modal": "新規ページ作成モーダルを表示", "todays": { "desc": "今日のメモを作成", "memo": "メモ" diff --git a/apps/app/public/static/locales/ja_JP/translation.json b/apps/app/public/static/locales/ja_JP/translation.json index 61a0c2c0c8b..50e7489e884 100644 --- a/apps/app/public/static/locales/ja_JP/translation.json +++ b/apps/app/public/static/locales/ja_JP/translation.json @@ -161,16 +161,20 @@ "not_allowed_to_see_this_page": "このページは閲覧できません", "Confirm": "確認", "Successfully requested": "正常に処理を受け付けました", - "form_validation": { - "error_message": "いくつかの値が設定されていません", - "required": "%sに値を入力してください", - "invalid_syntax": "%sの構文が不正です", - "title_required": "タイトルを入力してください", - "field_required": "{{target}}に値を入力してください" - }, - "page_name": "ページ名", - "folder_name": "フォルダ名", - "field": "フィールド", + "input_validation": { + "target": { + "page_name": "ページ名", + "folder_name": "フォルダ名", + "field": "フィールド" + }, + "message": { + "error_message": "いくつかの値が設定されていません", + "required": "%sに値を入力してください", + "invalid_syntax": "%sの構文が不正です", + "title_required": "タイトルを入力してください", + "field_required": "{{target}}に値を入力してください" + } + }, "not_creatable_page": { "message": "このパスではページ コンテンツを作成できません。" }, @@ -896,5 +900,8 @@ "show_wip_page": "WIP を表示", "size_s": "サイズ: S", "size_l": "サイズ: L" + }, + "create_page": { + "untitled": "無題のページ" } } diff --git a/apps/app/public/static/locales/zh_CN/commons.json b/apps/app/public/static/locales/zh_CN/commons.json index ffe0264f011..9108400f2d3 100644 --- a/apps/app/public/static/locales/zh_CN/commons.json +++ b/apps/app/public/static/locales/zh_CN/commons.json @@ -80,6 +80,7 @@ "create_page_dropdown": { "new_page": "新页面", + "open_page_create_modal": "打开新页面创建模式", "todays": { "desc": "Create today's memo", "memo": "memo" diff --git a/apps/app/public/static/locales/zh_CN/translation.json b/apps/app/public/static/locales/zh_CN/translation.json index 7de4d7c54fa..fd91d55a90b 100644 --- a/apps/app/public/static/locales/zh_CN/translation.json +++ b/apps/app/public/static/locales/zh_CN/translation.json @@ -167,16 +167,20 @@ "Confirm": "确定", "Successfully requested": "进程成功接受", "copied_to_clipboard": "它已复制到剪贴板。", - "form_validation": { - "error_message": "有些值不正确", - "required": "%s 是必需的", - "invalid_syntax": "%s的语法无效。", - "title_required": "标题是必需的。", - "field_required": "{{target}} 是必需的" - }, - "page_name": "页面名称", - "folder_name": "文件夹名称", - "field": "字段", + "input_validation": { + "target": { + "page_name": "页面名称", + "folder_name": "文件夹名称", + "field": "字段" + }, + "message": { + "error_message": "有些值不正确", + "required": "%s 是必需的", + "invalid_syntax": "%s的语法无效。", + "title_required": "标题是必需的。", + "field_required": "{{target}} 是必需的" + } + }, "not_creatable_page": { "message": "无法在此路径中创建页面内容。" }, @@ -866,5 +870,8 @@ "show_wip_page": "显示 WIP", "size_s": "尺寸: S", "size_l": "尺寸: L" + }, + "create_page": { + "untitled": "Untitled" } } diff --git a/apps/app/src/client/services/renderer/renderer.tsx b/apps/app/src/client/services/renderer/renderer.tsx index 4b14867830e..847cf663fef 100644 --- a/apps/app/src/client/services/renderer/renderer.tsx +++ b/apps/app/src/client/services/renderer/renderer.tsx @@ -1,7 +1,6 @@ import assert from 'assert'; import { isClient } from '@growi/core/dist/utils/browser-utils'; -import * as slides from '@growi/presentation'; import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client'; import * as drawio from '@growi/remark-drawio'; // eslint-disable-next-line import/extensions @@ -19,7 +18,6 @@ import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents import { Header } from '~/components/ReactMarkdownComponents/Header'; import { LightBox } from '~/components/ReactMarkdownComponents/LightBox'; import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment'; -import { SlideViewer } from '~/components/ReactMarkdownComponents/SlideViewer'; import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton'; import * as mermaid from '~/features/mermaid'; import { RehypeSanitizeOption } from '~/interfaces/rehype'; @@ -68,7 +66,6 @@ export const generateViewOptions = ( attachment.remarkPlugin, lsxGrowiDirective.remarkPlugin, refsGrowiDirective.remarkPlugin, - [slides.remarkPlugin, { isEnabledMarp: config.isEnabledMarp }], ); if (config.isEnabledLinebreaks) { remarkPlugins.push(breaks); @@ -84,7 +81,6 @@ export const generateViewOptions = ( drawio.sanitizeOption, mermaid.sanitizeOption, attachment.sanitizeOption, - slides.sanitizeOption, lsxGrowiDirective.sanitizeOption, refsGrowiDirective.sanitizeOption, )] @@ -119,7 +115,6 @@ export const generateViewOptions = ( components.mermaid = mermaid.MermaidViewer; components.attachment = RichAttachment; components.img = LightBox; - components.slide = SlideViewer; } if (config.isEnabledXssPrevention) { @@ -241,6 +236,21 @@ export const generatePresentationViewOptions = ( // based on simple view options const options = generateSimpleViewOptions(config, pagePath); + const { rehypePlugins } = options; + + + const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention + ? [sanitize, deepmerge( + addLineNumberAttribute.sanitizeOption, + )] + : () => {}; + + // add rehype plugins + rehypePlugins.push( + addLineNumberAttribute.rehypePlugin, + rehypeSanitizePlugin, + ); + if (config.isEnabledXssPrevention) { verifySanitizePlugin(options, false); } @@ -262,7 +272,6 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string) attachment.remarkPlugin, lsxGrowiDirective.remarkPlugin, refsGrowiDirective.remarkPlugin, - [slides.remarkPlugin, { isEnabledMarp: config.isEnabledMarp }], ); if (config.isEnabledLinebreaks) { remarkPlugins.push(breaks); @@ -281,7 +290,6 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string) lsxGrowiDirective.sanitizeOption, refsGrowiDirective.sanitizeOption, addLineNumberAttribute.sanitizeOption, - slides.sanitizeOption, )] : () => {}; @@ -306,7 +314,6 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string) components.mermaid = mermaid.MermaidViewer; components.attachment = RichAttachment; components.img = LightBox; - components.slide = SlideViewer; } if (config.isEnabledXssPrevention) { diff --git a/apps/app/src/client/services/renderer/slide-viewer-renderer.tsx b/apps/app/src/client/services/renderer/slide-viewer-renderer.tsx deleted file mode 100644 index f0792ea7b7b..00000000000 --- a/apps/app/src/client/services/renderer/slide-viewer-renderer.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client'; -import * as drawio from '@growi/remark-drawio'; -// eslint-disable-next-line import/extensions -import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client'; -import katex from 'rehype-katex'; -import sanitize from 'rehype-sanitize'; -import math from 'remark-math'; -import deepmerge from 'ts-deepmerge'; -import type { Pluggable } from 'unified'; - -import { LightBox } from '~/components/ReactMarkdownComponents/LightBox'; -import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment'; -import * as mermaid from '~/features/mermaid'; -import { RehypeSanitizeOption } from '~/interfaces/rehype'; -import type { RendererOptions } from '~/interfaces/renderer-options'; -import type { RendererConfig } from '~/interfaces/services/renderer'; -import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute'; -import * as attachment from '~/services/renderer/remark-plugins/attachment'; -import * as plantuml from '~/services/renderer/remark-plugins/plantuml'; -import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table'; -import { - commonSanitizeOption, generateCommonOptions, injectCustomSanitizeOption, verifySanitizePlugin, -} from '~/services/renderer/renderer'; - - -export const generatePresentationViewOptions = ( - config: RendererConfig, - pagePath: string, -): RendererOptions => { - const options = generateCommonOptions(pagePath); - - const { remarkPlugins, rehypePlugins, components } = options; - - // add remark plugins - remarkPlugins.push( - math, - [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }], - drawio.remarkPlugin, - mermaid.remarkPlugin, - xsvToTable.remarkPlugin, - attachment.remarkPlugin, - lsxGrowiDirective.remarkPlugin, - refsGrowiDirective.remarkPlugin, - ); - - if (config.xssOption === RehypeSanitizeOption.CUSTOM) { - injectCustomSanitizeOption(config); - } - - - const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention - ? [sanitize, deepmerge( - commonSanitizeOption, - drawio.sanitizeOption, - mermaid.sanitizeOption, - attachment.sanitizeOption, - lsxGrowiDirective.sanitizeOption, - refsGrowiDirective.sanitizeOption, - addLineNumberAttribute.sanitizeOption, - )] - : () => {}; - - // add rehype plugins - rehypePlugins.push( - [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }], - [refsGrowiDirective.rehypePlugin, { pagePath }], - rehypeSanitizePlugin, - addLineNumberAttribute.rehypePlugin, - katex, - ); - - // add components - if (components != null) { - components.lsx = lsxGrowiDirective.LsxImmutable; - components.ref = refsGrowiDirective.RefImmutable; - components.refs = refsGrowiDirective.RefsImmutable; - components.refimg = refsGrowiDirective.RefImgImmutable; - components.refsimg = refsGrowiDirective.RefsImgImmutable; - components.gallery = refsGrowiDirective.GalleryImmutable; - components.drawio = drawio.DrawioViewer; - components.mermaid = mermaid.MermaidViewer; - components.attachment = RichAttachment; - components.img = LightBox; - } - - if (config.isEnabledXssPrevention) { - verifySanitizePlugin(options, false); - } - return options; -}; diff --git a/apps/app/src/client/services/side-effects/yjs.ts b/apps/app/src/client/services/side-effects/yjs.ts new file mode 100644 index 00000000000..10c3409550a --- /dev/null +++ b/apps/app/src/client/services/side-effects/yjs.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect } from 'react'; + +import { useGlobalSocket } from '@growi/core/dist/swr'; + +import { SocketEventName } from '~/interfaces/websocket'; +import { useCurrentPageYjsData } from '~/stores/yjs'; + +export const useCurrentPageYjsDataEffect = (): void => { + const { data: socket } = useGlobalSocket(); + const { updateHasRevisionBodyDiff, updateAwarenessStateSize } = useCurrentPageYjsData(); + + const hasRevisionBodyDiffUpdateHandler = useCallback((hasRevisionBodyDiff: boolean) => { + updateHasRevisionBodyDiff(hasRevisionBodyDiff); + }, [updateHasRevisionBodyDiff]); + + const awarenessStateSizeUpdateHandler = useCallback(((awarenessStateSize: number) => { + updateAwarenessStateSize(awarenessStateSize); + }), [updateAwarenessStateSize]); + + useEffect(() => { + + if (socket == null) { return } + + socket.on(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler); + socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler); + + return () => { + socket.off(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler); + socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler); + }; + + }, [socket, awarenessStateSizeUpdateHandler, hasRevisionBodyDiffUpdateHandler]); +}; diff --git a/apps/app/src/client/util/input-validator.ts b/apps/app/src/client/util/input-validator.ts deleted file mode 100644 index e914051fee3..00000000000 --- a/apps/app/src/client/util/input-validator.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const AlertType = { - WARNING: 'warning', - ERROR: 'error', -} as const; - -export type AlertType = typeof AlertType[keyof typeof AlertType]; - -export const ValidationTarget = { - FOLDER: 'folder_name', - PAGE: 'page_name', - DEFAULT: 'field', -}; - -export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget]; - -export type AlertInfo = { - type?: AlertType - message?: string, - target?: string -} - -export const inputValidator = async(title: string | null, target?: string): Promise => { - const validationTarget = target || ValidationTarget.DEFAULT; - if (title == null || title === '' || title.trim() === '') { - return { - type: AlertType.WARNING, - message: 'form_validation.field_required', - target: validationTarget, - }; - } - return null; -}; diff --git a/apps/app/src/client/util/locale-utils.ts b/apps/app/src/client/util/locale-utils.ts index f6d822c2a9f..1b741ce37d2 100644 --- a/apps/app/src/client/util/locale-utils.ts +++ b/apps/app/src/client/util/locale-utils.ts @@ -2,7 +2,7 @@ import type { IncomingHttpHeaders } from 'http'; import { Lang } from '@growi/core'; -import * as nextI18NextConfig from '^/config/next-i18next.config'; +import { defaultLang } from '^/config/i18next.config'; // https://docs.google.com/spreadsheets/d/1FoYdyEraEQuWofzbYCDPKN7EdKgS_2ZrsDrOA8scgwQ const DIAGRAMS_NET_LANG_MAP = { @@ -31,7 +31,7 @@ const getPreferredLanguage = (sortedAcceptLanguagesArray: string[]): Lang => { const matchingLang = Object.keys(ACCEPT_LANG_MAP).find(key => lang.includes(key)); if (matchingLang) return ACCEPT_LANG_MAP[matchingLang]; } - return nextI18NextConfig.defaultLang; + return defaultLang; }; /** @@ -44,7 +44,7 @@ export const detectLocaleFromBrowserAcceptLanguage = (headers: IncomingHttpHeade const acceptLanguages = headers['accept-language']; if (acceptLanguages == null) { - return nextI18NextConfig.defaultLang; + return defaultLang; } // 1. trim blank spaces. diff --git a/apps/app/src/client/util/use-input-validator.ts b/apps/app/src/client/util/use-input-validator.ts new file mode 100644 index 00000000000..cb9deb12ce7 --- /dev/null +++ b/apps/app/src/client/util/use-input-validator.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; + +import { useTranslation } from 'next-i18next'; + +export const AlertType = { + WARNING: 'Warning', + ERROR: 'Error', +} as const; + +export type AlertType = typeof AlertType[keyof typeof AlertType]; + +export const ValidationTarget = { + FOLDER: 'folder_name', + PAGE: 'page_name', + DEFAULT: 'field', +}; + +export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget]; + +export type AlertInfo = { + type?: AlertType + message?: string, + target?: string +} + + +export type InputValidationResult = { + type: AlertType + typeLabel: string, + message: string, + target: string +} + +export type InputValidator = (input?: string, alertType?: AlertType) => InputValidationResult | void; + +export const useInputValidator = (validationTarget: ValidationTarget = ValidationTarget.DEFAULT): InputValidator => { + const { t } = useTranslation(); + + const inputValidator: InputValidator = useCallback((input?, alertType = AlertType.WARNING) => { + if ((input ?? '').trim() === '') { + return { + target: validationTarget, + type: alertType, + typeLabel: t(alertType), + message: t( + 'input_validation.message.field_required', + { target: t(`input_validation.target.${validationTarget}`) }, + ), + }; + } + + return; + }, [t, validationTarget]); + + return inputValidator; +}; diff --git a/apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx b/apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx index bbe02c5769c..7f9937e8acc 100644 --- a/apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx +++ b/apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx @@ -53,7 +53,7 @@ const CustomizePresentationSetting = (props: Props): JSX.Element => { href={`${t('admin:customize_settings.presentation_options.marp_in_gorwi_link')}`} target="_blank" rel="noopener noreferrer" - >{`${t('admin:customize_settings.presenattion_options.marp_in_growi')}`} + >{`${t('admin:customize_settings.presentation_options.marp_in_growi')}`}

diff --git a/apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx b/apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx index 2a8da7cb050..67bfb4bbfc9 100644 --- a/apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx +++ b/apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx @@ -32,6 +32,7 @@ type BookmarkFolderItemProps = { } export const BookmarkFolderItem: FC = (props: BookmarkFolderItemProps) => { + const BASE_FOLDER_PADDING = 15; const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK]; const { @@ -257,12 +258,13 @@ export const BookmarkFolderItem: FC = (props: BookmarkF {isRenameAction ? ( - +
+ +
) : ( <>
@@ -302,13 +304,10 @@ export const BookmarkFolderItem: FC = (props: BookmarkF {isCreateAction && ( -
- -
+ )} { renderChildFolder() diff --git a/apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx b/apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx index f3320a50b41..3094321d947 100644 --- a/apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx +++ b/apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx @@ -1,22 +1,68 @@ +import type { ChangeEvent } from 'react'; +import { useCallback, useRef, useState } from 'react'; + +import { useRect } from '@growi/ui/dist/utils'; import { useTranslation } from 'next-i18next'; +import type { AutosizeInputProps } from 'react-input-autosize'; +import { debounce } from 'throttle-debounce'; + +import type { InputValidationResult } from '~/client/util/use-input-validator'; +import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator'; -import { ValidationTarget } from '~/client/util/input-validator'; -import type { ClosableTextInputProps } from '~/components/Common/ClosableTextInput'; -import ClosableTextInput from '~/components/Common/ClosableTextInput'; +import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput'; +import type { SubmittableInputProps } from '../Common/SubmittableInput/types'; -type Props = ClosableTextInputProps; +type Props = Pick, 'value' | 'onSubmit' | 'onCancel'>; export const BookmarkFolderNameInput = (props: Props): JSX.Element => { const { t } = useTranslation(); + const { value, onSubmit, onCancel } = props; + + const parentRef = useRef(null); + const [parentRect] = useRect(parentRef); + + const [validationResult, setValidationResult] = useState(); + + + const inputValidator = useInputValidator(ValidationTarget.FOLDER); + + const changeHandler = useCallback(async(e: ChangeEvent) => { + const validationResult = inputValidator(e.target.value); + setValidationResult(validationResult ?? undefined); + }, [inputValidator]); + const changeHandlerDebounced = debounce(300, changeHandler); + + const cancelHandler = useCallback(() => { + setValidationResult(undefined); + onCancel?.(); + }, [onCancel]); + + const isInvalid = validationResult != null; + + const maxWidth = parentRect != null + ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined) + : undefined; + return ( -
- + + { isInvalid && ( +
+ {validationResult.message} +
+ ) }
); }; diff --git a/apps/app/src/components/Bookmarks/BookmarkItem.tsx b/apps/app/src/components/Bookmarks/BookmarkItem.tsx index 040d834acf3..0d2a03a2804 100644 --- a/apps/app/src/components/Bookmarks/BookmarkItem.tsx +++ b/apps/app/src/components/Bookmarks/BookmarkItem.tsx @@ -12,17 +12,16 @@ import { UncontrolledTooltip, DropdownToggle } from 'reactstrap'; import { bookmark, unbookmark, unlink } from '~/client/services/page-operation'; import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils'; -import { ValidationTarget } from '~/client/util/input-validator'; import { toastError, toastSuccess } from '~/client/util/toastr'; import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info'; import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info'; import { usePutBackPageModal } from '~/stores/modal'; import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page'; -import ClosableTextInput from '../Common/ClosableTextInput'; import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl'; import { PageListItemS } from '../PageList/PageListItemS'; +import { BookmarkItemRenameInput } from './BookmarkItemRenameInput'; import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn'; import { DragAndDropWrapper } from './DragAndDropWrapper'; @@ -163,13 +162,10 @@ export const BookmarkItem = (props: Props): JSX.Element => { > { isRenameInputShown ? ( - { setRenameInputShown(false) }} - validationTarget={ValidationTarget.PAGE} + onSubmit={rename} + onCancel={() => { setRenameInputShown(false) }} /> ) : } diff --git a/apps/app/src/components/Bookmarks/BookmarkItemRenameInput.tsx b/apps/app/src/components/Bookmarks/BookmarkItemRenameInput.tsx new file mode 100644 index 00000000000..760ed67dde5 --- /dev/null +++ b/apps/app/src/components/Bookmarks/BookmarkItemRenameInput.tsx @@ -0,0 +1,68 @@ +import type { ChangeEvent } from 'react'; +import { useCallback, useRef, useState } from 'react'; + +import { useRect } from '@growi/ui/dist/utils'; +import { useTranslation } from 'next-i18next'; +import type { AutosizeInputProps } from 'react-input-autosize'; +import { debounce } from 'throttle-debounce'; + +import type { InputValidationResult } from '~/client/util/use-input-validator'; +import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator'; + +import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput'; +import type { SubmittableInputProps } from '../Common/SubmittableInput/types'; + + +type Props = Pick, 'value' | 'onSubmit' | 'onCancel'>; + +export const BookmarkItemRenameInput = (props: Props): JSX.Element => { + const { t } = useTranslation(); + + const { value, onSubmit, onCancel } = props; + + const parentRef = useRef(null); + const [parentRect] = useRect(parentRef); + + const [validationResult, setValidationResult] = useState(); + + + const inputValidator = useInputValidator(ValidationTarget.PAGE); + + const changeHandler = useCallback(async(e: ChangeEvent) => { + const validationResult = inputValidator(e.target.value); + setValidationResult(validationResult ?? undefined); + }, [inputValidator]); + const changeHandlerDebounced = debounce(300, changeHandler); + + const cancelHandler = useCallback(() => { + setValidationResult(undefined); + onCancel?.(); + }, [onCancel]); + + const isInvalid = validationResult != null; + + const maxWidth = parentRect != null + ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined) + : undefined; + + return ( +
+ + { isInvalid && ( +
+ {validationResult.message} +
+ ) } +
+ ); +}; diff --git a/apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx b/apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx index f9968c26467..528fdebbbe6 100644 --- a/apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx +++ b/apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx @@ -13,7 +13,6 @@ export const BookmarkMoveToRootBtn: React.FC<{ onClickMoveToRootHandler(pageId)} className="grw-page-control-dropdown-item" - data-testid="add-remove-bookmark-btn" > bookmark {t('bookmark_folder.move_to_root')} diff --git a/apps/app/src/components/Common/ClosableTextInput.tsx b/apps/app/src/components/Common/ClosableTextInput.tsx deleted file mode 100644 index 035f8c253ea..00000000000 --- a/apps/app/src/components/Common/ClosableTextInput.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import type { FC } from 'react'; -import React, { - memo, useCallback, useEffect, useRef, useState, -} from 'react'; - -import { useTranslation } from 'next-i18next'; -import AutosizeInput from 'react-input-autosize'; - -import type { AlertInfo } from '~/client/util/input-validator'; -import { AlertType, inputValidator } from '~/client/util/input-validator'; - -export type ClosableTextInputProps = { - value?: string - placeholder?: string - validationTarget?: string, - useAutosizeInput?: boolean - inputClassName?: string, - onPressEnter?(inputText: string): void - onPressEscape?(inputText: string): void - onBlur?(inputText: string): void - onChange?(inputText: string): void -} - -const ClosableTextInput: FC = memo((props: ClosableTextInputProps) => { - const { t } = useTranslation(); - const { - validationTarget, onPressEnter, onPressEscape, onBlur, onChange, - } = props; - - const inputRef = useRef(null); - const [inputText, setInputText] = useState(props.value ?? ''); - const [currentAlertInfo, setAlertInfo] = useState(null); - const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false); - const [isComposing, setComposing] = useState(false); - - - const createValidation = useCallback(async(inputText: string) => { - const alertInfo = await inputValidator(inputText, validationTarget); - if (alertInfo && alertInfo.message != null && alertInfo.target != null) { - alertInfo.message = t(alertInfo.message, { target: t(alertInfo.target) }); - } - setAlertInfo(alertInfo); - }, [t, validationTarget]); - - const changeHandler = useCallback(async(e: React.ChangeEvent) => { - const inputText = e.target.value; - createValidation(inputText); - setInputText(inputText); - setIsAbleToShowAlert(true); - - onChange?.(inputText); - }, [createValidation, onChange]); - - const onFocusHandler = useCallback(async(e: React.ChangeEvent) => { - const inputText = e.target.value; - await createValidation(inputText); - }, [createValidation]); - - const pressEnterHandler = useCallback(() => { - if (currentAlertInfo == null) { - onPressEnter?.(inputText.trim()); - } - }, [currentAlertInfo, inputText, onPressEnter]); - - const onKeyDownHandler = useCallback((e) => { - switch (e.key) { - case 'Enter': - // Do nothing when composing - if (isComposing) { - return; - } - pressEnterHandler(); - break; - case 'Escape': - if (isComposing) { - return; - } - onPressEscape?.(inputText.trim()); - break; - default: - break; - } - }, [inputText, isComposing, pressEnterHandler, onPressEscape]); - - /* - * Hide when click outside the ref - */ - const onBlurHandler = useCallback(() => { - onBlur?.(inputText.trim()); - }, [inputText, onBlur]); - - // didMount - useEffect(() => { - // autoFocus - if (inputRef?.current == null) { - return; - } - inputRef.current.focus(); - }); - - - const AlertInfo = () => { - if (currentAlertInfo == null) { - return <>; - } - - const alertType = currentAlertInfo.type != null ? currentAlertInfo.type : AlertType.ERROR; - const alertMessage = currentAlertInfo.message != null ? currentAlertInfo.message : 'Invalid value'; - const alertTextStyle = alertType === AlertType.ERROR ? 'text-danger' : 'text-warning'; - const translation = alertType === AlertType.ERROR ? 'Error' : 'Warning'; - return ( -

{t(translation)}: {alertMessage}

- ); - }; - - const inputProps = { - 'data-testid': 'closable-text-input', - value: inputText || '', - ref: inputRef, - type: 'text', - placeholder: props.placeholder, - name: 'input', - onFocus: onFocusHandler, - onChange: changeHandler, - onKeyDown: onKeyDownHandler, - onCompositionStart: () => setComposing(true), - onCompositionEnd: () => setComposing(false), - onBlur: onBlurHandler, - }; - - const inputClassName = `form-control ${props.inputClassName ?? ''}`; - - return ( -
- { props.useAutosizeInput - ? - : - } - {isAbleToShowAlert && } -
- ); -}); - -ClosableTextInput.displayName = 'ClosableTextInput'; - -export default ClosableTextInput; diff --git a/apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx b/apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx index 6fe1066815b..2e14573379a 100644 --- a/apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx +++ b/apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx @@ -28,7 +28,7 @@ describe('PageItemControl.tsx', () => { render(); // when - const openPageMoveRenameModalButton = screen.getByTestId('open-page-move-rename-modal-btn'); + const openPageMoveRenameModalButton = screen.getByTestId('rename-page-btn'); await waitFor(() => userEvent.click(openPageMoveRenameModalButton, { pointerEventsCheck: PointerEventsCheckLevel.Never })); // then diff --git a/apps/app/src/components/Common/Dropdown/PageItemControl.tsx b/apps/app/src/components/Common/Dropdown/PageItemControl.tsx index 53acfad7dee..2b9a33cd8af 100644 --- a/apps/app/src/components/Common/Dropdown/PageItemControl.tsx +++ b/apps/app/src/components/Common/Dropdown/PageItemControl.tsx @@ -169,7 +169,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E bookmark { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') } @@ -180,7 +180,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && ( redo diff --git a/apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx b/apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx new file mode 100644 index 00000000000..66587d47609 --- /dev/null +++ b/apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx @@ -0,0 +1,30 @@ +import type { + ReactElement, +} from 'react'; + +import type { AutosizeInputProps } from 'react-input-autosize'; +import AutosizeInput from 'react-input-autosize'; + +import type { SubmittableInputProps } from './types'; +import { useSubmittable } from './use-submittable'; + + +export const getAdjustedMaxWidthForAutosizeInput = (parentMaxWidth: number, size: 'sm' | 'md' | 'lg' = 'md', isValid?: boolean): number => { + // eslint-disable-next-line no-nested-ternary + const bsFormPaddingSize = size === 'sm' ? 8 : size === 'md' ? 12 : 16; // by bootstrap form + // eslint-disable-next-line no-nested-ternary + const bsValidationIconSize = size === 'sm' ? 25 : size === 'md' ? 24 : 26; // by bootstrap form validation + + return parentMaxWidth + - bsFormPaddingSize * 2 // minus the padding (12px * 2) because AutosizeInput has "box-sizing: content-box;" + - (isValid === false ? bsValidationIconSize : 0); // minus the width for the exclamation icon +}; + +export const AutosizeSubmittableInput = (props: SubmittableInputProps): ReactElement => { + + const submittableProps = useSubmittable(props); + + return ( + + ); +}; diff --git a/apps/app/src/components/Common/SubmittableInput/SubmittableInput.tsx b/apps/app/src/components/Common/SubmittableInput/SubmittableInput.tsx new file mode 100644 index 00000000000..f005fc79f26 --- /dev/null +++ b/apps/app/src/components/Common/SubmittableInput/SubmittableInput.tsx @@ -0,0 +1,23 @@ +import type { + ReactElement, +} from 'react'; + +import type { SubmittableInputProps } from './types'; +import { useSubmittable } from './use-submittable'; + + +export const SubmittableInput = (props: SubmittableInputProps): ReactElement => { + // // autoFocus + // useEffect(() => { + // if (inputRef?.current == null) { + // return; + // } + // inputRef.current.focus(); + // }); + + const submittableProps = useSubmittable(props); + + return ( + + ); +}; diff --git a/apps/app/src/components/Common/SubmittableInput/index.ts b/apps/app/src/components/Common/SubmittableInput/index.ts new file mode 100644 index 00000000000..cf72105276b --- /dev/null +++ b/apps/app/src/components/Common/SubmittableInput/index.ts @@ -0,0 +1,2 @@ +export * from './SubmittableInput'; +export * from './AutosizeSubmittableInput'; diff --git a/apps/app/src/components/Common/SubmittableInput/types.d.ts b/apps/app/src/components/Common/SubmittableInput/types.d.ts new file mode 100644 index 00000000000..b98d8b3575c --- /dev/null +++ b/apps/app/src/components/Common/SubmittableInput/types.d.ts @@ -0,0 +1,7 @@ +export type SubmittableInputProps = InputHTMLAttributes> = + Omit, 'value' | 'onKeyDown' | 'onSubmit'> + & { + value?: string, + onSubmit?: (inputText: string) => void, + onCancel?: () => void, + } diff --git a/apps/app/src/components/Common/SubmittableInput/use-submittable.ts b/apps/app/src/components/Common/SubmittableInput/use-submittable.ts new file mode 100644 index 00000000000..b06e77219a4 --- /dev/null +++ b/apps/app/src/components/Common/SubmittableInput/use-submittable.ts @@ -0,0 +1,80 @@ +import type { + CompositionEvent, +} from 'react'; +import type React from 'react'; +import { + useCallback, useState, +} from 'react'; + +import type { SubmittableInputProps } from './types'; + +export const useSubmittable = (props: SubmittableInputProps): Partial> => { + + const { + value, + onChange, onBlur, + onCompositionStart, onCompositionEnd, + onSubmit, onCancel, + } = props; + + const [inputText, setInputText] = useState(value ?? ''); + const [isComposing, setComposing] = useState(false); + + const changeHandler = useCallback(async(e: React.ChangeEvent) => { + const inputText = e.target.value; + setInputText(inputText); + + onChange?.(e); + }, [onChange]); + + const keyDownHandler = useCallback((e) => { + switch (e.key) { + case 'Enter': + // Do nothing when composing + if (isComposing) { + return; + } + onSubmit?.(inputText.trim()); + break; + case 'Escape': + if (isComposing) { + return; + } + onCancel?.(); + break; + } + }, [inputText, isComposing, onCancel, onSubmit]); + + const blurHandler = useCallback((e) => { + // submit on blur + onSubmit?.(inputText.trim()); + onBlur?.(e); + }, [inputText, onSubmit, onBlur]); + + const compositionStartHandler = useCallback((e: CompositionEvent) => { + setComposing(true); + onCompositionStart?.(e); + }, [onCompositionStart]); + + const compositionEndHandler = useCallback((e: CompositionEvent) => { + setComposing(false); + onCompositionEnd?.(e); + }, [onCompositionEnd]); + + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + value: _value, onSubmit: _onSubmit, onCancel: _onCancel, + ...cleanedProps + } = props; + + return { + ...cleanedProps, + value: inputText, + onChange: changeHandler, + onKeyDown: keyDownHandler, + onBlur: blurHandler, + onCompositionStart: compositionStartHandler, + onCompositionEnd: compositionEndHandler, + }; + +}; diff --git a/apps/app/src/components/InstallerForm.tsx b/apps/app/src/components/InstallerForm.tsx index 7f6da3b691a..87232fd7a8e 100644 --- a/apps/app/src/components/InstallerForm.tsx +++ b/apps/app/src/components/InstallerForm.tsx @@ -10,7 +10,7 @@ import { i18n as i18nConfig } from '^/config/next-i18next.config'; import { apiv3Post } from '~/client/util/apiv3-client'; import { toastError } from '~/client/util/toastr'; - +import type { IErrorV3 } from '~/interfaces/errors/v3-error'; import styles from './InstallerForm.module.scss'; @@ -28,6 +28,8 @@ const InstallerForm = memo((): JSX.Element => { const [isLoading, setIsLoading] = useState(false); const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US); + const [registerErrors, setRegisterErrors] = useState([]); + const checkUserName = useCallback(async(event) => { const axios = require('axios').create({ headers: { @@ -70,6 +72,7 @@ const InstallerForm = memo((): JSX.Element => { }; try { + setRegisterErrors([]); await apiv3Post('/installer', data); router.push('/'); } @@ -77,6 +80,7 @@ const InstallerForm = memo((): JSX.Element => { const err = errs[0]; const code = err.code; setIsLoading(false); + setRegisterErrors(errs); if (code === 'failed_to_login_after_install') { toastError(t('installer.failed_to_login_after_install')); @@ -103,6 +107,19 @@ const InstallerForm = memo((): JSX.Element => {
+ + { + registerErrors != null && registerErrors.length > 0 && ( +

+ {registerErrors.map(err => ( + + {t(err.message)}
+
+ ))} +

+ ) + } +
diff --git a/apps/app/src/components/ItemsTree/ItemsTree.module.scss b/apps/app/src/components/ItemsTree/ItemsTree.module.scss index 19bea7c2960..e69de29bb2d 100644 --- a/apps/app/src/components/ItemsTree/ItemsTree.module.scss +++ b/apps/app/src/components/ItemsTree/ItemsTree.module.scss @@ -1,153 +0,0 @@ -@use '~/styles/mixins' as *; -$grw-sidebar-content-header-height: 58px; -$grw-sidebar-content-footer-height: 50px; -$grw-pagetree-item-padding-left: 10px; -$grw-pagetree-item-container-height: 40px; - -.grw-pagetree { - - .grw-pagetree-item-skeleton-text { - @include grw-skeleton-text($font-size:16px, $line-height:$grw-pagetree-item-container-height); - padding-left: 12px; - } - - .grw-pagetree-item-skeleton-text-child { - @extend .grw-pagetree-item-skeleton-text; - padding-left: 12px + $grw-pagetree-item-padding-left; - } - - :global { - - .list-group-item { - .grw-visible-on-hover { - display: none; - } - - &:hover { - .grw-visible-on-hover { - display: block; - } - - .grw-count-badge { - display: none; - } - } - - .grw-pagetree-triangle-btn { - border: 0; - transition: all 0.2s ease-out; - transform: rotate(0deg); - - &.grw-pagetree-open { - transform: rotate(90deg); - } - } - - .grw-pagetree-title-anchor { - width: 100%; - overflow: hidden; - text-decoration: none; - } - - .grw-pagetree-count-wrapper { - display: inline-block; - - &:hover { - display: none; - } - } - } - - .grw-pagetree-item-container { - .grw-triangle-container { - min-width: 35px; - height: $grw-pagetree-item-container-height; - } - } - } - &:global{ - // To realize a hierarchical structure, set multiplied padding-left to each pagetree-item - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: 0; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 2; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 3; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 4; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 5; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 6; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 7; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 8; - } - > .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 9; - } - .grw-pagetree-item-children { - > .grw-pagetree-item-container { - > .list-group-item { - padding-left: $grw-pagetree-item-padding-left * 10; - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } -} - - -.grw-pagetree :global { - .grw-pagetree-triangle-btn { - --btn-color: var(--bs-tertiary-color); - } -} diff --git a/apps/app/src/components/ItemsTree/ItemsTree.tsx b/apps/app/src/components/ItemsTree/ItemsTree.tsx index 85fcfd5d4be..63426e08de0 100644 --- a/apps/app/src/components/ItemsTree/ItemsTree.tsx +++ b/apps/app/src/components/ItemsTree/ItemsTree.tsx @@ -1,5 +1,5 @@ import React, { - useEffect, useRef, useState, useMemo, useCallback, + useEffect, useMemo, useCallback, } from 'react'; import path from 'path'; @@ -8,7 +8,6 @@ import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core'; import { useGlobalSocket } from '@growi/core/dist/swr'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; -import { debounce } from 'throttle-debounce'; import { toastError, toastSuccess } from '~/client/util/toastr'; import type { IPageForItem } from '~/interfaces/page'; @@ -23,7 +22,7 @@ import { useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList, } from '~/stores/page-listing'; import { mutateSearching } from '~/stores/search'; -import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui'; +import { usePageTreeDescCountMap } from '~/stores/ui'; import loggerFactory from '~/utils/logger'; import { ItemNode, type TreeItemProps } from '../TreeItem'; @@ -32,6 +31,7 @@ import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton'; import styles from './ItemsTree.module.scss'; +const moduleClass = styles['items-tree'] ?? ''; const logger = loggerFactory('growi:cli:ItemsTree'); @@ -115,7 +115,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => { const { data: currentPagePath } = useCurrentPagePath(); const { open: openDuplicateModal } = usePageDuplicateModal(); const { open: openDeleteModal } = usePageDeleteModal(); - const { data: sidebarScrollerRef } = useSidebarScrollerRef(); const { data: socket } = useGlobalSocket(); const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap(); @@ -123,9 +122,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => { // for mutation const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage(); - const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false); - - const rootElemRef = useRef(null); const renderingCondition = useMemo(() => { return { @@ -200,55 +196,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => { openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler }); }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]); - // *************************** Scroll on init *************************** - const scrollOnInit = useCallback(() => { - const scrollTargetElement = document.getElementById('grw-pagetree-current-page-item'); - - if (sidebarScrollerRef?.current == null || scrollTargetElement == null) { - return; - } - - logger.debug('scrollOnInit has invoked'); - - const scrollElement = sidebarScrollerRef.current.getScrollElement(); - - // NOTE: could not use scrollIntoView - // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move - - // calculate the center point - const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2; - scrollElement.scrollTo({ top: scrollTop }); - - setIsInitialScrollCompleted(true); - }, [sidebarScrollerRef]); - - const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]); - - useEffect(() => { - if (!isSecondStageRenderingCondition(renderingCondition) || isInitialScrollCompleted) { - return; - } - - const rootElement = rootElemRef.current as HTMLElement | null; - if (rootElement == null) { - return; - } - - const observerCallback = (mutationRecords: MutationRecord[]) => { - mutationRecords.forEach(() => scrollOnInitDebounced()); - }; - - const observer = new MutationObserver(observerCallback); - observer.observe(rootElement, { childList: true, subtree: true }); - - // first call for the situation that all rendering is complete at this point - scrollOnInitDebounced(); - - return () => { - observer.disconnect(); - }; - }, [isInitialScrollCompleted, renderingCondition, scrollOnInitDebounced]); - // ******************************* end ******************************* if (error1 != null || error2 != null) { // TODO: improve message @@ -275,7 +222,7 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => { if (initialItemNode != null) { return ( -
    +
      { return ( -
        - - - +
          + + +
        ); }; diff --git a/apps/app/src/components/Navbar/PageEditorModeManager.tsx b/apps/app/src/components/Navbar/PageEditorModeManager.tsx index 52508365398..5f2692a1bf6 100644 --- a/apps/app/src/components/Navbar/PageEditorModeManager.tsx +++ b/apps/app/src/components/Navbar/PageEditorModeManager.tsx @@ -1,13 +1,13 @@ -import React, { type ReactNode, useCallback } from 'react'; +import React, { type ReactNode, useCallback, useMemo } from 'react'; import { Origin } from '@growi/core'; import { useTranslation } from 'next-i18next'; - import { useCreatePageAndTransit } from '~/client/services/create-page'; import { toastError } from '~/client/util/toastr'; import { useIsNotFound } from '~/stores/page'; import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui'; +import { useCurrentPageYjsData } from '~/stores/yjs'; import { shouldCreateWipPage } from '../../utils/should-create-wip-page'; @@ -65,6 +65,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => { const { data: isNotFound } = useIsNotFound(); const { mutate: mutateEditorMode } = useEditorMode(); const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd(); + const { data: currentPageYjsData } = useCurrentPageYjsData(); const { isCreating, createAndTransit } = useCreatePageAndTransit(); @@ -87,6 +88,16 @@ export const PageEditorModeManager = (props: Props): JSX.Element => { const _isBtnDisabled = isCreating || isBtnDisabled; + const circleColor = useMemo(() => { + if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) { + return 'bg-primary'; + } + + if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) { + return 'bg-secondary'; + } + }, [currentPageYjsData]); + return ( <>
        { onClick={editButtonClickedHandler} > edit_square{t('Edit')} + { circleColor != null && } )}
        diff --git a/apps/app/src/components/Page/DisplaySwitcher.tsx b/apps/app/src/components/Page/DisplaySwitcher.tsx index fd3f04ffc63..313d4571923 100644 --- a/apps/app/src/components/Page/DisplaySwitcher.tsx +++ b/apps/app/src/components/Page/DisplaySwitcher.tsx @@ -4,6 +4,7 @@ import dynamic from 'next/dynamic'; import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed'; import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated'; +import { useCurrentPageYjsDataEffect } from '~/client/services/side-effects/yjs'; import { useIsEditable } from '~/stores/context'; import { useIsLatestRevision } from '~/stores/page'; import { EditorMode, useEditorMode } from '~/stores/ui'; @@ -26,6 +27,7 @@ export const DisplaySwitcher = (props: Props): JSX.Element => { usePageUpdatedEffect(); useHashChangedEffect(); + useCurrentPageYjsDataEffect(); return ( <> diff --git a/apps/app/src/components/Page/PageView.tsx b/apps/app/src/components/Page/PageView.tsx index 9e4ca91eb48..ee8bfc50957 100644 --- a/apps/app/src/components/Page/PageView.tsx +++ b/apps/app/src/components/Page/PageView.tsx @@ -4,6 +4,7 @@ import React, { import type { IPagePopulatedToShowRevision } from '@growi/core'; import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils'; +import { useSlidesByFrontmatter } from '@growi/presentation/dist/services'; import dynamic from 'next/dynamic'; import { useShouldExpandContent } from '~/client/services/layout'; @@ -40,6 +41,7 @@ const Comments = dynamic(() => import('../Comments').then(mod => const UsersHomepageFooter = dynamic(() => import('../UsersHomepageFooter') .then(mod => mod.UsersHomepageFooter), { ssr: false }); const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false }); +const SlideRenderer = dynamic(() => import('./SlideRenderer').then(mod => mod.SlideRenderer), { ssr: false }); type Props = { @@ -74,6 +76,10 @@ export const PageView = (props: Props): JSX.Element => { const shouldExpandContent = useShouldExpandContent(page); + const markdown = page?.revision?.body; + const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp); + + // *************************** Auto Scroll *************************** useEffect(() => { // do nothing if hash is empty @@ -90,6 +96,7 @@ export const PageView = (props: Props): JSX.Element => { }, [isCommentsLoaded]); // ******************************* end ******************************* + const specialContents = useMemo(() => { if (isIdenticalPathPage) { return ; @@ -128,15 +135,19 @@ export const PageView = (props: Props): JSX.Element => { return ; } - const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath); const markdown = page.revision.body; + const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath); return ( <>
        - + + { isSlide != null + ? + : + } { !isIdenticalPathPage && !isNotFound && (
        diff --git a/apps/app/src/components/Page/RevisionRenderer.tsx b/apps/app/src/components/Page/RevisionRenderer.tsx index 3f1c13457ca..89e30dd0154 100644 --- a/apps/app/src/components/Page/RevisionRenderer.tsx +++ b/apps/app/src/components/Page/RevisionRenderer.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; +import type { FallbackProps } from 'react-error-boundary'; +import { ErrorBoundary } from 'react-error-boundary'; import ReactMarkdown from 'react-markdown'; import type { RendererOptions } from '~/interfaces/renderer-options'; @@ -8,6 +9,7 @@ import loggerFactory from '~/utils/logger'; import 'katex/dist/katex.min.css'; + const logger = loggerFactory('components:Page:RevisionRenderer'); type Props = { diff --git a/apps/app/src/components/Page/SlideRenderer.tsx b/apps/app/src/components/Page/SlideRenderer.tsx new file mode 100644 index 00000000000..aa975a4600a --- /dev/null +++ b/apps/app/src/components/Page/SlideRenderer.tsx @@ -0,0 +1,26 @@ +import type { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown'; + +import { usePresentationViewOptions } from '~/stores/renderer'; + +import { Slides } from '../Presentation/Slides'; + +type SlideRendererProps = { + markdown: string, + marp?: boolean, +}; + +export const SlideRenderer = (props: SlideRendererProps): JSX.Element => { + + const { markdown, marp = false } = props; + + const { data: rendererOptions } = usePresentationViewOptions(); + + return ( + + {markdown} + + ); +}; diff --git a/apps/app/src/components/PageEditor/Preview.tsx b/apps/app/src/components/PageEditor/Preview.tsx index 17eeb30c9d2..a5b76a2d825 100644 --- a/apps/app/src/components/PageEditor/Preview.tsx +++ b/apps/app/src/components/PageEditor/Preview.tsx @@ -1,10 +1,12 @@ import type { CSSProperties } from 'react'; -import React from 'react'; + +import { useSlidesByFrontmatter } from '@growi/presentation/dist/services'; import type { RendererOptions } from '~/interfaces/renderer-options'; +import { useIsEnabledMarp } from '~/stores/context'; import RevisionRenderer from '../Page/RevisionRenderer'; - +import { SlideRenderer } from '../Page/SlideRenderer'; import styles from './Preview.module.scss'; @@ -28,17 +30,25 @@ const Preview = (props: Props): JSX.Element => { expandContentWidth, } = props; + const { data: isEnabledMarp } = useIsEnabledMarp(); + const isSlide = useSlidesByFrontmatter(markdown, isEnabledMarp); + const fluidLayoutClass = expandContentWidth ? 'fluid-layout' : ''; + return (
        - { markdown != null && ( - - ) } + { markdown != null + && ( + isSlide != null + ? + : + ) + }
        ); diff --git a/apps/app/src/components/PageHeader/PagePathHeader.tsx b/apps/app/src/components/PageHeader/PagePathHeader.tsx index b5f1199d8f7..c969d459061 100644 --- a/apps/app/src/components/PageHeader/PagePathHeader.tsx +++ b/apps/app/src/components/PageHeader/PagePathHeader.tsx @@ -1,3 +1,4 @@ +import type { ChangeEvent } from 'react'; import { useState, useCallback, memo, } from 'react'; @@ -6,13 +7,15 @@ import type { IPagePopulatedToShowRevision } from '@growi/core'; import { DevidedPagePath } from '@growi/core/dist/models'; import { normalizePath } from '@growi/core/dist/utils/path-utils'; import { useTranslation } from 'next-i18next'; +import { debounce } from 'throttle-debounce'; -import { ValidationTarget } from '~/client/util/input-validator'; +import type { InputValidationResult } from '~/client/util/use-input-validator'; +import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator'; import LinkedPagePath from '~/models/linked-page-path'; import { usePageSelectModal } from '~/stores/modal'; -import ClosableTextInput from '../Common/ClosableTextInput'; import { PagePathHierarchicalLink } from '../Common/PagePathHierarchicalLink'; +import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput'; import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils'; import { PageSelectModal } from '../PageSelectModal/PageSelectModal'; @@ -42,11 +45,20 @@ export const PagePathHeader = memo((props: Props): JSX.Element => { const [isRenameInputShown, setRenameInputShown] = useState(false); const [isHover, setHover] = useState(false); - // const [isIconHidden, setIsIconHidden] = useState(false); - const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal(); const isOpened = PageSelectModalData?.isOpened ?? false; + const [validationResult, setValidationResult] = useState(); + + const inputValidator = useInputValidator(ValidationTarget.PAGE); + + const changeHandler = useCallback(async(e: ChangeEvent) => { + const validationResult = inputValidator(e.target.value); + setValidationResult(validationResult ?? undefined); + }, [inputValidator]); + const changeHandlerDebounced = debounce(300, changeHandler); + + const pagePathRenameHandler = usePagePathRenameHandler(currentPage); @@ -55,6 +67,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => { pagePathRenameHandler(pathToRename, () => { setRenameInputShown(false); + setValidationResult(undefined); onRenameTerminated?.(); }, () => { @@ -64,6 +77,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => { const cancel = useCallback(() => { // reset + setValidationResult(undefined); setRenameInputShown(false); }, []); @@ -72,70 +86,52 @@ export const PagePathHeader = memo((props: Props): JSX.Element => { setRenameInputShown(true); }, []); - // TODO: https://redmine.weseek.co.jp/issues/141062 - // Truncate left side and don't use getElementById - // - // useEffect(() => { - // const areaElem = document.getElementById('grw-page-path-header-container'); - // const linkElem = document.getElementById('grw-page-path-hierarchical-link'); - - // const areaElemWidth = areaElem?.offsetWidth; - // const linkElemWidth = linkElem?.offsetWidth; - - // if (areaElemWidth && linkElemWidth) { - // setIsIconHidden(linkElemWidth > areaElemWidth); - // } - // else { - // setIsIconHidden(false); - // } - // }, [currentPage]); - // - // const styles: CSSProperties | undefined = isIconHidden ? { direction: 'rtl' } : undefined; - if (dPagePath.isRoot) { return <>; } + + const isInvalid = validationResult != null; + + const inputMaxWidth = maxWidth != null + ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'sm', validationResult != null ? false : undefined) - 16 + : undefined; + return (
        setHover(true)} onMouseLeave={() => setHover(false)} >
        { isRenameInputShown && (
        -
        ) } -
        +
        - - +
          +
        • - +
        • - +
        • - - - +
        • +
        +
        ); }; diff --git a/apps/app/src/components/Sidebar/Sidebar.tsx b/apps/app/src/components/Sidebar/Sidebar.tsx index 0dcdd71d391..849ae3e439d 100644 --- a/apps/app/src/components/Sidebar/Sidebar.tsx +++ b/apps/app/src/components/Sidebar/Sidebar.tsx @@ -1,6 +1,7 @@ import React, { type FC, memo, useCallback, useEffect, useState, + useRef, } from 'react'; import dynamic from 'next/dynamic'; @@ -13,6 +14,7 @@ import { useCurrentProductNavWidth, usePreferCollapsedMode, useSidebarMode, + useSidebarScrollerRef, } from '~/stores/ui'; import { DrawerToggler } from '../Common/DrawerToggler'; @@ -109,6 +111,10 @@ const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Elemen const { data: currentProductNavWidth } = useCurrentProductNavWidth(); const { data: isCollapsedContentsOpened, mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened(); + const sidebarScrollerRef = useRef(null); + const { mutate: mutateSidebarScroller } = useSidebarScrollerRef(); + mutateSidebarScroller(sidebarScrollerRef); + // open menu when collapsed mode const primaryItemHoverHandler = useCallback(() => { @@ -138,6 +144,7 @@ const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Elemen