Skip to content

Commit ed9b6cf

Browse files
Merge pull request #2968 from flyingbee2012/biwu/321release
Version bump to 9.22.0
2 parents 7597ae9 + eaa08c8 commit ed9b6cf

File tree

13 files changed

+952
-2
lines changed

13 files changed

+952
-2
lines changed

demo/scripts/controlsV2/sidePane/apiPlayground/apiEntries.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import InsertEntityPane from './insertEntity/InsertEntityPane';
3+
import PastePane from './paste/PastePane';
34
import { ApiPaneProps, ApiPlaygroundComponent } from './ApiPaneProps';
45

56
export interface ApiPlaygroundReactComponent
@@ -19,6 +20,10 @@ const apiEntries: { [key: string]: ApiEntry } = {
1920
name: 'Insert Entity',
2021
component: InsertEntityPane,
2122
},
23+
paste: {
24+
name: 'Paste',
25+
component: PastePane,
26+
},
2227
more: {
2328
name: 'Coming soon...',
2429
},
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.pasteHereTextarea {
2+
outline: none;
3+
resize: none;
4+
min-height: 50px;
5+
width: 100%;
6+
}
7+
8+
.showClipboardTextArea {
9+
outline: none;
10+
resize: none;
11+
min-height: 400px;
12+
width: 100%;
13+
}
14+
15+
.button {
16+
margin-right: 5px;
17+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import * as React from 'react';
2+
import { ApiPaneProps, ApiPlaygroundComponent } from '../ApiPaneProps';
3+
import { ClipboardData, PasteType, PluginEvent } from 'roosterjs-content-model-types';
4+
import { DefaultButton, PrimaryButton } from '@fluentui/react/lib/Button';
5+
import { extractClipboardItems } from 'roosterjs-content-model-dom';
6+
import { paste } from 'roosterjs-content-model-core';
7+
8+
const styles = require('./PastePane.scss');
9+
const pasteTypes: PasteType[] = ['normal', 'mergeFormat', 'asPlainText', 'asImage'];
10+
11+
interface PastePaneState {
12+
clipboardData: ClipboardData | undefined;
13+
shouldEncrypt: boolean;
14+
}
15+
16+
let lastClipboardData: ClipboardData | undefined = undefined;
17+
18+
export default class PastePane extends React.Component<ApiPaneProps, PastePaneState>
19+
implements ApiPlaygroundComponent {
20+
private clipboardDataRef = React.createRef<HTMLTextAreaElement>();
21+
private pasteTypeRef = React.createRef<HTMLSelectElement>();
22+
private shouldEncryptRef = React.createRef<HTMLInputElement>();
23+
private ignoreBeforePasteEvent: boolean = false;
24+
25+
constructor(props: ApiPaneProps) {
26+
super(props);
27+
this.state = {
28+
clipboardData: lastClipboardData,
29+
shouldEncrypt: false,
30+
};
31+
}
32+
33+
public onPluginEvent = (e: PluginEvent) => {
34+
if (e.eventType == 'beforePaste' && !this.ignoreBeforePasteEvent) {
35+
this.trySetClipboardData(e.clipboardData);
36+
}
37+
};
38+
39+
private downloadClipboardDataAsJson = () => {
40+
if (this.state.clipboardData) {
41+
const dataStr =
42+
'data:text/json;charset=utf-8,' +
43+
encodeURIComponent(JSON.stringify(this.getClipboardData()));
44+
const downloadAnchorNode = document.createElement('a');
45+
downloadAnchorNode.setAttribute('href', dataStr);
46+
downloadAnchorNode.setAttribute('download', 'clipboardData.json');
47+
document.body.appendChild(downloadAnchorNode); // required for firefox
48+
downloadAnchorNode.click();
49+
downloadAnchorNode.remove();
50+
} else {
51+
alert(
52+
'No clipboard data available to export, either paste in the text area above or use the extract clipboard programmatically button.'
53+
);
54+
}
55+
};
56+
57+
private importClipboardDataFromJson = () => {
58+
const input = document.createElement('input');
59+
input.type = 'file';
60+
input.accept = '.json';
61+
input.onchange = async e => {
62+
const file = (e.target as HTMLInputElement).files![0];
63+
const reader = new FileReader();
64+
reader.onload = async () => {
65+
const clipboardData = JSON.parse(reader.result as string);
66+
this.trySetClipboardData(clipboardData);
67+
};
68+
reader.readAsText(file);
69+
};
70+
input.click();
71+
};
72+
73+
private onExtractClipboardProgrammatically = async () => {
74+
const doc = this.clipboardDataRef.current.ownerDocument;
75+
const clipboard = doc.defaultView.navigator.clipboard;
76+
if (clipboard && clipboard.read) {
77+
try {
78+
const clipboardItems = await clipboard.read();
79+
const dataTransferItems = await Promise.all(
80+
createDataTransferItems(clipboardItems)
81+
);
82+
const clipboardData = await extractClipboardItems(dataTransferItems);
83+
this.trySetClipboardData(clipboardData);
84+
} catch {
85+
this.clipboardDataRef.current.value = 'Error parsing clipboard data';
86+
}
87+
}
88+
};
89+
90+
private trySetClipboardData(clipboardData: ClipboardData) {
91+
this.setState({
92+
clipboardData,
93+
});
94+
95+
lastClipboardData = clipboardData;
96+
}
97+
98+
private paste = () => {
99+
if (this.state.clipboardData) {
100+
const editor = this.props.getEditor();
101+
const pasteType = (this.pasteTypeRef.current.value || 'normal') as PasteType;
102+
103+
this.ignoreBeforePasteEvent = true;
104+
paste(editor, this.getClipboardData(), pasteType);
105+
this.ignoreBeforePasteEvent = false;
106+
} else {
107+
alert(
108+
'No clipboard data available to paste, either paste in the text area above or use the extract clipboard programmatically button.'
109+
);
110+
}
111+
};
112+
113+
private getClipboardData = () => {
114+
try {
115+
const clipboardData = Object.assign({}, this.state.clipboardData);
116+
if (this.state.shouldEncrypt) {
117+
clipboardData.text = clipboardData.text?.replace(/./g, '■');
118+
clipboardData.rawHtml = maskContent(clipboardData.rawHtml);
119+
clipboardData.html = maskContent(clipboardData.html);
120+
}
121+
return clipboardData;
122+
} catch {
123+
alert('Error masking clipboard data');
124+
return undefined;
125+
}
126+
};
127+
128+
private getClipboardDataJson = () => {
129+
try {
130+
return JSON.stringify(this.getClipboardData());
131+
} catch {
132+
return 'Error parsing clipboard data';
133+
}
134+
};
135+
136+
render() {
137+
return (
138+
<>
139+
<div>
140+
{this.state.clipboardData ? (
141+
<PrimaryButton
142+
iconProps={{
143+
iconName: 'Checkmark',
144+
}}
145+
text="Clipboard available. Export or paste with options below."
146+
/>
147+
) : (
148+
<DefaultButton
149+
iconProps={{ iconName: 'Error' }}
150+
text="No clipboard data available. Please paste content into the editor or import a JSON file."
151+
/>
152+
)}
153+
</div>
154+
<h3>Export / Import Clipboard Data</h3>
155+
<div>
156+
<div>
157+
<button
158+
className={styles.button}
159+
onClick={this.downloadClipboardDataAsJson}>
160+
Export Clipboard Data
161+
</button>
162+
<button
163+
className={styles.button}
164+
onClick={this.importClipboardDataFromJson}>
165+
Import Clipboard Data
166+
</button>
167+
</div>
168+
<details>
169+
<summary>Click to show the clipboard data</summary>
170+
<textarea
171+
placeholder="Clipboard data will be shown here"
172+
className={styles.showClipboardTextArea}
173+
ref={this.clipboardDataRef}
174+
readOnly
175+
value={this.getClipboardDataJson()}></textarea>
176+
</details>
177+
<details>
178+
<summary>Advanced actions</summary>
179+
<div>
180+
<label htmlFor="shouldEncrypt">
181+
Should mask the text content in clipboard
182+
</label>
183+
<input
184+
type="checkbox"
185+
value={this.state.shouldEncrypt ? 'checked' : ''}
186+
ref={this.shouldEncryptRef}
187+
onChange={e => {
188+
this.setState({
189+
clipboardData: this.state.clipboardData,
190+
shouldEncrypt: e.target.checked,
191+
});
192+
}}
193+
/>
194+
<hr />
195+
<div>
196+
<button onClick={this.onExtractClipboardProgrammatically}>
197+
Extract clipboard data programmatically
198+
</button>
199+
</div>
200+
</div>
201+
</details>
202+
</div>
203+
<h3>Paste using clipboard data</h3>
204+
205+
<div>
206+
<label htmlFor="SelectPasteType">Paste Type:</label>
207+
<select id="SelectPasteType" ref={this.pasteTypeRef}>
208+
{pasteTypes.map(pasteType => (
209+
<option value={pasteType} key={pasteType}>
210+
{pasteType}
211+
</option>
212+
))}
213+
</select>
214+
</div>
215+
<div>
216+
<button onClick={this.paste}>Paste</button>
217+
</div>
218+
</>
219+
);
220+
}
221+
}
222+
223+
const createDataTransfer = (
224+
kind: 'string' | 'file',
225+
type: string,
226+
blob: Blob
227+
): DataTransferItem => {
228+
const file = blob as File;
229+
return {
230+
kind,
231+
type,
232+
getAsFile: () => file,
233+
getAsString: (callback: (data: string) => void) => {
234+
blob.text().then(callback);
235+
},
236+
webkitGetAsEntry: () => null,
237+
};
238+
};
239+
240+
const createDataTransferItems = (data: ClipboardItems) => {
241+
const isTEXT = (type: string) => type.startsWith('text/');
242+
const dataTransferItems: Promise<DataTransferItem>[] = [];
243+
data.forEach(item => {
244+
item.types.forEach(type => {
245+
dataTransferItems.push(
246+
item
247+
.getType(type)
248+
.then(blob => createDataTransfer(isTEXT(type) ? 'string' : 'file', type, blob))
249+
);
250+
});
251+
});
252+
return dataTransferItems;
253+
};
254+
255+
const maskContent = (html: string | undefined): string => {
256+
return html
257+
? html.replace(/>([^<]+)</g, (_match, p1) => {
258+
return '>' + '■'.repeat(p1.length) + '<';
259+
})
260+
: undefined;
261+
};

demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const initialState: OptionState = {
4848
autoMailto: true,
4949
autoTel: true,
5050
removeListMargins: false,
51+
autoHorizontalLine: true,
5152
},
5253
markdownOptions: {
5354
bold: true,

demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export class Plugins extends PluginsBase<keyof BuildInPluginList> {
113113
private autoTel = React.createRef<HTMLInputElement>();
114114
private autoMailto = React.createRef<HTMLInputElement>();
115115
private removeListMargins = React.createRef<HTMLInputElement>();
116+
private horizontalLine = React.createRef<HTMLInputElement>();
116117
private markdownBold = React.createRef<HTMLInputElement>();
117118
private markdownItalic = React.createRef<HTMLInputElement>();
118119
private markdownStrikethrough = React.createRef<HTMLInputElement>();
@@ -188,6 +189,13 @@ export class Plugins extends PluginsBase<keyof BuildInPluginList> {
188189
(state, value) =>
189190
(state.autoFormatOptions.removeListMargins = value)
190191
)}
192+
{this.renderCheckBox(
193+
'Horizontal Line',
194+
this.horizontalLine,
195+
this.props.state.autoFormatOptions.autoHorizontalLine,
196+
(state, value) =>
197+
(state.autoFormatOptions.autoHorizontalLine = value)
198+
)}
191199
</>
192200
)}
193201
{this.renderPluginItem(

packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function getListAnnounceData(path: ReadonlyContentModelBlockGroup[]): Ann
2424
const listItem = path[index] as ContentModelListItem;
2525
const level = listItem.levels[listItem.levels.length - 1];
2626

27-
if (level.format.displayForDummyItem) {
27+
if (!level || level.format.displayForDummyItem) {
2828
return null;
2929
} else if (level.listType == 'OL') {
3030
const listNumber = getListNumber(path, listItem);

packages/roosterjs-content-model-api/test/modelApi/list/getListAnnounceDataTest.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ describe('getListAnnounceData', () => {
7171
expect(getAutoListStyleTypeSpy).not.toHaveBeenCalled();
7272
});
7373

74+
it('path has list item without list levels', () => {
75+
const doc = createContentModelDocument();
76+
const listItem = createListItem([]);
77+
78+
doc.blocks.push(listItem);
79+
80+
const result = getListAnnounceData([listItem, doc]);
81+
82+
expect(result).toEqual(null);
83+
expect(getAutoListStyleTypeSpy).not.toHaveBeenCalled();
84+
});
85+
7486
it('path with bullet list', () => {
7587
const doc = createContentModelDocument();
7688
const listItem = createListItem([createListLevel('UL')]);

packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ChangeSource } from 'roosterjs-content-model-dom';
2+
import { checkAndInsertHorizontalLine } from './horizontalLine/checkAndInsertHorizontalLine';
23
import { createLink } from './link/createLink';
34
import { formatTextSegmentBeforeSelectionMarker, promoteLink } from 'roosterjs-content-model-api';
45
import { keyboardListTrigger } from './list/keyboardListTrigger';
@@ -52,6 +53,7 @@ const DefaultOptions: Partial<AutoFormatOptions> = {
5253
autoFraction: false,
5354
autoOrdinals: false,
5455
removeListMargins: false,
56+
autoHorizontalLine: false,
5557
};
5658

5759
/**
@@ -72,6 +74,7 @@ export class AutoFormatPlugin implements EditorPlugin {
7274
* - autoUnlink: A boolean that enables or disables automatic hyperlink removal when pressing backspace. Defaults to false.
7375
* - autoTel: A boolean that enables or disables automatic hyperlink telephone numbers transformation. Defaults to false.
7476
* - autoMailto: A boolean that enables or disables automatic hyperlink email address transformation. Defaults to false.
77+
* - autoHorizontalLine: A boolean that enables or disables automatic horizontal line creation. Defaults to false.
7578
*/
7679
constructor(private options: AutoFormatOptions = DefaultOptions) {}
7780

@@ -270,10 +273,20 @@ export class AutoFormatPlugin implements EditorPlugin {
270273
}
271274
);
272275
}
276+
break;
277+
case 'Enter':
278+
this.handleEnterKey(editor, event);
279+
break;
273280
}
274281
}
275282
}
276283

284+
private handleEnterKey(editor: IEditor, event: KeyDownEvent) {
285+
if (this.options.autoHorizontalLine) {
286+
checkAndInsertHorizontalLine(editor, event);
287+
}
288+
}
289+
277290
private handleContentChangedEvent(editor: IEditor, event: ContentChangedEvent) {
278291
const { autoLink, autoTel, autoMailto } = this.options;
279292
if (event.source == 'Paste' && (autoLink || autoTel || autoMailto)) {

0 commit comments

Comments
 (0)