Skip to content

[FHL] Hint Text API and plugin #2961

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions demo/scripts/controlsV2/mainPane/MainPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
AutoFormatPlugin,
CustomReplacePlugin,
EditPlugin,
HintTextPlugin,
HiddenPropertyPlugin,
HyperlinkPlugin,
ImageEditPlugin,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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...',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiPaneProps, {}>
implements ApiPlaygroundComponent {
private word: React.RefObject<HTMLInputElement> = React.createRef();
private hintText: React.RefObject<HTMLInputElement> = React.createRef();

constructor(props: ApiPaneProps) {
super(props);

this.state = {};
}

render() {
return (
<>
<div>
When type word: <input type="text" ref={this.word} />
</div>
<div>
Insert hint text: <input type="text" ref={this.hintText} />
</div>
</>
);
}

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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const exportButton: RibbonButton<'buttonNameExport'> = {
const model = getCurrentContentModel();

if (model) {
editor.focus();
editor.formatContentModel(currentModel => {
mutateBlock(currentModel).blocks = model.blocks;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const initialState: OptionState = {
imageEditPlugin: true,
hyperlink: true,
customReplace: true,
hintText: true,
hiddenProperty: true,
},
defaultFormat: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface BuildInPluginList {
hyperlink: boolean;
imageEditPlugin: boolean;
customReplace: boolean;
hintText: boolean;
hiddenProperty: boolean;
}

Expand Down
1 change: 1 addition & 0 deletions demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ export class Plugins extends PluginsBase<keyof BuildInPluginList> {
)}
{this.renderPluginItem('customReplace', 'Custom Replace')}
{this.renderPluginItem('imageEditPlugin', 'ImageEditPlugin')}
{this.renderPluginItem('hintText', 'HintText')}
{this.renderPluginItem('hiddenProperty', 'Hidden Property')}
</tbody>
</table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ class CachePlugin implements PluginWithState<CachePluginState> {

break;

case 'hintText':
if (!this.state.domIndexer?.reconcileHintText(mutation.hintNode)) {
this.invalidateCache();
}
break;

case 'unknown':
this.invalidateCache();
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export type MutationType =
/**
* Child list is changed
*/
| 'childList';
| 'childList'
/**
* Hint text node is changed
*/
| 'hintText';

/**
* @internal
Expand Down Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createParagraph,
createSelectionMarker,
createText,
getHintText,
getObjectKeys,
isElementOfType,
isEntityDelimiter,
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
*/
Expand Down Expand Up @@ -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 ?? {};

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -223,6 +253,7 @@ export class DomIndexerImpl implements DomIndexer {
newSelection: DOMSelection,
oldSelection?: CacheSelection
): boolean {
let hintText: string | undefined;
if (oldSelection) {
let startNode: Node | undefined;

Expand All @@ -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);
Expand Down Expand Up @@ -293,7 +332,8 @@ export class DomIndexerImpl implements DomIndexer {
return !!this.reconcileNodeSelection(
startContainer,
startOffset,
model.format
model.format,
hintText
);
} else if (
startContainer == endContainer &&
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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];
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Node>();
const includedNodes = new Set<Node>();
Expand All @@ -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 (
Expand Down Expand Up @@ -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' });
}
Expand Down
Loading
Loading