@moldable-ai/editor 0.1.7 → 0.1.8
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/dist/components/markdown-editor.d.ts +7 -3
- package/dist/components/markdown-editor.d.ts.map +1 -1
- package/dist/components/markdown-editor.js +10 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/lexical/editor-theme.d.ts +2 -0
- package/dist/lib/lexical/editor-theme.d.ts.map +1 -1
- package/dist/lib/lexical/editor-theme.js +2 -0
- package/dist/lib/lexical/find-replace-plugin.d.ts +31 -0
- package/dist/lib/lexical/find-replace-plugin.d.ts.map +1 -0
- package/dist/lib/lexical/find-replace-plugin.js +205 -0
- package/dist/lib/lexical/headless-editor.d.ts.map +1 -1
- package/dist/lib/lexical/headless-editor.js +2 -0
- package/package.json +18 -17
- package/src/styles/index.css +13 -0
- package/dist/lib/lexical/translation.test.d.ts +0 -2
- package/dist/lib/lexical/translation.test.d.ts.map +0 -1
- package/dist/lib/lexical/translation.test.js +0 -329
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { type MarkdownEditorHandle } from '../lib/lexical/find-replace-plugin';
|
|
2
|
+
export interface MarkdownEditorProps {
|
|
2
3
|
value: string;
|
|
3
4
|
onChange: (value: string) => void;
|
|
4
5
|
placeholder?: string;
|
|
@@ -9,7 +10,10 @@ interface MarkdownEditorProps {
|
|
|
9
10
|
minHeight?: string;
|
|
10
11
|
maxHeight?: string;
|
|
11
12
|
hideMarkdownHint?: boolean;
|
|
13
|
+
findQuery?: string;
|
|
14
|
+
currentFindMatch?: number;
|
|
15
|
+
onFindMatchCountChange?: (count: number) => void;
|
|
12
16
|
}
|
|
13
|
-
export declare
|
|
14
|
-
export {};
|
|
17
|
+
export declare const MarkdownEditor: import("react").ForwardRefExoticComponent<MarkdownEditorProps & import("react").RefAttributes<MarkdownEditorHandle>>;
|
|
18
|
+
export type { MarkdownEditorHandle };
|
|
15
19
|
//# sourceMappingURL=markdown-editor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown-editor.d.ts","sourceRoot":"","sources":["../../src/components/markdown-editor.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"markdown-editor.d.ts","sourceRoot":"","sources":["../../src/components/markdown-editor.tsx"],"names":[],"mappings":"AAoBA,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,oCAAoC,CAAA;AAoB3C,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,sBAAsB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CACjD;AAED,eAAO,MAAM,cAAc,sHAmIzB,CAAA;AAEF,YAAY,EAAE,oBAAoB,EAAE,CAAA"}
|
|
@@ -13,11 +13,12 @@ import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPl
|
|
|
13
13
|
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
|
14
14
|
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
|
15
15
|
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin';
|
|
16
|
-
import { useCallback, useRef } from 'react';
|
|
16
|
+
import { forwardRef, useCallback, useRef } from 'react';
|
|
17
17
|
import { cn } from '@moldable-ai/ui';
|
|
18
18
|
import { LINK_MATCHERS } from '../lib/lexical/auto-link-config';
|
|
19
19
|
import { ClickableLinkPlugin } from '../lib/lexical/clickable-link-plugin';
|
|
20
20
|
import { editorTheme } from '../lib/lexical/editor-theme';
|
|
21
|
+
import { FIND_REPLACE_UPDATE_TAG, FindReplacePlugin, } from '../lib/lexical/find-replace-plugin';
|
|
21
22
|
import { $convertFromMarkdownString, $convertToMarkdownString, markdownTransformers, } from '../lib/lexical/markdown-transformers';
|
|
22
23
|
import { SyncPlugin } from '../lib/lexical/sync-plugin';
|
|
23
24
|
import { FloatingToolbar } from './floating-toolbar';
|
|
@@ -25,9 +26,10 @@ import { CodeHighlightNode, CodeNode } from '@lexical/code';
|
|
|
25
26
|
import { HorizontalRuleNode } from '@lexical/extension';
|
|
26
27
|
import { AutoLinkNode, LinkNode } from '@lexical/link';
|
|
27
28
|
import { ListItemNode, ListNode } from '@lexical/list';
|
|
29
|
+
import { MarkNode } from '@lexical/mark';
|
|
28
30
|
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
|
29
31
|
import { $createParagraphNode, $getRoot } from 'lexical';
|
|
30
|
-
export function MarkdownEditor({ value, onChange, placeholder = 'Write something...', disabled = false, autoFocus = false, className, contentClassName, minHeight = '150px', maxHeight = '50vh', hideMarkdownHint = false, }) {
|
|
32
|
+
export const MarkdownEditor = forwardRef(function MarkdownEditor({ value, onChange, placeholder = 'Write something...', disabled = false, autoFocus = false, className, contentClassName, minHeight = '150px', maxHeight = '50vh', hideMarkdownHint = false, findQuery = '', currentFindMatch = 0, onFindMatchCountChange, }, ref) {
|
|
31
33
|
const initialValueRef = useRef(value);
|
|
32
34
|
const initialConfig = {
|
|
33
35
|
namespace: 'MarkdownEditor',
|
|
@@ -54,6 +56,7 @@ export function MarkdownEditor({ value, onChange, placeholder = 'Write something
|
|
|
54
56
|
CodeHighlightNode,
|
|
55
57
|
AutoLinkNode,
|
|
56
58
|
LinkNode,
|
|
59
|
+
MarkNode,
|
|
57
60
|
HorizontalRuleNode,
|
|
58
61
|
],
|
|
59
62
|
onError: (error) => {
|
|
@@ -62,7 +65,9 @@ export function MarkdownEditor({ value, onChange, placeholder = 'Write something
|
|
|
62
65
|
theme: editorTheme,
|
|
63
66
|
editable: !disabled,
|
|
64
67
|
};
|
|
65
|
-
const handleChange = useCallback((editorState) => {
|
|
68
|
+
const handleChange = useCallback((editorState, _editor, tags) => {
|
|
69
|
+
if (tags.has(FIND_REPLACE_UPDATE_TAG))
|
|
70
|
+
return;
|
|
66
71
|
editorState.read(() => {
|
|
67
72
|
// Use shouldPreserveNewLines: true to preserve empty paragraphs as extra newlines
|
|
68
73
|
const markdown = $convertToMarkdownString({
|
|
@@ -76,5 +81,5 @@ export function MarkdownEditor({ value, onChange, placeholder = 'Write something
|
|
|
76
81
|
}
|
|
77
82
|
});
|
|
78
83
|
}, [onChange]);
|
|
79
|
-
return (_jsx(LexicalComposer, { initialConfig: initialConfig, children: _jsxs("div", { className: cn('relative h-full', className), "data-lexical-editor-container": true, children: [_jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: cn('prose prose-sm caret-primary dark:prose-invert max-w-none resize-none overflow-auto bg-transparent outline-none', disabled && 'cursor-not-allowed opacity-50', contentClassName), style: { minHeight, maxHeight }, "aria-placeholder": placeholder, "data-lexical-editor": "true", placeholder: _jsx("div", { className: "text-muted-foreground pointer-events-none absolute left-0 top-1 text-sm opacity-75", children: placeholder }) }), ErrorBoundary: LexicalErrorBoundary }), _jsx(HistoryPlugin, {}), autoFocus && _jsx(AutoFocusPlugin, {}), _jsx(ListPlugin, {}), _jsx(CheckListPlugin, {}), _jsx(TabIndentationPlugin, {}), _jsx(LinkPlugin, {}), _jsx(AutoLinkPlugin, { matchers: LINK_MATCHERS }), _jsx(ClickableLinkPlugin, {}), _jsx(FloatingToolbar, {}), _jsx(MarkdownShortcutPlugin, { transformers: markdownTransformers }), _jsx(OnChangePlugin, { onChange: handleChange, ignoreSelectionChange: true }), _jsx(SyncPlugin, { value: value, initialValueRef: initialValueRef }), !hideMarkdownHint && (_jsx("div", { className: "mt-1 flex justify-end", children: _jsx("span", { className: "text-muted-foreground/60 text-xs", children: "Markdown supported" }) }))] }) }));
|
|
80
|
-
}
|
|
84
|
+
return (_jsx(LexicalComposer, { initialConfig: initialConfig, children: _jsxs("div", { className: cn('relative h-full', className), "data-lexical-editor-container": true, children: [_jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: cn('prose prose-sm caret-primary dark:prose-invert max-w-none resize-none overflow-auto bg-transparent outline-none', disabled && 'cursor-not-allowed opacity-50', contentClassName), style: { minHeight, maxHeight }, "aria-placeholder": placeholder, "data-lexical-editor": "true", placeholder: _jsx("div", { className: "text-muted-foreground pointer-events-none absolute left-0 top-1 text-sm opacity-75", children: placeholder }) }), ErrorBoundary: LexicalErrorBoundary }), _jsx(HistoryPlugin, {}), autoFocus && _jsx(AutoFocusPlugin, {}), _jsx(ListPlugin, {}), _jsx(CheckListPlugin, {}), _jsx(TabIndentationPlugin, {}), _jsx(LinkPlugin, {}), _jsx(AutoLinkPlugin, { matchers: LINK_MATCHERS }), _jsx(ClickableLinkPlugin, {}), _jsx(FloatingToolbar, {}), _jsx(MarkdownShortcutPlugin, { transformers: markdownTransformers }), _jsx(OnChangePlugin, { onChange: handleChange, ignoreSelectionChange: true }), _jsx(FindReplacePlugin, { query: findQuery, currentIndex: currentFindMatch, onMatchCountChange: onFindMatchCountChange, editorRef: ref }), _jsx(SyncPlugin, { value: value, initialValueRef: initialValueRef }), !hideMarkdownHint && (_jsx("div", { className: "mt-1 flex justify-end", children: _jsx("span", { className: "text-muted-foreground/60 text-xs", children: "Markdown supported" }) }))] }) }));
|
|
85
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { MarkdownEditor } from './components/markdown-editor';
|
|
1
|
+
export { MarkdownEditor, type MarkdownEditorHandle, type MarkdownEditorProps, } from './components/markdown-editor';
|
|
2
2
|
export { FloatingToolbar } from './components/floating-toolbar';
|
|
3
3
|
export { $convertFromMarkdownString, $convertToMarkdownString, markdownTransformers, HR, } from './lib/lexical/markdown-transformers';
|
|
4
4
|
export { editorTheme } from './lib/lexical/editor-theme';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,cAAc,EACd,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,GACzB,MAAM,8BAA8B,CAAA;AACrC,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAA;AAG/D,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,oBAAoB,EACpB,EAAE,GACH,MAAM,qCAAqC,CAAA;AAC5C,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AACxD,OAAO,EACL,aAAa,EACb,SAAS,EACT,WAAW,GACZ,MAAM,gCAAgC,CAAA;AACvC,OAAO,EACL,kBAAkB,EAClB,KAAK,oBAAoB,GAC1B,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AACtD,OAAO,EACL,mBAAmB,EACnB,SAAS,GACV,MAAM,qCAAqC,CAAA;AAG5C,OAAO,EAAE,4BAA4B,EAAE,MAAM,+BAA+B,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Components
|
|
2
|
-
export { MarkdownEditor } from './components/markdown-editor';
|
|
2
|
+
export { MarkdownEditor, } from './components/markdown-editor';
|
|
3
3
|
export { FloatingToolbar } from './components/floating-toolbar';
|
|
4
4
|
// Lexical utilities (for advanced use cases)
|
|
5
5
|
export { $convertFromMarkdownString, $convertToMarkdownString, markdownTransformers, HR, } from './lib/lexical/markdown-transformers';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"editor-theme.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/editor-theme.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,WAAW
|
|
1
|
+
{"version":3,"file":"editor-theme.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/editor-theme.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCvB,CAAA"}
|
|
@@ -32,6 +32,8 @@ export const editorTheme = {
|
|
|
32
32
|
listitemUnchecked: 'lexical-listitem-unchecked',
|
|
33
33
|
},
|
|
34
34
|
link: 'text-primary underline hover:text-primary/80 cursor-pointer',
|
|
35
|
+
mark: 'lexical-mark',
|
|
36
|
+
markOverlap: 'lexical-mark-overlap',
|
|
35
37
|
quote: 'border-l-4 border-muted px-4 py-2 my-2 text-muted-foreground',
|
|
36
38
|
code: 'bg-muted p-3 my-4 rounded-md overflow-x-auto font-mono text-sm leading-loose text-muted-foreground',
|
|
37
39
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ForwardedRef } from 'react';
|
|
2
|
+
import { type LexicalEditor } from 'lexical';
|
|
3
|
+
export interface MarkdownEditorHandle {
|
|
4
|
+
replaceCurrentFindMatch: (replacement: string) => boolean;
|
|
5
|
+
replaceAllFindMatches: (replacement: string) => number;
|
|
6
|
+
focus: () => void;
|
|
7
|
+
}
|
|
8
|
+
interface FindReplacePluginProps {
|
|
9
|
+
query: string;
|
|
10
|
+
currentIndex: number;
|
|
11
|
+
onMatchCountChange?: (count: number) => void;
|
|
12
|
+
editorRef: ForwardedRef<MarkdownEditorHandle>;
|
|
13
|
+
}
|
|
14
|
+
export interface FindMatch {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const FIND_MATCH_MARK_ID = "moldable-find-match";
|
|
19
|
+
export declare const FIND_CURRENT_MARK_ID = "moldable-find-current";
|
|
20
|
+
export declare const FIND_REPLACE_UPDATE_TAG = "moldable-find-replace";
|
|
21
|
+
export declare function getTextMatches(text: string, query: string): FindMatch[];
|
|
22
|
+
export declare function $clearFindMarks(): void;
|
|
23
|
+
export declare function $getFindMatches(query: string): FindMatch[];
|
|
24
|
+
export declare function $applyFindHighlights(query: string, currentIndex: number): number;
|
|
25
|
+
export declare function updateFindHighlights(editor: LexicalEditor, query: string, currentIndex: number): number;
|
|
26
|
+
export declare function registerFindMarkResolver(editor: LexicalEditor): () => void;
|
|
27
|
+
export declare function replaceFindMatch(editor: LexicalEditor, query: string, replacement: string, currentIndex: number): boolean;
|
|
28
|
+
export declare function replaceAllFindMatches(editor: LexicalEditor, query: string, replacement: string): number;
|
|
29
|
+
export declare function FindReplacePlugin({ query, currentIndex, onMatchCountChange, editorRef, }: FindReplacePluginProps): null;
|
|
30
|
+
export {};
|
|
31
|
+
//# sourceMappingURL=find-replace-plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"find-replace-plugin.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/find-replace-plugin.tsx"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,YAAY,EAKlB,MAAM,OAAO,CAAA;AASd,OAAO,EAML,KAAK,aAAa,EAGnB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,oBAAoB;IACnC,uBAAuB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAA;IACzD,qBAAqB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAA;IACtD,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,UAAU,sBAAsB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;IACpB,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5C,SAAS,EAAE,YAAY,CAAC,oBAAoB,CAAC,CAAA;CAC9C;AAaD,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,eAAO,MAAM,kBAAkB,wBAAwB,CAAA;AACvD,eAAO,MAAM,oBAAoB,0BAA0B,CAAA;AAC3D,eAAO,MAAM,uBAAuB,0BAA0B,CAAA;AAM9D,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,CAiBvE;AAwDD,wBAAgB,eAAe,SAgB9B;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,eAG5C;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,UAoBvE;AAED,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,UAcrB;AAeD,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,aAAa,cAa7D;AAED,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,YAAY,EAAE,MAAM,WAsBrB;AAED,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,UAsBpB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,YAAY,EACZ,kBAAkB,EAClB,SAAS,GACV,EAAE,sBAAsB,QAqDxB"}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
+
import { useCallback, useEffect, useImperativeHandle, useRef, } from 'react';
|
|
4
|
+
import { $createMarkNode, $isMarkNode, $unwrapMarkNode, $wrapSelectionInMarkNode, MarkNode, } from '@lexical/mark';
|
|
5
|
+
import { mergeRegister, registerNestedElementResolver } from '@lexical/utils';
|
|
6
|
+
import { $createRangeSelection, $getRoot, $getSelection, $nodesOfType, $setSelection, } from 'lexical';
|
|
7
|
+
export const FIND_MATCH_MARK_ID = 'moldable-find-match';
|
|
8
|
+
export const FIND_CURRENT_MARK_ID = 'moldable-find-current';
|
|
9
|
+
export const FIND_REPLACE_UPDATE_TAG = 'moldable-find-replace';
|
|
10
|
+
function getNormalizedQuery(query) {
|
|
11
|
+
return query.trim().toLocaleLowerCase();
|
|
12
|
+
}
|
|
13
|
+
export function getTextMatches(text, query) {
|
|
14
|
+
const normalizedQuery = getNormalizedQuery(query);
|
|
15
|
+
if (!normalizedQuery)
|
|
16
|
+
return [];
|
|
17
|
+
const normalizedText = text.toLocaleLowerCase();
|
|
18
|
+
const matches = [];
|
|
19
|
+
let searchFrom = 0;
|
|
20
|
+
while (searchFrom <= normalizedText.length - normalizedQuery.length) {
|
|
21
|
+
const start = normalizedText.indexOf(normalizedQuery, searchFrom);
|
|
22
|
+
if (start === -1)
|
|
23
|
+
break;
|
|
24
|
+
matches.push({ start, end: start + normalizedQuery.length });
|
|
25
|
+
searchFrom = start + normalizedQuery.length;
|
|
26
|
+
}
|
|
27
|
+
return matches;
|
|
28
|
+
}
|
|
29
|
+
function $collectTextEntries() {
|
|
30
|
+
const entries = [];
|
|
31
|
+
let text = '';
|
|
32
|
+
for (const node of $getRoot().getAllTextNodes()) {
|
|
33
|
+
const value = node.getTextContent();
|
|
34
|
+
const start = text.length;
|
|
35
|
+
text += value;
|
|
36
|
+
entries.push({ node, start, end: text.length });
|
|
37
|
+
}
|
|
38
|
+
return { entries, text };
|
|
39
|
+
}
|
|
40
|
+
function getTextPosition(entries, offset, bias) {
|
|
41
|
+
if (bias === 'next') {
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (offset >= entry.start && offset < entry.end) {
|
|
44
|
+
return { node: entry.node, offset: offset - entry.start };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (offset > entry.start && offset <= entry.end) {
|
|
51
|
+
return { node: entry.node, offset: offset - entry.start };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const fallback = bias === 'next' ? entries[0] : entries.at(-1);
|
|
56
|
+
if (!fallback)
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
node: fallback.node,
|
|
60
|
+
offset: bias === 'next' ? 0 : fallback.node.getTextContentSize(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function $createSelectionForMatch(match) {
|
|
64
|
+
const { entries } = $collectTextEntries();
|
|
65
|
+
const start = getTextPosition(entries, match.start, 'next');
|
|
66
|
+
const end = getTextPosition(entries, match.end, 'previous');
|
|
67
|
+
if (!start || !end)
|
|
68
|
+
return null;
|
|
69
|
+
const selection = $createRangeSelection();
|
|
70
|
+
selection.setTextNodeRange(start.node, start.offset, end.node, end.offset);
|
|
71
|
+
return selection;
|
|
72
|
+
}
|
|
73
|
+
export function $clearFindMarks() {
|
|
74
|
+
for (const markNode of $nodesOfType(MarkNode)) {
|
|
75
|
+
let nextNode = markNode;
|
|
76
|
+
if (nextNode.hasID(FIND_MATCH_MARK_ID)) {
|
|
77
|
+
nextNode = nextNode.deleteID(FIND_MATCH_MARK_ID);
|
|
78
|
+
}
|
|
79
|
+
if (nextNode.hasID(FIND_CURRENT_MARK_ID)) {
|
|
80
|
+
nextNode = nextNode.deleteID(FIND_CURRENT_MARK_ID);
|
|
81
|
+
}
|
|
82
|
+
if (nextNode.getIDs().length === 0) {
|
|
83
|
+
$unwrapMarkNode(nextNode);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function $getFindMatches(query) {
|
|
88
|
+
const { text } = $collectTextEntries();
|
|
89
|
+
return getTextMatches(text, query);
|
|
90
|
+
}
|
|
91
|
+
export function $applyFindHighlights(query, currentIndex) {
|
|
92
|
+
$clearFindMarks();
|
|
93
|
+
const matches = $getFindMatches(query);
|
|
94
|
+
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
|
95
|
+
const selection = $createSelectionForMatch(matches[index]);
|
|
96
|
+
if (selection) {
|
|
97
|
+
$wrapSelectionInMarkNode(selection, false, FIND_MATCH_MARK_ID);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const currentMatch = matches[currentIndex] ?? matches[0];
|
|
101
|
+
if (currentMatch) {
|
|
102
|
+
const selection = $createSelectionForMatch(currentMatch);
|
|
103
|
+
if (selection) {
|
|
104
|
+
$wrapSelectionInMarkNode(selection, false, FIND_CURRENT_MARK_ID);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return matches.length;
|
|
108
|
+
}
|
|
109
|
+
export function updateFindHighlights(editor, query, currentIndex) {
|
|
110
|
+
let matchCount = 0;
|
|
111
|
+
editor.update(() => {
|
|
112
|
+
const previousSelection = $getSelection()?.clone() ?? null;
|
|
113
|
+
matchCount = $applyFindHighlights(query, currentIndex);
|
|
114
|
+
$setSelection(previousSelection);
|
|
115
|
+
}, { discrete: true, tag: FIND_REPLACE_UPDATE_TAG });
|
|
116
|
+
return matchCount;
|
|
117
|
+
}
|
|
118
|
+
function scrollCurrentMatchIntoView(editor) {
|
|
119
|
+
const editorRoot = editor.getRootElement();
|
|
120
|
+
const currentMatch = editorRoot?.querySelector('.lexical-mark-overlap');
|
|
121
|
+
currentMatch?.scrollIntoView({
|
|
122
|
+
behavior: 'smooth',
|
|
123
|
+
block: 'center',
|
|
124
|
+
inline: 'nearest',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
export function registerFindMarkResolver(editor) {
|
|
128
|
+
return registerNestedElementResolver(editor, MarkNode, (from) => $createMarkNode(from.getIDs()), (from, to) => {
|
|
129
|
+
if (!$isMarkNode(to))
|
|
130
|
+
return;
|
|
131
|
+
for (const id of from.getIDs()) {
|
|
132
|
+
to.addID(id);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export function replaceFindMatch(editor, query, replacement, currentIndex) {
|
|
137
|
+
let replaced = false;
|
|
138
|
+
editor.update(() => {
|
|
139
|
+
$clearFindMarks();
|
|
140
|
+
const matches = $getFindMatches(query);
|
|
141
|
+
const match = matches[currentIndex] ?? matches[0];
|
|
142
|
+
if (!match)
|
|
143
|
+
return;
|
|
144
|
+
const selection = $createSelectionForMatch(match);
|
|
145
|
+
if (!selection)
|
|
146
|
+
return;
|
|
147
|
+
$setSelection(selection);
|
|
148
|
+
selection.insertText(replacement);
|
|
149
|
+
replaced = true;
|
|
150
|
+
}, { discrete: true });
|
|
151
|
+
return replaced;
|
|
152
|
+
}
|
|
153
|
+
export function replaceAllFindMatches(editor, query, replacement) {
|
|
154
|
+
let replacedCount = 0;
|
|
155
|
+
editor.update(() => {
|
|
156
|
+
$clearFindMarks();
|
|
157
|
+
const matches = $getFindMatches(query);
|
|
158
|
+
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
|
159
|
+
const selection = $createSelectionForMatch(matches[index]);
|
|
160
|
+
if (!selection)
|
|
161
|
+
continue;
|
|
162
|
+
$setSelection(selection);
|
|
163
|
+
selection.insertText(replacement);
|
|
164
|
+
replacedCount += 1;
|
|
165
|
+
}
|
|
166
|
+
}, { discrete: true });
|
|
167
|
+
return replacedCount;
|
|
168
|
+
}
|
|
169
|
+
export function FindReplacePlugin({ query, currentIndex, onMatchCountChange, editorRef, }) {
|
|
170
|
+
const [editor] = useLexicalComposerContext();
|
|
171
|
+
const onMatchCountChangeRef = useRef(onMatchCountChange);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
onMatchCountChangeRef.current = onMatchCountChange;
|
|
174
|
+
}, [onMatchCountChange]);
|
|
175
|
+
useImperativeHandle(editorRef, () => ({
|
|
176
|
+
replaceCurrentFindMatch: (replacement) => replaceFindMatch(editor, query, replacement, currentIndex),
|
|
177
|
+
replaceAllFindMatches: (replacement) => replaceAllFindMatches(editor, query, replacement),
|
|
178
|
+
focus: () => editor.focus(),
|
|
179
|
+
}), [currentIndex, editor, query]);
|
|
180
|
+
const applyHighlights = useCallback(() => {
|
|
181
|
+
const matchCount = updateFindHighlights(editor, query, currentIndex);
|
|
182
|
+
onMatchCountChangeRef.current?.(matchCount);
|
|
183
|
+
if (getNormalizedQuery(query) && matchCount > 0) {
|
|
184
|
+
window.requestAnimationFrame(() => scrollCurrentMatchIntoView(editor));
|
|
185
|
+
}
|
|
186
|
+
}, [currentIndex, editor, query]);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
return registerFindMarkResolver(editor);
|
|
189
|
+
}, [editor]);
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
applyHighlights();
|
|
192
|
+
const unregister = mergeRegister(editor.registerUpdateListener(({ tags }) => {
|
|
193
|
+
if (tags.has(FIND_REPLACE_UPDATE_TAG))
|
|
194
|
+
return;
|
|
195
|
+
applyHighlights();
|
|
196
|
+
}), () => {
|
|
197
|
+
editor.update(() => $clearFindMarks(), {
|
|
198
|
+
discrete: true,
|
|
199
|
+
tag: FIND_REPLACE_UPDATE_TAG,
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
return unregister;
|
|
203
|
+
}, [applyHighlights, editor]);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headless-editor.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/headless-editor.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"headless-editor.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/headless-editor.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,wBAAgB,4BAA4B,oCAmB3C"}
|
|
@@ -4,6 +4,7 @@ import { HorizontalRuleNode } from '@lexical/extension';
|
|
|
4
4
|
import { createHeadlessEditor } from '@lexical/headless';
|
|
5
5
|
import { AutoLinkNode, LinkNode } from '@lexical/link';
|
|
6
6
|
import { ListItemNode, ListNode } from '@lexical/list';
|
|
7
|
+
import { MarkNode } from '@lexical/mark';
|
|
7
8
|
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
|
8
9
|
/**
|
|
9
10
|
* Creates a headless Lexical editor for server-side operations.
|
|
@@ -20,6 +21,7 @@ export function createMoldableHeadlessEditor() {
|
|
|
20
21
|
CodeHighlightNode,
|
|
21
22
|
AutoLinkNode,
|
|
22
23
|
LinkNode,
|
|
24
|
+
MarkNode,
|
|
23
25
|
HorizontalRuleNode,
|
|
24
26
|
],
|
|
25
27
|
theme: editorTheme,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moldable-ai/editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Rich text markdown editor for Moldable applications",
|
|
5
5
|
"author": "Desiderata LLC",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -32,18 +32,28 @@
|
|
|
32
32
|
"src/styles",
|
|
33
33
|
"LICENSE"
|
|
34
34
|
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"dev": "tsc --watch",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"lint": "eslint . --ext .ts,.tsx --max-warnings 0",
|
|
41
|
+
"format": "prettier --write . --ignore-path ../../.prettierignore",
|
|
42
|
+
"check-types": "tsc --noEmit"
|
|
43
|
+
},
|
|
35
44
|
"dependencies": {
|
|
36
45
|
"@lexical/clipboard": "^0.39.0",
|
|
37
46
|
"@lexical/code": "^0.39.0",
|
|
38
47
|
"@lexical/extension": "^0.39.0",
|
|
48
|
+
"@lexical/headless": "^0.39.0",
|
|
39
49
|
"@lexical/html": "^0.39.0",
|
|
40
50
|
"@lexical/link": "^0.39.0",
|
|
41
51
|
"@lexical/list": "^0.39.0",
|
|
52
|
+
"@lexical/mark": "^0.39.0",
|
|
42
53
|
"@lexical/markdown": "^0.39.0",
|
|
43
54
|
"@lexical/react": "^0.39.0",
|
|
44
55
|
"@lexical/rich-text": "^0.39.0",
|
|
45
56
|
"@lexical/selection": "^0.39.0",
|
|
46
|
-
"@lexical/headless": "^0.39.0",
|
|
47
57
|
"@lexical/utils": "^0.39.0",
|
|
48
58
|
"lexical": "^0.39.0",
|
|
49
59
|
"lucide-react": "^0.473.0",
|
|
@@ -52,30 +62,21 @@
|
|
|
52
62
|
"remark-gfm": "^4.0.1"
|
|
53
63
|
},
|
|
54
64
|
"devDependencies": {
|
|
65
|
+
"@moldable-ai/eslint-config": "workspace:*",
|
|
66
|
+
"@moldable-ai/prettier-config": "workspace:*",
|
|
67
|
+
"@moldable-ai/typescript-config": "workspace:*",
|
|
68
|
+
"@moldable-ai/ui": "workspace:^",
|
|
55
69
|
"@types/jsdom": "^27.0.0",
|
|
56
70
|
"@types/node": "^24.0.3",
|
|
57
71
|
"@types/react": "^19.1.6",
|
|
58
72
|
"@types/react-dom": "^19.1.6",
|
|
59
73
|
"jsdom": "^27.4.0",
|
|
60
74
|
"typescript": "^5.8.3",
|
|
61
|
-
"vitest": "^4.0.16"
|
|
62
|
-
"@moldable-ai/eslint-config": "0.1.4",
|
|
63
|
-
"@moldable-ai/typescript-config": "0.1.3",
|
|
64
|
-
"@moldable-ai/prettier-config": "0.1.3",
|
|
65
|
-
"@moldable-ai/ui": "^0.2.7"
|
|
75
|
+
"vitest": "^4.0.16"
|
|
66
76
|
},
|
|
67
77
|
"peerDependencies": {
|
|
68
78
|
"@moldable-ai/ui": ">=0.2.7",
|
|
69
79
|
"react": "^19.0.0",
|
|
70
80
|
"react-dom": "^19.0.0"
|
|
71
|
-
},
|
|
72
|
-
"scripts": {
|
|
73
|
-
"build": "tsc",
|
|
74
|
-
"dev": "tsc --watch",
|
|
75
|
-
"test": "vitest run",
|
|
76
|
-
"test:watch": "vitest",
|
|
77
|
-
"lint": "eslint . --ext .ts,.tsx --max-warnings 0",
|
|
78
|
-
"format": "prettier --write . --ignore-path ../../.prettierignore",
|
|
79
|
-
"check-types": "tsc --noEmit"
|
|
80
81
|
}
|
|
81
|
-
}
|
|
82
|
+
}
|
package/src/styles/index.css
CHANGED
|
@@ -80,6 +80,19 @@
|
|
|
80
80
|
word-break: break-word;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/* Find/replace highlights */
|
|
84
|
+
.lexical-mark {
|
|
85
|
+
border-radius: 0.2em;
|
|
86
|
+
background-color: color-mix(in oklch, var(--primary) 24%, transparent);
|
|
87
|
+
color: inherit;
|
|
88
|
+
padding: 0 0.05em;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.lexical-mark-overlap {
|
|
92
|
+
background-color: color-mix(in oklch, var(--primary) 48%, transparent);
|
|
93
|
+
box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 45%, transparent);
|
|
94
|
+
}
|
|
95
|
+
|
|
83
96
|
/* Mention styles */
|
|
84
97
|
.mention {
|
|
85
98
|
color: hsl(var(--primary));
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"translation.test.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/translation.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { createMoldableHeadlessEditor } from './headless-editor';
|
|
3
|
-
import { $extractTranslatableSegments, $applyTranslations, $getTextSegmentsForTranslation, translateEditorState, } from './translation';
|
|
4
|
-
import { $convertFromMarkdownString, $convertToMarkdownString, markdownTransformers } from './markdown-transformers';
|
|
5
|
-
describe('Translation Utilities', () => {
|
|
6
|
-
describe('$extractTranslatableSegments', () => {
|
|
7
|
-
it('extracts text from a simple paragraph', async () => {
|
|
8
|
-
const editor = createMoldableHeadlessEditor();
|
|
9
|
-
await new Promise((resolve) => {
|
|
10
|
-
editor.update(() => {
|
|
11
|
-
$convertFromMarkdownString({ markdown: 'Hello world' });
|
|
12
|
-
resolve();
|
|
13
|
-
});
|
|
14
|
-
});
|
|
15
|
-
const segments = await new Promise((resolve) => {
|
|
16
|
-
editor.getEditorState().read(() => {
|
|
17
|
-
resolve($extractTranslatableSegments());
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
expect(segments).toHaveLength(1);
|
|
21
|
-
expect(segments[0].text).toBe('Hello world');
|
|
22
|
-
});
|
|
23
|
-
it('extracts text from multiple paragraphs', async () => {
|
|
24
|
-
const editor = createMoldableHeadlessEditor();
|
|
25
|
-
await new Promise((resolve) => {
|
|
26
|
-
editor.update(() => {
|
|
27
|
-
$convertFromMarkdownString({ markdown: 'First paragraph\n\nSecond paragraph' });
|
|
28
|
-
resolve();
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
const segments = await new Promise((resolve) => {
|
|
32
|
-
editor.getEditorState().read(() => {
|
|
33
|
-
resolve($extractTranslatableSegments());
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
expect(segments).toHaveLength(2);
|
|
37
|
-
expect(segments[0].text).toBe('First paragraph');
|
|
38
|
-
expect(segments[1].text).toBe('Second paragraph');
|
|
39
|
-
});
|
|
40
|
-
it('extracts text from headings', async () => {
|
|
41
|
-
const editor = createMoldableHeadlessEditor();
|
|
42
|
-
await new Promise((resolve) => {
|
|
43
|
-
editor.update(() => {
|
|
44
|
-
$convertFromMarkdownString({ markdown: '# Title\n\nContent' });
|
|
45
|
-
resolve();
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
const segments = await new Promise((resolve) => {
|
|
49
|
-
editor.getEditorState().read(() => {
|
|
50
|
-
resolve($extractTranslatableSegments());
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
expect(segments).toHaveLength(2);
|
|
54
|
-
expect(segments[0].text).toBe('Title');
|
|
55
|
-
expect(segments[1].text).toBe('Content');
|
|
56
|
-
});
|
|
57
|
-
it('extracts text from list items', async () => {
|
|
58
|
-
const editor = createMoldableHeadlessEditor();
|
|
59
|
-
await new Promise((resolve) => {
|
|
60
|
-
editor.update(() => {
|
|
61
|
-
$convertFromMarkdownString({ markdown: '- Item 1\n- Item 2\n- Item 3' });
|
|
62
|
-
resolve();
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
const segments = await new Promise((resolve) => {
|
|
66
|
-
editor.getEditorState().read(() => {
|
|
67
|
-
resolve($extractTranslatableSegments());
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
expect(segments).toHaveLength(3);
|
|
71
|
-
expect(segments[0].text).toBe('Item 1');
|
|
72
|
-
expect(segments[1].text).toBe('Item 2');
|
|
73
|
-
expect(segments[2].text).toBe('Item 3');
|
|
74
|
-
});
|
|
75
|
-
it('handles bold and italic text as separate segments', async () => {
|
|
76
|
-
const editor = createMoldableHeadlessEditor();
|
|
77
|
-
await new Promise((resolve) => {
|
|
78
|
-
editor.update(() => {
|
|
79
|
-
$convertFromMarkdownString({ markdown: 'This is **bold** and *italic* text' });
|
|
80
|
-
resolve();
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
const segments = await new Promise((resolve) => {
|
|
84
|
-
editor.getEditorState().read(() => {
|
|
85
|
-
resolve($extractTranslatableSegments());
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
// Bold and italic are separate text nodes
|
|
89
|
-
expect(segments.length).toBeGreaterThanOrEqual(3);
|
|
90
|
-
const allText = segments.map(s => s.text).join('');
|
|
91
|
-
expect(allText).toContain('bold');
|
|
92
|
-
expect(allText).toContain('italic');
|
|
93
|
-
});
|
|
94
|
-
it('extracts text from blockquotes', async () => {
|
|
95
|
-
const editor = createMoldableHeadlessEditor();
|
|
96
|
-
await new Promise((resolve) => {
|
|
97
|
-
editor.update(() => {
|
|
98
|
-
$convertFromMarkdownString({ markdown: '> This is a quote' });
|
|
99
|
-
resolve();
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
const segments = await new Promise((resolve) => {
|
|
103
|
-
editor.getEditorState().read(() => {
|
|
104
|
-
resolve($extractTranslatableSegments());
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
expect(segments).toHaveLength(1);
|
|
108
|
-
expect(segments[0].text).toBe('This is a quote');
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
describe('$applyTranslations', () => {
|
|
112
|
-
it('applies translations to text nodes', async () => {
|
|
113
|
-
const editor = createMoldableHeadlessEditor();
|
|
114
|
-
// Set up initial content
|
|
115
|
-
await new Promise((resolve) => {
|
|
116
|
-
editor.update(() => {
|
|
117
|
-
$convertFromMarkdownString({ markdown: 'Hello world' });
|
|
118
|
-
resolve();
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
// Get segments
|
|
122
|
-
const segments = await new Promise((resolve) => {
|
|
123
|
-
editor.getEditorState().read(() => {
|
|
124
|
-
resolve($extractTranslatableSegments());
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
// Apply translations
|
|
128
|
-
const translationMap = new Map();
|
|
129
|
-
translationMap.set(segments[0].nodeKey, 'Hej verden');
|
|
130
|
-
await new Promise((resolve) => {
|
|
131
|
-
editor.update(() => {
|
|
132
|
-
$applyTranslations(translationMap);
|
|
133
|
-
resolve();
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
// Verify translation was applied
|
|
137
|
-
const result = await new Promise((resolve) => {
|
|
138
|
-
editor.getEditorState().read(() => {
|
|
139
|
-
resolve($convertToMarkdownString({ transformers: markdownTransformers }));
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
expect(result).toBe('Hej verden');
|
|
143
|
-
});
|
|
144
|
-
it('preserves structure when applying translations', async () => {
|
|
145
|
-
const editor = createMoldableHeadlessEditor();
|
|
146
|
-
await new Promise((resolve) => {
|
|
147
|
-
editor.update(() => {
|
|
148
|
-
$convertFromMarkdownString({ markdown: '# Title\n\n- Item 1\n- Item 2' });
|
|
149
|
-
resolve();
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
const segments = await new Promise((resolve) => {
|
|
153
|
-
editor.getEditorState().read(() => {
|
|
154
|
-
resolve($extractTranslatableSegments());
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
// Translate each segment
|
|
158
|
-
const translationMap = new Map();
|
|
159
|
-
segments.forEach((seg) => {
|
|
160
|
-
if (seg.text === 'Title')
|
|
161
|
-
translationMap.set(seg.nodeKey, 'Titel');
|
|
162
|
-
if (seg.text === 'Item 1')
|
|
163
|
-
translationMap.set(seg.nodeKey, 'Element 1');
|
|
164
|
-
if (seg.text === 'Item 2')
|
|
165
|
-
translationMap.set(seg.nodeKey, 'Element 2');
|
|
166
|
-
});
|
|
167
|
-
await new Promise((resolve) => {
|
|
168
|
-
editor.update(() => {
|
|
169
|
-
$applyTranslations(translationMap);
|
|
170
|
-
resolve();
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
const result = await new Promise((resolve) => {
|
|
174
|
-
editor.getEditorState().read(() => {
|
|
175
|
-
resolve($convertToMarkdownString({ transformers: markdownTransformers }));
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
expect(result).toContain('# Titel');
|
|
179
|
-
expect(result).toContain('- Element 1');
|
|
180
|
-
expect(result).toContain('- Element 2');
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
describe('$getTextSegmentsForTranslation', () => {
|
|
184
|
-
it('returns parallel arrays of texts and keys', async () => {
|
|
185
|
-
const editor = createMoldableHeadlessEditor();
|
|
186
|
-
await new Promise((resolve) => {
|
|
187
|
-
editor.update(() => {
|
|
188
|
-
$convertFromMarkdownString({ markdown: 'Hello\n\nWorld' });
|
|
189
|
-
resolve();
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
const result = await new Promise((resolve) => {
|
|
193
|
-
editor.getEditorState().read(() => {
|
|
194
|
-
resolve($getTextSegmentsForTranslation());
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
expect(result.texts).toHaveLength(2);
|
|
198
|
-
expect(result.nodeKeys).toHaveLength(2);
|
|
199
|
-
expect(result.texts[0]).toBe('Hello');
|
|
200
|
-
expect(result.texts[1]).toBe('World');
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
describe('translateEditorState', () => {
|
|
204
|
-
it('translates content and returns markdown', async () => {
|
|
205
|
-
const editor = createMoldableHeadlessEditor();
|
|
206
|
-
await new Promise((resolve) => {
|
|
207
|
-
editor.update(() => {
|
|
208
|
-
$convertFromMarkdownString({ markdown: '# Hello\n\nWorld' });
|
|
209
|
-
resolve();
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
// Mock translate function
|
|
213
|
-
const translateFn = async (texts) => {
|
|
214
|
-
return texts.map((t) => {
|
|
215
|
-
if (t === 'Hello')
|
|
216
|
-
return 'Bonjour';
|
|
217
|
-
if (t === 'World')
|
|
218
|
-
return 'Monde';
|
|
219
|
-
return t;
|
|
220
|
-
});
|
|
221
|
-
};
|
|
222
|
-
const result = await translateEditorState(editor, translateFn);
|
|
223
|
-
expect(result).toContain('# Bonjour');
|
|
224
|
-
expect(result).toContain('Monde');
|
|
225
|
-
});
|
|
226
|
-
it('preserves formatting during translation', async () => {
|
|
227
|
-
const editor = createMoldableHeadlessEditor();
|
|
228
|
-
await new Promise((resolve) => {
|
|
229
|
-
editor.update(() => {
|
|
230
|
-
$convertFromMarkdownString({
|
|
231
|
-
markdown: '**Bold text** and *italic text*'
|
|
232
|
-
});
|
|
233
|
-
resolve();
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
const translateFn = async (texts) => {
|
|
237
|
-
return texts.map((t) => `[${t}]`); // Just wrap for testing
|
|
238
|
-
};
|
|
239
|
-
const result = await translateEditorState(editor, translateFn);
|
|
240
|
-
// Should preserve bold and italic markers
|
|
241
|
-
expect(result).toContain('**');
|
|
242
|
-
expect(result).toContain('*');
|
|
243
|
-
});
|
|
244
|
-
it('handles complex medical note structure', async () => {
|
|
245
|
-
const editor = createMoldableHeadlessEditor();
|
|
246
|
-
const medicalNote = `# Patient Notes
|
|
247
|
-
|
|
248
|
-
## Chief Complaint
|
|
249
|
-
Patient presents with chest pain.
|
|
250
|
-
|
|
251
|
-
## Assessment
|
|
252
|
-
- Stable condition
|
|
253
|
-
- No immediate concerns
|
|
254
|
-
|
|
255
|
-
> Doctor's note: Follow up in 2 weeks`;
|
|
256
|
-
await new Promise((resolve) => {
|
|
257
|
-
editor.update(() => {
|
|
258
|
-
$convertFromMarkdownString({ markdown: medicalNote });
|
|
259
|
-
resolve();
|
|
260
|
-
});
|
|
261
|
-
});
|
|
262
|
-
// Simple translation that uppercases everything
|
|
263
|
-
const translateFn = async (texts) => {
|
|
264
|
-
return texts.map((t) => t.toUpperCase());
|
|
265
|
-
};
|
|
266
|
-
const result = await translateEditorState(editor, translateFn);
|
|
267
|
-
// Should preserve structure
|
|
268
|
-
expect(result).toContain('# PATIENT NOTES');
|
|
269
|
-
expect(result).toContain('## CHIEF COMPLAINT');
|
|
270
|
-
expect(result).toContain('- STABLE CONDITION');
|
|
271
|
-
expect(result).toContain('>');
|
|
272
|
-
});
|
|
273
|
-
it('handles empty content', async () => {
|
|
274
|
-
const editor = createMoldableHeadlessEditor();
|
|
275
|
-
await new Promise((resolve) => {
|
|
276
|
-
editor.update(() => {
|
|
277
|
-
$convertFromMarkdownString({ markdown: '' });
|
|
278
|
-
resolve();
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
const translateFn = async (texts) => texts;
|
|
282
|
-
const result = await translateEditorState(editor, translateFn);
|
|
283
|
-
expect(result).toBe('');
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
|
-
describe('round-trip translation', () => {
|
|
287
|
-
const testCases = [
|
|
288
|
-
{ name: 'simple paragraph', markdown: 'Hello world' },
|
|
289
|
-
{ name: 'heading', markdown: '# Title' },
|
|
290
|
-
{ name: 'multiple headings', markdown: '# H1\n\n## H2\n\n### H3' },
|
|
291
|
-
{ name: 'bullet list', markdown: '- Item 1\n- Item 2\n- Item 3' },
|
|
292
|
-
{ name: 'numbered list', markdown: '1. First\n2. Second\n3. Third' },
|
|
293
|
-
{ name: 'blockquote', markdown: '> This is a quote' },
|
|
294
|
-
{ name: 'bold text', markdown: '**bold**' },
|
|
295
|
-
{ name: 'italic text', markdown: '*italic*' },
|
|
296
|
-
{ name: 'mixed formatting', markdown: 'Normal **bold** and *italic*' },
|
|
297
|
-
{ name: 'code inline', markdown: 'Use `code` here' },
|
|
298
|
-
{ name: 'horizontal rule', markdown: 'Before\n\n***\n\nAfter' },
|
|
299
|
-
];
|
|
300
|
-
testCases.forEach(({ name, markdown }) => {
|
|
301
|
-
it(`preserves structure for ${name}`, async () => {
|
|
302
|
-
const editor = createMoldableHeadlessEditor();
|
|
303
|
-
await new Promise((resolve) => {
|
|
304
|
-
editor.update(() => {
|
|
305
|
-
$convertFromMarkdownString({ markdown });
|
|
306
|
-
resolve();
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
// Identity translation (no change)
|
|
310
|
-
const translateFn = async (texts) => texts;
|
|
311
|
-
const result = await translateEditorState(editor, translateFn);
|
|
312
|
-
// Re-import and re-export to normalize
|
|
313
|
-
const editor2 = createMoldableHeadlessEditor();
|
|
314
|
-
await new Promise((resolve) => {
|
|
315
|
-
editor2.update(() => {
|
|
316
|
-
$convertFromMarkdownString({ markdown });
|
|
317
|
-
resolve();
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
const expected = await new Promise((resolve) => {
|
|
321
|
-
editor2.getEditorState().read(() => {
|
|
322
|
-
resolve($convertToMarkdownString({ transformers: markdownTransformers }));
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
expect(result).toBe(expected);
|
|
326
|
-
});
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
});
|