@moldable-ai/editor 0.1.6 → 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.
@@ -1,4 +1,5 @@
1
- interface MarkdownEditorProps {
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 function MarkdownEditor({ value, onChange, placeholder, disabled, autoFocus, className, contentClassName, minHeight, maxHeight, hideMarkdownHint, }: MarkdownEditorProps): import("react/jsx-runtime").JSX.Element;
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":"AAsCA,UAAU,mBAAmB;IAC3B,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;CAC3B;AAED,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,QAAQ,EACR,WAAkC,EAClC,QAAgB,EAChB,SAAiB,EACjB,SAAS,EACT,gBAAgB,EAChB,SAAmB,EACnB,SAAkB,EAClB,gBAAwB,GACzB,EAAE,mBAAmB,2CAsGrB"}
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';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA;AAC7D,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"}
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';
@@ -32,6 +32,8 @@ export declare const editorTheme: {
32
32
  listitemUnchecked: string;
33
33
  };
34
34
  link: string;
35
+ mark: string;
36
+ markOverlap: string;
35
37
  quote: string;
36
38
  code: string;
37
39
  };
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCvB,CAAA"}
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":"AAQA;;;GAGG;AACH,wBAAgB,4BAA4B,oCAkB3C"}
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.6",
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",
@@ -45,14 +45,15 @@
45
45
  "@lexical/clipboard": "^0.39.0",
46
46
  "@lexical/code": "^0.39.0",
47
47
  "@lexical/extension": "^0.39.0",
48
+ "@lexical/headless": "^0.39.0",
48
49
  "@lexical/html": "^0.39.0",
49
50
  "@lexical/link": "^0.39.0",
50
51
  "@lexical/list": "^0.39.0",
52
+ "@lexical/mark": "^0.39.0",
51
53
  "@lexical/markdown": "^0.39.0",
52
54
  "@lexical/react": "^0.39.0",
53
55
  "@lexical/rich-text": "^0.39.0",
54
56
  "@lexical/selection": "^0.39.0",
55
- "@lexical/headless": "^0.39.0",
56
57
  "@lexical/utils": "^0.39.0",
57
58
  "lexical": "^0.39.0",
58
59
  "lucide-react": "^0.473.0",
@@ -74,7 +75,7 @@
74
75
  "vitest": "^4.0.16"
75
76
  },
76
77
  "peerDependencies": {
77
- "@moldable-ai/ui": ">=0.2.5",
78
+ "@moldable-ai/ui": ">=0.2.7",
78
79
  "react": "^19.0.0",
79
80
  "react-dom": "^19.0.0"
80
81
  }
@@ -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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=translation.test.d.ts.map
@@ -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
- });