|
| 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 | +}; |
0 commit comments