@cloud-ru/ds-markdown 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +201 -0
  3. package/README.md +273 -0
  4. package/dist/cjs/components/Markdown/CodeBlock.d.ts +9 -0
  5. package/dist/cjs/components/Markdown/CodeBlock.js +58 -0
  6. package/dist/cjs/components/Markdown/Markdown.d.ts +2 -0
  7. package/dist/cjs/components/Markdown/Markdown.js +25 -0
  8. package/dist/cjs/components/Markdown/index.d.ts +1 -0
  9. package/dist/cjs/components/Markdown/index.js +17 -0
  10. package/dist/cjs/components/Markdown/styles.module.css +205 -0
  11. package/dist/cjs/components/MarkdownEditor/MarkdownEditor.d.ts +2 -0
  12. package/dist/cjs/components/MarkdownEditor/MarkdownEditor.js +176 -0
  13. package/dist/cjs/components/MarkdownEditor/index.d.ts +1 -0
  14. package/dist/cjs/components/MarkdownEditor/index.js +17 -0
  15. package/dist/cjs/components/MarkdownEditor/styles.module.css +273 -0
  16. package/dist/cjs/components/Toolbar/Toolbar.d.ts +9 -0
  17. package/dist/cjs/components/Toolbar/Toolbar.js +173 -0
  18. package/dist/cjs/components/Toolbar/constants.d.ts +13 -0
  19. package/dist/cjs/components/Toolbar/constants.js +40 -0
  20. package/dist/cjs/components/Toolbar/hooks/index.d.ts +2 -0
  21. package/dist/cjs/components/Toolbar/hooks/index.js +18 -0
  22. package/dist/cjs/components/Toolbar/hooks/useToolbarHotkeys.d.ts +21 -0
  23. package/dist/cjs/components/Toolbar/hooks/useToolbarHotkeys.js +59 -0
  24. package/dist/cjs/components/Toolbar/hooks/useToolbarOverflow.d.ts +14 -0
  25. package/dist/cjs/components/Toolbar/hooks/useToolbarOverflow.js +87 -0
  26. package/dist/cjs/components/Toolbar/index.d.ts +2 -0
  27. package/dist/cjs/components/Toolbar/index.js +20 -0
  28. package/dist/cjs/components/Toolbar/styles.module.css +17 -0
  29. package/dist/cjs/components/index.d.ts +2 -0
  30. package/dist/cjs/components/index.js +20 -0
  31. package/dist/cjs/constants.d.ts +65 -0
  32. package/dist/cjs/constants.js +67 -0
  33. package/dist/cjs/helperComponents/ButtonHeading/ButtonHeading.d.ts +6 -0
  34. package/dist/cjs/helperComponents/ButtonHeading/ButtonHeading.js +61 -0
  35. package/dist/cjs/helperComponents/ButtonHeading/index.d.ts +1 -0
  36. package/dist/cjs/helperComponents/ButtonHeading/index.js +17 -0
  37. package/dist/cjs/helperComponents/ButtonHeading/styles.module.css +3 -0
  38. package/dist/cjs/helperComponents/Buttons/Buttons.d.ts +10 -0
  39. package/dist/cjs/helperComponents/Buttons/Buttons.js +21 -0
  40. package/dist/cjs/helperComponents/Buttons/index.d.ts +1 -0
  41. package/dist/cjs/helperComponents/Buttons/index.js +17 -0
  42. package/dist/cjs/helperComponents/Buttons/styles.module.css +17 -0
  43. package/dist/cjs/helperComponents/Modals/CustomizeTableModal.d.ts +8 -0
  44. package/dist/cjs/helperComponents/Modals/CustomizeTableModal.js +39 -0
  45. package/dist/cjs/helperComponents/Modals/ImageModal.d.ts +7 -0
  46. package/dist/cjs/helperComponents/Modals/ImageModal.js +44 -0
  47. package/dist/cjs/helperComponents/Modals/LinkModal.d.ts +13 -0
  48. package/dist/cjs/helperComponents/Modals/LinkModal.js +42 -0
  49. package/dist/cjs/helperComponents/Modals/index.d.ts +3 -0
  50. package/dist/cjs/helperComponents/Modals/index.js +19 -0
  51. package/dist/cjs/helperComponents/Modals/styles.module.css +17 -0
  52. package/dist/cjs/helperComponents/PrivateButton/PrivateButton.d.ts +17 -0
  53. package/dist/cjs/helperComponents/PrivateButton/PrivateButton.js +27 -0
  54. package/dist/cjs/helperComponents/PrivateButton/index.d.ts +1 -0
  55. package/dist/cjs/helperComponents/PrivateButton/index.js +17 -0
  56. package/dist/cjs/helperComponents/PrivateButton/styles.module.css +140 -0
  57. package/dist/cjs/helperComponents/TableSelectItem/TableSelectItem.d.ts +11 -0
  58. package/dist/cjs/helperComponents/TableSelectItem/TableSelectItem.js +12 -0
  59. package/dist/cjs/helperComponents/TableSelectItem/index.d.ts +1 -0
  60. package/dist/cjs/helperComponents/TableSelectItem/index.js +17 -0
  61. package/dist/cjs/helperComponents/TableSelectItem/styles.module.css +101 -0
  62. package/dist/cjs/helperComponents/TableSelectSettingsGrid/TableSelectSettingsGrid.d.ts +4 -0
  63. package/dist/cjs/helperComponents/TableSelectSettingsGrid/TableSelectSettingsGrid.js +27 -0
  64. package/dist/cjs/helperComponents/TableSelectSettingsGrid/index.d.ts +1 -0
  65. package/dist/cjs/helperComponents/TableSelectSettingsGrid/index.js +17 -0
  66. package/dist/cjs/helperComponents/TableSelectSettingsGrid/styles.module.css +54 -0
  67. package/dist/cjs/helperComponents/index.d.ts +6 -0
  68. package/dist/cjs/helperComponents/index.js +22 -0
  69. package/dist/cjs/index.d.ts +4 -0
  70. package/dist/cjs/index.js +20 -0
  71. package/dist/cjs/locale/index.d.ts +199 -0
  72. package/dist/cjs/locale/index.js +104 -0
  73. package/dist/cjs/lowlight.d.ts +14 -0
  74. package/dist/cjs/lowlight.js +35 -0
  75. package/dist/cjs/mixins.css +0 -0
  76. package/dist/cjs/package.json +3 -0
  77. package/dist/cjs/rawMarkdownCommands.d.ts +16 -0
  78. package/dist/cjs/rawMarkdownCommands.js +145 -0
  79. package/dist/cjs/style.css +875 -0
  80. package/dist/cjs/styles/syntax.module.css +47 -0
  81. package/dist/cjs/toolbarApi.d.ts +33 -0
  82. package/dist/cjs/toolbarApi.js +100 -0
  83. package/dist/cjs/types.d.ts +52 -0
  84. package/dist/cjs/types.js +2 -0
  85. package/dist/esm/components/Markdown/CodeBlock.d.ts +9 -0
  86. package/dist/esm/components/Markdown/CodeBlock.js +52 -0
  87. package/dist/esm/components/Markdown/Markdown.d.ts +2 -0
  88. package/dist/esm/components/Markdown/Markdown.js +19 -0
  89. package/dist/esm/components/Markdown/index.d.ts +1 -0
  90. package/dist/esm/components/Markdown/index.js +1 -0
  91. package/dist/esm/components/Markdown/styles.module.css +205 -0
  92. package/dist/esm/components/MarkdownEditor/MarkdownEditor.d.ts +2 -0
  93. package/dist/esm/components/MarkdownEditor/MarkdownEditor.js +170 -0
  94. package/dist/esm/components/MarkdownEditor/index.d.ts +1 -0
  95. package/dist/esm/components/MarkdownEditor/index.js +1 -0
  96. package/dist/esm/components/MarkdownEditor/styles.module.css +273 -0
  97. package/dist/esm/components/Toolbar/Toolbar.d.ts +9 -0
  98. package/dist/esm/components/Toolbar/Toolbar.js +167 -0
  99. package/dist/esm/components/Toolbar/constants.d.ts +13 -0
  100. package/dist/esm/components/Toolbar/constants.js +37 -0
  101. package/dist/esm/components/Toolbar/hooks/index.d.ts +2 -0
  102. package/dist/esm/components/Toolbar/hooks/index.js +2 -0
  103. package/dist/esm/components/Toolbar/hooks/useToolbarHotkeys.d.ts +21 -0
  104. package/dist/esm/components/Toolbar/hooks/useToolbarHotkeys.js +56 -0
  105. package/dist/esm/components/Toolbar/hooks/useToolbarOverflow.d.ts +14 -0
  106. package/dist/esm/components/Toolbar/hooks/useToolbarOverflow.js +84 -0
  107. package/dist/esm/components/Toolbar/index.d.ts +2 -0
  108. package/dist/esm/components/Toolbar/index.js +2 -0
  109. package/dist/esm/components/Toolbar/styles.module.css +17 -0
  110. package/dist/esm/components/index.d.ts +2 -0
  111. package/dist/esm/components/index.js +4 -0
  112. package/dist/esm/constants.d.ts +65 -0
  113. package/dist/esm/constants.js +61 -0
  114. package/dist/esm/helperComponents/ButtonHeading/ButtonHeading.d.ts +6 -0
  115. package/dist/esm/helperComponents/ButtonHeading/ButtonHeading.js +55 -0
  116. package/dist/esm/helperComponents/ButtonHeading/index.d.ts +1 -0
  117. package/dist/esm/helperComponents/ButtonHeading/index.js +1 -0
  118. package/dist/esm/helperComponents/ButtonHeading/styles.module.css +3 -0
  119. package/dist/esm/helperComponents/Buttons/Buttons.d.ts +10 -0
  120. package/dist/esm/helperComponents/Buttons/Buttons.js +13 -0
  121. package/dist/esm/helperComponents/Buttons/index.d.ts +1 -0
  122. package/dist/esm/helperComponents/Buttons/index.js +1 -0
  123. package/dist/esm/helperComponents/Buttons/styles.module.css +17 -0
  124. package/dist/esm/helperComponents/Modals/CustomizeTableModal.d.ts +8 -0
  125. package/dist/esm/helperComponents/Modals/CustomizeTableModal.js +33 -0
  126. package/dist/esm/helperComponents/Modals/ImageModal.d.ts +7 -0
  127. package/dist/esm/helperComponents/Modals/ImageModal.js +38 -0
  128. package/dist/esm/helperComponents/Modals/LinkModal.d.ts +13 -0
  129. package/dist/esm/helperComponents/Modals/LinkModal.js +36 -0
  130. package/dist/esm/helperComponents/Modals/index.d.ts +3 -0
  131. package/dist/esm/helperComponents/Modals/index.js +3 -0
  132. package/dist/esm/helperComponents/Modals/styles.module.css +17 -0
  133. package/dist/esm/helperComponents/PrivateButton/PrivateButton.d.ts +17 -0
  134. package/dist/esm/helperComponents/PrivateButton/PrivateButton.js +21 -0
  135. package/dist/esm/helperComponents/PrivateButton/index.d.ts +1 -0
  136. package/dist/esm/helperComponents/PrivateButton/index.js +1 -0
  137. package/dist/esm/helperComponents/PrivateButton/styles.module.css +140 -0
  138. package/dist/esm/helperComponents/TableSelectItem/TableSelectItem.d.ts +11 -0
  139. package/dist/esm/helperComponents/TableSelectItem/TableSelectItem.js +6 -0
  140. package/dist/esm/helperComponents/TableSelectItem/index.d.ts +1 -0
  141. package/dist/esm/helperComponents/TableSelectItem/index.js +1 -0
  142. package/dist/esm/helperComponents/TableSelectItem/styles.module.css +101 -0
  143. package/dist/esm/helperComponents/TableSelectSettingsGrid/TableSelectSettingsGrid.d.ts +4 -0
  144. package/dist/esm/helperComponents/TableSelectSettingsGrid/TableSelectSettingsGrid.js +21 -0
  145. package/dist/esm/helperComponents/TableSelectSettingsGrid/index.d.ts +1 -0
  146. package/dist/esm/helperComponents/TableSelectSettingsGrid/index.js +1 -0
  147. package/dist/esm/helperComponents/TableSelectSettingsGrid/styles.module.css +54 -0
  148. package/dist/esm/helperComponents/index.d.ts +6 -0
  149. package/dist/esm/helperComponents/index.js +6 -0
  150. package/dist/esm/index.d.ts +4 -0
  151. package/dist/esm/index.js +4 -0
  152. package/dist/esm/locale/index.d.ts +199 -0
  153. package/dist/esm/locale/index.js +101 -0
  154. package/dist/esm/lowlight.d.ts +14 -0
  155. package/dist/esm/lowlight.js +29 -0
  156. package/dist/esm/mixins.css +0 -0
  157. package/dist/esm/rawMarkdownCommands.d.ts +16 -0
  158. package/dist/esm/rawMarkdownCommands.js +135 -0
  159. package/dist/esm/style.css +875 -0
  160. package/dist/esm/styles/syntax.module.css +47 -0
  161. package/dist/esm/toolbarApi.d.ts +33 -0
  162. package/dist/esm/toolbarApi.js +96 -0
  163. package/dist/esm/types.d.ts +52 -0
  164. package/dist/esm/types.js +1 -0
  165. package/dist/tsconfig.cjs.tsbuildinfo +1 -0
  166. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  167. package/package.json +84 -0
  168. package/src/components/Markdown/CodeBlock.tsx +96 -0
  169. package/src/components/Markdown/Markdown.tsx +47 -0
  170. package/src/components/Markdown/index.ts +1 -0
  171. package/src/components/Markdown/styles.module.scss +105 -0
  172. package/src/components/MarkdownEditor/MarkdownEditor.tsx +322 -0
  173. package/src/components/MarkdownEditor/index.ts +1 -0
  174. package/src/components/MarkdownEditor/styles.module.scss +201 -0
  175. package/src/components/Toolbar/Toolbar.tsx +292 -0
  176. package/src/components/Toolbar/constants.tsx +63 -0
  177. package/src/components/Toolbar/hooks/index.ts +2 -0
  178. package/src/components/Toolbar/hooks/useToolbarHotkeys.ts +74 -0
  179. package/src/components/Toolbar/hooks/useToolbarOverflow.ts +102 -0
  180. package/src/components/Toolbar/index.ts +2 -0
  181. package/src/components/Toolbar/styles.module.scss +29 -0
  182. package/src/components/index.ts +4 -0
  183. package/src/constants.ts +67 -0
  184. package/src/helperComponents/ButtonHeading/ButtonHeading.tsx +100 -0
  185. package/src/helperComponents/ButtonHeading/index.ts +1 -0
  186. package/src/helperComponents/ButtonHeading/styles.module.scss +5 -0
  187. package/src/helperComponents/Buttons/Buttons.tsx +30 -0
  188. package/src/helperComponents/Buttons/index.ts +1 -0
  189. package/src/helperComponents/Buttons/styles.module.scss +26 -0
  190. package/src/helperComponents/Modals/CustomizeTableModal.tsx +82 -0
  191. package/src/helperComponents/Modals/ImageModal.tsx +84 -0
  192. package/src/helperComponents/Modals/LinkModal.tsx +92 -0
  193. package/src/helperComponents/Modals/index.ts +3 -0
  194. package/src/helperComponents/Modals/styles.module.scss +21 -0
  195. package/src/helperComponents/PrivateButton/PrivateButton.tsx +88 -0
  196. package/src/helperComponents/PrivateButton/index.ts +1 -0
  197. package/src/helperComponents/PrivateButton/styles.module.scss +77 -0
  198. package/src/helperComponents/TableSelectItem/TableSelectItem.tsx +43 -0
  199. package/src/helperComponents/TableSelectItem/index.ts +1 -0
  200. package/src/helperComponents/TableSelectItem/styles.module.scss +26 -0
  201. package/src/helperComponents/TableSelectSettingsGrid/TableSelectSettingsGrid.tsx +48 -0
  202. package/src/helperComponents/TableSelectSettingsGrid/index.ts +1 -0
  203. package/src/helperComponents/TableSelectSettingsGrid/styles.module.scss +65 -0
  204. package/src/helperComponents/index.ts +6 -0
  205. package/src/index.ts +4 -0
  206. package/src/locale/index.ts +105 -0
  207. package/src/lowlight.ts +30 -0
  208. package/src/mixins.scss +123 -0
  209. package/src/rawMarkdownCommands.ts +179 -0
  210. package/src/styles/syntax.module.scss +54 -0
  211. package/src/toolbarApi.ts +152 -0
  212. package/src/types.ts +56 -0
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@cloud-ru/ds-markdown",
3
+ "version": "1.0.0",
4
+ "description": "Просмотр и редактирование markdown — рендер GFM и WYSIWYG-редактор на TipTap.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/cloud-ru-tech/snack-v2.git",
8
+ "directory": "packages/markdown"
9
+ },
10
+ "homepage": "https://github.com/cloud-ru-tech/snack-v2/tree/master/packages/markdown",
11
+ "license": "Apache-2.0",
12
+ "author": "Nikita Ershov <niershov@cloud.ru>",
13
+ "type": "module",
14
+ "main": "./dist/cjs/index.js",
15
+ "module": "./dist/esm/index.js",
16
+ "types": "./dist/esm/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "source": "./src/index.ts",
20
+ "types": "./dist/esm/index.d.ts",
21
+ "import": "./dist/esm/index.js",
22
+ "require": "./dist/cjs/index.js"
23
+ },
24
+ "./locale": {
25
+ "source": "./src/locale/index.ts",
26
+ "types": "./dist/esm/locale/index.d.ts",
27
+ "import": "./dist/esm/locale/index.js",
28
+ "require": "./dist/cjs/locale/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src",
34
+ "./CHANGELOG.md",
35
+ "./LICENSE"
36
+ ],
37
+ "sideEffects": [
38
+ "*.css",
39
+ "**/*.css"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "@tiptap/core": "^3.29.1",
46
+ "@tiptap/extension-code-block-lowlight": "^3.29.1",
47
+ "@tiptap/extension-image": "^3.29.1",
48
+ "@tiptap/extension-placeholder": "^3.29.1",
49
+ "@tiptap/extension-table": "^3.29.1",
50
+ "@tiptap/extension-table-cell": "^3.29.1",
51
+ "@tiptap/extension-table-header": "^3.29.1",
52
+ "@tiptap/extension-table-row": "^3.29.1",
53
+ "@tiptap/markdown": "^3.29.1",
54
+ "@tiptap/pm": "^3.29.1",
55
+ "@tiptap/react": "^3.29.1",
56
+ "@tiptap/starter-kit": "^3.29.1",
57
+ "classnames": "^2.5.1",
58
+ "highlight.js": "^11.11.1",
59
+ "lowlight": "^3.3.0",
60
+ "react-markdown": "^10.1.0",
61
+ "react-textarea-autosize": "^8.5.9",
62
+ "rehype-highlight": "^7.0.2",
63
+ "remark-gfm": "^4.0.1",
64
+ "unified": "^11.0.5",
65
+ "@cloud-ru/ds-button": "^1.0.0",
66
+ "@cloud-ru/ds-divider": "^1.0.0",
67
+ "@cloud-ru/ds-icons": "^1.0.0",
68
+ "@cloud-ru/ds-fields": "^1.0.0",
69
+ "@cloud-ru/ds-locale": "^1.0.0",
70
+ "@cloud-ru/ds-materials": "^1.0.0",
71
+ "@cloud-ru/ds-list": "^1.0.0",
72
+ "@cloud-ru/ds-toggles": "^1.0.0",
73
+ "@cloud-ru/ds-tooltip": "^1.0.0",
74
+ "@cloud-ru/ds-modal": "^1.0.0",
75
+ "@cloud-ru/ds-portal-context": "^1.0.0",
76
+ "@cloud-ru/ds-scroll": "^1.0.0",
77
+ "@cloud-ru/ds-typography": "^1.0.0",
78
+ "@cloud-ru/ds-utils": "^1.0.0"
79
+ },
80
+ "devDependencies": {
81
+ "sass": "1.102.0",
82
+ "typescript": "5.9.3"
83
+ }
84
+ }
@@ -0,0 +1,96 @@
1
+ import { Button } from '@cloud-ru/ds-button';
2
+ import { CheckSVG, CopySVG } from '@cloud-ru/ds-icons/interface/system';
3
+ import { Scroll } from '@cloud-ru/ds-scroll';
4
+ import { copyToClipboard } from '@cloud-ru/ds-utils';
5
+ import cn from 'classnames';
6
+ import { Children, isValidElement, ReactNode, useRef, useState } from 'react';
7
+
8
+ import { TEST_IDS } from '../../constants';
9
+ import { markdownLocale } from '../../locale';
10
+ import styles from './styles.module.scss';
11
+
12
+ const LANGUAGE_PATTERN = /language-([\w-]+)/;
13
+ const COPIED_RESET_MS = 1600;
14
+
15
+ /** Рекурсивно собирает текстовое содержимое узла — для копирования и подсчёта строк. */
16
+ function extractText(node: ReactNode): string {
17
+ if (node === null || node === undefined || typeof node === 'boolean') return '';
18
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
19
+ if (Array.isArray(node)) return node.map(extractText).join('');
20
+ if (isValidElement<{ children?: ReactNode }>(node)) return extractText(node.props.children);
21
+ return '';
22
+ }
23
+
24
+ type CodeBlockProps = {
25
+ /** `<code>`-элемент с подсветкой, который react-markdown передаёт внутрь `pre`. */
26
+ children: ReactNode;
27
+ /** Колбэк на копирование. Получает сырой код блока. */
28
+ onCopyClick?(code: string): void;
29
+ };
30
+
31
+ export function CodeBlock({ children, onCopyClick }: CodeBlockProps) {
32
+ const { t } = markdownLocale.useTranslations();
33
+ const [copied, setCopied] = useState(false);
34
+ const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
35
+
36
+ const codeElement = Children.toArray(children).find(child => isValidElement(child));
37
+ const codeProps = isValidElement<{ className?: string; children?: ReactNode }>(codeElement)
38
+ ? codeElement.props
39
+ : { className: '', children };
40
+
41
+ const className = codeProps.className ?? '';
42
+ const codeChildren = codeProps.children;
43
+
44
+ const language = LANGUAGE_PATTERN.exec(className)?.[1];
45
+ const raw = extractText(codeChildren).replace(/\n$/, '');
46
+ const lineCount = raw.length === 0 ? 1 : raw.split('\n').length;
47
+
48
+ const markCopied = () => {
49
+ setCopied(true);
50
+ if (resetTimer.current) clearTimeout(resetTimer.current);
51
+ resetTimer.current = setTimeout(() => setCopied(false), COPIED_RESET_MS);
52
+ };
53
+
54
+ const handleCopy = () => {
55
+ onCopyClick?.(raw);
56
+ // `copyToClipboard` из @cloud-ru/ds-utils сам падает на execCommand, если Clipboard API недоступен
57
+ // (insecure-context). Ждать результат не нужно: галочка показывается по факту клика.
58
+ copyToClipboard(raw);
59
+ markCopied();
60
+ };
61
+
62
+ return (
63
+ <div className={styles.codeBlock}>
64
+ <div className={styles.codeHeader}>
65
+ <span className={styles.codeLang}>{language ?? 'text'}</span>
66
+ <Button
67
+ view='function'
68
+ appearance='neutral'
69
+ size='m'
70
+ icon={copied ? <CheckSVG /> : <CopySVG />}
71
+ className={styles.codeCopy}
72
+ data-test-id={TEST_IDS.viewerCodeCopy}
73
+ data-copied={copied || undefined}
74
+ aria-label={copied ? t('copied') : t('copy')}
75
+ onClick={handleCopy}
76
+ />
77
+ </div>
78
+ <div className={styles.codeBody}>
79
+ <span className={styles.codeGutter} aria-hidden='true'>
80
+ {Array.from({ length: lineCount }, (_, i) => (
81
+ <span key={i} className={styles.codeLineNo}>
82
+ {i + 1}
83
+ </span>
84
+ ))}
85
+ </span>
86
+ {/* Горизонтальный скролл длинных строк кода — через @cloud-ru/ds-scroll вместо нативного overflow.
87
+ Вертикаль скрыта: блок растёт по высоте вместе с нумерацией строк. */}
88
+ <Scroll className={styles.codeScroll} overflow={{ y: 'hidden' }}>
89
+ <pre className={styles.codePre}>
90
+ <code className={cn(styles.codeContent, className)}>{codeChildren}</code>
91
+ </pre>
92
+ </Scroll>
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,47 @@
1
+ import { extractSupportProps } from '@cloud-ru/ds-utils';
2
+ import cn from 'classnames';
3
+ import { useMemo } from 'react';
4
+ import ReactMarkdown, { Components } from 'react-markdown';
5
+ import rehypeHighlight from 'rehype-highlight';
6
+ import remarkGfm from 'remark-gfm';
7
+
8
+ import { TEST_IDS } from '../../constants';
9
+ import syntaxStyles from '../../styles/syntax.module.scss';
10
+ import { MarkdownProps } from '../../types';
11
+ import { CodeBlock } from './CodeBlock';
12
+ import styles from './styles.module.scss';
13
+
14
+ export function Markdown({
15
+ value,
16
+ remarkPlugins,
17
+ rehypePlugins,
18
+ components,
19
+ skipHtml = true,
20
+ onCodeCopyClick,
21
+ className,
22
+ 'data-test-id': dataTestId = TEST_IDS.viewer,
23
+ ...rest
24
+ }: MarkdownProps) {
25
+ const supportProps = extractSupportProps(rest);
26
+
27
+ const mergedComponents = useMemo<Components>(
28
+ () => ({
29
+ pre: ({ children }) => <CodeBlock onCopyClick={onCodeCopyClick}>{children}</CodeBlock>,
30
+ ...components,
31
+ }),
32
+ [components, onCodeCopyClick],
33
+ );
34
+
35
+ return (
36
+ <div {...supportProps} className={cn(styles.root, syntaxStyles.syntax, className)} data-test-id={dataTestId}>
37
+ <ReactMarkdown
38
+ remarkPlugins={[remarkGfm, ...(remarkPlugins ?? [])]}
39
+ rehypePlugins={[[rehypeHighlight, { detect: true }], ...(rehypePlugins ?? [])]}
40
+ skipHtml={skipHtml}
41
+ components={mergedComponents}
42
+ >
43
+ {value}
44
+ </ReactMarkdown>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1 @@
1
+ export * from './Markdown';
@@ -0,0 +1,105 @@
1
+ @use '@cloud-ru/ds-figma-variables/build/scss/styles/styles.module' as base;
2
+ @use '@cloud-ru/ds-figma-variables/build/scss/components/markdown.module' as md;
3
+ @use '../../mixins' as mx;
4
+
5
+ .root {
6
+ box-sizing: border-box;
7
+ max-width: 100%;
8
+
9
+ font-family: md.$sn-brand-font-fontFamily-regular-body;
10
+ font-size: md.$sn-density-typography-roleBody-fontSizeM;
11
+ line-height: md.$sn-density-typography-roleBody-lineHeightM;
12
+ color: md.$sn-theme-color-available-version-textMain;
13
+
14
+ @include mx.markdown-content;
15
+ @include mx.markdown-block-spacings;
16
+
17
+ // Code-блок: заголовок (язык + copy) + тело (нумерация строк + код).
18
+ // Структуру задаёт компонент CodeBlock, подсветку — .syntax (hljs).
19
+ .codeBlock {
20
+ overflow: hidden;
21
+ border: md.$sn-primitive-strokeWeight-strokeRegular solid md.$sn-theme-color-available-borderColor;
22
+ border-radius: md.$sn-density-radius-2xs;
23
+ background: md.$sn-theme-color-neutral-background2Level;
24
+ }
25
+
26
+ .codeHeader {
27
+ display: flex;
28
+ gap: base.$sn-primitive-dimension-8;
29
+ align-items: center;
30
+ justify-content: space-between;
31
+
32
+ padding: base.$sn-primitive-dimension-6 base.$sn-primitive-dimension-12;
33
+ border-bottom: md.$sn-primitive-strokeWeight-strokeRegular solid md.$sn-theme-color-available-borderColor;
34
+ }
35
+
36
+ .codeLang {
37
+ font-family: md.$sn-brand-font-fontFamily-mono-body;
38
+ font-size: md.$sn-density-typography-roleBody-fontSizeS;
39
+ line-height: md.$sn-density-typography-roleBody-lineHeightS;
40
+ color: md.$sn-theme-color-available-version-textSecondary;
41
+ text-transform: lowercase;
42
+ }
43
+
44
+ // Кнопка копирования — Button (view=function, appearance=neutral) из @cloud-ru/ds-button;
45
+ // здесь только success-состояние «скопировано».
46
+ .codeCopy[data-copied] {
47
+ color: md.$sn-theme-color-green-text;
48
+ }
49
+
50
+ .codeBody {
51
+ display: flex;
52
+ max-width: 100%;
53
+ }
54
+
55
+ .codeGutter {
56
+ user-select: none;
57
+
58
+ display: flex;
59
+ flex-direction: column;
60
+ flex-shrink: 0;
61
+
62
+ padding: base.$sn-primitive-dimension-12 0;
63
+ border-right: md.$sn-primitive-strokeWeight-strokeRegular solid md.$sn-theme-color-available-borderColor;
64
+
65
+ font-family: md.$sn-brand-font-fontFamily-mono-body;
66
+ font-size: md.$sn-density-typography-roleBody-fontSizeM;
67
+ line-height: md.$sn-density-typography-roleBody-lineHeightM;
68
+ color: md.$sn-theme-color-available-version-textTertiary;
69
+ text-align: right;
70
+ }
71
+
72
+ .codeLineNo {
73
+ padding: 0 base.$sn-primitive-dimension-12;
74
+ }
75
+
76
+ .codeScroll {
77
+ flex: 1;
78
+ margin: 0;
79
+ }
80
+
81
+ .codePre {
82
+ margin: 0;
83
+ padding: base.$sn-primitive-dimension-12;
84
+ }
85
+
86
+ .codeContent {
87
+ min-height: 0;
88
+ padding: 0;
89
+
90
+ font-size: md.$sn-density-typography-roleBody-fontSizeM;
91
+ line-height: md.$sn-density-typography-roleBody-lineHeightM;
92
+
93
+ background: transparent;
94
+ }
95
+
96
+ hr {
97
+ border: 0;
98
+ border-top: md.$sn-primitive-strokeWeight-strokeRegular solid md.$sn-theme-color-available-borderColor;
99
+ }
100
+
101
+ img {
102
+ max-width: 100%;
103
+ height: auto;
104
+ }
105
+ }
@@ -0,0 +1,322 @@
1
+ import { Button } from '@cloud-ru/ds-button';
2
+ import { FieldShell } from '@cloud-ru/ds-fields';
3
+ import { CrossCircleSVG } from '@cloud-ru/ds-icons/interface/system';
4
+ import { Scroll } from '@cloud-ru/ds-scroll';
5
+ import { Switch } from '@cloud-ru/ds-toggles';
6
+ import { extractSupportProps, isBrowser, useDebounce, useValueControl } from '@cloud-ru/ds-utils';
7
+ import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
8
+ import { Image } from '@tiptap/extension-image';
9
+ import Placeholder from '@tiptap/extension-placeholder';
10
+ import { Table } from '@tiptap/extension-table';
11
+ import { TableCell } from '@tiptap/extension-table-cell';
12
+ import { TableHeader } from '@tiptap/extension-table-header';
13
+ import { TableRow } from '@tiptap/extension-table-row';
14
+ import { Markdown as MarkdownExtension } from '@tiptap/markdown';
15
+ import { EditorContent, useEditor } from '@tiptap/react';
16
+ import StarterKit from '@tiptap/starter-kit';
17
+ import cn from 'classnames';
18
+ import { ChangeEvent, FocusEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
19
+ import TextareaAutosize from 'react-textarea-autosize';
20
+
21
+ import { ON_CHANGE_DEBOUNCE_MS, TEST_IDS } from '../../constants';
22
+ import { markdownLocale } from '../../locale';
23
+ import { lowlight } from '../../lowlight';
24
+ import { RawEdit } from '../../rawMarkdownCommands';
25
+ import syntaxStyles from '../../styles/syntax.module.scss';
26
+ import { createRawApi, createWysiwygApi, ToolbarApi } from '../../toolbarApi';
27
+ import { MarkdownEditorProps } from '../../types';
28
+ import { DEFAULT_TOOLBAR, Toolbar } from '../Toolbar';
29
+ import styles from './styles.module.scss';
30
+
31
+ export function MarkdownEditor({
32
+ value,
33
+ defaultValue,
34
+ onChange,
35
+ preview,
36
+ defaultPreview,
37
+ onPreviewChange,
38
+ label = 'Markdown field',
39
+ previewLabel = 'Preview',
40
+ hideHeader = false,
41
+ placeholder,
42
+ spellCheck = true,
43
+ resizable = true,
44
+ toolbar,
45
+ className,
46
+ 'data-test-id': dataTestId = TEST_IDS.editor,
47
+ ...rest
48
+ }: MarkdownEditorProps) {
49
+ const supportProps = extractSupportProps(rest);
50
+ const { t } = markdownLocale.useTranslations();
51
+
52
+ // onChange не передаём в useValueControl: внутреннее значение обновляется сразу (uncontrolled
53
+ // рендер, видимость clear), а наружу onChange уходит с дебаунсом ON_CHANGE_DEBOUNCE_MS.
54
+ const [currentValue, setValue] = useValueControl<string>({ value, defaultValue });
55
+
56
+ const latestMarkdown = useRef(currentValue ?? '');
57
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
58
+ const editorContentRef = useRef<HTMLDivElement>(null);
59
+
60
+ const emitChange = useDebounce(() => onChange?.(latestMarkdown.current), ON_CHANGE_DEBOUNCE_MS);
61
+
62
+ const [previewMode, setPreview] = useValueControl<boolean>({
63
+ value: preview,
64
+ defaultValue: defaultPreview ?? false,
65
+ onChange: onPreviewChange,
66
+ });
67
+
68
+ const [previewFocusVisible, setPreviewFocusVisible] = useState(false);
69
+ const [previewHover, setPreviewHover] = useState(false);
70
+
71
+ const extensions = useMemo<Parameters<typeof useEditor>[0]['extensions']>(
72
+ () => [
73
+ StarterKit.configure({
74
+ codeBlock: false,
75
+ link: { openOnClick: true },
76
+ }),
77
+ MarkdownExtension,
78
+ Placeholder.configure({ placeholder: placeholder ?? '' }),
79
+ Table.configure({ resizable: true }),
80
+ TableRow,
81
+ TableHeader,
82
+ TableCell,
83
+ CodeBlockLowlight.configure({ lowlight }),
84
+ // Ресайз картинок не включаем: контент сериализуется в markdown (`![alt](src)`),
85
+ // а синтаксис markdown не хранит ширину/высоту — размер всё равно терялся бы на round-trip.
86
+ Image,
87
+ ],
88
+ [placeholder],
89
+ );
90
+
91
+ const editor = useEditor(
92
+ {
93
+ extensions,
94
+ content: currentValue ?? '',
95
+ // Начальное значение — markdown-строка, парсим её в rich-узлы (а не как HTML/text).
96
+ contentType: 'markdown',
97
+ immediatelyRender: false,
98
+ onUpdate({ editor }) {
99
+ const markdown = editor.getMarkdown();
100
+ latestMarkdown.current = markdown;
101
+ setValue(markdown);
102
+ emitChange();
103
+ },
104
+ },
105
+ [extensions],
106
+ );
107
+
108
+ useEffect(() => {
109
+ if (!editor || value === undefined) {
110
+ return;
111
+ }
112
+ // Во время ввода не перетираем контент (иначе курсор прыгает на конец) —
113
+ // синхронизируем только внешние изменения value, пока редактор не в фокусе.
114
+ if (editor.isFocused) {
115
+ return;
116
+ }
117
+
118
+ if (editor.getMarkdown() === value) {
119
+ return;
120
+ }
121
+ editor.commands.setContent(value, { contentType: 'markdown' });
122
+ }, [editor, value]);
123
+
124
+ useEffect(() => {
125
+ // При переходе raw → preview подтягиваем в TipTap последний markdown,
126
+ // набранный в textarea (latestMarkdown держит актуальное значение обоих режимов).
127
+ if (!editor || !previewMode) {
128
+ return;
129
+ }
130
+
131
+ const markdown = latestMarkdown.current;
132
+
133
+ if (editor.getMarkdown() === markdown) {
134
+ return;
135
+ }
136
+
137
+ editor.commands.setContent(markdown, { contentType: 'markdown' });
138
+ }, [editor, previewMode]);
139
+
140
+ // Toolbar форматирует WYSIWYG в preview-режиме; в raw-режиме (textarea) он виден, но
141
+ // кнопки вставляют markdown-разметку. `toolbar === false` прячет тулбар целиком.
142
+ const toolbarItems = toolbar === false ? null : (toolbar ?? DEFAULT_TOOLBAR);
143
+ const showHeader = !hideHeader;
144
+ const hasValue = Boolean(currentValue && currentValue.length > 0);
145
+
146
+ const handleRawValueChange = useCallback(
147
+ (e: ChangeEvent<HTMLTextAreaElement>) => {
148
+ const markdown = e.target.value;
149
+ latestMarkdown.current = markdown;
150
+ setValue(markdown);
151
+ emitChange();
152
+ },
153
+ [emitChange, setValue],
154
+ );
155
+
156
+ // Применяет результат raw-команды тулбара к textarea: новый текст + восстановление выделения.
157
+ const applyRawEdit = useCallback(
158
+ (edit: RawEdit) => {
159
+ latestMarkdown.current = edit.text;
160
+ setValue(edit.text);
161
+ emitChange();
162
+
163
+ // Восстанавливаем фокус и выделение после того, как React применит новое значение textarea.
164
+ if (isBrowser()) {
165
+ requestAnimationFrame(() => {
166
+ const el = textareaRef.current;
167
+ if (!el) {
168
+ return;
169
+ }
170
+
171
+ el.focus();
172
+ el.setSelectionRange(edit.selStart, edit.selEnd);
173
+ });
174
+ }
175
+ },
176
+ [setValue, emitChange],
177
+ );
178
+
179
+ // Тулбар работает в обоих режимах: WYSIWYG (TipTap) в preview, markdown-исходник (textarea) — в raw.
180
+ const toolbarApi = useMemo<ToolbarApi | null>(() => {
181
+ if (previewMode) {
182
+ return editor ? createWysiwygApi(editor) : null;
183
+ }
184
+
185
+ return createRawApi(textareaRef, applyRawEdit, {
186
+ linkText: t('linkText'),
187
+ tableColumn: index => t('table.column', { index }),
188
+ });
189
+ }, [previewMode, editor, applyRawEdit, t]);
190
+
191
+ const handleClearButtonClick = useCallback(() => {
192
+ latestMarkdown.current = '';
193
+ setValue('');
194
+ emitChange();
195
+ editor?.commands.clearContent(true);
196
+ textareaRef.current?.focus();
197
+ }, [editor, emitChange, setValue]);
198
+
199
+ const handlePreviewFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
200
+ setPreviewFocusVisible(event.target.matches(':focus-visible'));
201
+ }, []);
202
+
203
+ const handlePreviewBlur = useCallback(() => setPreviewFocusVisible(false), []);
204
+
205
+ // Клик в пустую зону поля (ниже контента, когда поле растянуто ресайзом, но контент короткий)
206
+ // переводит фокус в активный инпут и ставит курсор в конец — паритет с FieldTextArea, где
207
+ // контент сам не заполняет всю высоту. Клики по самому инпуту/кнопкам/скроллбару не трогаем.
208
+ const handleContentClick = useCallback(
209
+ (event: MouseEvent<HTMLDivElement>) => {
210
+ const textarea = textareaRef.current;
211
+ const target = event.target as HTMLElement;
212
+
213
+ // Клик по самому инпуту (textarea / WYSIWYG-контейнеру) обрабатывается нативно, фокус не форвардим.
214
+ if (target === textarea || editorContentRef.current?.contains(target)) {
215
+ return;
216
+ }
217
+
218
+ if (previewMode) {
219
+ editor?.commands.focus('end');
220
+ return;
221
+ }
222
+
223
+ if (textarea) {
224
+ textarea.focus();
225
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
226
+ }
227
+ },
228
+ [previewMode, editor],
229
+ );
230
+
231
+ const toolbarNode =
232
+ toolbarItems && toolbarApi ? (
233
+ <div className={styles.toolbarSlot}>
234
+ <Toolbar api={toolbarApi} items={toolbarItems} />
235
+ </div>
236
+ ) : undefined;
237
+
238
+ return (
239
+ <div
240
+ {...supportProps}
241
+ className={cn(styles.root, syntaxStyles.syntax, className)}
242
+ data-test-id={dataTestId}
243
+ data-preview={previewMode || undefined}
244
+ >
245
+ {showHeader && (
246
+ <div className={styles.header} data-test-id={TEST_IDS.editorHeader}>
247
+ <label className={styles.previewToggle}>
248
+ <Switch size='s' checked={previewMode} onChange={setPreview} data-test-id={TEST_IDS.editorPreviewToggle} />
249
+
250
+ <span className={styles.previewToggleLabel}>{previewLabel}</span>
251
+ </label>
252
+
253
+ {label && (
254
+ <span className={styles.label} data-test-id={TEST_IDS.editorLabel}>
255
+ {label}
256
+ </span>
257
+ )}
258
+ </div>
259
+ )}
260
+
261
+ <FieldShell
262
+ data-test-id={TEST_IDS.editorField}
263
+ header={toolbarNode}
264
+ focusVisible={previewFocusVisible}
265
+ hover={previewHover}
266
+ onMouseEnter={() => setPreviewHover(true)}
267
+ onMouseLeave={() => setPreviewHover(false)}
268
+ >
269
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
270
+ <div
271
+ className={styles.contentArea}
272
+ onClick={handleContentClick}
273
+ onFocus={handlePreviewFocus}
274
+ onBlur={handlePreviewBlur}
275
+ >
276
+ <Scroll
277
+ className={styles.resizeBox}
278
+ size='s'
279
+ resize={resizable ? 'vertical' : 'none'}
280
+ overflow={{ x: 'hidden' }}
281
+ >
282
+ {previewMode ? (
283
+ <EditorContent
284
+ ref={editorContentRef}
285
+ editor={editor}
286
+ className={styles.content}
287
+ data-test-id={TEST_IDS.editorContent}
288
+ spellCheck={spellCheck}
289
+ />
290
+ ) : (
291
+ <div className={styles.rawInputWrapper}>
292
+ <TextareaAutosize
293
+ ref={textareaRef}
294
+ className={styles.rawInput}
295
+ data-test-id={TEST_IDS.editorRawInput}
296
+ value={currentValue ?? ''}
297
+ onChange={handleRawValueChange}
298
+ placeholder={placeholder}
299
+ spellCheck={spellCheck}
300
+ />
301
+ </div>
302
+ )}
303
+ </Scroll>
304
+
305
+ {hasValue && (
306
+ <div className={styles.postfix}>
307
+ <Button
308
+ view='function'
309
+ appearance='neutral'
310
+ size='m'
311
+ icon={<CrossCircleSVG />}
312
+ data-test-id={TEST_IDS.editorClear}
313
+ aria-label={t('clear')}
314
+ onClick={handleClearButtonClick}
315
+ />
316
+ </div>
317
+ )}
318
+ </div>
319
+ </FieldShell>
320
+ </div>
321
+ );
322
+ }
@@ -0,0 +1 @@
1
+ export * from './MarkdownEditor';