@open-mercato/ui 0.6.5-develop.5048.1.fd82f4ae17 → 0.6.5-develop.5099.1.85c0aff78c

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 (38) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +7 -57
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/detail/InlineEditors.js +2 -51
  5. package/dist/backend/detail/InlineEditors.js.map +3 -3
  6. package/dist/backend/inputs/MarkdownField.js +20 -0
  7. package/dist/backend/inputs/MarkdownField.js.map +7 -0
  8. package/dist/backend/inputs/MdxEditorImpl.js +98 -0
  9. package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
  10. package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
  11. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  12. package/dist/backend/markdown/markdownToPlainText.js +29 -0
  13. package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
  14. package/dist/types/css.d.js +1 -0
  15. package/dist/types/css.d.js.map +7 -0
  16. package/package.json +4 -3
  17. package/src/backend/CrudForm.tsx +3 -67
  18. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
  19. package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
  20. package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
  21. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +0 -1
  22. package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
  23. package/src/backend/__tests__/CrudForm.navigation.test.tsx +0 -1
  24. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +0 -1
  25. package/src/backend/__tests__/CrudForm.render.test.tsx +0 -1
  26. package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
  27. package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
  28. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +0 -1
  29. package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
  30. package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
  31. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
  32. package/src/backend/detail/InlineEditors.tsx +14 -76
  33. package/src/backend/inputs/MarkdownField.tsx +34 -0
  34. package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
  35. package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
  36. package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
  37. package/src/backend/markdown/markdownToPlainText.ts +41 -0
  38. package/src/types/css.d.ts +5 -0
@@ -1,7 +1,35 @@
1
+ const NAMED_HTML_ENTITIES = {
2
+ amp: "&",
3
+ lt: "<",
4
+ gt: ">",
5
+ quot: '"',
6
+ apos: "'",
7
+ nbsp: " ",
8
+ hellip: "\u2026",
9
+ mdash: "\u2014",
10
+ ndash: "\u2013",
11
+ rsquo: "\u2019",
12
+ lsquo: "\u2018",
13
+ rdquo: "\u201D",
14
+ ldquo: "\u201C"
15
+ };
16
+ function decodeHtmlEntities(value) {
17
+ return value.replace(/&#(\d+);/g, (match, dec) => {
18
+ const code = Number(dec);
19
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
20
+ }).replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
21
+ const code = parseInt(hex, 16);
22
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
23
+ }).replace(
24
+ /&([a-zA-Z][a-zA-Z0-9]*);/g,
25
+ (match, name) => Object.prototype.hasOwnProperty.call(NAMED_HTML_ENTITIES, name) ? NAMED_HTML_ENTITIES[name] : match
26
+ );
27
+ }
1
28
  function markdownToPlainText(input) {
2
29
  if (!input) return "";
3
30
  let text = String(input);
4
31
  text = text.replace(/```[\s\S]*?```/g, " ");
32
+ text = text.replace(/<[^>]+>/g, " ");
5
33
  text = text.replace(/`([^`]+)`/g, "$1");
6
34
  text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, " ");
7
35
  text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
@@ -15,6 +43,7 @@ function markdownToPlainText(input) {
15
43
  text = text.replace(/\*([^*]+)\*/g, "$1");
16
44
  text = text.replace(/_([^_]+)_/g, "$1");
17
45
  text = text.replace(/\|/g, " ");
46
+ text = decodeHtmlEntities(text);
18
47
  text = text.replace(/\s+/g, " ").trim();
19
48
  return text;
20
49
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/markdown/markdownToPlainText.ts"],
4
- "sourcesContent": ["export function markdownToPlainText(input: string | null | undefined): string {\n if (!input) return ''\n let text = String(input)\n text = text.replace(/```[\\s\\S]*?```/g, ' ')\n text = text.replace(/`([^`]+)`/g, '$1')\n text = text.replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, ' ')\n text = text.replace(/\\[([^\\]]+)\\]\\([^)]*\\)/g, '$1')\n text = text.replace(/^>\\s?/gm, '')\n text = text.replace(/^#{1,6}\\s+/gm, '')\n text = text.replace(/^\\s*[-*+]\\s+/gm, '')\n text = text.replace(/^\\s*\\d+\\.\\s+/gm, '')\n text = text.replace(/~~([^~]+)~~/g, '$1')\n text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n text = text.replace(/__([^_]+)__/g, '$1')\n text = text.replace(/\\*([^*]+)\\*/g, '$1')\n text = text.replace(/_([^_]+)_/g, '$1')\n text = text.replace(/\\|/g, ' ')\n text = text.replace(/\\s+/g, ' ').trim()\n return text\n}\n"],
5
- "mappings": "AAAO,SAAS,oBAAoB,OAA0C;AAC5E,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,OAAO,OAAO,KAAK;AACvB,SAAO,KAAK,QAAQ,mBAAmB,GAAG;AAC1C,SAAO,KAAK,QAAQ,cAAc,IAAI;AACtC,SAAO,KAAK,QAAQ,yBAAyB,GAAG;AAChD,SAAO,KAAK,QAAQ,0BAA0B,IAAI;AAClD,SAAO,KAAK,QAAQ,WAAW,EAAE;AACjC,SAAO,KAAK,QAAQ,gBAAgB,EAAE;AACtC,SAAO,KAAK,QAAQ,kBAAkB,EAAE;AACxC,SAAO,KAAK,QAAQ,kBAAkB,EAAE;AACxC,SAAO,KAAK,QAAQ,gBAAgB,IAAI;AACxC,SAAO,KAAK,QAAQ,oBAAoB,IAAI;AAC5C,SAAO,KAAK,QAAQ,gBAAgB,IAAI;AACxC,SAAO,KAAK,QAAQ,gBAAgB,IAAI;AACxC,SAAO,KAAK,QAAQ,cAAc,IAAI;AACtC,SAAO,KAAK,QAAQ,OAAO,GAAG;AAC9B,SAAO,KAAK,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACtC,SAAO;AACT;",
4
+ "sourcesContent": ["const NAMED_HTML_ENTITIES: Record<string, string> = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n hellip: '\u2026',\n mdash: '\u2014',\n ndash: '\u2013',\n rsquo: '\u2019',\n lsquo: '\u2018',\n rdquo: '\u201D',\n ldquo: '\u201C',\n}\n\nfunction decodeHtmlEntities(value: string): string {\n return value\n .replace(/&#(\\d+);/g, (match, dec: string) => {\n const code = Number(dec)\n return Number.isFinite(code) ? String.fromCodePoint(code) : match\n })\n .replace(/&#x([0-9a-fA-F]+);/g, (match, hex: string) => {\n const code = parseInt(hex, 16)\n return Number.isFinite(code) ? String.fromCodePoint(code) : match\n })\n .replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name: string) =>\n Object.prototype.hasOwnProperty.call(NAMED_HTML_ENTITIES, name) ? NAMED_HTML_ENTITIES[name] : match,\n )\n}\n\n/**\n * Converts a Markdown (or legacy HTML) string into a plain-text preview suitable for compact,\n * read-only contexts such as DataTable list cells. Strips Markdown syntax, strips HTML tags, and\n * decodes HTML entities, then collapses whitespace. Handling legacy HTML keeps list previews clean\n * for records authored before the editor moved back to Markdown \u2014 no data migration required.\n *\n * This is NOT a sanitizer \u2014 render rich content with the Markdown components, not this helper.\n */\nexport function markdownToPlainText(input: string | null | undefined): string {\n if (!input) return ''\n let text = String(input)\n text = text.replace(/```[\\s\\S]*?```/g, ' ')\n text = text.replace(/<[^>]+>/g, ' ')\n text = text.replace(/`([^`]+)`/g, '$1')\n text = text.replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, ' ')\n text = text.replace(/\\[([^\\]]+)\\]\\([^)]*\\)/g, '$1')\n text = text.replace(/^>\\s?/gm, '')\n text = text.replace(/^#{1,6}\\s+/gm, '')\n text = text.replace(/^\\s*[-*+]\\s+/gm, '')\n text = text.replace(/^\\s*\\d+\\.\\s+/gm, '')\n text = text.replace(/~~([^~]+)~~/g, '$1')\n text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n text = text.replace(/__([^_]+)__/g, '$1')\n text = text.replace(/\\*([^*]+)\\*/g, '$1')\n text = text.replace(/_([^_]+)_/g, '$1')\n text = text.replace(/\\|/g, ' ')\n text = decodeHtmlEntities(text)\n text = text.replace(/\\s+/g, ' ').trim()\n return text\n}\n"],
5
+ "mappings": "AAAA,MAAM,sBAA8C;AAAA,EAClD,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AACT;AAEA,SAAS,mBAAmB,OAAuB;AACjD,SAAO,MACJ,QAAQ,aAAa,CAAC,OAAO,QAAgB;AAC5C,UAAM,OAAO,OAAO,GAAG;AACvB,WAAO,OAAO,SAAS,IAAI,IAAI,OAAO,cAAc,IAAI,IAAI;AAAA,EAC9D,CAAC,EACA,QAAQ,uBAAuB,CAAC,OAAO,QAAgB;AACtD,UAAM,OAAO,SAAS,KAAK,EAAE;AAC7B,WAAO,OAAO,SAAS,IAAI,IAAI,OAAO,cAAc,IAAI,IAAI;AAAA,EAC9D,CAAC,EACA;AAAA,IAAQ;AAAA,IAA6B,CAAC,OAAO,SAC5C,OAAO,UAAU,eAAe,KAAK,qBAAqB,IAAI,IAAI,oBAAoB,IAAI,IAAI;AAAA,EAChG;AACJ;AAUO,SAAS,oBAAoB,OAA0C;AAC5E,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,OAAO,OAAO,KAAK;AACvB,SAAO,KAAK,QAAQ,mBAAmB,GAAG;AAC1C,SAAO,KAAK,QAAQ,YAAY,GAAG;AACnC,SAAO,KAAK,QAAQ,cAAc,IAAI;AACtC,SAAO,KAAK,QAAQ,yBAAyB,GAAG;AAChD,SAAO,KAAK,QAAQ,0BAA0B,IAAI;AAClD,SAAO,KAAK,QAAQ,WAAW,EAAE;AACjC,SAAO,KAAK,QAAQ,gBAAgB,EAAE;AACtC,SAAO,KAAK,QAAQ,kBAAkB,EAAE;AACxC,SAAO,KAAK,QAAQ,kBAAkB,EAAE;AACxC,SAAO,KAAK,QAAQ,gBAAgB,IAAI;AACxC,SAAO,KAAK,QAAQ,oBAAoB,IAAI;AAC5C,SAAO,KAAK,QAAQ,gBAAgB,IAAI;AACxC,SAAO,KAAK,QAAQ,gBAAgB,IAAI;AACxC,SAAO,KAAK,QAAQ,cAAc,IAAI;AACtC,SAAO,KAAK,QAAQ,OAAO,GAAG;AAC9B,SAAO,mBAAmB,IAAI;AAC9B,SAAO,KAAK,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACtC,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=css.d.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.5-develop.5048.1.fd82f4ae17",
3
+ "version": "0.6.5-develop.5099.1.85c0aff78c",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -131,6 +131,7 @@
131
131
  "@dnd-kit/core": "^6.3.1",
132
132
  "@dnd-kit/sortable": "^10.0.0",
133
133
  "@dnd-kit/utilities": "^3.2.2",
134
+ "@mdxeditor/editor": "^4.0.2",
134
135
  "@radix-ui/react-accordion": "^1.2.4",
135
136
  "@radix-ui/react-dialog": "^1.1.6",
136
137
  "@radix-ui/react-popover": "^1.1.6",
@@ -149,13 +150,13 @@
149
150
  "remark-gfm": "^4.0.1"
150
151
  },
151
152
  "peerDependencies": {
152
- "@open-mercato/shared": "0.6.5-develop.5048.1.fd82f4ae17",
153
+ "@open-mercato/shared": "0.6.5-develop.5099.1.85c0aff78c",
153
154
  "react": ">=18.0.0",
154
155
  "react-dom": ">=18.0.0",
155
156
  "react-is": ">=18.0.0"
156
157
  },
157
158
  "devDependencies": {
158
- "@open-mercato/shared": "0.6.5-develop.5048.1.fd82f4ae17",
159
+ "@open-mercato/shared": "0.6.5-develop.5099.1.85c0aff78c",
159
160
  "@testing-library/dom": "^10.4.1",
160
161
  "@testing-library/jest-dom": "^6.9.1",
161
162
  "@testing-library/react": "^16.3.1",
@@ -29,7 +29,6 @@ import {
29
29
  SelectValue,
30
30
  } from '../primitives/select'
31
31
  import { flash } from './FlashMessages'
32
- import dynamic from 'next/dynamic'
33
32
  import { FormHeader } from './forms/FormHeader'
34
33
  import { FormFooter } from './forms/FormFooter'
35
34
  import { Button } from '../primitives/button'
@@ -89,7 +88,6 @@ import { withScopedApiRequestHeaders } from './utils/apiCall'
89
88
  import { buildOptimisticLockHeader, extractOptimisticLockConflict } from './utils/optimisticLock'
90
89
  import { surfaceRecordConflict } from './conflicts'
91
90
  import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/validation'
92
- import type { MDEditorProps as UiWMDEditorProps } from '@uiw/react-md-editor'
93
91
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
94
92
  import { FieldDefinitionsManager, type FieldDefinitionsManagerHandle } from './custom-fields/FieldDefinitionsManager'
95
93
  import { useConfirmDialog } from './confirm-dialog'
@@ -107,6 +105,7 @@ import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/share
107
105
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
108
106
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
109
107
  import { RichEditor, type RichEditorLabels } from '../primitives/rich-editor'
108
+ import MarkdownField from './inputs/MarkdownField'
110
109
 
111
110
  // Stable empty options array to avoid creating a new [] every render
112
111
  const EMPTY_OPTIONS: CrudFieldOption[] = []
@@ -3894,74 +3893,11 @@ function TextAreaInput({
3894
3893
  )
3895
3894
  }
3896
3895
 
3897
- // Markdown editor using @uiw/react-md-editor (client-only)
3896
+ // Markdown editor — WYSIWYG MDXEditor (Lexical) wired to the CrudForm value/onChange contract.
3898
3897
  type MDProps = { value?: string; onChange: (md: string) => void }
3899
- const MDEditor = dynamic(async () => {
3900
- const mod = await import('@uiw/react-md-editor')
3901
- return mod.default
3902
- }, { ssr: false }) as React.ComponentType<UiWMDEditorProps>
3903
-
3904
- type MarkdownPreviewOptions = NonNullable<UiWMDEditorProps['previewOptions']>
3905
-
3906
- let markdownPreviewOptionsPromise: Promise<MarkdownPreviewOptions> | null = null
3907
-
3908
- async function loadMarkdownPreviewOptions(): Promise<MarkdownPreviewOptions> {
3909
- if (!markdownPreviewOptionsPromise) {
3910
- markdownPreviewOptionsPromise = import('remark-gfm')
3911
- .then((mod) => ({ remarkPlugins: [mod.default ?? mod] } as MarkdownPreviewOptions))
3912
- .catch(() => ({} as MarkdownPreviewOptions))
3913
- }
3914
- return markdownPreviewOptionsPromise
3915
- }
3916
-
3917
- const EMPTY_PREVIEW_OPTIONS: MarkdownPreviewOptions = {}
3918
3898
 
3919
3899
  const MarkdownEditor = React.memo(function MarkdownEditor({ value = '', onChange }: MDProps) {
3920
- const containerRef = React.useRef<HTMLDivElement | null>(null)
3921
- const [local, setLocal] = React.useState<string>(value)
3922
- const [previewOptions, setPreviewOptions] = React.useState<MarkdownPreviewOptions>(EMPTY_PREVIEW_OPTIONS)
3923
- const typingRef = React.useRef(false)
3924
-
3925
- React.useEffect(() => {
3926
- if (!typingRef.current) setLocal(value)
3927
- }, [value])
3928
-
3929
- React.useEffect(() => {
3930
- let mounted = true
3931
- void loadMarkdownPreviewOptions().then((resolved) => {
3932
- if (!mounted) return
3933
- setPreviewOptions(resolved)
3934
- })
3935
- return () => {
3936
- mounted = false
3937
- }
3938
- }, [])
3939
-
3940
- const handleChange = React.useCallback((v?: string) => {
3941
- typingRef.current = true
3942
- setLocal(v ?? '')
3943
- }, [])
3944
-
3945
- const commit = React.useCallback(() => {
3946
- if (!typingRef.current) return
3947
- typingRef.current = false
3948
- onChange(local)
3949
- requestAnimationFrame(() => {
3950
- const ta = containerRef.current?.querySelector('textarea') as HTMLTextAreaElement | null
3951
- ta?.focus()
3952
- })
3953
- }, [local, onChange])
3954
-
3955
- return (
3956
- <div ref={containerRef} data-color-mode="light" className="w-full" onBlur={() => commit()}>
3957
- <MDEditor
3958
- value={local}
3959
- height={220}
3960
- onChange={handleChange}
3961
- previewOptions={previewOptions}
3962
- />
3963
- </div>
3964
- )
3900
+ return <MarkdownField value={value} onChange={onChange} />
3965
3901
  }, (prev, next) => prev.value === next.value)
3966
3902
 
3967
3903
  // HTML Rich Text editor wrapper for the CrudForm builtin `editor: 'html'`.
@@ -14,7 +14,6 @@ jest.mock('next/navigation', () => ({
14
14
  useSearchParams: () => new URLSearchParams(),
15
15
  }))
16
16
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
17
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
18
17
  jest.mock('../confirm-dialog', () => ({
19
18
  useConfirmDialog: () => ({
20
19
  confirm: jest.fn().mockResolvedValue(true),
@@ -4,7 +4,6 @@ jest.mock('next/navigation', () => ({
4
4
  useSearchParams: () => new URLSearchParams(),
5
5
  }))
6
6
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
7
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
8
7
 
9
8
  import * as React from 'react'
10
9
  import { renderToString } from 'react-dom/server'
@@ -9,7 +9,6 @@ jest.mock('next/navigation', () => ({
9
9
  useSearchParams: () => new URLSearchParams(),
10
10
  }))
11
11
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
12
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
13
12
  jest.mock('../confirm-dialog', () => ({
14
13
  useConfirmDialog: () => ({
15
14
  confirm: jest.fn().mockResolvedValue(true),
@@ -16,7 +16,6 @@ jest.mock('next/navigation', () => ({
16
16
  useSearchParams: () => new URLSearchParams(),
17
17
  }))
18
18
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
19
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
20
19
  jest.mock('../confirm-dialog', () => ({
21
20
  useConfirmDialog: () => ({
22
21
  confirm: jest.fn().mockResolvedValue(true),
@@ -7,7 +7,6 @@ jest.mock('next/navigation', () => ({
7
7
  useSearchParams: () => new URLSearchParams(),
8
8
  }))
9
9
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
10
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
11
10
  jest.mock('../injection/InjectionSpot', () => ({
12
11
  __esModule: true,
13
12
  InjectionSpot: () => null,
@@ -9,7 +9,6 @@ jest.mock('next/navigation', () => ({
9
9
  useSearchParams: () => new URLSearchParams(),
10
10
  }))
11
11
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
12
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
13
12
  jest.mock('../confirm-dialog', () => ({
14
13
  useConfirmDialog: () => ({
15
14
  confirm: confirmDialogMock,
@@ -16,7 +16,6 @@ jest.mock('next/navigation', () => ({
16
16
  useSearchParams: () => new URLSearchParams(),
17
17
  }))
18
18
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
19
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
20
19
  jest.mock('../confirm-dialog', () => ({
21
20
  useConfirmDialog: () => ({
22
21
  confirm: jest.fn().mockResolvedValue(true),
@@ -12,7 +12,6 @@ jest.mock('next/navigation', () => ({
12
12
  useSearchParams: () => new URLSearchParams(),
13
13
  }))
14
14
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
15
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
16
15
  jest.mock('../injection/InjectionSpot', () => ({
17
16
  __esModule: true,
18
17
  InjectionSpot: () => null,
@@ -12,7 +12,6 @@ jest.mock('next/navigation', () => ({
12
12
  useSearchParams: () => new URLSearchParams(),
13
13
  }))
14
14
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
15
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
16
15
  jest.mock('../injection/InjectionSpot', () => ({
17
16
  __esModule: true,
18
17
  InjectionSpot: () => null,
@@ -11,7 +11,6 @@ jest.mock('next/navigation', () => ({
11
11
  useSearchParams: () => new URLSearchParams(),
12
12
  }))
13
13
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
14
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
15
14
  jest.mock('../confirm-dialog', () => ({
16
15
  useConfirmDialog: () => ({
17
16
  confirm: confirmDialogMock,
@@ -13,7 +13,6 @@ jest.mock('next/navigation', () => ({
13
13
  useSearchParams: () => new URLSearchParams(),
14
14
  }))
15
15
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
16
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
17
16
  jest.mock('../confirm-dialog', () => ({
18
17
  useConfirmDialog: () => ({
19
18
  confirm: confirmDialogMock,
@@ -7,7 +7,6 @@ jest.mock('next/navigation', () => ({
7
7
  useSearchParams: () => new URLSearchParams(),
8
8
  }))
9
9
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
10
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
11
10
  jest.mock('../injection/InjectionSpot', () => ({
12
11
  __esModule: true,
13
12
  InjectionSpot: () => null,
@@ -5,7 +5,6 @@ jest.mock('next/navigation', () => ({
5
5
  useSearchParams: () => new URLSearchParams(),
6
6
  }))
7
7
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
8
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
9
8
 
10
9
  import * as React from 'react'
11
10
  import { fireEvent, screen } from '@testing-library/react'
@@ -24,10 +24,23 @@ describe('heavy libraries are lazy-loaded', () => {
24
24
  expect(source).not.toMatch(/next\/dynamic/)
25
25
  })
26
26
 
27
- it('CrudForm does not statically import remark-gfm', () => {
27
+ it('CrudForm does not statically import the heavy markdown editor', () => {
28
28
  const source = read('packages/ui/src/backend/CrudForm.tsx')
29
- expect(source).not.toMatch(/^[^\n/]*import\s+[^'\n]+from\s+['"]remark-gfm['"]/m)
30
- expect(source).toMatch(/import\(['"]remark-gfm['"]\)/)
29
+ expect(source).not.toMatch(/from\s+['"]@mdxeditor\/editor['"]/)
30
+ expect(source).toMatch(/from\s+['"]\.\/inputs\/MarkdownField['"]/)
31
+ })
32
+
33
+ it('MarkdownField lazy-loads MdxEditorImpl and does not statically import it', () => {
34
+ const source = read('packages/ui/src/backend/inputs/MarkdownField.tsx')
35
+ expect(source).not.toMatch(/^[^\n/]*import\s+[^'\n]+from\s+['"]\.\/MdxEditorImpl['"]/m)
36
+ expect(source).toMatch(/import\(['"]\.\/MdxEditorImpl['"]\)/)
37
+ expect(source).toMatch(/next\/dynamic/)
38
+ })
39
+
40
+ it('MdxEditorImpl owns the @mdxeditor/editor runtime import and CSS', () => {
41
+ const source = read('packages/ui/src/backend/inputs/MdxEditorImpl.tsx')
42
+ expect(source).toMatch(/from\s+['"]@mdxeditor\/editor['"]/)
43
+ expect(source).toMatch(/['"]@mdxeditor\/editor\/style\.css['"]/)
31
44
  })
32
45
 
33
46
  it('resources resource-types list page does not import markdown libraries', () => {
@@ -1,7 +1,6 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import dynamic from 'next/dynamic'
5
4
  import { FileCode, Loader2, Mail, Pencil, Phone, X } from 'lucide-react'
6
5
  import type { PluggableList } from 'unified'
7
6
  import { PhoneNumberField } from '@open-mercato/ui/backend/inputs/PhoneNumberField'
@@ -444,40 +443,10 @@ export type InlineMultilineEditorProps = {
444
443
  renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode
445
444
  }
446
445
 
447
- type UiMarkdownEditorProps = {
448
- value?: string
449
- height?: number
450
- onChange?: (value?: string) => void
451
- previewOptions?: { remarkPlugins?: unknown[] }
452
- }
453
-
454
446
  const isTestEnv =
455
447
  typeof process !== 'undefined' &&
456
448
  (process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
457
449
 
458
- function MarkdownEditorFallback() {
459
- const t = useT()
460
- return (
461
- <LoadingMessage label={t('ui.detail.inline.editorLoading', 'Loading editor…')} className="min-h-[200px] justify-center" />
462
- )
463
- }
464
-
465
- const MarkdownEditorTestStub: React.ComponentType<UiMarkdownEditorProps> = ({ value, onChange }) => (
466
- <Textarea
467
- data-testid="markdown-editor"
468
- rows={8}
469
- value={value ?? ''}
470
- onChange={(event) => onChange?.(event.target.value)}
471
- />
472
- )
473
-
474
- const MarkdownEditorComponent: React.ComponentType<UiMarkdownEditorProps> = isTestEnv
475
- ? MarkdownEditorTestStub
476
- : (dynamic(() => import('@uiw/react-md-editor'), {
477
- ssr: false,
478
- loading: () => <MarkdownEditorFallback />,
479
- }) as unknown as React.ComponentType<UiMarkdownEditorProps>)
480
-
481
450
  let markdownPluginsPromise: Promise<PluggableList> | null = null
482
451
 
483
452
  async function loadMarkdownPlugins(): Promise<PluggableList> {
@@ -510,7 +479,6 @@ export function InlineMultilineEditor({
510
479
  const [saving, setSaving] = React.useState(false)
511
480
  const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(true)
512
481
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
513
- const markdownEditorRef = React.useRef<HTMLDivElement | null>(null)
514
482
  const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])
515
483
  const fallbackError = React.useMemo(
516
484
  () => t('ui.detail.inline.error', 'Failed to save value.'),
@@ -540,14 +508,6 @@ export function InlineMultilineEditor({
540
508
 
541
509
  React.useEffect(() => {
542
510
  if (!editing) return
543
- if (isMarkdownEnabled) {
544
- const element = markdownEditorRef.current?.querySelector('textarea')
545
- if (!element) return
546
- window.requestAnimationFrame(() => {
547
- element.focus()
548
- })
549
- return
550
- }
551
511
  const element = textareaRef.current
552
512
  if (!element) return
553
513
  window.requestAnimationFrame(() => {
@@ -674,42 +634,20 @@ export function InlineMultilineEditor({
674
634
  }
675
635
  }}
676
636
  >
677
- {isMarkdownEnabled ? (
678
- <div
679
- ref={markdownEditorRef}
680
- className={cn(
681
- 'w-full rounded-md border border-muted-foreground/30 bg-background p-2',
682
- saving ? 'pointer-events-none opacity-75' : null,
683
- )}
684
- >
685
- <div data-color-mode="light" className="w-full">
686
- <MarkdownEditorComponent
687
- value={draft}
688
- height={220}
689
- onChange={(nextValue) => {
690
- if (error) setError(null)
691
- setDraft(typeof nextValue === 'string' ? nextValue : '')
692
- }}
693
- previewOptions={{ remarkPlugins: markdownPlugins }}
694
- />
695
- </div>
696
- </div>
697
- ) : (
698
- <Textarea
699
- ref={textareaRef}
700
- rows={3}
701
- className="w-full resize-none overflow-hidden rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
702
- placeholder={placeholder}
703
- value={draft}
704
- onChange={(event) => {
705
- if (error) setError(null)
706
- setDraft(event.target.value)
707
- }}
708
- onInput={(event) => adjustTextareaSize(event.currentTarget)}
709
- autoFocus
710
- disabled={saving}
711
- />
712
- )}
637
+ <Textarea
638
+ ref={textareaRef}
639
+ rows={isMarkdownEnabled ? 8 : 3}
640
+ className="w-full resize-none overflow-hidden rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
641
+ placeholder={placeholder}
642
+ value={draft}
643
+ onChange={(event) => {
644
+ if (error) setError(null)
645
+ setDraft(event.target.value)
646
+ }}
647
+ onInput={(event) => adjustTextareaSize(event.currentTarget)}
648
+ autoFocus
649
+ disabled={saving}
650
+ />
713
651
  {error ? <p className="text-xs text-destructive">{error}</p> : null}
714
652
  <div className="flex items-center gap-2">
715
653
  <Button type="submit" size="sm" disabled={saving}>
@@ -0,0 +1,34 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import type { ComponentType } from 'react'
6
+
7
+ export type MarkdownFieldProps = {
8
+ value?: string
9
+ onChange: (markdown: string) => void
10
+ }
11
+
12
+ const isTestEnv =
13
+ typeof process !== 'undefined' &&
14
+ (process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
15
+
16
+ // Lightweight stand-in for unit tests (jsdom): MDXEditor/Lexical pull in ESM + CSS that jest
17
+ // would have to transform, so under test we render a plain controlled textarea instead.
18
+ const MarkdownFieldTestStub: ComponentType<MarkdownFieldProps> = ({ value, onChange }) => (
19
+ <textarea
20
+ data-testid="markdown-field"
21
+ className="min-h-[160px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
22
+ value={value ?? ''}
23
+ onChange={(event) => onChange(event.target.value)}
24
+ />
25
+ )
26
+
27
+ const MarkdownFieldLive = dynamic(() => import('./MdxEditorImpl'), { ssr: false }) as ComponentType<MarkdownFieldProps>
28
+
29
+ // Canonical Markdown editor for the whole app: a WYSIWYG MDXEditor (Lexical) that emits Markdown.
30
+ // Client-only (Lexical needs the DOM), loaded via next/dynamic with ssr disabled. Use this
31
+ // everywhere a Markdown editing field is needed.
32
+ const MarkdownField: ComponentType<MarkdownFieldProps> = isTestEnv ? MarkdownFieldTestStub : MarkdownFieldLive
33
+
34
+ export default MarkdownField
@@ -0,0 +1,113 @@
1
+ /// <reference path="../../types/css.d.ts" />
2
+ "use client"
3
+
4
+ import * as React from 'react'
5
+ import {
6
+ MDXEditor,
7
+ type MDXEditorMethods,
8
+ headingsPlugin,
9
+ listsPlugin,
10
+ quotePlugin,
11
+ thematicBreakPlugin,
12
+ linkPlugin,
13
+ linkDialogPlugin,
14
+ imagePlugin,
15
+ tablePlugin,
16
+ markdownShortcutPlugin,
17
+ diffSourcePlugin,
18
+ toolbarPlugin,
19
+ UndoRedo,
20
+ BoldItalicUnderlineToggles,
21
+ CodeToggle,
22
+ StrikeThroughSupSubToggles,
23
+ ListsToggle,
24
+ BlockTypeSelect,
25
+ CreateLink,
26
+ InsertImage,
27
+ InsertTable,
28
+ InsertThematicBreak,
29
+ Separator,
30
+ DiffSourceToggleWrapper,
31
+ } from '@mdxeditor/editor'
32
+ import '@mdxeditor/editor/style.css'
33
+ import { cn } from '@open-mercato/shared/lib/utils'
34
+ import { useTheme } from '../../theme'
35
+
36
+ type MdxEditorImplProps = {
37
+ value?: string
38
+ onChange: (markdown: string) => void
39
+ }
40
+
41
+ // WYSIWYG Markdown editor (MDXEditor / Lexical) wired to the CrudForm value/onChange
42
+ // contract. Markdown is the source of truth. Edits are buffered locally and committed
43
+ // on blur to avoid re-rendering the whole form on every keystroke.
44
+ export default function MdxEditorImpl({ value = '', onChange }: MdxEditorImplProps) {
45
+ const editorRef = React.useRef<MDXEditorMethods>(null)
46
+ const latestRef = React.useRef<string>(value)
47
+ const typingRef = React.useRef(false)
48
+ const { resolvedTheme } = useTheme()
49
+
50
+ // External value changes (form reset / async initial load) are pushed into the editor,
51
+ // but never while the user is actively typing (would reset the caret).
52
+ React.useEffect(() => {
53
+ if (typingRef.current) return
54
+ if (value !== latestRef.current) {
55
+ latestRef.current = value
56
+ editorRef.current?.setMarkdown(value ?? '')
57
+ }
58
+ }, [value])
59
+
60
+ const commit = React.useCallback(() => {
61
+ if (!typingRef.current) return
62
+ typingRef.current = false
63
+ onChange(latestRef.current)
64
+ }, [onChange])
65
+
66
+ return (
67
+ <div className="w-full overflow-hidden rounded-md border border-input bg-background" onBlur={commit}>
68
+ <MDXEditor
69
+ ref={editorRef}
70
+ markdown={value ?? ''}
71
+ onChange={(markdown) => {
72
+ typingRef.current = true
73
+ latestRef.current = markdown
74
+ }}
75
+ className={cn('om-mdx-editor', resolvedTheme === 'dark' && 'dark-theme')}
76
+ contentEditableClassName="om-mdx-prose"
77
+ plugins={[
78
+ headingsPlugin(),
79
+ listsPlugin(),
80
+ quotePlugin(),
81
+ thematicBreakPlugin(),
82
+ linkPlugin(),
83
+ linkDialogPlugin(),
84
+ imagePlugin(),
85
+ tablePlugin(),
86
+ markdownShortcutPlugin(),
87
+ diffSourcePlugin({ viewMode: 'rich-text' }),
88
+ toolbarPlugin({
89
+ toolbarContents: () => (
90
+ <DiffSourceToggleWrapper>
91
+ <UndoRedo />
92
+ <Separator />
93
+ <BoldItalicUnderlineToggles />
94
+ <CodeToggle />
95
+ <StrikeThroughSupSubToggles />
96
+ <Separator />
97
+ <ListsToggle />
98
+ <Separator />
99
+ <BlockTypeSelect />
100
+ <Separator />
101
+ <CreateLink />
102
+ <InsertImage />
103
+ <Separator />
104
+ <InsertTable />
105
+ <InsertThematicBreak />
106
+ </DiffSourceToggleWrapper>
107
+ ),
108
+ }),
109
+ ]}
110
+ />
111
+ </div>
112
+ )
113
+ }