@antscorp/antsomi-ui 2.0.111 → 2.0.112

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.
@@ -38,7 +38,7 @@ import { analyzeFont, defaultShouldShowBubbleMenu, getAllFullLinkGroups, handleL
38
38
  import { BubbleMenu } from './ui/BubbleMenu';
39
39
  import { LinkPopover } from './ui/LinkPopover';
40
40
  const TextEditorInternal = memo(forwardRef((props, ref) => {
41
- const { id, className, config, editable = true, initialContent = '', dataAttributes, onUpdateDebounced = 300, defaultTextStyle: defaultTextStyleProp, linkHandler: outerLinkHandler, smartTagHandler: outerSmartTagHandler, bubbleMenuProps, style, render, onUpdate, onFocus, onChangeFont, } = props;
41
+ const { id, className, config, editable = true, initialContent = '', dataAttributes, onUpdateDebounced = 300, defaultTextStyle: defaultTextStyleProp, linkHandler: outerLinkHandler, smartTagHandler: outerSmartTagHandler, bubbleMenuProps, style, render, onUpdate, onFocus, onCreate, onChangeFont, } = props;
42
42
  // Initialize bubbleMenuContainer with default value (document.body)
43
43
  // This ensures BubbleMenu receives a valid appendTo prop on first render
44
44
  const bubbleMenuContainer = useRef((() => {
@@ -106,7 +106,7 @@ const TextEditorInternal = memo(forwardRef((props, ref) => {
106
106
  bulletList: false,
107
107
  orderedList: false,
108
108
  listItem: false,
109
- undoRedo: {
109
+ undoRedo: config?.UndoRedo ?? {
110
110
  depth: 100,
111
111
  },
112
112
  }),
@@ -153,7 +153,9 @@ const TextEditorInternal = memo(forwardRef((props, ref) => {
153
153
  Focus.configure({
154
154
  className: 'has-focused',
155
155
  }),
156
- SmartTag,
156
+ SmartTag.configure({
157
+ generateSmartTagId: config?.SmartTag?.generateId,
158
+ }),
157
159
  TextAlign.configure({
158
160
  types: ['heading', 'paragraph'],
159
161
  }),
@@ -187,6 +189,14 @@ const TextEditorInternal = memo(forwardRef((props, ref) => {
187
189
  if (safeParseContent !== initialContent) {
188
190
  editor.commands.setContent(safeParseContent);
189
191
  }
192
+ const text = editor.getText();
193
+ const json = editor.getJSON();
194
+ const html = editor.getHTML();
195
+ onCreate?.({
196
+ html: htmlSerializerForOutput(html),
197
+ text,
198
+ json,
199
+ });
190
200
  },
191
201
  onUpdate: ({ editor }) => {
192
202
  handleOnUpdateDebounce(editor);
@@ -291,23 +301,20 @@ const TextEditorInternal = memo(forwardRef((props, ref) => {
291
301
  })
292
302
  .run();
293
303
  },
304
+ getHTML: () => htmlSerializerForOutput(editor.getHTML()),
294
305
  eachLinkGroup: handleEachLinkGroup,
295
306
  eachSmartag: handleEachSmartag,
296
307
  setSmartTag: handleSetSmartTag,
297
308
  deleteSmartTag: handleDeleteSmartTag,
298
309
  updateSmartTagAttrs: handleUpdateSmartTagAttrs,
299
310
  blur: handleBlur,
311
+ setContent: editor.commands.setContent,
300
312
  style: contentRef.current?.style,
301
- editor,
302
313
  }));
303
314
  if (!editor) {
304
315
  return null;
305
316
  }
306
- return (_jsxs(_Fragment, { children: [_jsx(StyledEditorContent, { className: clsx(`${ANTSOMI_COMPONENT_PREFIX_CLS}-text-editor`, className), "$textStyle": defaultTextStyle, style: {
307
- // Inline styles apply to inner html elements (support email client)
308
- ...defaultTextStyle,
309
- ...style,
310
- }, id: id, ref: contentRef, editor: editor, ...dataAttributes }), _jsx(LinkPopover, { editor: editor }), !linkFormVisible && (_jsxs(_Fragment, { children: [_jsx(BubbleMenu, { pluginKey: "linkPreviewBubbleMenu", ...bubbleMenuProps, appendTo: bubbleMenuContainer.current, editor: editor, shouldShow: shouldShowLinkPreview, children: _jsx(LinkPreviewToolbar, { editor: editor, linkHanlder: {
317
+ const bubbleMenus = (bubbleMenuProps?.enabled ?? true) ? (_jsxs(_Fragment, { children: [_jsx(LinkPopover, { editor: editor }), !linkFormVisible && (_jsxs(_Fragment, { children: [_jsx(BubbleMenu, { pluginKey: "linkPreviewBubbleMenu", ...bubbleMenuProps, appendTo: bubbleMenuContainer.current, editor: editor, shouldShow: shouldShowLinkPreview, children: _jsx(LinkPreviewToolbar, { editor: editor, linkHanlder: {
311
318
  onUpsert: () => {
312
319
  handleLinkAction(editor.view, linkHandler);
313
320
  },
@@ -325,7 +332,12 @@ const TextEditorInternal = memo(forwardRef((props, ref) => {
325
332
  weight,
326
333
  analysis: analyzeFont(font),
327
334
  });
328
- } }) })] }))] }));
335
+ } }) })] }))] })) : null;
336
+ return (_jsxs(_Fragment, { children: [_jsx(StyledEditorContent, { className: clsx(`${ANTSOMI_COMPONENT_PREFIX_CLS}-text-editor`, className), "$textStyle": defaultTextStyle, style: {
337
+ // Inline styles apply to inner html elements (support email client)
338
+ ...defaultTextStyle,
339
+ ...style,
340
+ }, id: id, ref: contentRef, editor: editor, ...dataAttributes }), bubbleMenus] }));
329
341
  }));
330
342
  TextEditorInternal.displayName = 'TextEditorInternal';
331
343
  const TextEditorWithProvider = forwardRef((props, ref) => {
@@ -343,8 +355,8 @@ const TextEditorWithProvider = forwardRef((props, ref) => {
343
355
  get style() {
344
356
  return editorRef.current?.style;
345
357
  },
346
- get editor() {
347
- return editorRef.current?.editor ?? null;
358
+ get getHTML() {
359
+ return editorRef.current.getHTML;
348
360
  },
349
361
  get setLink() {
350
362
  return editorRef.current.setLink;
@@ -376,6 +388,9 @@ const TextEditorWithProvider = forwardRef((props, ref) => {
376
388
  get blur() {
377
389
  return editorRef.current.blur;
378
390
  },
391
+ get setContent() {
392
+ return editorRef.current.setContent;
393
+ },
379
394
  // Forward TextEditorProviderRefHandler methods
380
395
  updateColors: colors => providerRef.current?.updateColors?.(colors),
381
396
  }), []);
@@ -40,22 +40,41 @@ export const CustomLink = TiptapLinkExtension.extend({
40
40
  setCustomLink: ({ attrs, content }) => ({ state, chain }) => {
41
41
  const { selection } = state;
42
42
  const { from, to } = selection;
43
- let linkContent = textBetween(state, from, to);
44
- if (content && linkContent !== content) {
45
- linkContent = content;
43
+ // Check if custom content is provided and different from current text
44
+ const currentText = textBetween(state, from, to);
45
+ const shouldUseCustomContent = content && currentText !== content;
46
+ if (shouldUseCustomContent) {
47
+ // Use custom content as string (replace content)
48
+ return chain()
49
+ .focus()
50
+ .deleteSelection()
51
+ .insertContent(content)
52
+ .command(({ tr }) => {
53
+ const endPos = tr.selection.from;
54
+ const startPos = endPos - content.length;
55
+ tr.setSelection(TextSelection.create(tr.doc, startPos, endPos));
56
+ return true;
57
+ })
58
+ .setLink({
59
+ ...attrs,
60
+ href: attrs?.href || './',
61
+ })
62
+ .command(({ tr }) => {
63
+ const endPos = tr.selection.to;
64
+ tr.setSelection(TextSelection.create(tr.doc, endPos, endPos));
65
+ return true;
66
+ })
67
+ .run();
46
68
  }
47
- return (chain()
69
+ // Preserve existing content structure (atom nodes like smartTag)
70
+ // Just apply link mark on current selection without modifying content
71
+ return chain()
48
72
  .focus()
49
- .deleteSelection()
50
- .insertContent(linkContent)
51
73
  .command(({ tr }) => {
52
- const endPos = tr.selection.from;
53
- const startPos = endPos - linkContent.length;
54
- tr.setSelection(TextSelection.create(tr.doc, startPos, endPos));
74
+ // Ensure selection is set correctly
75
+ tr.setSelection(TextSelection.create(tr.doc, from, to));
55
76
  return true;
56
77
  })
57
- // .setUnderline()
58
- // .setColor(LINK_TEXT_COLOR)
59
78
  .setLink({
60
79
  ...attrs,
61
80
  href: attrs?.href || './',
@@ -65,7 +84,7 @@ export const CustomLink = TiptapLinkExtension.extend({
65
84
  tr.setSelection(TextSelection.create(tr.doc, endPos, endPos));
66
85
  return true;
67
86
  })
68
- .run());
87
+ .run();
69
88
  },
70
89
  deleteCustomLink: predicate => ({ state, chain }) => {
71
90
  const { doc } = state;
@@ -5,6 +5,11 @@ import { Attrs } from '@tiptap/pm/model';
5
5
  */
6
6
  export interface SmartTagOptions {
7
7
  HTMLAttributes: Record<string, unknown>;
8
+ /**
9
+ * Custom function to generate unique IDs for smart tags
10
+ * If not provided, falls back to uniqid library
11
+ */
12
+ generateSmartTagId?: () => string;
8
13
  }
9
14
  /**
10
15
  * Represents the options for the SmartTag node.
@@ -1,4 +1,7 @@
1
- import { Node, mergeAttributes } from '@tiptap/core';
1
+ import { Node, getMarksBetween, mergeAttributes } from '@tiptap/core';
2
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
3
+ import uniqid from 'uniqid';
4
+ import { isSmartTagNode } from '../types';
2
5
  export const EXTENSION_NAME = 'smartTag';
3
6
  /**
4
7
  * Represents a dynamic tag node in the editor.
@@ -12,6 +15,7 @@ export const SmartTag = Node.create({
12
15
  addOptions() {
13
16
  return {
14
17
  HTMLAttributes: {},
18
+ generateSmartTagId: () => uniqid(),
15
19
  };
16
20
  },
17
21
  addAttributes() {
@@ -29,6 +33,11 @@ export const SmartTag = Node.create({
29
33
  parseHTML: el => el.getAttribute('tag'),
30
34
  renderHTML: attrs => (attrs.tag ? { tag: attrs.tag } : null),
31
35
  },
36
+ // Transient attribute - not persisted to HTML, used for tracking paste operations
37
+ sourceId: {
38
+ default: null,
39
+ rendered: false, // Don't include in HTML output
40
+ },
32
41
  };
33
42
  },
34
43
  parseHTML() {
@@ -57,10 +66,10 @@ export const SmartTag = Node.create({
57
66
  addCommands() {
58
67
  return {
59
68
  setSmartTag: attrs => ({ chain, state }) => {
60
- const { selection, storedMarks } = state;
61
- const { $from, from, to } = selection;
69
+ const { selection } = state;
70
+ const { from, to } = selection;
62
71
  // Get marks from inside the selection to capture boundary marks
63
- const marks = storedMarks || (from !== to ? state.doc.resolve(from + 1).marks() : $from.marks());
72
+ const marks = getMarksBetween(from, to, state.doc).map(markRange => markRange.mark);
64
73
  const smartTagNode = this.type.create(attrs);
65
74
  const nodeWithMarks = marks.length > 0 ? smartTagNode.mark(marks) : smartTagNode;
66
75
  return chain()
@@ -100,4 +109,86 @@ export const SmartTag = Node.create({
100
109
  },
101
110
  };
102
111
  },
112
+ addProseMirrorPlugins() {
113
+ // Smart Tag Deduplication Plugin
114
+ // Responsibility: Set sourceId for pasted smart tags (for external component to handle cloning)
115
+ const deduplicationPlugin = new Plugin({
116
+ key: new PluginKey('smartTagDeduplication'),
117
+ appendTransaction: (transactions, oldState, newState) => {
118
+ // Only process if there was a paste or document change
119
+ const hasPaste = transactions.some(tr => tr.getMeta('paste'));
120
+ const hasDocChange = transactions.some(tr => tr.docChanged);
121
+ if (!hasPaste && !hasDocChange) {
122
+ return null;
123
+ }
124
+ // Build position mapping from oldState to newState
125
+ // This accounts for position shifts when pasting
126
+ const { mapping } = transactions[0];
127
+ // Build a map of existing smart tag IDs in oldState (before paste)
128
+ const oldStateIds = new Map();
129
+ oldState.doc.descendants((node, pos) => {
130
+ if (isSmartTagNode(node)) {
131
+ oldStateIds.set(node.attrs.id, { pos });
132
+ }
133
+ });
134
+ // Collect all smart tag IDs in newState (after paste)
135
+ const newStateIds = new Map();
136
+ newState.doc.descendants((node, pos) => {
137
+ if (isSmartTagNode(node)) {
138
+ const { id } = node.attrs;
139
+ if (!newStateIds.has(id)) {
140
+ newStateIds.set(id, []);
141
+ }
142
+ newStateIds.get(id).push({ pos, node });
143
+ }
144
+ });
145
+ // Find duplicates: IDs that appear multiple times in newState
146
+ const nodesToSetSourceId = new Map();
147
+ newStateIds.forEach((positions, id) => {
148
+ if (positions.length > 1) {
149
+ // This ID has duplicates
150
+ const oldInfo = oldStateIds.get(id);
151
+ if (oldInfo) {
152
+ // Map old position to new position accounting for document changes
153
+ const mappedPos = mapping.map(oldInfo.pos);
154
+ // Filter out the original node (by mapped position), keep only pasted nodes
155
+ const pastedNodes = positions.filter(({ pos }) => pos !== mappedPos);
156
+ if (pastedNodes.length > 0) {
157
+ nodesToSetSourceId.set(id, pastedNodes);
158
+ }
159
+ }
160
+ else {
161
+ // No original in oldState - all are new (e.g., paste multiple new smart tags)
162
+ // Pick first as "original", rest get sourceId
163
+ const [_first, ...rest] = positions;
164
+ if (rest.length > 0) {
165
+ nodesToSetSourceId.set(id, rest);
166
+ }
167
+ }
168
+ }
169
+ });
170
+ // If no duplicates found, no action needed
171
+ if (nodesToSetSourceId.size === 0) {
172
+ return null;
173
+ }
174
+ // Set sourceId for pasted nodes (do NOT generate new IDs)
175
+ // External component will handle ID generation and attrs cloning
176
+ const { tr } = newState;
177
+ let hasChanges = false;
178
+ nodesToSetSourceId.forEach((positions, sourceId) => {
179
+ positions.forEach(({ pos, node }) => {
180
+ // Only set sourceId, keep original ID
181
+ tr.setNodeMarkup(pos, undefined, {
182
+ ...node.attrs,
183
+ sourceId,
184
+ id: this.options.generateSmartTagId?.() || uniqid(),
185
+ });
186
+ hasChanges = true;
187
+ });
188
+ });
189
+ return hasChanges ? tr : null;
190
+ },
191
+ });
192
+ return [deduplicationPlugin];
193
+ },
103
194
  });
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import type { JSONContent, Editor, MarkRange } from '@tiptap/core';
2
+ import type { JSONContent, MarkRange } from '@tiptap/core';
3
3
  import { BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu';
4
4
  import type { Mark, Node } from '@tiptap/pm/model';
5
5
  import { ORDERED_LIST_STYLE_TYPE, UNORDERED_LIST_STYLE_TYPE } from './constants';
@@ -7,7 +7,7 @@ import type { OverrideProperties } from 'type-fest';
7
7
  export type HandleSmartTagRef = {
8
8
  setSmartTag: (attrs: SmartTagAttrs) => void;
9
9
  deleteSmartTag: (id: string) => void;
10
- updateSmartTagAttrs: (id: string, updatedAttrs: Omit<SmartTagAttrs, 'id'>) => void;
10
+ updateSmartTagAttrs: (id: string, updatedAttrs: Partial<SmartTagAttrs>) => void;
11
11
  };
12
12
  export type HandleLinkRef = {
13
13
  setLink: (params: {
@@ -30,7 +30,8 @@ export type HandleLinkRef = {
30
30
  export type TextEditorRef = HandleSmartTagRef & HandleLinkRef & {
31
31
  blur: (perserveSelection?: boolean) => void;
32
32
  style?: CSSStyleDeclaration;
33
- readonly editor: Editor | null;
33
+ getHTML?: () => string;
34
+ setContent?: (text: string) => void;
34
35
  };
35
36
  export type SmartTagHandler = Partial<{
36
37
  edit: (id: string, event: React.MouseEvent) => void;
@@ -126,6 +127,7 @@ export type ToolbarConfig = {
126
127
  * Configuration options for TextEditor components
127
128
  */
128
129
  export type Config = Partial<{
130
+ UndoRedo: false;
129
131
  /** Font family configuration */
130
132
  FontFamily: {
131
133
  /** Array of available font configurations */
@@ -140,6 +142,9 @@ export type Config = Partial<{
140
142
  /** Whether to use custom bullet styles */
141
143
  useCustomBullet?: boolean;
142
144
  };
145
+ SmartTag: {
146
+ generateId?: () => string;
147
+ };
143
148
  /** Toolbar configuration */
144
149
  Toolbar: ToolbarConfig;
145
150
  }>;
@@ -169,7 +174,9 @@ export type TextEditorProps = {
169
174
  smartTagHandler?: SmartTagHandler;
170
175
  linkHandler?: LinkHandler;
171
176
  editable?: boolean;
172
- bubbleMenuProps?: Pick<BubbleMenuPluginProps, 'options' | 'appendTo'>;
177
+ bubbleMenuProps?: Pick<BubbleMenuPluginProps, 'options' | 'appendTo'> & {
178
+ enabled?: boolean;
179
+ };
173
180
  defaultTextStyle?: Partial<TextStyle>;
174
181
  dataAttributes?: Record<`data-${string}`, unknown>;
175
182
  onUpdateDebounced?: number;
@@ -180,6 +187,11 @@ export type TextEditorProps = {
180
187
  text: string;
181
188
  json: JSONContent;
182
189
  }) => void;
190
+ onCreate?: (args: {
191
+ html: string;
192
+ text: string;
193
+ json: JSONContent;
194
+ }) => void;
183
195
  onChangeFont?: (changeInfo: {
184
196
  font: FontConfig;
185
197
  weight: number;
@@ -211,6 +223,7 @@ export type LinkAttrs = {
211
223
  }>;
212
224
  export type SmartTagAttrs = {
213
225
  id: string;
226
+ sourceId?: string;
214
227
  } & Partial<{
215
228
  content: string;
216
229
  tag: string;
@@ -25,7 +25,7 @@ export const FormattingToolbar = (props) => {
25
25
  }, [config?.Toolbar]);
26
26
  // Map action names to React components
27
27
  const actionComponentMap = useMemo(() => ({
28
- fontFamily: (_jsx(FontFamilyAction, { editor: editor, fonts: config?.FontFamily?.fonts, onChange: (font, weight) => onChangeFont?.({ font, weight }), fontGroupingFn: config?.FontFamily?.fontGroupingFn, groupOrder: config?.FontFamily?.groupOrder }, "fontFamily")),
28
+ fontFamily: (_jsx(FontFamilyAction, { editor: editor, fonts: config?.FontFamily?.fonts, onChange: (font, weight) => onChangeFont?.({ font, weight }), fontGroupingFn: config?.FontFamily?.fontGroupingFn, groupOrder: config?.FontFamily?.groupOrder, defaultFontFamily: defaultTextStyle.fontFamily }, "fontFamily")),
29
29
  fontSize: (_jsx(FontSizeAction, { editor: editor, defaultFontSize: defaultTextStyle.fontSize }, "fontSize")),
30
30
  bold: _jsx(BoldAction, { editor: editor }, "bold"),
31
31
  italic: _jsx(ItalicAction, { editor: editor }, "italic"),
@@ -14,5 +14,6 @@ export interface FontFamilyActionProps {
14
14
  groupOrder?: string[] | GroupOrderFunction;
15
15
  /** Callback when font selection changes */
16
16
  onChange?: (font: FontConfig, weight: number) => void;
17
+ defaultFontFamily?: string;
17
18
  }
18
19
  export declare const FontFamilyAction: (props: FontFamilyActionProps) => import("react/jsx-runtime").JSX.Element;
@@ -5,7 +5,7 @@ import { getPrimaryFontFamily } from '@antscorp/antsomi-ui/es/utils';
5
5
  import { useEditorState } from '@tiptap/react';
6
6
  import { useCallback, useEffect, useRef } from 'react';
7
7
  export const FontFamilyAction = (props) => {
8
- const { editor, fonts = DEFAULT_FONT_CONFIGS, onChange, fontGroupingFn, groupOrder } = props;
8
+ const { editor, fonts = DEFAULT_FONT_CONFIGS, onChange, fontGroupingFn, groupOrder, defaultFontFamily, } = props;
9
9
  const onChangeRef = useRef();
10
10
  useEffect(() => {
11
11
  onChangeRef.current = onChange;
@@ -15,7 +15,7 @@ export const FontFamilyAction = (props) => {
15
15
  selector: ({ editor: editorInstance }) => {
16
16
  const { fontWeight, fontFamily } = editorInstance.getAttributes('textStyle') || {};
17
17
  return {
18
- fontValue: getPrimaryFontFamily(fontFamily),
18
+ fontValue: getPrimaryFontFamily(fontFamily || defaultFontFamily),
19
19
  fontWeight: Number(fontWeight),
20
20
  };
21
21
  },
@@ -177,8 +177,9 @@ export const extendSelectionToFullLinks = (state, from, to) => {
177
177
  }
178
178
  }
179
179
  }
180
- // Collect all link ranges including adjacent ones with same href
181
- const allLinkRanges = [];
180
+ // Track min/max positions directly instead of collecting all ranges
181
+ let newFrom = from;
182
+ let newTo = to;
182
183
  const processedRanges = new Set();
183
184
  for (const linkRange of linkMarkRanges) {
184
185
  const rangeKey = `${linkRange.from}-${linkRange.to}`;
@@ -186,23 +187,22 @@ export const extendSelectionToFullLinks = (state, from, to) => {
186
187
  continue;
187
188
  const { href } = linkRange.mark.attrs;
188
189
  if (!href) {
189
- allLinkRanges.push(linkRange);
190
+ newFrom = Math.min(newFrom, linkRange.from);
191
+ newTo = Math.max(newTo, linkRange.to);
190
192
  processedRanges.add(rangeKey);
191
193
  continue;
192
194
  }
193
195
  // Find all adjacent links with same href
194
196
  const adjacentLinks = findAdjacentLinksWithSameHref(linkRange, href, linkEndingAt, linkStartingAt, state.doc);
195
- adjacentLinks.forEach(range => {
196
- const key = `${range.from}-${range.to}`;
197
- if (!processedRanges.has(key)) {
198
- allLinkRanges.push(range);
199
- processedRanges.add(key);
200
- }
197
+ // Since adjacentLinks is sorted (before links unshifted, after links pushed),
198
+ // first link has minimum from, last link has maximum to
199
+ newFrom = Math.min(newFrom, adjacentLinks[0].from);
200
+ newTo = Math.max(newTo, adjacentLinks[adjacentLinks.length - 1].to);
201
+ // Mark all adjacent links as processed
202
+ adjacentLinks.forEach(adjLink => {
203
+ processedRanges.add(`${adjLink.from}-${adjLink.to}`);
201
204
  });
202
205
  }
203
- // Calculate final range from all collected links
204
- const newFrom = Math.min(...allLinkRanges.map(range => range.from), from);
205
- const newTo = Math.max(...allLinkRanges.map(range => range.to), to);
206
206
  return {
207
207
  from: newFrom,
208
208
  to: newTo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antscorp/antsomi-ui",
3
- "version": "2.0.111",
3
+ "version": "2.0.112",
4
4
  "description": "An enterprise-class UI design language and React UI library.",
5
5
  "sideEffects": [
6
6
  "dist/*",