@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +7 -57
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +4 -3
- package/src/backend/CrudForm.tsx +3 -67
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.render.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- 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": ["
|
|
5
|
-
"mappings": "
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
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.
|
|
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.
|
|
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",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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(
|
|
30
|
-
expect(source).toMatch(/
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
+
}
|