1/* 2 * Copyright (c) 2023-2023 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16import pasteboard from '@ohos.pasteboard' 17import { BusinessError } from '@ohos.base'; 18import hilog from '@ohos.hilog'; 19 20const WITHOUT_BUILDER = -2 21 22export interface EditorMenuOptions { 23 icon: ResourceStr 24 action?: () => void 25 builder?: () => void 26} 27 28export interface ExpandedMenuOptions extends MenuItemOptions { 29 action?: () => void; 30} 31 32export interface EditorEventInfo { 33 content?: RichEditorSelection; 34} 35 36export interface SelectionMenuOptions { 37 editorMenuOptions?: Array<EditorMenuOptions> 38 expandedMenuOptions?: Array<ExpandedMenuOptions> 39 controller?: RichEditorController 40 onPaste?: (event?: EditorEventInfo) => void 41 onCopy?: (event?: EditorEventInfo) => void 42 onCut?: (event?: EditorEventInfo) => void; 43 onSelectAll?: (event?: EditorEventInfo) => void; 44} 45 46interface SelectionMenuTheme { 47 imageSize: number; 48 buttonSize: number; 49 menuSpacing: number; 50 editorOptionMargin: number; 51 expandedOptionPadding: number; 52 defaultMenuWidth: number; 53 imageFillColor: Resource; 54 backGroundColor: Resource; 55 iconBorderRadius: Resource; 56 containerBorderRadius: Resource; 57 cutIcon: Resource; 58 copyIcon: Resource; 59 pasteIcon: Resource; 60 selectAllIcon: Resource; 61 shareIcon: Resource; 62 translateIcon: Resource; 63 searchIcon: Resource; 64 arrowDownIcon: Resource; 65 iconPanelShadowStyle: ShadowStyle; 66} 67 68const defaultTheme: SelectionMenuTheme = { 69 imageSize: 24, 70 buttonSize: 48, 71 menuSpacing: 8, 72 editorOptionMargin: 1, 73 expandedOptionPadding: 3, 74 defaultMenuWidth: 256, 75 imageFillColor: $r('sys.color.ohos_id_color_primary'), 76 backGroundColor: $r('sys.color.ohos_id_color_dialog_bg'), 77 iconBorderRadius: $r('sys.float.ohos_id_corner_radius_default_m'), 78 containerBorderRadius: $r('sys.float.ohos_id_corner_radius_card'), 79 cutIcon: $r("sys.media.ohos_ic_public_cut"), 80 copyIcon: $r("sys.media.ohos_ic_public_copy"), 81 pasteIcon: $r("sys.media.ohos_ic_public_paste"), 82 selectAllIcon: $r("sys.media.ohos_ic_public_select_all"), 83 shareIcon: $r("sys.media.ohos_ic_public_share"), 84 translateIcon: $r("sys.media.ohos_ic_public_translate_c2e"), 85 searchIcon: $r("sys.media.ohos_ic_public_search_filled"), 86 arrowDownIcon: $r("sys.media.ohos_ic_public_arrow_down"), 87 iconPanelShadowStyle: ShadowStyle.OUTER_DEFAULT_MD, 88} 89 90@Component 91struct SelectionMenuComponent { 92 editorMenuOptions?: Array<EditorMenuOptions> 93 expandedMenuOptions?: Array<ExpandedMenuOptions> 94 controller?: RichEditorController 95 onPaste?: (event?: EditorEventInfo) => void 96 onCopy?: (event?: EditorEventInfo) => void 97 onCut?: (event?: EditorEventInfo) => void; 98 onSelectAll?: (event?: EditorEventInfo) => void; 99 private theme: SelectionMenuTheme = defaultTheme; 100 101 @Builder 102 CloserFun() { 103 } 104 105 @BuilderParam builder: CustomBuilder = this.CloserFun 106 @State showExpandedMenuOptions: boolean = false 107 @State showCustomerIndex: number = -1 108 @State customerChange: boolean = false 109 @State cutAndCopyEnable: boolean = false 110 @State pasteEnable: boolean = false 111 @State visibilityValue: Visibility = Visibility.Visible 112 @State customMenuSize: string | number = '100%' 113 private customMenuHeight: number = this.theme.menuSpacing 114 private fontWeightTable: string[] = ["100", "200", "300", "400", "500", "600", "700", "800", "900", "bold", "normal", "bolder", "lighter", "medium", "regular"] 115 116 aboutToAppear() { 117 if (this.controller) { 118 let richEditorSelection = this.controller.getSelection() 119 let start = richEditorSelection.selection[0] 120 let end = richEditorSelection.selection[1] 121 if (start !== end) { 122 this.cutAndCopyEnable = true 123 } 124 if (start === 0 && this.controller.getSpans({ start: end + 1, end: end + 1 }).length === 0) { 125 this.visibilityValue = Visibility.None 126 } else { 127 this.visibilityValue = Visibility.Visible 128 } 129 } else if (this.expandedMenuOptions && this.expandedMenuOptions.length > 0) { 130 this.showExpandedMenuOptions = true 131 } 132 let sysBoard = pasteboard.getSystemPasteboard() 133 if (sysBoard && sysBoard.hasDataSync()) { 134 this.pasteEnable = true 135 } 136 if (!(this.editorMenuOptions && this.editorMenuOptions.length > 0)) { 137 this.customMenuHeight = 0 138 } 139 } 140 141 build() { 142 Column() { 143 if (this.editorMenuOptions && this.editorMenuOptions.length > 0) { 144 this.IconPanel() 145 } 146 Scroll() { 147 this.SystemMenu() 148 } 149 .backgroundColor(this.theme.backGroundColor) 150 .flexShrink(1) 151 .shadow(this.theme.iconPanelShadowStyle) 152 .borderRadius(this.theme.containerBorderRadius) 153 .onAreaChange((oldValue: Area, newValue: Area) => { 154 let newValueHeight = newValue.height as number 155 let oldValueHeight = oldValue.height as number 156 this.customMenuHeight += newValueHeight - oldValueHeight 157 this.customMenuSize = this.customMenuHeight 158 }) 159 } 160 .useShadowBatching(true) 161 .flexShrink(1) 162 .height(this.customMenuSize) 163 } 164 165 pushDataToPasteboard(richEditorSelection: RichEditorSelection) { 166 let sysBoard = pasteboard.getSystemPasteboard() 167 let pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, '') 168 if (richEditorSelection.spans && richEditorSelection.spans.length > 0) { 169 let count = richEditorSelection.spans.length 170 for (let i = count - 1; i >= 0; i--) { 171 let item = richEditorSelection.spans[i] 172 if ((item as RichEditorTextSpanResult)?.textStyle) { 173 let span = item as RichEditorTextSpanResult 174 let style = span.textStyle 175 let data = pasteboard.createRecord(pasteboard.MIMETYPE_TEXT_PLAIN, 176 span.value.substring(span.offsetInSpan[0], span.offsetInSpan[1])) 177 let prop = pasteData.getProperty() 178 let temp: Record<string, Object> = { 179 'color': style.fontColor, 180 'size': style.fontSize, 181 'style': style.fontStyle, 182 'weight': this.fontWeightTable[style.fontWeight], 183 'fontFamily': style.fontFamily, 184 'decorationType': style.decoration.type, 185 'decorationColor': style.decoration.color 186 } 187 prop.additions[i] = temp; 188 pasteData.addRecord(data) 189 pasteData.setProperty(prop) 190 } 191 } 192 } 193 sysBoard.clearData() 194 sysBoard.setData(pasteData).then(() => { 195 hilog.info(0x3900, "Ace", 'SelectionMenu copy option, Succeeded in setting PasteData.'); 196 }).catch((err: BusinessError) => { 197 hilog.info(0x3900, "Ace", 'SelectionMenu copy option, Failed to set PasteData. Cause:' + err.message); 198 }) 199 } 200 201 popDataFromPasteboard(richEditorSelection: RichEditorSelection) { 202 let start = richEditorSelection.selection[0] 203 let end = richEditorSelection.selection[1] 204 if (start === end && this.controller) { 205 start = this.controller.getCaretOffset() 206 end = this.controller.getCaretOffset() 207 } 208 let moveOffset = 0 209 let sysBoard = pasteboard.getSystemPasteboard() 210 sysBoard.getData((err, data) => { 211 if (err) { 212 return 213 } 214 let count = data.getRecordCount() 215 for (let i = 0; i < count; i++) { 216 const element = data.getRecord(i); 217 let tex: RichEditorTextStyle = { 218 fontSize: 16, 219 fontColor: Color.Black, 220 fontWeight: FontWeight.Normal, 221 fontFamily: "HarmonyOS Sans", 222 fontStyle: FontStyle.Normal, 223 decoration: { type: TextDecorationType.None, color: "#FF000000" } 224 } 225 if (data.getProperty() && data.getProperty().additions[i]) { 226 const tmp = data.getProperty().additions[i] as Record<string, Object | undefined>; 227 if (tmp.color) { 228 tex.fontColor = tmp.color as ResourceColor; 229 } 230 if (tmp.size) { 231 tex.fontSize = tmp.size as Length | number; 232 } 233 if (tmp.style) { 234 tex.fontStyle = tmp.style as FontStyle; 235 } 236 if (tmp.weight) { 237 tex.fontWeight = tmp.weight as number | FontWeight | string; 238 } 239 if (tmp.fontFamily) { 240 tex.fontFamily = tmp.fontFamily as ResourceStr; 241 } 242 if (tmp.decorationType && tex.decoration) { 243 tex.decoration.type = tmp.decorationType as TextDecorationType; 244 } 245 if (tmp.decorationColor && tex.decoration) { 246 tex.decoration.color = tmp.decorationColor as ResourceColor; 247 } 248 if (tex.decoration) { 249 tex.decoration = { type: tex.decoration.type, color: tex.decoration.color } 250 } 251 } 252 if (element && element.plainText && element.mimeType === pasteboard.MIMETYPE_TEXT_PLAIN && this.controller) { 253 this.controller.addTextSpan(element.plainText, 254 { 255 style: tex, 256 offset: start + moveOffset 257 } 258 ) 259 moveOffset += element.plainText.length 260 } 261 } 262 if (this.controller) { 263 this.controller.setCaretOffset(start + moveOffset) 264 } 265 if (start !== end && this.controller) { 266 this.controller.deleteSpans({ start: start + moveOffset, end: end + moveOffset }) 267 } 268 }) 269 } 270 271 measureButtonWidth(): number { 272 if (this.editorMenuOptions && this.editorMenuOptions.length < 5) { 273 return (this.theme.defaultMenuWidth - this.theme.expandedOptionPadding * 2 - 274 this.theme.editorOptionMargin * 2 * this.editorMenuOptions.length) / this.editorMenuOptions.length 275 } 276 return this.theme.buttonSize 277 } 278 279 @Builder 280 IconPanel() { 281 Flex({ wrap: FlexWrap.Wrap }) { 282 if (this.editorMenuOptions) { 283 ForEach(this.editorMenuOptions, (item: EditorMenuOptions, index: number) => { 284 Button() { 285 Image(item.icon) 286 .width(this.theme.imageSize) 287 .height(this.theme.imageSize) 288 .fillColor(this.theme.imageFillColor) 289 .focusable(true) 290 .draggable(false) 291 } 292 .enabled(!(!item.action && !item.builder)) 293 .type(ButtonType.Normal) 294 .margin(this.theme.editorOptionMargin) 295 .backgroundColor(this.theme.backGroundColor) 296 .onClick(() => { 297 if (item.builder) { 298 this.builder = item.builder 299 this.showCustomerIndex = index 300 this.showExpandedMenuOptions = false 301 this.customerChange = !this.customerChange 302 } else { 303 this.showCustomerIndex = WITHOUT_BUILDER 304 if (!this.controller) { 305 this.showExpandedMenuOptions = true 306 } 307 } 308 if (item.action) { 309 item.action() 310 } 311 }) 312 .borderRadius(this.theme.iconBorderRadius) 313 .width(this.measureButtonWidth()) 314 .height(this.theme.buttonSize) 315 }) 316 } 317 } 318 .onAreaChange((oldValue: Area, newValue: Area) => { 319 let newValueHeight = newValue.height as number 320 let oldValueHeight = oldValue.height as number 321 this.customMenuHeight += newValueHeight - oldValueHeight 322 this.customMenuSize = this.customMenuHeight 323 }) 324 .clip(true) 325 .width(this.theme.defaultMenuWidth) 326 .padding(this.theme.expandedOptionPadding) 327 .borderRadius(this.theme.containerBorderRadius) 328 .margin({ bottom: this.theme.menuSpacing }) 329 .backgroundColor(this.theme.backGroundColor) 330 .shadow(this.theme.iconPanelShadowStyle) 331 } 332 333 @Builder 334 SystemMenu() { 335 Column() { 336 if (this.showCustomerIndex === -1 && 337 (this.controller || (this.expandedMenuOptions && this.expandedMenuOptions.length > 0))) { 338 Menu() { 339 if (this.controller) { 340 MenuItemGroup() { 341 MenuItem({ startIcon: this.theme.cutIcon, content: "剪切", labelInfo: "Ctrl+X" }) 342 .enabled(this.cutAndCopyEnable) 343 .onClick(() => { 344 if (!this.controller) { 345 return 346 } 347 let richEditorSelection = this.controller.getSelection() 348 if (this.onCut) { 349 this.onCut({ content: richEditorSelection }) 350 } else { 351 this.pushDataToPasteboard(richEditorSelection); 352 this.controller.deleteSpans({ 353 start: richEditorSelection.selection[0], 354 end: richEditorSelection.selection[1] 355 }) 356 } 357 }) 358 MenuItem({ startIcon: this.theme.copyIcon, content: "复制", labelInfo: "Ctrl+C" }) 359 .enabled(this.cutAndCopyEnable) 360 .onClick(() => { 361 if (!this.controller) { 362 return 363 } 364 let richEditorSelection = this.controller.getSelection() 365 if (this.onCopy) { 366 this.onCopy({ content: richEditorSelection }) 367 } else { 368 this.pushDataToPasteboard(richEditorSelection); 369 this.controller.closeSelectionMenu() 370 } 371 }) 372 MenuItem({ startIcon: this.theme.pasteIcon, content: "粘贴", labelInfo: "Ctrl+V" }) 373 .enabled(this.pasteEnable) 374 .onClick(() => { 375 if (!this.controller) { 376 return 377 } 378 let richEditorSelection = this.controller.getSelection() 379 if (this.onPaste) { 380 this.onPaste({ content: richEditorSelection }) 381 } else { 382 this.popDataFromPasteboard(richEditorSelection) 383 this.controller.closeSelectionMenu() 384 } 385 }) 386 MenuItem({ startIcon: this.theme.selectAllIcon, content: "全选", labelInfo: "Ctrl+A" }) 387 .visibility(this.visibilityValue) 388 .onClick(() => { 389 if (!this.controller) { 390 return 391 } 392 if (this.onSelectAll) { 393 let richEditorSelection = this.controller.getSelection() 394 this.onSelectAll({ content: richEditorSelection }) 395 } else { 396 this.controller.setSelection(-1, -1) 397 this.visibilityValue = Visibility.None 398 } 399 this.controller.closeSelectionMenu() 400 }) 401 } 402 } 403 if (this.controller && !this.showExpandedMenuOptions && 404 this.expandedMenuOptions && this.expandedMenuOptions.length > 0) { 405 MenuItem({ content: "更多", endIcon: this.theme.arrowDownIcon }) 406 .onClick(() => { 407 this.showExpandedMenuOptions = true 408 this.customMenuSize = '100%' 409 }) 410 } else if (this.showExpandedMenuOptions && this.expandedMenuOptions && this.expandedMenuOptions.length > 0) { 411 ForEach(this.expandedMenuOptions, (expandedMenuOptionItem: ExpandedMenuOptions, index) => { 412 MenuItem({ 413 startIcon: expandedMenuOptionItem.startIcon, 414 content: expandedMenuOptionItem.content, 415 endIcon: expandedMenuOptionItem.endIcon, 416 labelInfo: expandedMenuOptionItem.labelInfo, 417 builder: expandedMenuOptionItem.builder 418 }) 419 .onClick(() => { 420 if (expandedMenuOptionItem.action) { 421 expandedMenuOptionItem.action() 422 } 423 }) 424 }) 425 } 426 } 427 .onVisibleAreaChange([0.0, 1.0], () => { 428 if (!this.controller) { 429 return 430 } 431 let richEditorSelection = this.controller.getSelection() 432 let start = richEditorSelection.selection[0] 433 let end = richEditorSelection.selection[1] 434 if (start !== end) { 435 this.cutAndCopyEnable = true 436 } 437 if (start === 0 && this.controller.getSpans({ start: end + 1, end: end + 1 }).length === 0) { 438 this.visibilityValue = Visibility.None 439 } else { 440 this.visibilityValue = Visibility.Visible 441 } 442 }) 443 .radius(this.theme.containerBorderRadius) 444 .clip(true) 445 .width(this.theme.defaultMenuWidth) 446 } else if (this.showCustomerIndex > -1 && this.builder) { 447 if (this.customerChange) { 448 this.builder() 449 } else { 450 this.builder() 451 } 452 } 453 } 454 .width(this.theme.defaultMenuWidth) 455 } 456} 457 458@Builder 459export function SelectionMenu(options: SelectionMenuOptions) { 460 SelectionMenuComponent({ 461 editorMenuOptions: options.editorMenuOptions, 462 expandedMenuOptions: options.expandedMenuOptions, 463 controller: options.controller, 464 onPaste: options.onPaste, 465 onCopy: options.onCopy, 466 onCut: options.onCut, 467 onSelectAll: options.onSelectAll 468 }) 469}