From e5072e7551ad524a270e744daeb03842092092a3 Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Thu, 6 Mar 2025 22:05:09 -0800 Subject: [PATCH 1/4] Hint Text API and plugin --- .../contentModel/buttons/exportButton.ts | 1 + .../lib/modelApi/editing/cloneModel.ts | 5 ++++- .../context/defaultContentModelHandlers.ts | 2 ++ .../lib/modelToDom/handlers/handleSegment.ts | 4 ++++ .../segment/ContentModelSelectionMarker.ts | 17 +++++++++++++++-- .../lib/context/DomToModelSettings.ts | 5 +++++ .../lib/context/ModelToDomSettings.ts | 6 ++++++ .../roosterjs-content-model-types/lib/index.ts | 1 + 8 files changed, 38 insertions(+), 3 deletions(-) diff --git a/demo/scripts/controlsV2/sidePane/contentModel/buttons/exportButton.ts b/demo/scripts/controlsV2/sidePane/contentModel/buttons/exportButton.ts index 32ab1ba6d9bd..a18d94ecc086 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/buttons/exportButton.ts +++ b/demo/scripts/controlsV2/sidePane/contentModel/buttons/exportButton.ts @@ -10,6 +10,7 @@ export const exportButton: RibbonButton<'buttonNameExport'> = { const model = getCurrentContentModel(); if (model) { + editor.focus(); editor.formatContentModel(currentModel => { mutateBlock(currentModel).blocks = model.blocks; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts index b874b04a9731..7a98d9edd92a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts @@ -345,7 +345,10 @@ function cloneGeneralBlock( function cloneSelectionMarker( marker: ReadonlyContentModelSelectionMarker ): ContentModelSelectionMarker { - return Object.assign({ isSelected: marker.isSelected }, cloneSegmentBase(marker)); + return Object.assign( + { isSelected: marker.isSelected, hintText: marker.hintText }, + cloneSegmentBase(marker) + ); } function cloneImage(image: ReadonlyContentModelImage): ContentModelImage { diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts index 3e89f31b82a0..d66fe0ad096a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts @@ -11,6 +11,7 @@ import { handleListItem } from '../handlers/handleListItem'; import { handleParagraph } from '../handlers/handleParagraph'; import { handleSegment } from '../handlers/handleSegment'; import { handleSegmentDecorator } from '../handlers/handleSegmentDecorator'; +import { handleSelectionMarker } from '../handlers/handleSelectionMarker'; import { handleTable } from '../handlers/handleTable'; import { handleText } from '../handlers/handleText'; import type { ContentModelHandlerMap } from 'roosterjs-content-model-types'; @@ -34,6 +35,7 @@ export const defaultContentModelHandlers: ContentModelHandlerMap = { formatContainer: handleFormatContainer, segment: handleSegment, segmentDecorator: handleSegmentDecorator, + selectionMarker: handleSelectionMarker, table: handleTable, text: handleText, }; diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts index b3863eb9ce68..1605b0e3c54e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts @@ -42,6 +42,10 @@ export const handleSegment: ContentModelSegmentHandler = ( case 'Entity': context.modelHandlers.entitySegment(doc, parent, segment, context, segmentNodes); break; + + case 'SelectionMarker': + context.modelHandlers.selectionMarker(doc, parent, segment, context, segmentNodes); + break; } // If end position is not set, or it is not finalized, and current segment is still in selection, set end position diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts index 2e1f9b400ebe..cf758f392383 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts @@ -3,13 +3,26 @@ import type { ReadonlyContentModelSegmentBase, } from './ContentModelSegmentBase'; +/** + * Common part of Content Model of IMG + */ +export interface ContentModelSelectionMarkerCommon { + /** + * Hint text shown next to the selection marker + */ + hintText?: string; +} + /** * Content Model of Selection Marker */ -export interface ContentModelSelectionMarker extends ContentModelSegmentBase<'SelectionMarker'> {} +export interface ContentModelSelectionMarker + extends ContentModelSelectionMarkerCommon, + ContentModelSegmentBase<'SelectionMarker'> {} /** * Content Model of Selection Marker (Readonly) */ export interface ReadonlyContentModelSelectionMarker - extends ReadonlyContentModelSegmentBase<'SelectionMarker'> {} + extends Readonly, + ReadonlyContentModelSegmentBase<'SelectionMarker'> {} diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts index 792697643468..035c684bd84e 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts @@ -85,6 +85,11 @@ export type ElementProcessorMap = { */ '#text': ElementProcessor; + /** + * Processor for hint text node after selection marker + */ + hintText: ElementProcessor; + /** * Processor for text node with selection. * This is an internal processor used by #text processor diff --git a/packages/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts b/packages/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts index 61899252ce65..e89d5dd049e9 100644 --- a/packages/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts +++ b/packages/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts @@ -1,3 +1,4 @@ +import type { ContentModelSelectionMarker } from '../contentModel/segment/ContentModelSelectionMarker'; import type { Definition } from '../metadata/Definition'; import type { ContentModelBlock } from '../contentModel/block/ContentModelBlock'; import type { ContentModelBlockFormat } from '../contentModel/format/ContentModelBlockFormat'; @@ -160,6 +161,11 @@ export type ContentModelHandlerMap = { */ segmentDecorator: ContentModelSegmentHandler; + /** + * Content Model type for ContentModelSelectionMarker + */ + selectionMarker: ContentModelSegmentHandler; + /** * Content Model type for ContentModelTable */ diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index a2ddd2d4fe3d..0c277c298c59 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -194,6 +194,7 @@ export { ReadonlyContentModelText, } from './contentModel/segment/ContentModelText'; export { + ContentModelSelectionMarkerCommon, ContentModelSelectionMarker, ReadonlyContentModelSelectionMarker, } from './contentModel/segment/ContentModelSelectionMarker'; From 75003bd0497b01d800bf36425b7c0380114b5c44 Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Thu, 6 Mar 2025 22:06:09 -0800 Subject: [PATCH 2/4] hint text --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 2 + .../sidePane/apiPlayground/apiEntries.ts | 5 + .../apiPlayground/hintText/hintTextPane.tsx | 61 +++++++++ .../editorOptions/EditorOptionsPlugin.ts | 1 + .../sidePane/editorOptions/OptionState.ts | 1 + .../sidePane/editorOptions/Plugins.tsx | 3 +- .../lib/corePlugin/cache/CachePlugin.ts | 6 + .../lib/corePlugin/cache/MutationType.ts | 20 ++- .../lib/corePlugin/cache/domIndexerImpl.ts | 79 ++++++++++- .../corePlugin/cache/textMutationObserver.ts | 21 ++- .../domToModel/context/defaultProcessors.ts | 2 + .../processors/hintTextProcessor.ts | 19 +++ .../processors/knownElementProcessor.ts | 3 + .../lib/domUtils/hintText.ts | 68 ++++++++++ .../roosterjs-content-model-dom/lib/index.ts | 6 + .../modelToDom/handlers/handleParagraph.ts | 8 +- .../handlers/handleSelectionMarker.ts | 26 ++++ .../optimizers/removeUnnecessarySpan.ts | 3 +- .../lib/hintText/HintTextPlugin.ts | 123 ++++++++++++++++++ .../lib/hintText/addHintText.ts | 30 +++++ .../lib/hintText/clearHintText.ts | 45 +++++++ .../lib/index.ts | 3 + .../lib/watermark/isModelEmptyFast.ts | 3 +- .../lib/context/DomIndexer.ts | 14 ++ 24 files changed, 540 insertions(+), 12 deletions(-) create mode 100644 demo/scripts/controlsV2/sidePane/apiPlayground/hintText/hintTextPane.tsx create mode 100644 packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts create mode 100644 packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts create mode 100644 packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSelectionMarker.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/hintText/HintTextPlugin.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/hintText/addHintText.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/hintText/clearHintText.ts diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 9574898a2900..13674cbeb5a2 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -58,6 +58,7 @@ import { AutoFormatPlugin, CustomReplacePlugin, EditPlugin, + HintTextPlugin, HyperlinkPlugin, ImageEditPlugin, MarkdownPlugin, @@ -527,6 +528,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { : linkTitle ), pluginList.customReplace && new CustomReplacePlugin(customReplacements), + pluginList.hintText && new HintTextPlugin(), ].filter(x => !!x); } } diff --git a/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts b/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts index f968198a1c2c..5d6c5024354b 100644 --- a/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts +++ b/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import HintTextPane from './hintText/hintTextPane'; import InsertEntityPane from './insertEntity/InsertEntityPane'; import { ApiPaneProps, ApiPlaygroundComponent } from './ApiPaneProps'; @@ -19,6 +20,10 @@ const apiEntries: { [key: string]: ApiEntry } = { name: 'Insert Entity', component: InsertEntityPane, }, + hintText: { + name: 'Hint Text', + component: HintTextPane, + }, more: { name: 'Coming soon...', }, diff --git a/demo/scripts/controlsV2/sidePane/apiPlayground/hintText/hintTextPane.tsx b/demo/scripts/controlsV2/sidePane/apiPlayground/hintText/hintTextPane.tsx new file mode 100644 index 000000000000..82bfb116339f --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/apiPlayground/hintText/hintTextPane.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { addHintText } from 'roosterjs-content-model-plugins'; +import { ApiPaneProps, ApiPlaygroundComponent } from '../ApiPaneProps'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; +import type { PluginEvent } from 'roosterjs-content-model-types'; + +export default class HintTextPane extends React.Component + implements ApiPlaygroundComponent { + private word: React.RefObject = React.createRef(); + private hintText: React.RefObject = React.createRef(); + + constructor(props: ApiPaneProps) { + super(props); + + this.state = {}; + } + + render() { + return ( + <> +
+ When type word: +
+
+ Insert hint text: +
+ + ); + } + + onPluginEvent(e: PluginEvent) { + const word = this.word.current.value; + const hintText = this.hintText.current.value; + const editor = this.props.getEditor(); + + if (e.eventType == 'input' && word && hintText) { + let shouldAdd = false; + + // Demo code only, do not do this in real production + editor.formatContentModel(model => { + const selections = getSelectedSegmentsAndParagraphs(model, false, false); + + if (selections.length == 1 && selections[0][0].segmentType == 'SelectionMarker') { + const [segment, paragraph] = selections[0]; + const index = paragraph.segments.indexOf(segment); + const text = index > 0 ? paragraph.segments[index - 1] : null; + + if (text.segmentType == 'Text' && text.text.endsWith(this.word.current.value)) { + shouldAdd = true; + } + } + + return false; + }); + + if (shouldAdd) { + addHintText(editor, hintText); + } + } + } +} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index b6dd6a3b73b9..633e4f6c82fe 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -21,6 +21,7 @@ const initialState: OptionState = { imageEditPlugin: true, hyperlink: true, customReplace: true, + hintText: true, }, defaultFormat: { fontFamily: 'Calibri', diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index dbf2a967302a..b7300111c219 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -22,6 +22,7 @@ export interface BuildInPluginList { hyperlink: boolean; imageEditPlugin: boolean; customReplace: boolean; + hintText: boolean; } export interface OptionState { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index c9e83bc06b08..dc962f68f919 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -296,7 +296,8 @@ export class Plugins extends PluginsBase { ) )} {this.renderPluginItem('customReplace', 'Custom Replace')} - {this.renderPluginItem('imageEditPlugin', 'ImageEditPlugin')} + {this.renderPluginItem('imageEditPlugin', 'ImageEditPlugin')}{' '} + {this.renderPluginItem('hintText', 'HintText')} ); diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts index 7ad9cb7f3ec0..1ebc54a9f104 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -155,6 +155,12 @@ class CachePlugin implements PluginWithState { break; + case 'hintText': + if (!this.state.domIndexer?.reconcileHintText(mutation.hintNode)) { + this.invalidateCache(); + } + break; + case 'unknown': this.invalidateCache(); break; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/MutationType.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/MutationType.ts index 03a8d5137167..eedd70fe69ab 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/MutationType.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/MutationType.ts @@ -17,7 +17,11 @@ export type MutationType = /** * Child list is changed */ - | 'childList'; + | 'childList' + /** + * Hint text node is changed + */ + | 'hintText'; /** * @internal @@ -54,4 +58,16 @@ export interface ChildListMutation extends MutationBase<'childList'> { /** * @internal */ -export type Mutation = UnknownMutation | ElementIdMutation | TextMutation | ChildListMutation; +export interface HintTextMutation extends MutationBase<'hintText'> { + hintNode: HTMLElement; +} + +/** + * @internal + */ +export type Mutation = + | UnknownMutation + | ElementIdMutation + | TextMutation + | ChildListMutation + | HintTextMutation; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index 2d2b80723abe..23ceb28107dc 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -5,6 +5,7 @@ import { createParagraph, createSelectionMarker, createText, + getHintText, getObjectKeys, isElementOfType, isEntityDelimiter, @@ -36,6 +37,14 @@ export interface SegmentItem { segments: ContentModelSegment[]; } +/** + * @internal Export for test only + */ +export interface HintNodeItem { + // Selection marker can be easily removed and recreated during reconciling, so we don't store the selection marker, but only paragraph here + paragraph: ContentModelParagraph; +} + /** * @internal Export for test only */ @@ -58,6 +67,13 @@ export interface IndexedSegmentNode extends Node { __roosterjsContentModel: SegmentItem; } +/** + * @internal Export for test only + */ +export interface IndexedHintNode extends HTMLElement { + __roosterjsContentModel: HintNodeItem; +} + /** * @internal Export for test only */ @@ -114,6 +130,12 @@ function isIndexedSegment(node: Node): node is IndexedSegmentNode { ); } +function isIndexedHintNode(node: Node): node is IndexedHintNode { + const { paragraph } = (node as IndexedHintNode).__roosterjsContentModel ?? {}; + + return paragraph && paragraph.blockType == 'Paragraph' && Array.isArray(paragraph.segments); +} + function isIndexedDelimiter(node: Node): node is IndexedEntityDelimiter { const { entity, parent } = (node as IndexedEntityDelimiter).__roosterjsContentModel ?? {}; @@ -166,6 +188,14 @@ export class DomIndexerImpl implements DomIndexer { }; } + onSelectionMarker(node: HTMLElement, paragraph: ContentModelParagraph) { + const indexedMarker = node as IndexedHintNode; + + indexedMarker.__roosterjsContentModel = { + paragraph, + }; + } + onParagraph(paragraphElement: HTMLElement) { let previousText: Text | null = null; @@ -223,6 +253,7 @@ export class DomIndexerImpl implements DomIndexer { newSelection: DOMSelection, oldSelection?: CacheSelection ): boolean { + let hintText: string | undefined; if (oldSelection) { if ( oldSelection.type == 'range' && @@ -230,6 +261,14 @@ export class DomIndexerImpl implements DomIndexer { isNodeOfType(oldSelection.start.node, 'TEXT_NODE') && isIndexedSegment(oldSelection.start.node) ) { + const { paragraph } = oldSelection.start.node.__roosterjsContentModel; + const marker = paragraph.segments.filter( + (x: ContentModelSegment): x is ContentModelSelectionMarker => + x.segmentType == 'SelectionMarker' && !!x.hintText + )[0]; + + hintText = marker?.hintText; + this.reconcileTextSelection(oldSelection.start.node); } else { setSelection(model); @@ -289,7 +328,8 @@ export class DomIndexerImpl implements DomIndexer { return !!this.reconcileNodeSelection( startContainer, startOffset, - model.format + model.format, + hintText ); } else if ( startContainer == endContainer && @@ -301,7 +341,12 @@ export class DomIndexerImpl implements DomIndexer { return ( isIndexedSegment(startContainer) && - !!this.reconcileTextSelection(startContainer, startOffset, endOffset) + !!this.reconcileTextSelection( + startContainer, + startOffset, + endOffset, + hintText + ) ); } else { const marker1 = this.reconcileNodeSelection(startContainer, startOffset); @@ -383,6 +428,26 @@ export class DomIndexerImpl implements DomIndexer { } } + reconcileHintText(hintNode: HTMLElement) { + let hintText: string; + + if (isIndexedHintNode(hintNode) && (hintText = getHintText(hintNode))) { + const { paragraph } = hintNode.__roosterjsContentModel; + const markers = paragraph.segments.filter( + (x: ContentModelSegment): x is ContentModelSelectionMarker => + x.segmentType == 'SelectionMarker' + ); + + if (markers.length == 1) { + markers[0].hintText = hintText; + + return true; + } + } + + return false; + } + private onBlockEntityDelimiter( node: Node | null, entity: ContentModelEntity, @@ -404,11 +469,12 @@ export class DomIndexerImpl implements DomIndexer { private reconcileNodeSelection( node: Node, offset: number, - defaultFormat?: ContentModelSegmentFormat + defaultFormat?: ContentModelSegmentFormat, + hintText?: string ): Selectable | undefined { if (isNodeOfType(node, 'TEXT_NODE')) { if (isIndexedSegment(node)) { - return this.reconcileTextSelection(node, offset); + return this.reconcileTextSelection(node, offset, undefined /*endOffset*/, hintText); } else if (isIndexedDelimiter(node)) { return this.reconcileDelimiterSelection(node, defaultFormat); } else { @@ -444,7 +510,8 @@ export class DomIndexerImpl implements DomIndexer { private reconcileTextSelection( textNode: IndexedSegmentNode, startOffset?: number, - endOffset?: number + endOffset?: number, + hintText?: string ) { const { paragraph, segments } = textNode.__roosterjsContentModel; const first = segments[0]; @@ -469,6 +536,8 @@ export class DomIndexerImpl implements DomIndexer { if (endOffset === undefined) { const marker = createSelectionMarker(first.format); + + marker.hintText = hintText; newSegments.push(marker); if (startOffset < (textNode.nodeValue ?? '').length) { diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts index d20aa100740a..7703385074a2 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts @@ -2,6 +2,7 @@ import { createDOMHelper } from '../../editor/core/DOMHelperImpl'; import { findClosestBlockEntityContainer, findClosestEntityWrapper, + hasHintTextClass, isNodeOfType, } from 'roosterjs-content-model-dom'; import type { DOMHelper, TextMutationObserver } from 'roosterjs-content-model-types'; @@ -47,6 +48,7 @@ class TextMutationObserverImpl implements TextMutationObserver { let addedNodes: Node[] = []; let removedNodes: Node[] = []; let reconcileText = false; + let hintNode: HTMLElement | null = null; const ignoredNodes = new Set(); const includedNodes = new Set(); @@ -55,7 +57,17 @@ class TextMutationObserverImpl implements TextMutationObserver { const mutation = mutations[i]; const target = mutation.target; - if (ignoredNodes.has(target)) { + if (hintNode == target) { + continue; + } else if (isNodeOfType(target, 'ELEMENT_NODE') && hasHintTextClass(target)) { + if (!hintNode) { + hintNode = target; + } else { + canHandle = false; + } + + continue; + } else if (ignoredNodes.has(target)) { continue; } else if (!includedNodes.has(target)) { if ( @@ -123,6 +135,13 @@ class TextMutationObserverImpl implements TextMutationObserver { if (reconcileText) { this.onMutation({ type: 'text' }); } + + if (hintNode) { + this.onMutation({ + type: 'hintText', + hintNode, + }); + } } else { this.onMutation({ type: 'unknown' }); } diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts b/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts index 0783be37e4f6..fa8849c829b7 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts @@ -8,6 +8,7 @@ import { fontProcessor } from '../processors/fontProcessor'; import { formatContainerProcessor } from '../processors/formatContainerProcessor'; import { generalProcessor } from '../processors/generalProcessor'; import { headingProcessor } from '../processors/headingProcessor'; +import { hintTextProcessor } from '../processors/hintTextProcessor'; import { hrProcessor } from '../processors/hrProcessor'; import { imageProcessor } from '../processors/imageProcessor'; import { knownElementProcessor } from '../processors/knownElementProcessor'; @@ -59,6 +60,7 @@ export const defaultProcessorMap: ElementProcessorMap = { '*': generalProcessor, '#text': textProcessor, + hintText: hintTextProcessor, textWithSelection: textWithSelectionProcessor, element: elementProcessor, entity: entityProcessor, diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts new file mode 100644 index 000000000000..59f2fd95ceb2 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts @@ -0,0 +1,19 @@ +import { ensureParagraph } from '../../modelApi/common/ensureParagraph'; +import { getHintText } from '../../domUtils/hintText'; +import type { ElementProcessor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const hintTextProcessor: ElementProcessor = (group, element, context) => { + const paragraph = ensureParagraph(group, context.blockFormat); + const lastSegment = paragraph.segments[paragraph.segments.length - 1]; + + if (lastSegment?.segmentType == 'SelectionMarker') { + const hintText = getHintText(element); + + if (hintText) { + lastSegment.hintText = hintText; + } + } +}; diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/knownElementProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/knownElementProcessor.ts index eadecf321764..996667dc364e 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/knownElementProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/knownElementProcessor.ts @@ -3,6 +3,7 @@ import { blockProcessor } from './blockProcessor'; import { createParagraph } from '../../modelApi/creators/createParagraph'; import { formatContainerProcessor } from './formatContainerProcessor'; import { getDefaultStyle } from '../utils/getDefaultStyle'; +import { hasHintTextClass } from '../../domUtils/hintText'; import { isBlockElement } from '../utils/isBlockElement'; import { parseFormat } from '../utils/parseFormat'; import { stackFormat } from '../utils/stackFormat'; @@ -68,6 +69,8 @@ export const knownElementProcessor: ElementProcessor = (group, elem ) ); } + } else if (hasHintTextClass(element)) { + context.elementProcessors.hintText(group, element, context); } else { stackFormat( context, diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts b/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts new file mode 100644 index 000000000000..6138f36caeef --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts @@ -0,0 +1,68 @@ +import { isElementOfType } from './isElementOfType'; +import { moveChildNodes } from './moveChildNodes'; +import type { DOMHelper } from 'roosterjs-content-model-types'; + +const HintTextClass = 'roosterjs-hint-text'; + +interface HintTextElement extends HTMLSpanElement { + __roosterjsHintText: string; +} + +const ZWS = '\u200B'; + +/** + * For a given SPAN element, setup the hint text node inside it with other necessary properties. + * If the container SPAN already has child nodes, they will be removed. + * @param containerSpan The container SPAN element + * @param hintText The hint text to be set + * @returns + */ +export function setupHintTextNode(containerSpan: HTMLSpanElement, hintText: string) { + moveChildNodes(containerSpan); + + const doc = containerSpan.ownerDocument; + const zws1 = doc.createTextNode(ZWS); + const zws2 = doc.createTextNode(ZWS); + + const hintInnerNode = doc.createElement('span'); + const hintNode = containerSpan as HintTextElement; + + hintNode.__roosterjsHintText = hintText; + hintNode.className = HintTextClass; + hintNode.appendChild(zws1); + hintNode.appendChild(hintInnerNode); + hintNode.appendChild(zws2); + + const shadowRoot = hintInnerNode.attachShadow({ mode: 'open' }); + const hintTextNode = doc.createElement('span'); + hintTextNode.textContent = hintText; + hintTextNode.style.color = '#999'; + shadowRoot.appendChild(hintTextNode); +} + +/** + * Get the hint text from a given element, if any. Otherwise return empty string. + * @param element The element to get hint text from + * @returns The hint text + */ +export function getHintText(element: HTMLElement): string { + return (element as HintTextElement)?.__roosterjsHintText ?? ''; +} + +/** + * Check if the given element is a hint text element + * @param element The element to check + * @returns True if the element is a hint text element, otherwise false + */ +export function hasHintTextClass(element: HTMLElement): boolean { + return isElementOfType(element, 'span') && element.classList.contains(HintTextClass); +} + +/** + * Get the hint text element from a given DOM helper, if any. Otherwise return null. + * @param domHelper The domHelper to get hint text element from + * @returns Hint text element, or null if not found + */ +export function getHintTextElement(domHelper: DOMHelper): HTMLSpanElement | null { + return domHelper.queryElements('.' + HintTextClass)[0] as HTMLElement | null; +} diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 2723beb30353..81e818bafe12 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -109,6 +109,12 @@ export { readFile } from './domUtils/readFile'; export { transformColor } from './domUtils/style/transformColor'; export { extractClipboardItems } from './domUtils/event/extractClipboardItems'; export { cacheGetEventData } from './domUtils/event/cacheGetEventData'; +export { + hasHintTextClass, + getHintTextElement, + getHintText, + setupHintTextNode, +} from './domUtils/hintText'; export { isBlockGroupOfType } from './modelApi/typeCheck/isBlockGroupOfType'; diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index ef6732c9dde6..4dffd7b19ec2 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -1,5 +1,7 @@ import { applyFormat } from '../utils/applyFormat'; import { getObjectKeys } from '../../domUtils/getObjectKeys'; +import { hasHintTextClass } from '../../domUtils/hintText'; +import { isNodeOfType } from '../../domUtils/isNodeOfType'; import { optimize } from '../optimizers/optimize'; import { reuseCachedElement } from '../../domUtils/reuseCachedElement'; import { stackFormat } from '../utils/stackFormat'; @@ -77,7 +79,11 @@ export const handleParagraph: ContentModelBlockHandler = context.modelHandlers.segment(doc, parent, segment, context, newSegments); newSegments.forEach(node => { - context.domIndexer?.onSegment(node, paragraph, [segment]); + if (isNodeOfType(node, 'ELEMENT_NODE') && hasHintTextClass(node)) { + context.domIndexer?.onSelectionMarker(node, paragraph); + } else { + context.domIndexer?.onSegment(node, paragraph, [segment]); + } }); }); } diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSelectionMarker.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSelectionMarker.ts new file mode 100644 index 000000000000..5741330e8172 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSelectionMarker.ts @@ -0,0 +1,26 @@ +import { setupHintTextNode } from '../../domUtils/hintText'; +import type { + ContentModelSegmentHandler, + ContentModelSelectionMarker, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const handleSelectionMarker: ContentModelSegmentHandler = ( + doc, + parent, + segment, + _, + segmentNodes +) => { + if (segment.hintText) { + const hintNode = doc.createElement('span'); + + setupHintTextNode(hintNode, segment.hintText); + + parent.appendChild(hintNode); + + segmentNodes?.push(hintNode); + } +}; diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts index ee048a395bbb..5f64a4699e98 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts @@ -8,7 +8,8 @@ export function removeUnnecessarySpan(root: Node) { if ( isNodeOfType(child, 'ELEMENT_NODE') && child.tagName == 'SPAN' && - child.attributes.length == 0 + child.attributes.length == 0 && + !child.shadowRoot ) { const node = child; let refNode = child.nextSibling; diff --git a/packages/roosterjs-content-model-plugins/lib/hintText/HintTextPlugin.ts b/packages/roosterjs-content-model-plugins/lib/hintText/HintTextPlugin.ts new file mode 100644 index 000000000000..ae179c963ce6 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/hintText/HintTextPlugin.ts @@ -0,0 +1,123 @@ +import { clearHintText } from './clearHintText'; +import { + getHintTextElement, + hasHintTextClass, + isNodeOfType, + getHintText, + setupHintTextNode, +} from 'roosterjs-content-model-dom'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; + +/** + * A plugin to handle hint text in editor + */ +export class HintTextPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private hintText = ''; + + constructor() {} + + getName() { + return 'HintText'; + } + + initialize(editor: IEditor) { + this.editor = editor; + + const document = this.editor.getDocument(); + document.addEventListener('selectionchange', this.onSelectionChange); + } + + dispose() { + const document = this.editor?.getDocument(); + document?.addEventListener('selectionchange', this.onSelectionChange); + + this.editor = null; + } + + willHandleEventExclusively(event: PluginEvent) { + return !!this.hintText && event.eventType == 'keyDown' && event.rawEvent.key == 'Tab'; + } + + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + + switch (event.eventType) { + case 'editorReady': + case 'contentChanged': + case 'rewriteFromModel': + this.onChange(this.editor); + break; + + case 'keyDown': + this.onKeyDown(this.editor, event.rawEvent); + break; + } + } + + private onKeyDown(editor: IEditor, event: KeyboardEvent) { + if (this.hintText) { + const key = event.key; + let hintElement: HTMLSpanElement | undefined | null; + + if ( + key == this.hintText.substring(0, 1) && + (hintElement = getHintTextElement(editor.getDOMHelper())) + ) { + this.hintText = this.hintText.substring(1); + setupHintTextNode(hintElement, this.hintText); + } else { + const applyText = key == 'Tab'; + + clearHintText(editor, applyText); + + if (applyText) { + event.preventDefault(); + } + } + } + } + + private onChange(editor: IEditor) { + const hintElement = getHintTextElement(editor.getDOMHelper()); + + if (hintElement) { + this.hintText = getHintText(hintElement); + + if (!this.hintText) { + hintElement.remove(); + } + } else { + this.hintText = ''; + } + } + + private onSelectionChange = () => { + const doc = this.editor?.getDocument(); + const selection = doc?.defaultView?.getSelection(); + + if (this.editor && doc && selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const node = range.startContainer; + + if ( + isNodeOfType(node, 'ELEMENT_NODE') && + hasHintTextClass(node) && + this.editor.getDOMHelper().isNodeInEditor(node) + ) { + const range = doc.createRange(); + + range.setStartBefore(node); + range.collapse(true /* toStart*/); + + this.editor.setDOMSelection({ + type: 'range', + isReverted: false, + range, + }); + } + } + }; +} diff --git a/packages/roosterjs-content-model-plugins/lib/hintText/addHintText.ts b/packages/roosterjs-content-model-plugins/lib/hintText/addHintText.ts new file mode 100644 index 000000000000..5cf15df7b9c7 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/hintText/addHintText.ts @@ -0,0 +1,30 @@ +import { getSelectedSegmentsAndParagraphs, mutateSegment } from 'roosterjs-content-model-dom'; +import type { IEditor } from 'roosterjs-content-model-types'; + +/** + * Add hint text to current selection + * @param editor The editor to add hint text to + * @param text The hint text to add + */ +export function addHintText(editor: IEditor, text: string) { + editor.formatContentModel(model => { + let isChanged = false; + + const selections = getSelectedSegmentsAndParagraphs( + model, + false /*includingFormatHolder*/, + false /*includingEntity*/ + ); + + selections.forEach(([segment, paragraph]) => { + if (paragraph && segment.segmentType == 'SelectionMarker') { + mutateSegment(paragraph, segment, mutableSegment => { + mutableSegment.hintText = text; + isChanged = true; + }); + } + }); + + return isChanged; + }); +} diff --git a/packages/roosterjs-content-model-plugins/lib/hintText/clearHintText.ts b/packages/roosterjs-content-model-plugins/lib/hintText/clearHintText.ts new file mode 100644 index 000000000000..bedfaa6ea7cf --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/hintText/clearHintText.ts @@ -0,0 +1,45 @@ +import { + createText, + getSelectedSegmentsAndParagraphs, + mutateSegment, +} from 'roosterjs-content-model-dom'; +import type { IEditor } from 'roosterjs-content-model-types'; + +/** + * Clear hint text if any in current selection + * @param editor The editor to clear hint text from + * @param apply True to apply the hint text into editor, to be a normal text, otherwise just remove the hint text + */ +export function clearHintText(editor: IEditor, apply?: boolean) { + editor.formatContentModel(model => { + let isChanged = false; + + const selections = getSelectedSegmentsAndParagraphs( + model, + false /*includingFormatHolder*/, + false /*includingEntity*/ + ); + + selections.forEach(([segment, paragraph]) => { + if (paragraph && segment.segmentType == 'SelectionMarker' && segment.hintText) { + const text = createText( + segment.hintText, + segment.format, + segment.link, + segment.code + ); + + mutateSegment(paragraph, segment, (mutableSegment, mutablePara, index) => { + delete mutableSegment.hintText; + isChanged = true; + + if (apply) { + mutablePara.segments.splice(index, 0, text); + } + }); + } + }); + + return isChanged; + }); +} diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 0ce5c79b0fe4..7867e66e891d 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -39,3 +39,6 @@ export { PickerSelectionChangMode, PickerDirection, PickerHandler } from './pick export { CustomReplacePlugin, CustomReplace } from './customReplace/CustomReplacePlugin'; export { ImageEditPlugin } from './imageEdit/ImageEditPlugin'; export { ImageEditOptions } from './imageEdit/types/ImageEditOptions'; +export { HintTextPlugin } from './hintText/HintTextPlugin'; +export { addHintText } from './hintText/addHintText'; +export { clearHintText } from './hintText/clearHintText'; diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts index 5089e3284617..b9776b4d3a62 100644 --- a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts +++ b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts @@ -20,7 +20,8 @@ export function isModelEmptyFast(model: ReadonlyContentModelBlockGroup): boolean x.segmentType == 'Entity' || x.segmentType == 'Image' || x.segmentType == 'General' || - (x.segmentType == 'Text' && x.text) + (x.segmentType == 'Text' && x.text) || + (x.segmentType == 'SelectionMarker' && !!x.hintText) ) ) { return false; // Has meaningful segments, it is not empty diff --git a/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts b/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts index 60a3429a919b..e97bb9891915 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts @@ -24,6 +24,13 @@ export interface DomIndexer { segments: ContentModelSegment[] ) => void; + /** + * Invoked when a selection marker is created in DOM tree with hint text + * @param node The hint text node + * @param paragraph Parent paragraph of this selection marker + */ + onSelectionMarker: (node: HTMLElement, paragraph: ContentModelParagraph) => void; + /** * Invoked when new paragraph node is created in DOM tree * @param paragraphElement The new DOM node for this paragraph @@ -83,4 +90,11 @@ export interface DomIndexer { * @returns True if the changed nodes are successfully reconciled, otherwise false */ reconcileChildList: (addedNodes: ArrayLike, removedNodes: ArrayLike) => boolean; + + /** + * When hint text node is changed, we can use this method to do sync the change from editor into content model. + * @param hintNode The hint text node that is changed + * @returns True if the changed hint text node is successfully reconciled, otherwise false + */ + reconcileHintText: (hintNode: HTMLElement) => boolean; } From 3ea0198447de03d61bd6be0242c1589890834bc0 Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Thu, 6 Mar 2025 22:20:35 -0800 Subject: [PATCH 3/4] fix test --- .../command/paste/mergePasteContentTest.ts | 7 ++++++- .../corePlugin/cache/domIndexerImplTest.ts | 20 +++++++++++++++---- .../domToModel/processors/brProcessorTest.ts | 2 ++ .../processors/entityProcessorTest.ts | 2 ++ .../processors/generalProcessorTest.ts | 2 ++ .../processors/imageProcessorTest.ts | 2 ++ .../processors/tableProcessorTest.ts | 2 ++ .../processors/textProcessorTest.ts | 6 ++++++ .../handlers/handleParagraphTest.ts | 4 ++++ .../modelToDom/handlers/handleTableTest.ts | 2 ++ .../test/imageEdit/ImageEditPluginTest.ts | 6 ++++++ .../test/paste/e2e/cmPasteFromExcelTest.ts | 1 + 12 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts index b8a4779b761c..713f1c5aa6cd 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -2024,7 +2024,12 @@ describe('mergePasteContent', () => { blockType: 'Paragraph', segments: [ { segmentType: 'Text', text: 'text', format: {} }, - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + hintText: undefined, + }, ], format: {}, cachedElement: undefined, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index 4937f5205c83..6040dfa3f5ab 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -14,6 +14,7 @@ import { ContentModelDocument, ContentModelLink, ContentModelSegment, + ContentModelSelectionMarker, DOMSelection, } from 'roosterjs-content-model-types'; import { @@ -433,6 +434,7 @@ describe('domIndexerImpl.reconcileSelection', () => { segmentType: 'SelectionMarker', format: {}, isSelected: true, + hintText: undefined, }, segment2, ], @@ -579,8 +581,14 @@ describe('domIndexerImpl.reconcileSelection', () => { text: 't2', format: {}, }; - const marker1 = createSelectionMarker(); - const marker2 = createSelectionMarker(); + const marker1: ContentModelSelectionMarker = { + ...createSelectionMarker(), + hintText: undefined, + }; + const marker2: ContentModelSelectionMarker = { + ...createSelectionMarker(), + hintText: undefined, + }; expect(result).toBeTrue(); expect(node1.__roosterjsContentModel).toEqual({ @@ -640,7 +648,10 @@ describe('domIndexerImpl.reconcileSelection', () => { isSelected: true, }; - const marker1 = createSelectionMarker(); + const marker1: ContentModelSelectionMarker = { + ...createSelectionMarker(), + hintText: undefined, + }; const marker2 = createSelectionMarker(); expect(result).toBeTrue(); @@ -907,7 +918,7 @@ describe('domIndexerImpl.reconcileSelection', () => { expect(paragraph).toEqual({ blockType: 'Paragraph', format: {}, - segments: [segment1, createSelectionMarker(), segment2], + segments: [segment1, { ...createSelectionMarker(), hintText: undefined }, segment2], }); expect(setSelectionSpy).toHaveBeenCalled(); expect(model.hasRevertedRangeSelection).toBeFalsy(); @@ -1092,6 +1103,7 @@ describe('domIndexerImpl.reconcileSelection', () => { format: {}, isSelected: true, link, + hintText: undefined, }, segment2, ], diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts index 5d36f9b76e64..c9c63d74a0c8 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts @@ -78,6 +78,8 @@ describe('brProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index 52b005ea005f..10cedf7264e0 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -263,6 +263,8 @@ describe('entityProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index 528415eb76ff..8f1fda0d1625 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -398,6 +398,8 @@ describe('generalProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index ec391daf6a69..657a816eb785 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -327,6 +327,8 @@ describe('imageProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index c18816850d4d..a8362aca179f 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -294,6 +294,8 @@ describe('tableProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index 40fb02ac06b4..5aba06fbbbde 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -581,6 +581,8 @@ describe('textProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; @@ -619,6 +621,8 @@ describe('textProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; @@ -668,6 +672,8 @@ describe('textProcessor', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 8a0c2ca2d5eb..7caa3b18aa50 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -669,6 +669,8 @@ describe('handleParagraph', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; @@ -720,6 +722,8 @@ describe('handleParagraph', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index 8e60ecf31a18..0fa87ce46f73 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -614,6 +614,8 @@ describe('handleTable', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 53a51cd097c0..7634cb902ef3 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -893,6 +893,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: true, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, { segmentType: 'Text', @@ -1196,6 +1197,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { segmentType: 'SelectionMarker', isSelected: true, format: {}, + hintText: undefined, }, ], segmentFormat: undefined, @@ -1584,6 +1586,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: false, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, levels: [ { @@ -1636,6 +1639,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: true, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, ], segmentFormat: undefined, @@ -1721,6 +1725,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: false, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, levels: [ { @@ -1860,6 +1865,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: false, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, levels: [ { diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index 5e4853c83c58..f9a0a91e8b6f 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -90,6 +90,7 @@ describe(ID, () => { textColor: '', underline: false, }, + hintText: undefined, }, { segmentType: 'Br', isSelected: undefined, format: {} }, ], From 8e8b6d8a41dd89e6f5da62de55117a1d71a4d8a7 Mon Sep 17 00:00:00 2001 From: "Jiuqing Song (from Dev Box)" Date: Mon, 19 May 2025 11:56:02 -0700 Subject: [PATCH 4/4] Improve --- .../processors/hintTextProcessor.ts | 2 +- .../hiddenProperties/hiddenProperty.ts | 6 +++++ .../lib/domUtils/hiddenProperties/hintText.ts | 19 +++++++++++++ .../lib/domUtils/hintText.ts | 27 +++++-------------- .../roosterjs-content-model-dom/lib/index.ts | 8 ++---- 5 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hintText.ts diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts index 59f2fd95ceb2..839f2e8180b2 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/hintTextProcessor.ts @@ -1,5 +1,5 @@ import { ensureParagraph } from '../../modelApi/common/ensureParagraph'; -import { getHintText } from '../../domUtils/hintText'; +import { getHintText } from '../../domUtils/hiddenProperties/hintText'; import type { ElementProcessor } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hiddenProperty.ts b/packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hiddenProperty.ts index 2f6f56974745..bc25341067bd 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hiddenProperty.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hiddenProperty.ts @@ -17,6 +17,12 @@ export interface HiddenProperty { * Specify the image state. Example: if the image is in editable state */ imageState?: string; + + /** + * Specify the hint text for this element + * This is useful for scenarios when we show a SPAN as a hint text + */ + hintText?: string; } interface NodeWithHiddenProperty extends Node { diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hintText.ts b/packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hintText.ts new file mode 100644 index 000000000000..b0c1d7453168 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domUtils/hiddenProperties/hintText.ts @@ -0,0 +1,19 @@ +import { getHiddenProperty, setHiddenProperty } from './hiddenProperty'; + +/** + * Get hint text from element + * @param element The element to get hint text from + * @returns Hint text or undefined if not found + */ +export function getHintText(element: HTMLElement): string { + return getHiddenProperty(element, 'hintText') ?? ''; +} + +/** + * Set hint text to element + * @param element The element to set hint text to + * @param hintText The hint text to set + */ +export function setHintText(element: HTMLElement, hintText: string): void { + setHiddenProperty(element, 'hintText', hintText); +} diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts b/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts index 6138f36caeef..e65ebcb56af4 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts @@ -1,13 +1,9 @@ import { isElementOfType } from './isElementOfType'; import { moveChildNodes } from './moveChildNodes'; +import { setHintText } from './hiddenProperties/hintText'; import type { DOMHelper } from 'roosterjs-content-model-types'; const HintTextClass = 'roosterjs-hint-text'; - -interface HintTextElement extends HTMLSpanElement { - __roosterjsHintText: string; -} - const ZWS = '\u200B'; /** @@ -25,13 +21,13 @@ export function setupHintTextNode(containerSpan: HTMLSpanElement, hintText: stri const zws2 = doc.createTextNode(ZWS); const hintInnerNode = doc.createElement('span'); - const hintNode = containerSpan as HintTextElement; - hintNode.__roosterjsHintText = hintText; - hintNode.className = HintTextClass; - hintNode.appendChild(zws1); - hintNode.appendChild(hintInnerNode); - hintNode.appendChild(zws2); + setHintText(containerSpan, hintText); + + containerSpan.className = HintTextClass; + containerSpan.appendChild(zws1); + containerSpan.appendChild(hintInnerNode); + containerSpan.appendChild(zws2); const shadowRoot = hintInnerNode.attachShadow({ mode: 'open' }); const hintTextNode = doc.createElement('span'); @@ -40,15 +36,6 @@ export function setupHintTextNode(containerSpan: HTMLSpanElement, hintText: stri shadowRoot.appendChild(hintTextNode); } -/** - * Get the hint text from a given element, if any. Otherwise return empty string. - * @param element The element to get hint text from - * @returns The hint text - */ -export function getHintText(element: HTMLElement): string { - return (element as HintTextElement)?.__roosterjsHintText ?? ''; -} - /** * Check if the given element is a hint text element * @param element The element to check diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 29a5614fa043..b6a5ab8a5150 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -120,16 +120,12 @@ export { transformColor } from './domUtils/style/transformColor'; export { normalizeFontFamily } from './domUtils/style/normalizeFontFamily'; export { extractClipboardItems } from './domUtils/event/extractClipboardItems'; export { cacheGetEventData } from './domUtils/event/cacheGetEventData'; -export { - hasHintTextClass, - getHintTextElement, - getHintText, - setupHintTextNode, -} from './domUtils/hintText'; +export { hasHintTextClass, getHintTextElement, setupHintTextNode } from './domUtils/hintText'; export { setParagraphMarker, getParagraphMarker, } from './domUtils/hiddenProperties/paragraphMarker'; +export { getHintText, setHintText } from './domUtils/hiddenProperties/hintText'; export { setImageState, getImageState } from './domUtils/hiddenProperties/imageState'; export { isBlockGroupOfType } from './modelApi/typeCheck/isBlockGroupOfType';