Skip to content

Commit

Permalink
Content Model: Support get and apply segment format (#1518)
Browse files Browse the repository at this point in the history
  • Loading branch information
JiuqingSong authored Jan 20, 2023
1 parent 060d3ec commit 9bd0625
Show file tree
Hide file tree
Showing 14 changed files with 1,032 additions and 83 deletions.
4 changes: 4 additions & 0 deletions demo/scripts/controls/MainPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'
import EditorOptionsPlugin from './sidePane/editorOptions/EditorOptionsPlugin';
import EventViewPlugin from './sidePane/eventViewer/EventViewPlugin';
import ExperimentalContentModelEditor from './editor/ExperimentalContentModelEditor';
import FormatPainterPlugin from './contentModel/plugins/FormatPainterPlugin';
import FormatStatePlugin from './sidePane/formatState/FormatStatePlugin';
import getToggleablePlugins from './getToggleablePlugins';
import MainPaneBase from './MainPaneBase';
Expand Down Expand Up @@ -128,6 +129,7 @@ class MainPane extends MainPaneBase {
private updateContentPlugin: UpdateContentPlugin;
private toggleablePlugins: EditorPlugin[] | null = null;
private contentModelPlugin: ContentModelPlugin;
private formatPainterPlugin: FormatPainterPlugin;
private mainWindowButtons: RibbonButton<RibbonStringKeys>[];
private popoutWindowButtons: RibbonButton<RibbonStringKeys>[];

Expand All @@ -150,6 +152,7 @@ class MainPane extends MainPaneBase {
this.emojiPlugin = createEmojiPlugin();
this.updateContentPlugin = createUpdateContentPlugin(UpdateMode.OnDispose, this.onUpdate);
this.contentModelPlugin = new ContentModelPlugin();
this.formatPainterPlugin = new FormatPainterPlugin();
this.mainWindowButtons = getButtons([
...AllButtonKeys,
darkMode,
Expand Down Expand Up @@ -437,6 +440,7 @@ class MainPane extends MainPaneBase {
this.pasteOptionPlugin,
this.emojiPlugin,
this.contentModelPlugin,
this.formatPainterPlugin,
];

if (this.state.showSidePane || this.state.popoutWindow) {
Expand Down
71 changes: 71 additions & 0 deletions demo/scripts/controls/contentModel/plugins/FormatPainterPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { EditorPlugin, IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types';
import {
applySegmentFormat,
ContentModelSegmentFormat,
getSegmentFormat,
IExperimentalContentModelEditor,
} from 'roosterjs-content-model';

const FORMATPAINTERCURSOR_SVG = require('./formatpaintercursor.svg');
const FORMATPAINTERCURSOR_STYLE = `;cursor: url("${FORMATPAINTERCURSOR_SVG}") 8.5 16, auto`;
const CURSOR_REGEX = /;?\s*cursor:\s*url\(\".*?\"\)[^;]*/gi;

interface FormatPainterFormatHolder {
format: ContentModelSegmentFormat | null;
}

export default class FormatPainterPlugin implements EditorPlugin {
private editor: IExperimentalContentModelEditor | null = null;

getName() {
return 'FormatPainter';
}

initialize(editor: IEditor) {
this.editor = editor as IExperimentalContentModelEditor;
}

dispose() {
this.editor = null;
}

onPluginEvent(event: PluginEvent) {
if (this.editor && event.eventType == PluginEventType.MouseUp) {
const formatHolder = getFormatHolder(this.editor);

if (formatHolder.format) {
applySegmentFormat(this.editor, formatHolder.format);
formatHolder.format = null;

setFormatPainterCursor(this.editor, false /*isOn*/);
}
}
}

static startFormatPainter(editor: IExperimentalContentModelEditor) {
const formatHolder = getFormatHolder(editor);
const format = getSegmentFormat(editor);

if (format) {
formatHolder.format = { ...format };
setFormatPainterCursor(editor, true /*isOn*/);
}
}
}

function getFormatHolder(editor: IEditor): FormatPainterFormatHolder {
return editor.getCustomData('__FormatPainterFormat', () => {
return {} as FormatPainterFormatHolder;
});
}

function setFormatPainterCursor(editor: IEditor, isOn: boolean) {
let styles = editor.getEditorDomAttribute('style') || '';
styles = styles.replace(CURSOR_REGEX, '');

if (isOn) {
styles += FORMATPAINTERCURSOR_STYLE;
}

editor.setEditorDomAttribute('style', styles);
}
22 changes: 22 additions & 0 deletions demo/scripts/controls/contentModel/plugins/formatpaintercursor.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { decreaseFontSizeButton } from './decreaseFontSizeButton';
import { decreaseIndentButton } from './decreaseIndentButton';
import { fontButton } from './fontButton';
import { fontSizeButton } from './fontSizeButton';
import { formatPainterButton } from './formatPainterButton';
import { formatTableButton } from './formatTableButton';
import { increaseFontSizeButton } from './increaseFontSizeButton';
import { increaseIndentButton } from './increaseIndentButton';
Expand Down Expand Up @@ -44,6 +45,7 @@ import {
} from './tableEditButtons';

const buttons = [
formatPainterButton,
boldButton,
italicButton,
underlineButton,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import FormatPainterPlugin from '../../contentModel/plugins/FormatPainterPlugin';
import isContentModelEditor from '../../editor/isContentModelEditor';
import { RibbonButton } from 'roosterjs-react';

/**
* @internal
* "Format Painter" button on the format ribbon
*/
export const formatPainterButton: RibbonButton<'formatPainter'> = {
key: 'formatPainter',
unlocalizedText: 'Format painter',
iconName: 'Brush',
onClick: editor => {
if (isContentModelEditor(editor)) {
FormatPainterPlugin.startFormatPainter(editor);
}
return true;
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { contains } from 'roosterjs-editor-dom';
import { ContentModelBlockGroup } from '../../publicTypes/group/ContentModelBlockGroup';
import { DomToModelContext } from '../../publicTypes/context/DomToModelContext';
import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets';
import { handleRegularSelection, processChildNode } from './childProcessor';

interface FormatStateContext extends DomToModelContext {
/**
* An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored,
* but use the top element in this stack instead in childProcessor.
*/
nodeStack?: Node[];
}

/**
* @internal
* In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create
* content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node.
* This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection,
* then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state
*/
export function reducedModelChildProcessor(
group: ContentModelBlockGroup,
parent: ParentNode,
context: FormatStateContext
) {
if (context.selectionRootNode) {
if (!context.nodeStack) {
context.nodeStack = createNodeStack(parent, context.selectionRootNode);
}

const stackChild = context.nodeStack.pop();

if (stackChild) {
const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent);

// If selection is not on this node, skip getting node index to save some time since we don't need it here
const index =
nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1;

if (index >= 0) {
handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset);
}

processChildNode(group, stackChild, context);

if (index >= 0) {
handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset);
}
} else {
// No child node from node stack, that means we have reached the deepest node of selection.
// Now we can use default child processor to perform full sub tree scanning for content model,
// So that all selected node will be included.
context.defaultElementProcessors.child(group, parent, context);
}
}
}

function createNodeStack(root: Node, startNode: Node): Node[] {
const result: Node[] = [];
let node: Node | null = startNode;

while (node && contains(root, node)) {
result.push(node);
node = node.parentNode;
}

return result;
}

function getChildIndex(parent: ParentNode, stackChild: Node) {
let index = 0;
let child = parent.firstChild;

while (child && child != stackChild) {
index++;
child = child.nextSibling;
}
return index;
}
2 changes: 2 additions & 0 deletions packages/roosterjs-content-model/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { default as setFontName } from './publicApi/segment/setFontName';
export { default as setFontSize } from './publicApi/segment/setFontSize';
export { default as setTextColor } from './publicApi/segment/setTextColor';
export { default as changeFontSize } from './publicApi/segment/changeFontSize';
export { default as applySegmentFormat } from './publicApi/segment/applySegmentFormat';
export { default as changeCapitalization } from './publicApi/segment/changeCapitalization';
export { default as insertImage } from './publicApi/insert/insertImage';
export { default as setListStyle } from './publicApi/list/setListStyle';
Expand All @@ -30,6 +31,7 @@ export { default as setDirection } from './publicApi/block/setDirection';
export { default as setHeaderLevel } from './publicApi/block/setHeaderLevel';
export { default as toggleBlockQuote } from './publicApi/block/toggleBlockQuote';
export { default as getFormatState } from './publicApi/format/getFormatState';
export { default as getSegmentFormat } from './publicApi/format/getSegmentFormat';
export { default as clearFormat } from './publicApi/format/clearFormat';
export { default as insertLink } from './publicApi/link/insertLink';
export { default as removeLink } from './publicApi/link/removeLink';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import { contains } from 'roosterjs-editor-dom';
import { ContentModelBlockGroup } from '../../publicTypes/group/ContentModelBlockGroup';
import { DomToModelContext } from '../../publicTypes/context/DomToModelContext';
import { FormatState } from 'roosterjs-editor-types';
import { formatWithContentModel } from '../utils/formatWithContentModel';
import { getRegularSelectionOffsets } from '../../domToModel/utils/getRegularSelectionOffsets';
import { IExperimentalContentModelEditor } from '../../publicTypes/IExperimentalContentModelEditor';
import { reducedModelChildProcessor } from '../../domToModel/processors/reducedModelChildProcessor';
import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState';
import {
handleRegularSelection,
processChildNode,
} from '../../domToModel/processors/childProcessor';

/**
* Get current format state
Expand Down Expand Up @@ -43,77 +36,3 @@ export default function getFormatState(editor: IExperimentalContentModelEditor):

return result;
}

interface FormatStateContext extends DomToModelContext {
/**
* An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored,
* but use the top element in this stack instead in childProcessor.
*/
nodeStack?: Node[];
}

/**
* In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create
* content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node.
* This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection,
* then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state
*/
function reducedModelChildProcessor(
group: ContentModelBlockGroup,
parent: ParentNode,
context: FormatStateContext
) {
if (context.selectionRootNode) {
if (!context.nodeStack) {
context.nodeStack = createNodeStack(parent, context.selectionRootNode);
}

const stackChild = context.nodeStack.pop();

if (stackChild) {
const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent);

// If selection is not on this node, skip getting node index to save some time since we don't need it here
const index =
nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1;

if (index >= 0) {
handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset);
}

processChildNode(group, stackChild, context);

if (index >= 0) {
handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset);
}
} else {
// No child node from node stack, that means we have reached the deepest node of selection.
// Now we can use default child processor to perform full sub tree scanning for content model,
// So that all selected node will be included.
context.defaultElementProcessors.child(group, parent, context);
}
}
}

function createNodeStack(root: Node, startNode: Node): Node[] {
const result: Node[] = [];
let node: Node | null = startNode;

while (node && contains(root, node)) {
result.push(node);
node = node.parentNode;
}

return result;
}

function getChildIndex(parent: ParentNode, stackChild: Node) {
let index = 0;
let child = parent.firstChild;

while (child && child != stackChild) {
index++;
child = child.nextSibling;
}
return index;
}
Loading

0 comments on commit 9bd0625

Please sign in to comment.