diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index affa2a0068aa..f9430028a312 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -60,6 +60,7 @@ import { AutoFormatPlugin, CustomReplacePlugin, EditPlugin, + HintTextPlugin, HiddenPropertyPlugin, HyperlinkPlugin, ImageEditPlugin, @@ -540,6 +541,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { : linkTitle ), pluginList.customReplace && new CustomReplacePlugin(customReplacements), + pluginList.hintText && new HintTextPlugin(), pluginList.hiddenProperty && new HiddenPropertyPlugin({ undeletableLinkChecker: undeletableLinkChecker, diff --git a/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts b/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts index ddd954353389..a0d22e32f3d8 100644 --- a/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts +++ b/demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import CreateModelFromHtmlPane from './createModelFromHtml/CreateModelFromHtmlPane'; +import HintTextPane from './hintText/hintTextPane'; import InsertCustomContainerPane from './insertCustomContainer/InsertCustomContainerPane'; import InsertEntityPane from './insertEntity/InsertEntityPane'; import PastePane from './paste/PastePane'; @@ -34,6 +35,10 @@ const apiEntries: { [key: string]: ApiEntry } = { name: 'Insert Custom Container', component: InsertCustomContainerPane, }, + 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/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/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 1312a4152dfd..6b614c816116 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, hiddenProperty: true, }, defaultFormat: { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index bb37560ea2b2..431a2c0f2d7f 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; hiddenProperty: boolean; } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 986eb3f97f4f..c06a79e0fff8 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -315,6 +315,7 @@ export class Plugins extends PluginsBase { )} {this.renderPluginItem('customReplace', 'Custom Replace')} {this.renderPluginItem('imageEditPlugin', 'ImageEditPlugin')} + {this.renderPluginItem('hintText', 'HintText')} {this.renderPluginItem('hiddenProperty', 'Hidden Property')} 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 8e46e0f8d6a9..66ac02da3477 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -163,6 +163,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 cf29ec6335d9..9722f43c8183 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) { let startNode: Node | undefined; @@ -234,6 +265,14 @@ export class DomIndexerImpl implements DomIndexer { isIndexedSegment(startNode) && startNode.__roosterjsContentModel.segments.length > 0 ) { + const { paragraph } = startNode.__roosterjsContentModel; + const marker = paragraph.segments.filter( + (x: ContentModelSegment): x is ContentModelSelectionMarker => + x.segmentType == 'SelectionMarker' && !!x.hintText + )[0]; + + hintText = marker?.hintText; + this.reconcileTextSelection(startNode); } else { setSelection(model); @@ -293,7 +332,8 @@ export class DomIndexerImpl implements DomIndexer { return !!this.reconcileNodeSelection( startContainer, startOffset, - model.format + model.format, + hintText ); } else if ( startContainer == endContainer && @@ -305,7 +345,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); @@ -387,6 +432,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, @@ -408,11 +473,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 { @@ -448,7 +514,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]; @@ -473,6 +540,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-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 599ff2cac64d..0361108955c1 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(); @@ -949,7 +960,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(); @@ -1134,6 +1145,7 @@ describe('domIndexerImpl.reconcileSelection', () => { format: {}, isSelected: true, link, + hintText: undefined, }, segment2, ], 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..839f2e8180b2 --- /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/hiddenProperties/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 2210e7849752..826e2e6b78bb 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'; @@ -69,6 +70,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/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 new file mode 100644 index 000000000000..e65ebcb56af4 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domUtils/hintText.ts @@ -0,0 +1,55 @@ +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'; +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'); + + 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'); + hintTextNode.textContent = hintText; + hintTextNode.style.color = '#999'; + shadowRoot.appendChild(hintTextNode); +} + +/** + * 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 6a23f20a86bd..b6a5ab8a5150 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -120,10 +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, 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'; 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/handleParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index 3973ecd482a6..7b690a8d39a1 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/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-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-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 75c22053da83..8342c02f7ce9 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -299,6 +299,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 31e4c0a2259e..b5714b781fe5 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -691,6 +691,8 @@ describe('handleParagraph', () => { onBlockEntity: null!, reconcileElementId: null!, onMergeText: null!, + onSelectionMarker: null!, + reconcileHintText: null!, }; context.domIndexer = domIndexer; @@ -742,6 +744,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/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 d0a648a5ce8d..adbfe9cd41a5 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -39,5 +39,8 @@ 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'; export { HiddenPropertyPlugin } from './hiddenProperty/HiddenPropertyPlugin'; export { HiddenPropertyOptions } from './hiddenProperty/HiddenPropertyOptions'; diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts index f0e1641ffe50..8d3b0c1f6382 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts @@ -14,7 +14,9 @@ function extractStyleTagsFromHtml(htmlContent: string): string[] { while (styleStartIndex >= 0) { const styleEndIndex = lowerCaseHtmlContent.indexOf(STYLE_TAG_END, styleStartIndex); if (styleEndIndex >= 0) { - const styleContent = htmlContent.substring(styleStartIndex + STYLE_TAG.length, styleEndIndex).trim(); + const styleContent = htmlContent + .substring(styleStartIndex + STYLE_TAG.length, styleEndIndex) + .trim(); styles.push(styleContent); styleStartIndex = lowerCaseHtmlContent.indexOf(STYLE_TAG, styleEndIndex); } else { 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-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 4ada9f5898dd..39ebec008b01 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -951,6 +951,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: true, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, { segmentType: 'Text', @@ -1254,6 +1255,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { segmentType: 'SelectionMarker', isSelected: true, format: {}, + hintText: undefined, }, ], segmentFormat: undefined, @@ -1642,6 +1644,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: false, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, levels: [ { @@ -1694,6 +1697,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: true, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, ], segmentFormat: undefined, @@ -1779,6 +1783,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { isSelected: false, segmentType: 'SelectionMarker', format: {}, + hintText: undefined, }, levels: [ { @@ -1918,6 +1923,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: {} }, ], 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/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; } 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 59d9ee58da68..c491c8e212a0 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -196,6 +196,7 @@ export { ReadonlyContentModelText, } from './contentModel/segment/ContentModelText'; export { + ContentModelSelectionMarkerCommon, ContentModelSelectionMarker, ReadonlyContentModelSelectionMarker, } from './contentModel/segment/ContentModelSelectionMarker';