diff --git a/.release-please/config.json b/.release-please/config.json index 64e55f122..59cf015d5 100644 --- a/.release-please/config.json +++ b/.release-please/config.json @@ -12,6 +12,7 @@ ], "packages": { "packages/editor": {}, - "packages/latex-extension": {} + "packages/latex-extension": {}, + "packages/page-constructor-extension": {} } } \ No newline at end of file diff --git a/.release-please/manifest.json b/.release-please/manifest.json index 8c009cd03..90db2e0d1 100644 --- a/.release-please/manifest.json +++ b/.release-please/manifest.json @@ -1,4 +1,5 @@ { "packages/editor": "15.39.0", - "packages/latex-extension": "0.1.0" + "packages/latex-extension": "0.1.0", + "packages/page-constructor-extension": "0.0.0" } \ No newline at end of file diff --git a/demo/.storybook/main.ts b/demo/.storybook/main.ts index 3bb194d8b..98ee7fce7 100644 --- a/demo/.storybook/main.ts +++ b/demo/.storybook/main.ts @@ -50,6 +50,17 @@ const config: StorybookConfig = { process: require.resolve('process/browser'), }; + config.ignoreWarnings ||= []; + config.ignoreWarnings.push(/\.js\.map$/); + config.module ||= {}; + config.module.rules ||= []; + config.module.rules.push({ + test: /\.(js|css)\.map$/, + include: /node_modules/, + type: 'asset/resource' as const, + generator: {emit: false}, + }); + config.watchOptions ||= {}; config.watchOptions.ignored = /node_modules([\\]+|\/)+(?!@gravity-ui\/markdown-editor)/; diff --git a/demo/package.json b/demo/package.json index d31f59d65..70b8f30b8 100644 --- a/demo/package.json +++ b/demo/package.json @@ -26,12 +26,15 @@ "@diplodoc/html-extension": "catalog:peer-diplodoc", "@diplodoc/latex-extension": "catalog:peer-diplodoc", "@diplodoc/mermaid-extension": "catalog:peer-diplodoc", + "@diplodoc/page-constructor-extension": "catalog:peer-diplodoc", "@diplodoc/quote-link-extension": "catalog:peer-diplodoc", "@diplodoc/tabs-extension": "catalog:peer-diplodoc", "@diplodoc/transform": "catalog:peer-diplodoc", "@gravity-ui/components": "catalog:peer-gravity", "@gravity-ui/markdown-editor": "workspace:*", "@gravity-ui/markdown-editor-latex-extension": "workspace:*", + "@gravity-ui/markdown-editor-page-constructor-extension": "workspace:*", + "@gravity-ui/page-constructor": "catalog:peer-gravity", "@gravity-ui/uikit": "catalog:peer-gravity", "markdown-it": "catalog:peers" }, diff --git a/demo/src/components/Playground.tsx b/demo/src/components/Playground.tsx index dc3d8fed0..3bfa7b486 100644 --- a/demo/src/components/Playground.tsx +++ b/demo/src/components/Playground.tsx @@ -32,6 +32,8 @@ import { wLatexBlockItemData, wLatexInlineItemData, } from '@gravity-ui/markdown-editor-latex-extension/configs'; +import {YfmPageConstructorExtension} from '@gravity-ui/markdown-editor-page-constructor-extension'; +import {wYfmPageConstructorItemData} from '@gravity-ui/markdown-editor-page-constructor-extension/configs'; import {Button, DropdownMenu} from '@gravity-ui/uikit'; import {getPlugins} from '../defaults/md-plugins'; @@ -54,6 +56,7 @@ const wCommandMenuConfig = wysiwygToolbarConfigs.wCommandMenuConfig.concat( wLatexInlineItemData, wLatexBlockItemData, wysiwygToolbarConfigs.wMermaidItemData, + wYfmPageConstructorItemData, wysiwygToolbarConfigs.wYfmHtmlBlockItemData, ); @@ -211,6 +214,16 @@ export const Playground = memo((props) => { }, theme: {dark: 'dark', light: 'forest'}, }) + .use(YfmPageConstructorExtension, { + autoSave: { + enabled: + storyAdditionalControls?.yfmPageConstructorAutoSaveEnabled ?? + true, + delay: + storyAdditionalControls?.yfmPageConstructorAutoSaveDelay ?? + 1000, + }, + }) .use(FoldingHeading) .use(YfmHtmlBlock, { useConfig: useYfmHtmlBlockStyles, diff --git a/demo/src/components/SplitModePreview.tsx b/demo/src/components/SplitModePreview.tsx index d94feb4fb..0e8d2e847 100644 --- a/demo/src/components/SplitModePreview.tsx +++ b/demo/src/components/SplitModePreview.tsx @@ -11,10 +11,16 @@ import { withMermaid, } from '@gravity-ui/markdown-editor/view/hocs/withMermaid/index.js'; import {withYfmHtmlBlock} from '@gravity-ui/markdown-editor/view/hocs/withYfmHtml/index.js'; +import {withYfmPageConstructor} from '@gravity-ui/markdown-editor-page-constructor-extension/view'; import {useThemeValue} from '@gravity-ui/uikit'; import type MarkdownIt from 'markdown-it'; -import {LATEX_RUNTIME, MERMAID_RUNTIME, YFM_HTML_BLOCK_RUNTIME} from '../defaults/md-plugins'; +import { + LATEX_RUNTIME, + MERMAID_RUNTIME, + PAGE_CONSTRUCTOR_RUNTIME, + YFM_HTML_BLOCK_RUNTIME, +} from '../defaults/md-plugins'; import useYfmHtmlBlockStyles from '../hooks/useYfmHtmlBlockStyles'; const ML_ATTR = 'data-ml'; @@ -22,7 +28,9 @@ const mermaidConfig: MermaidConfig = {theme: 'forest'}; const Preview = withMermaid({runtime: MERMAID_RUNTIME})( withLatex({runtime: LATEX_RUNTIME})( - withYfmHtmlBlock({runtime: YFM_HTML_BLOCK_RUNTIME})(YfmStaticView), + withYfmPageConstructor({runtime: PAGE_CONSTRUCTOR_RUNTIME})( + withYfmHtmlBlock({runtime: YFM_HTML_BLOCK_RUNTIME})(YfmStaticView), + ), ), ); diff --git a/demo/src/defaults/md-plugins.ts b/demo/src/defaults/md-plugins.ts index 2a1a9c495..9375a09c0 100644 --- a/demo/src/defaults/md-plugins.ts +++ b/demo/src/defaults/md-plugins.ts @@ -5,6 +5,7 @@ import '@diplodoc/folding-headings-extension/runtime'; import {transform as yfmHtmlBlock} from '@diplodoc/html-extension'; import {transform as latex} from '@diplodoc/latex-extension'; import {transform as mermaid} from '@diplodoc/mermaid-extension'; +import {transform as yfmPageConstructor} from '@diplodoc/page-constructor-extension'; import {transform as yfmTabs} from '@diplodoc/tabs-extension'; import anchors from '@diplodoc/transform/lib/plugins/anchors'; import checkbox from '@diplodoc/transform/lib/plugins/checkbox'; @@ -30,6 +31,7 @@ import type {PluginWithParams} from 'markdown-it/lib'; export const LATEX_RUNTIME = 'extension:latex'; export const MERMAID_RUNTIME = 'extension:mermaid'; export const YFM_HTML_BLOCK_RUNTIME = 'extension:yfm-html-block'; +export const PAGE_CONSTRUCTOR_RUNTIME = 'extension:page-constructor'; type GetPluginsOptions = { directiveSyntax?: RenderPreviewParams['directiveSyntax']; @@ -88,6 +90,7 @@ export function getPlugins({ latex({bundle: false, validate: false, runtime: LATEX_RUNTIME}), mark, mermaid({bundle: false, runtime: MERMAID_RUNTIME}), + yfmPageConstructor({bundle: false, runtime: PAGE_CONSTRUCTOR_RUNTIME}), sub, yfmHtmlBlock({ bundle: false, diff --git a/demo/src/stories/yfm/YFM.stories.tsx b/demo/src/stories/yfm/YFM.stories.tsx index a1238f042..df7625a78 100644 --- a/demo/src/stories/yfm/YFM.stories.tsx +++ b/demo/src/stories/yfm/YFM.stories.tsx @@ -87,3 +87,14 @@ export const MermaidDiagram: Story = { }, }, }; + +export const YfmPageConstructor: Story = { + name: 'YFM Page Constructor', + args: { + initial: markup.yfmPageConstructor, + storyAdditionalControls: { + yfmPageConstructorAutoSaveEnabled: true, + yfmPageConstructorAutoSaveDelay: 500, + }, + }, +}; diff --git a/demo/src/stories/yfm/content.ts b/demo/src/stories/yfm/content.ts index 5b6ca136f..1ad1a2a7f 100644 --- a/demo/src/stories/yfm/content.ts +++ b/demo/src/stories/yfm/content.ts @@ -311,6 +311,19 @@ sequenceDiagram Alice->>Bob: Hi Bob Bob->>Alice: Hi Alice \`\`\` +`.trim(), + + yfmPageConstructor: ` +  + +## YFM Page Constructor (optional feature) + +::: page-constructor +blocks: + - type: 'header-block' + title: 'Title' + description: 'Description' +::: `.trim(), foldingHeadings: ` diff --git a/demo/tests/playwright/playwright.config.ts b/demo/tests/playwright/playwright.config.ts index 1d3cef646..234fdd95c 100644 --- a/demo/tests/playwright/playwright.config.ts +++ b/demo/tests/playwright/playwright.config.ts @@ -37,6 +37,7 @@ const ctViteConfig: ViteInlineConfig = { alias: { ...aliasesFromTsConf, '~@gravity-ui/uikit/styles/mixins': '@gravity-ui/uikit/styles/mixins', + '~@diplodoc/transform/dist/css/yfm.css': '@diplodoc/transform/dist/css/yfm.css', }, }, optimizeDeps: { diff --git a/demo/tests/visual-tests/YfmExtensions.visual.test.tsx b/demo/tests/visual-tests/YfmExtensions.visual.test.tsx index 49bdc29b9..b450eb767 100644 --- a/demo/tests/visual-tests/YfmExtensions.visual.test.tsx +++ b/demo/tests/visual-tests/YfmExtensions.visual.test.tsx @@ -49,6 +49,12 @@ test.describe('Extensions, YFM', () => { await mount(); await wait.loadersHidden(); + await expectScreenshot(); + }); + test('YFM Page Constructor', async ({mount, expectScreenshot, wait}) => { + await mount(); + await wait.loadersHidden(); + await expectScreenshot(); }); }); diff --git a/demo/tests/visual-tests/__snapshots__/YfmExtensions.visual.test.tsx-snapshots/Extensions-YFM-YFM-Page-Constructor-dark-chromium-linux.png b/demo/tests/visual-tests/__snapshots__/YfmExtensions.visual.test.tsx-snapshots/Extensions-YFM-YFM-Page-Constructor-dark-chromium-linux.png new file mode 100644 index 000000000..795e8668a Binary files /dev/null and b/demo/tests/visual-tests/__snapshots__/YfmExtensions.visual.test.tsx-snapshots/Extensions-YFM-YFM-Page-Constructor-dark-chromium-linux.png differ diff --git a/demo/tests/visual-tests/__snapshots__/YfmExtensions.visual.test.tsx-snapshots/Extensions-YFM-YFM-Page-Constructor-light-chromium-linux.png b/demo/tests/visual-tests/__snapshots__/YfmExtensions.visual.test.tsx-snapshots/Extensions-YFM-YFM-Page-Constructor-light-chromium-linux.png new file mode 100644 index 000000000..ae8d3903a Binary files /dev/null and b/demo/tests/visual-tests/__snapshots__/YfmExtensions.visual.test.tsx-snapshots/Extensions-YFM-YFM-Page-Constructor-light-chromium-linux.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index b5d3a24f8..17f222057 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -60,4 +60,21 @@ export default defineConfig( }, }, }, + { + files: ['./packages/page-constructor-extension/**/*'], + languageOptions: { + parserOptions: { + project: './packages/page-constructor-extension/tsconfig.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './packages/page-constructor-extension/tsconfig.json', + }, + }, + }, + }, ); diff --git a/packages/page-constructor-extension/.prettierignore b/packages/page-constructor-extension/.prettierignore new file mode 100644 index 000000000..378eac25d --- /dev/null +++ b/packages/page-constructor-extension/.prettierignore @@ -0,0 +1 @@ +build diff --git a/packages/page-constructor-extension/README.md b/packages/page-constructor-extension/README.md new file mode 100644 index 000000000..765bd017b --- /dev/null +++ b/packages/page-constructor-extension/README.md @@ -0,0 +1,49 @@ +# @gravity-ui/markdown-editor-page-constructor-extension + +Page Constructor extension for [@gravity-ui/markdown-editor](https://github.com/gravity-ui/markdown-editor). + +Provides a WYSIWYG editing experience for [Page Constructor](https://github.com/gravity-ui/page-constructor) blocks inside the Markdown editor, as well as a preview HOC for split-mode rendering. + +## Installation + +```bash +npm install @gravity-ui/markdown-editor-page-constructor-extension +``` + +### Required peer dependencies + +```bash +npm install @gravity-ui/markdown-editor @gravity-ui/uikit @gravity-ui/page-constructor @diplodoc/page-constructor-extension react react-dom +``` + +## Usage + +### WYSIWYG extension + +```typescript +import {YfmPageConstructorExtension} from '@gravity-ui/markdown-editor-page-constructor-extension'; + +builder.use(YfmPageConstructorExtension, { + autoSave: {enabled: true, delay: 1000}, +}); +``` + +### Toolbar button + +```typescript +import {wYfmPageConstructorItemData} from '@gravity-ui/markdown-editor-page-constructor-extension/configs'; +``` + +### Split-mode preview HOC + +```typescript +import {withYfmPageConstructor} from '@gravity-ui/markdown-editor-page-constructor-extension/view'; + +const PAGE_CONSTRUCTOR_RUNTIME = 'extension:page-constructor'; + +const Preview = withYfmPageConstructor({runtime: PAGE_CONSTRUCTOR_RUNTIME})(YfmStaticView); +``` + +## License + +MIT diff --git a/packages/page-constructor-extension/__mocks__/styleMock.cjs b/packages/page-constructor-extension/__mocks__/styleMock.cjs new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/packages/page-constructor-extension/__mocks__/styleMock.cjs @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/page-constructor-extension/gulpfile.mjs b/packages/page-constructor-extension/gulpfile.mjs new file mode 100644 index 000000000..5ae14b32e --- /dev/null +++ b/packages/page-constructor-extension/gulpfile.mjs @@ -0,0 +1,20 @@ +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {series, task} from '@markdown-editor/gulp-tasks'; +import {registerBuildTasks} from '@markdown-editor/gulp-tasks/build'; + +import pkg from './package.json' with {type: 'json'}; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const BUILD_DIR = resolve('build'); +const NODE_MODULES_DIR = resolve(__dirname, 'node_modules'); + +registerBuildTasks({ + version: pkg.version, + buildDir: BUILD_DIR, + nodeModulesDir: NODE_MODULES_DIR, +}); + +task('default', series('clean', 'build')); diff --git a/packages/page-constructor-extension/jest.config.cjs b/packages/page-constructor-extension/jest.config.cjs new file mode 100644 index 000000000..360d3f955 --- /dev/null +++ b/packages/page-constructor-extension/jest.config.cjs @@ -0,0 +1,20 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + moduleNameMapper: { + '\\.(css|scss)$': '/__mocks__/styleMock.cjs', + }, + transformIgnorePatterns: ['node_modules/(?!(@gravity-ui|@diplodoc)/)'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: { + verbatimModuleSyntax: false, + }, + }, + ], + }, +}; diff --git a/packages/page-constructor-extension/package.json b/packages/page-constructor-extension/package.json new file mode 100644 index 000000000..183c1e765 --- /dev/null +++ b/packages/page-constructor-extension/package.json @@ -0,0 +1,115 @@ +{ + "name": "@gravity-ui/markdown-editor-page-constructor-extension", + "version": "0.0.0", + "description": "Page Constructor extension for @gravity-ui/markdown-editor", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gravity-ui/markdown-editor" + }, + "keywords": [ + "md", + "yfm", + "wysiwyg", + "markdown", + "prosemirror", + "page-constructor" + ], + "scripts": { + "clean": "gulp clean", + "build": "gulp build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "run-p -cs lint:*", + "lint:js": "eslint './**/*.{js,jsx,mjs,ts,tsx}'", + "lint:styles": "stylelint './**/*.{css,scss}' --allow-empty-input", + "lint:prettier": "prettier --check './**/*.{js,jsx,mjs,ts,tsx,css,scss}'", + "test": "jest --config jest.config.cjs", + "prepublishOnly": "nx lint && nx clean && nx build" + }, + "exports": { + ".": { + "import": { + "types": "./build/esm/index.d.ts", + "default": "./build/esm/index.js" + }, + "require": { + "types": "./build/cjs/index.d.ts", + "default": "./build/cjs/index.js" + } + }, + "./specs": { + "import": { + "types": "./build/esm/specs.d.ts", + "default": "./build/esm/specs.js" + }, + "require": { + "types": "./build/cjs/specs.d.ts", + "default": "./build/cjs/specs.js" + } + }, + "./configs": { + "import": { + "types": "./build/esm/configs.d.ts", + "default": "./build/esm/configs.js" + }, + "require": { + "types": "./build/cjs/configs.d.ts", + "default": "./build/cjs/configs.js" + } + }, + "./view": { + "import": { + "types": "./build/esm/view.d.ts", + "default": "./build/esm/view.js" + }, + "require": { + "types": "./build/cjs/view.d.ts", + "default": "./build/cjs/view.js" + } + } + }, + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/esm/index.d.ts", + "files": [ + "build", + "README.md" + ], + "dependencies": { + "@gravity-ui/icons": "^2.12.0", + "react-error-boundary": "^3.1.4", + "tslib": "catalog:ts" + }, + "devDependencies": { + "@diplodoc/page-constructor-extension": "catalog:peer-diplodoc", + "@gravity-ui/markdown-editor": "workspace:*", + "@gravity-ui/page-constructor": "catalog:peer-gravity", + "@gravity-ui/uikit": "catalog:peer-gravity", + "@markdown-editor/gulp-tasks": "workspace:*", + "@markdown-editor/linters": "workspace:*", + "@markdown-editor/tsconfig": "workspace:*", + "@types/jest": "^29.5.0", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", + "gulp-cli": "catalog:", + "jest": "^29.7.0", + "npm-run-all": "^4.1.5", + "react": "catalog:react", + "react-dom": "catalog:react", + "ts-dedent": "^2.2.0", + "ts-jest": "^29.2.0", + "typescript": "catalog:ts" + }, + "peerDependencies": { + "@diplodoc/page-constructor-extension": "^0.13.3", + "@gravity-ui/markdown-editor": "workspace:^15.38.1", + "@gravity-ui/page-constructor": "^7.0.0", + "@gravity-ui/uikit": "^7.1.0", + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "sideEffects": [ + "*.css", + "*.scss" + ] +} diff --git a/packages/page-constructor-extension/src/YfmPageConstructor.test.ts b/packages/page-constructor-extension/src/YfmPageConstructor.test.ts new file mode 100644 index 000000000..669371669 --- /dev/null +++ b/packages/page-constructor-extension/src/YfmPageConstructor.test.ts @@ -0,0 +1,128 @@ +import type {Parser, Serializer} from '@gravity-ui/markdown-editor'; +import { + BaseNode, + BaseSchemaSpecs, + BlockquoteSpecs, + ExtensionsManager, + blockquoteNodeName, +} from '@gravity-ui/markdown-editor'; +import type {Node} from '@gravity-ui/markdown-editor/pm/model'; +import {builders} from '@gravity-ui/markdown-editor/pm/test-builder'; +import dd from 'ts-dedent'; + +import {YfmPageConstructorSpecsExtension} from './extension/YfmPageConstructorSpecs'; +import {YfmPageConstructorAttrs, yfmPageConstructorNodeName} from './extension/const'; + +jest.mock('@gravity-ui/markdown-editor', () => { + const actual = jest.requireActual('@gravity-ui/markdown-editor'); + return { + ...actual, + generateEntityId: (name = 'entity') => `${name}-eff-000-0ab`, + }; +}); + +function createMarkupChecker({parser, serializer}: {parser: Parser; serializer: Serializer}) { + function parse(text: string, doc: Node) { + expect(parser.parse(text).toJSON()).toEqual(doc.toJSON()); + } + + function serialize(doc: Node, text: string) { + expect(serializer.serialize(doc)).toBe(text); + } + + function same(text: string, doc: Node) { + parse(text, doc); + serialize(doc, text); + } + + return {same, parse, serialize}; +} + +const { + schema, + markupParser: parser, + serializer, +} = new ExtensionsManager({ + extensions: (builder) => + builder.use(BaseSchemaSpecs, {}).use(BlockquoteSpecs).use(YfmPageConstructorSpecsExtension), +}).buildDeps(); + +const {doc, yfmPageConstructor, quote} = builders<'doc' | 'yfmPageConstructor' | 'quote'>(schema, { + doc: {nodeType: BaseNode.Doc}, + yfmPageConstructor: {nodeType: yfmPageConstructorNodeName}, + quote: {nodeType: blockquoteNodeName}, +}); + +const {same} = createMarkupChecker({parser, serializer}); + +describe('YfmPageConstructor extension', () => { + it('should parse yfm page-constructor block', () => + same( + '::: page-constructor\nblocks:\n - type: "header-block"\n title: "Title"\n:::', + doc( + yfmPageConstructor({ + [YfmPageConstructorAttrs.content]: + 'blocks:\n - type: "header-block"\n title: "Title"\n', + [YfmPageConstructorAttrs.EntityId]: `${yfmPageConstructorNodeName}-eff-000-0ab`, + }), + ), + )); + + it('should parse yfm page-constructor block with multiline yaml', () => { + const yamlContent = dd` + blocks: + - type: "header-block" + title: "Hello World" + description: "Some description" + + `; + + const markup = dd` + ::: page-constructor + blocks: + - type: "header-block" + title: "Hello World" + description: "Some description" + ::: + `; + + same( + markup, + doc( + yfmPageConstructor({ + [YfmPageConstructorAttrs.content]: yamlContent, + [YfmPageConstructorAttrs.EntityId]: `${yfmPageConstructorNodeName}-eff-000-0ab`, + }), + ), + ); + }); + + it('should parse yfm page-constructor inside blockquote', () => { + const content = dd` + blocks: + - type: "header-block" + title: "Title" + + `; + + const markup = dd` + > ::: page-constructor + > blocks: + > - type: "header-block" + > title: "Title" + > ::: + `; + + same( + markup, + doc( + quote( + yfmPageConstructor({ + [YfmPageConstructorAttrs.content]: content, + [YfmPageConstructorAttrs.EntityId]: `${yfmPageConstructorNodeName}-eff-000-0ab`, + }), + ), + ), + ); + }); +}); diff --git a/packages/page-constructor-extension/src/configs.ts b/packages/page-constructor-extension/src/configs.ts new file mode 100644 index 000000000..062a5471e --- /dev/null +++ b/packages/page-constructor-extension/src/configs.ts @@ -0,0 +1 @@ +export {wYfmPageConstructorItemData} from './extension/toolbar'; diff --git a/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/NodeView.tsx b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/NodeView.tsx new file mode 100644 index 000000000..af62bbf27 --- /dev/null +++ b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/NodeView.tsx @@ -0,0 +1,141 @@ +import { + generateEntityId, + getReactRendererFromState, + isInvalidEntityId, + removeNode, +} from '@gravity-ui/markdown-editor'; +import type {Node} from '@gravity-ui/markdown-editor/pm/model'; +import type {EditorView, NodeView} from '@gravity-ui/markdown-editor/pm/view'; +import {Portal} from '@gravity-ui/uikit'; + +import type {YfmPageConstructorExtensionOptions} from '..'; +import { + YfmPageConstructorConsts, + defaultYfmPageConstructorEntityId, +} from '../YfmPageConstructorSpecs/const'; + +import {STOP_EVENT_CLASSNAME, YfmPageConstructorView} from './YfmPageConstructorView'; + +export class WYfmPageConstructorNodeView implements NodeView { + readonly dom: HTMLElement; + private node: Node; + private readonly view: EditorView; + private readonly getPos: () => number | undefined; + private readonly renderItem; + private readonly options: YfmPageConstructorExtensionOptions; + + constructor( + node: Node, + view: EditorView, + getPos: () => number | undefined, + options: YfmPageConstructorExtensionOptions, + ) { + this.node = node; + this.view = view; + this.getPos = getPos; + this.options = options; + + this.dom = document.createElement('div'); + this.dom.classList.add('yfm-page-constructor-container'); + this.dom.contentEditable = 'false'; + + this.renderItem = getReactRendererFromState(view.state).createItem( + 'yfm-page-constructor-view', + this.renderPageConstructor.bind(this), + ); + + this.validateEntityId(); + } + + update(node: Node) { + if (node.type !== this.node.type) { + return false; + } + + this.node = node; + this.renderItem.rerender(); + + return true; + } + + destroy() { + this.renderItem.remove(); + } + + ignoreMutation() { + return true; + } + + stopEvent(e: Event) { + const {target} = e; + + if (target instanceof Element) { + return target.classList.contains(STOP_EVENT_CLASSNAME); + } + + return false; + } + + private validateEntityId() { + if ( + isInvalidEntityId({ + node: this.node, + doc: this.view.state.doc, + defaultId: defaultYfmPageConstructorEntityId, + }) + ) { + const newId = generateEntityId(YfmPageConstructorConsts.NodeName); + + this.view.dispatch( + this.view.state.tr.setNodeAttribute( + this.getPos()!, + YfmPageConstructorConsts.NodeAttrs.EntityId, + newId, + ), + ); + } + } + + private onChange = (content: string) => { + const pos = this.getPos(); + + if (pos === undefined) { + return; + } + + this.view.dispatch( + this.view.state.tr.setNodeAttribute( + pos, + YfmPageConstructorConsts.NodeAttrs.content, + content, + ), + ); + }; + + private onRemove = () => { + const pos = this.getPos(); + + if (pos === undefined) { + return; + } + + removeNode({node: this.node, pos, tr: this.view.state.tr, dispatch: this.view.dispatch}); + this.view.focus(); + }; + + private renderPageConstructor() { + return ( + + + + ); + } +} diff --git a/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructor.scss b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructor.scss new file mode 100644 index 000000000..ff0c5e9c5 --- /dev/null +++ b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructor.scss @@ -0,0 +1,82 @@ +.g-md-yfm-page-constructor { + position: relative; + + display: flex; + justify-content: space-between; + gap: 12px; + + min-height: 30px; + padding: 4px; + + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-m); + + &_edit { + display: flex; + } + + &__constructor { + flex: 1 1 auto; + + min-width: 0; + height: fit-content; + + .pc-layout { + min-height: auto; + } + } + + &__error { + flex: 1 1 auto; + + font-family: var(--g-font-family-monospace); + white-space: pre-wrap; + word-break: break-word; + + color: var(--g-color-text-danger); + } + + &__preview { + flex: 1 1 auto; + + min-width: 0; + height: fit-content; + + .pc-layout { + min-height: auto; + } + } + + &__editor { + flex: 1 1 auto; + + min-width: 320px; + + white-space: nowrap; + caret-color: auto; + } + + &__controls { + display: flex; + justify-content: flex-end; + gap: 8px; + + margin-top: 8px; + } + + &__menu { + position: absolute; + z-index: 10; + top: 0; + right: 0; + + padding: 2px; + + border-radius: var(--g-border-radius-s); + background: var(--g-color-base-background); + } +} + +.yfm-page-constructor-container { + padding-bottom: 20px; +} diff --git a/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructorPreview.tsx b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructorPreview.tsx new file mode 100644 index 000000000..26a8bebf0 --- /dev/null +++ b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructorPreview.tsx @@ -0,0 +1,75 @@ +import {useMemo} from 'react'; + +import {loadPageContent} from '@diplodoc/page-constructor-extension/plugin/csr'; +import {cn} from '@gravity-ui/markdown-editor'; +import { + PageConstructor as PageConstructorContent, + PageConstructorProvider, + type PageContent, + type Theme, +} from '@gravity-ui/page-constructor'; +import type {ContentTransformerProps} from '@gravity-ui/page-constructor/server'; +import {contentTransformer} from '@gravity-ui/page-constructor/server'; +import {Flex, Text, useThemeType} from '@gravity-ui/uikit'; +import {ErrorBoundary} from 'react-error-boundary'; + +const b = cn('yfm-page-constructor'); + +export type TransformerOptions = false | ContentTransformerProps['options']; + +export const YfmPageConstructorPreview: React.FC<{ + text: string; + transformerOptions: TransformerOptions; +}> = ({text = '', transformerOptions}) => { + const theme = useThemeType(); + const loadResult = useMemo(() => loadPageContent(text), [text]); + + const pageContent = useMemo(() => { + if (!loadResult.success) { + return undefined; + } + if (transformerOptions === false) { + return loadResult.content as PageContent; + } + return { + ...loadResult.content, + ...contentTransformer({ + content: loadResult.content as PageContent, + options: transformerOptions, + }), + }; + }, [loadResult, transformerOptions]); + + if (!loadResult.success) { + return ( +
+
{String(loadResult.error)}
+
+ ); + } + + return ( +
+ ( + + + {String(error?.message ?? error)} + + + )} + > + + + + +
+ ); +}; diff --git a/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructorView.tsx b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructorView.tsx new file mode 100644 index 000000000..1aa99460b --- /dev/null +++ b/packages/page-constructor-extension/src/extension/YfmPageConstructorNodeView/YfmPageConstructorView.tsx @@ -0,0 +1,198 @@ +import {Suspense, lazy, useCallback, useEffect, useMemo, useState} from 'react'; + +import {Ellipsis as DotsIcon} from '@gravity-ui/icons'; +import { + SharedStateKey, + cn, + useAutoSave, + useBooleanState, + useElementState, + useSharedEditingState, +} from '@gravity-ui/markdown-editor'; +import {TextAreaFixed as TextArea} from '@gravity-ui/markdown-editor/_/forms/TextInput.js'; +import type {Node} from '@gravity-ui/markdown-editor/pm/model'; +import type {EditorView} from '@gravity-ui/markdown-editor/pm/view'; +import {Button, Flex, Icon, Loader, Menu, Popup, type PopupPlacement} from '@gravity-ui/uikit'; + +import {i18n} from '../../i18n'; +import {YfmPageConstructorConsts} from '../YfmPageConstructorSpecs/const'; +import type {YfmPageConstructorExtensionOptions} from '../index'; +import type {YfmPageConstructorEntitySharedState} from '../types'; + +import type {TransformerOptions} from './YfmPageConstructorPreview'; + +import './YfmPageConstructor.scss'; + +export {type TransformerOptions}; +export const STOP_EVENT_CLASSNAME = 'prosemirror-stop-event'; + +const b = cn('yfm-page-constructor'); + +const popupPlacement: PopupPlacement = ['bottom-end']; + +const YfmPageConstructorPreviewLazy: React.LazyExoticComponent< + React.FC<{text: string; transformerOptions: TransformerOptions}> +> = lazy(() => + // @ts-ignore error TS2835: Relative import paths need explicit file extensions in ECMAScript (cjs build) + import('./YfmPageConstructorPreview').then((m) => ({default: m.YfmPageConstructorPreview})), +); + +const YfmPageConstructorPreview: React.FC<{ + text: string; + transformerOptions: TransformerOptions; + className?: string; +}> = (props) => ( + }> + + +); + +const PageConstructorEditMode: React.FC<{ + initialText: string; + onSave: (v: string) => void; + onCancel: () => void; + autoSave: YfmPageConstructorExtensionOptions['autoSave']; + transformerOptions: TransformerOptions; +}> = ({initialText, onSave, onCancel, autoSave, transformerOptions}) => { + const {value, handleChange, handleManualSave, isSaveDisabled, isAutoSaveEnabled} = useAutoSave({ + initialValue: initialText || '', + onSave, + onClose: onCancel, + autoSave, + }); + + return ( +
+ +
+