|
| 1 | +import * as React from 'react'; |
| 2 | +import { ContentModelContentChangedEvent } from 'roosterjs-content-model-editor'; |
| 3 | +import { EntityOperation, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; |
| 4 | +import { SidePaneElementProps } from '../SidePaneElement'; |
| 5 | +import { |
| 6 | + getObjectKeys, |
| 7 | + getTagOfNode, |
| 8 | + HtmlSanitizer, |
| 9 | + readFile, |
| 10 | + safeInstanceOf, |
| 11 | +} from 'roosterjs-editor-dom'; |
| 12 | + |
| 13 | +const styles = require('./EventViewPane.scss'); |
| 14 | + |
| 15 | +export interface EventEntry { |
| 16 | + index: number; |
| 17 | + time: Date; |
| 18 | + event: PluginEvent; |
| 19 | +} |
| 20 | + |
| 21 | +export interface EventViewPaneState { |
| 22 | + displayCount: number; |
| 23 | + currentIndex: number; |
| 24 | +} |
| 25 | + |
| 26 | +const EventTypeMap: { [key in PluginEventType]: string } = { |
| 27 | + [PluginEventType.BeforeDispose]: 'BeforeDispose', |
| 28 | + [PluginEventType.BeforePaste]: 'BeforePaste', |
| 29 | + [PluginEventType.CompositionEnd]: 'CompositionEnd', |
| 30 | + [PluginEventType.ContentChanged]: 'ContentChanged', |
| 31 | + [PluginEventType.EditorReady]: 'EditorReady', |
| 32 | + [PluginEventType.EntityOperation]: 'EntityOperation', |
| 33 | + [PluginEventType.ExtractContentWithDom]: 'ExtractContentWithDom', |
| 34 | + [PluginEventType.KeyDown]: 'KeyDown', |
| 35 | + [PluginEventType.KeyPress]: 'KeyPress', |
| 36 | + [PluginEventType.KeyUp]: 'KeyUp', |
| 37 | + [PluginEventType.MouseDown]: 'MouseDown', |
| 38 | + [PluginEventType.MouseUp]: 'MouseUp', |
| 39 | + [PluginEventType.Input]: 'Input', |
| 40 | + [PluginEventType.PendingFormatStateChanged]: 'PendingFormatStateChanged', |
| 41 | + [PluginEventType.Scroll]: 'Scroll', |
| 42 | + [PluginEventType.BeforeCutCopy]: 'BeforeCutCopy', |
| 43 | + [PluginEventType.ContextMenu]: 'ContextMenu', |
| 44 | + [PluginEventType.EnteredShadowEdit]: 'EnteredShadowEdit', |
| 45 | + [PluginEventType.LeavingShadowEdit]: 'LeavingShadowEdit', |
| 46 | + [PluginEventType.EditImage]: 'EditImage', |
| 47 | + [PluginEventType.BeforeSetContent]: 'BeforeSetContent', |
| 48 | + [PluginEventType.ZoomChanged]: 'ZoomChanged', |
| 49 | + [PluginEventType.SelectionChanged]: 'SelectionChanged', |
| 50 | + [PluginEventType.BeforeKeyboardEditing]: 'BeforeKeyboardEditing', |
| 51 | +}; |
| 52 | + |
| 53 | +const EntityOperationMap: { [key in EntityOperation]: string } = { |
| 54 | + [EntityOperation.AddShadowRoot]: 'AddShadowRoot', |
| 55 | + [EntityOperation.RemoveShadowRoot]: 'RemoveShadowRoot', |
| 56 | + [EntityOperation.Click]: 'Click', |
| 57 | + [EntityOperation.ContextMenu]: 'ContextMenu', |
| 58 | + [EntityOperation.Escape]: 'Escape', |
| 59 | + [EntityOperation.NewEntity]: 'NewEntity', |
| 60 | + [EntityOperation.Overwrite]: 'Overwrite', |
| 61 | + [EntityOperation.PartialOverwrite]: 'PartialOverwrite', |
| 62 | + [EntityOperation.RemoveFromEnd]: 'RemoveFromEnd', |
| 63 | + [EntityOperation.RemoveFromStart]: 'RemoveFromStart', |
| 64 | + [EntityOperation.ReplaceTemporaryContent]: 'ReplaceTemporaryContent', |
| 65 | + [EntityOperation.UpdateEntityState]: 'UpdateEntityState', |
| 66 | +}; |
| 67 | + |
| 68 | +export default class ContentModelEventViewPane extends React.Component< |
| 69 | + SidePaneElementProps, |
| 70 | + EventViewPaneState |
| 71 | +> { |
| 72 | + private events: EventEntry[] = []; |
| 73 | + private displayCount = React.createRef<HTMLSelectElement>(); |
| 74 | + private lastIndex = 0; |
| 75 | + |
| 76 | + constructor(props: SidePaneElementProps) { |
| 77 | + super(props); |
| 78 | + this.state = { |
| 79 | + displayCount: 20, |
| 80 | + currentIndex: -1, |
| 81 | + }; |
| 82 | + } |
| 83 | + |
| 84 | + render() { |
| 85 | + let displayCount = Math.min(this.events.length, this.state.displayCount); |
| 86 | + let displayedEvents = |
| 87 | + displayCount > 0 ? this.events.slice(this.events.length - displayCount) : []; |
| 88 | + displayedEvents = displayedEvents.reverse(); |
| 89 | + |
| 90 | + return ( |
| 91 | + <> |
| 92 | + <div> |
| 93 | + Show item count: |
| 94 | + <select |
| 95 | + defaultValue={this.state.displayCount.toString()} |
| 96 | + ref={this.displayCount} |
| 97 | + onChange={this.onDisplayCountChanged}> |
| 98 | + <option value={'0'}>Disabled</option> |
| 99 | + <option value={'20'}>20</option> |
| 100 | + <option value={'50'}>50</option> |
| 101 | + <option value={'100'}>100</option> |
| 102 | + </select>{' '} |
| 103 | + <button onClick={this.clear}>Clear all</button> |
| 104 | + </div> |
| 105 | + <div> |
| 106 | + {displayedEvents.map(event => ( |
| 107 | + <details key={event.index.toString()}> |
| 108 | + <summary> |
| 109 | + {`${event.time.getHours()}:${event.time.getMinutes()}:${event.time.getSeconds()}.${event.time.getMilliseconds()} `} |
| 110 | + {EventTypeMap[event.event.eventType]} |
| 111 | + </summary> |
| 112 | + <div className={styles.eventContent}> |
| 113 | + {this.renderEvent(event.event)} |
| 114 | + </div> |
| 115 | + </details> |
| 116 | + ))} |
| 117 | + </div> |
| 118 | + </> |
| 119 | + ); |
| 120 | + } |
| 121 | + |
| 122 | + addEvent(event: PluginEvent) { |
| 123 | + if (this.state.displayCount > 0) { |
| 124 | + if (event.eventType == PluginEventType.BeforePaste) { |
| 125 | + const sanitizer = new HtmlSanitizer(event.sanitizingOption); |
| 126 | + const fragment = event.fragment.cloneNode(true /*deep*/) as DocumentFragment; |
| 127 | + |
| 128 | + sanitizer.convertGlobalCssToInlineCss(fragment); |
| 129 | + sanitizer.sanitize(fragment); |
| 130 | + (event.clipboardData as any).html = this.getHtml(fragment); |
| 131 | + } |
| 132 | + |
| 133 | + this.events.push({ |
| 134 | + time: new Date(), |
| 135 | + event: event, |
| 136 | + index: this.lastIndex++, |
| 137 | + }); |
| 138 | + |
| 139 | + while (this.events.length > 100) { |
| 140 | + this.events.shift(); |
| 141 | + } |
| 142 | + this.setState({ |
| 143 | + currentIndex: this.lastIndex, |
| 144 | + }); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + private renderEvent(event: PluginEvent): JSX.Element { |
| 149 | + switch (event.eventType) { |
| 150 | + case PluginEventType.KeyDown: |
| 151 | + case PluginEventType.KeyPress: |
| 152 | + case PluginEventType.KeyUp: |
| 153 | + return ( |
| 154 | + <span> |
| 155 | + Key= |
| 156 | + {event.rawEvent.which} |
| 157 | + </span> |
| 158 | + ); |
| 159 | + |
| 160 | + case PluginEventType.MouseDown: |
| 161 | + case PluginEventType.MouseUp: |
| 162 | + case PluginEventType.ContextMenu: |
| 163 | + return ( |
| 164 | + <span> |
| 165 | + Button= |
| 166 | + {event.rawEvent.button}, SrcElement= |
| 167 | + {event.rawEvent.target && getTagOfNode(event.rawEvent.target as Node)}, |
| 168 | + PageX= |
| 169 | + {event.rawEvent.pageX}, PageY= |
| 170 | + {event.rawEvent.pageY} |
| 171 | + </span> |
| 172 | + ); |
| 173 | + |
| 174 | + case PluginEventType.ContentChanged: |
| 175 | + return ( |
| 176 | + <span> |
| 177 | + Source= |
| 178 | + {event.source}, Data= |
| 179 | + {event.data && event.data.toString && event.data.toString()} |
| 180 | + {!!(event as ContentModelContentChangedEvent).contentModel && ( |
| 181 | + <details> |
| 182 | + <summary>Content Model</summary> |
| 183 | + <pre className={styles.eventContent}> |
| 184 | + {JSON.stringify( |
| 185 | + (event as ContentModelContentChangedEvent).contentModel, |
| 186 | + (key, value) => |
| 187 | + safeInstanceOf(value, 'Node') |
| 188 | + ? Object.prototype.toString.apply(value) |
| 189 | + : key == 'src' |
| 190 | + ? value.length > 100 |
| 191 | + ? value.substring(0, 97) + '...' |
| 192 | + : value |
| 193 | + : value, |
| 194 | + 2 |
| 195 | + )} |
| 196 | + </pre> |
| 197 | + </details> |
| 198 | + )} |
| 199 | + </span> |
| 200 | + ); |
| 201 | + |
| 202 | + case PluginEventType.BeforePaste: |
| 203 | + return ( |
| 204 | + <span> |
| 205 | + Types= |
| 206 | + {event.clipboardData.types.join()} |
| 207 | + {this.renderPasteContent('Plain text', event.clipboardData.text)} |
| 208 | + {this.renderPasteContent( |
| 209 | + 'Sanitized HTML', |
| 210 | + (event.clipboardData as any).html |
| 211 | + )} |
| 212 | + {this.renderPasteContent('Original HTML', event.clipboardData.rawHtml)} |
| 213 | + {this.renderPasteContent('Image', event.clipboardData.image, img => ( |
| 214 | + <img |
| 215 | + ref={ref => ref && this.renderImage(ref, img)} |
| 216 | + className={styles.img} |
| 217 | + /> |
| 218 | + ))} |
| 219 | + {this.renderPasteContent( |
| 220 | + 'LinkPreview', |
| 221 | + event.clipboardData.linkPreview |
| 222 | + ? JSON.stringify(event.clipboardData.linkPreview) |
| 223 | + : '' |
| 224 | + )} |
| 225 | + Paste from keyboard or native context menu: |
| 226 | + {event.clipboardData.pasteNativeEvent ? ' true' : ' false'} |
| 227 | + {getObjectKeys(event.clipboardData.customValues).map(contentType => |
| 228 | + this.renderPasteContent( |
| 229 | + contentType, |
| 230 | + event.clipboardData.customValues[contentType] |
| 231 | + ) |
| 232 | + )} |
| 233 | + </span> |
| 234 | + ); |
| 235 | + case PluginEventType.PendingFormatStateChanged: |
| 236 | + const formatState = event.formatState; |
| 237 | + const keys = getObjectKeys(formatState); |
| 238 | + return <span>{keys.map(key => `${key}=${event.formatState[key]}; `)}</span>; |
| 239 | + |
| 240 | + case PluginEventType.EntityOperation: |
| 241 | + const { |
| 242 | + operation, |
| 243 | + entity: { id, type }, |
| 244 | + } = event; |
| 245 | + return ( |
| 246 | + <span> |
| 247 | + Operation={EntityOperationMap[operation]} Type={type}; Id={id} |
| 248 | + </span> |
| 249 | + ); |
| 250 | + |
| 251 | + case PluginEventType.BeforeCutCopy: |
| 252 | + const { isCut } = event; |
| 253 | + return <span>isCut={isCut ? 'true' : 'false'}</span>; |
| 254 | + |
| 255 | + case PluginEventType.EditImage: |
| 256 | + return ( |
| 257 | + <> |
| 258 | + <span>new src={event.newSrc.substr(0, 100)}</span> |
| 259 | + </> |
| 260 | + ); |
| 261 | + |
| 262 | + case PluginEventType.ZoomChanged: |
| 263 | + return ( |
| 264 | + <span> |
| 265 | + Old value={event.oldZoomScale} New value={event.newZoomScale} |
| 266 | + </span> |
| 267 | + ); |
| 268 | + |
| 269 | + case PluginEventType.BeforeKeyboardEditing: |
| 270 | + return <span>Key code={event.rawEvent.which}</span>; |
| 271 | + |
| 272 | + default: |
| 273 | + return null; |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + private clear = () => { |
| 278 | + this.events = []; |
| 279 | + this.setState({ |
| 280 | + currentIndex: -1, |
| 281 | + }); |
| 282 | + }; |
| 283 | + |
| 284 | + private renderImage = (img: HTMLImageElement, imageFile: File) => { |
| 285 | + readFile(imageFile, dataUrl => (img.src = dataUrl)); |
| 286 | + }; |
| 287 | + |
| 288 | + private onDisplayCountChanged = () => { |
| 289 | + let value = parseInt(this.displayCount.current.value); |
| 290 | + this.setState({ |
| 291 | + displayCount: value, |
| 292 | + }); |
| 293 | + }; |
| 294 | + |
| 295 | + private renderPasteContent( |
| 296 | + title: string, |
| 297 | + content: any, |
| 298 | + renderer: (content: any) => JSX.Element = content => <span>{content}</span> |
| 299 | + ): JSX.Element { |
| 300 | + return ( |
| 301 | + content && ( |
| 302 | + <details> |
| 303 | + <summary>{title}</summary> |
| 304 | + <div className={styles.pasteContent}>{renderer(content)}</div> |
| 305 | + </details> |
| 306 | + ) |
| 307 | + ); |
| 308 | + } |
| 309 | + |
| 310 | + private getHtml(fragment: DocumentFragment) { |
| 311 | + const stringArray: string[] = []; |
| 312 | + for (let child = fragment.firstChild; child; child = child.nextSibling) { |
| 313 | + stringArray.push( |
| 314 | + safeInstanceOf(child, 'HTMLElement') |
| 315 | + ? child.outerHTML |
| 316 | + : safeInstanceOf(child, 'Text') |
| 317 | + ? child.nodeValue |
| 318 | + : '' |
| 319 | + ); |
| 320 | + } |
| 321 | + |
| 322 | + return stringArray.join(''); |
| 323 | + } |
| 324 | +} |
0 commit comments