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, span.value.substring(span.offsetInSpan[0], span.offsetInSpan[1])) 176 let prop = pasteData.getProperty() 177 let temp: Record<string, Object> = { 178 'color': style.fontColor, 179 'size': style.fontSize, 180 'style': style.fontStyle, 181 'weight': this.fontWeightTable[style.fontWeight], 182 'fontFamily': style.fontFamily, 183 'decorationType': style.decoration.type, 184 'decorationColor': style.decoration.color 185 } 186 prop.additions[i] = temp; 187 pasteData.addRecord(data) 188 pasteData.setProperty(prop) 189 } 190 } 191 } 192 sysBoard.clearData() 193 sysBoard.setData(pasteData).then(() => { 194 hilog.info(0x3900, "Ace", 'SelectionMenu copy option, Succeeded in setting PasteData.'); 195 }).catch((err: BusinessError) => { 196 hilog.info(0x3900, "Ace", 'SelectionMenu copy option, Failed to set PasteData. Cause:' + err.message); 197 }) 198 } 199 200 popDataFromPasteboard(richEditorSelection: RichEditorSelection) { 201 let start = richEditorSelection.selection[0] 202 let end = richEditorSelection.selection[1] 203 if (start === end && this.controller) { 204 start = this.controller.getCaretOffset() 205 end = this.controller.getCaretOffset() 206 } 207 let moveOffset = 0 208 let sysBoard = pasteboard.getSystemPasteboard() 209 sysBoard.getData((err, data) => { 210 if (err) { 211 return 212 } 213 let count = data.getRecordCount() 214 for (let i = 0; i < count; i++) { 215 const element = data.getRecord(i); 216 let tex: RichEditorTextStyle = { 217 fontSize: 16, 218 fontColor: Color.Black, 219 fontWeight: FontWeight.Normal, 220 fontFamily: "HarmonyOS Sans", 221 fontStyle: FontStyle.Normal, 222 decoration: { type: TextDecorationType.None, color: "#FF000000" } 223 } 224 if (data.getProperty() && data.getProperty().additions[i]) { 225 const tmp = data.getProperty().additions[i] as Record<string, Object | undefined>; 226 if (tmp.color) { 227 tex.fontColor = tmp.color as ResourceColor; 228 } 229 if (tmp.size) { 230 tex.fontSize = tmp.size as Length | number; 231 } 232 if (tmp.style) { 233 tex.fontStyle = tmp.style as FontStyle; 234 } 235 if (tmp.weight) { 236 tex.fontWeight = tmp.weight as number | FontWeight | string; 237 } 238 if (tmp.fontFamily) { 239 tex.fontFamily = tmp.fontFamily as ResourceStr; 240 } 241 if (tmp.decorationType && tex.decoration) { 242 tex.decoration.type = tmp.decorationType as TextDecorationType; 243 } 244 if (tmp.decorationColor && tex.decoration) { 245 tex.decoration.color = tmp.decorationColor as ResourceColor; 246 } 247 if (tex.decoration) { 248 tex.decoration = { type: tex.decoration.type, color: tex.decoration.color } 249 } 250 } 251 if (element && element.plainText && element.mimeType === pasteboard.MIMETYPE_TEXT_PLAIN && this.controller) { 252 this.controller.addTextSpan(element.plainText, 253 { 254 style: tex, 255 offset: start + moveOffset 256 } 257 ) 258 moveOffset += element.plainText.length 259 } 260 } 261 if (this.controller) { 262 this.controller.setCaretOffset(start + moveOffset) 263 } 264 if (start !== end && this.controller) { 265 this.controller.deleteSpans({ start: start + moveOffset, end: end + moveOffset }) 266 } 267 }) 268 } 269 270 measureButtonWidth(): number { 271 if (this.editorMenuOptions && this.editorMenuOptions.length < 5) { 272 return (this.theme.defaultMenuWidth - this.theme.expandedOptionPadding * 2 - this.theme.editorOptionMargin * 2 * this.editorMenuOptions.length) / this.editorMenuOptions.length 273 } 274 return this.theme.buttonSize 275 } 276 277 @Builder 278 IconPanel() { 279 Flex({ wrap: FlexWrap.Wrap }) { 280 if (this.editorMenuOptions) { 281 ForEach(this.editorMenuOptions, (item: EditorMenuOptions, index: number) => { 282 Button() { 283 Image(item.icon) 284 .width(this.theme.imageSize) 285 .height(this.theme.imageSize) 286 .fillColor(this.theme.imageFillColor) 287 .focusable(true) 288 .draggable(false) 289 } 290 .enabled(!(!item.action && !item.builder)) 291 .type(ButtonType.Normal) 292 .margin(this.theme.editorOptionMargin) 293 .backgroundColor(this.theme.backGroundColor) 294 .onClick(() => { 295 if (item.builder) { 296 this.builder = item.builder 297 this.showCustomerIndex = index 298 this.showExpandedMenuOptions = false 299 this.customerChange = !this.customerChange 300 } else { 301 this.showCustomerIndex = WITHOUT_BUILDER 302 if (!this.controller) { 303 this.showExpandedMenuOptions = true 304 } 305 } 306 if (item.action) { 307 item.action() 308 } 309 }) 310 .borderRadius(this.theme.iconBorderRadius) 311 .width(this.measureButtonWidth()) 312 .height(this.theme.buttonSize) 313 }) 314 } 315 } 316 .onAreaChange((oldValue: Area, newValue: Area) => { 317 let newValueHeight = newValue.height as number 318 let oldValueHeight = oldValue.height as number 319 this.customMenuHeight += newValueHeight - oldValueHeight 320 this.customMenuSize = this.customMenuHeight 321 }) 322 .clip(true) 323 .width(this.theme.defaultMenuWidth) 324 .padding(this.theme.expandedOptionPadding) 325 .borderRadius(this.theme.containerBorderRadius) 326 .margin({ bottom: this.theme.menuSpacing }) 327 .backgroundColor(this.theme.backGroundColor) 328 .shadow(this.theme.iconPanelShadowStyle) 329 } 330 331 @Builder 332 SystemMenu() { 333 Column() { 334 if (this.showCustomerIndex === -1 && (this.controller || (this.expandedMenuOptions && this.expandedMenuOptions.length > 0))) { 335 Menu() { 336 if (this.controller) { 337 MenuItemGroup() { 338 MenuItem({ startIcon: this.theme.cutIcon, content: "剪切", labelInfo: "Ctrl+X" }) 339 .enabled(this.cutAndCopyEnable) 340 .onClick(() => { 341 if (!this.controller) { 342 return 343 } 344 let richEditorSelection = this.controller.getSelection() 345 if (this.onCut) { 346 this.onCut({ content: richEditorSelection }) 347 } else { 348 this.pushDataToPasteboard(richEditorSelection); 349 this.controller.deleteSpans({ 350 start: richEditorSelection.selection[0], 351 end: richEditorSelection.selection[1] 352 }) 353 } 354 }) 355 MenuItem({ startIcon: this.theme.copyIcon, content: "复制", labelInfo: "Ctrl+C" }) 356 .enabled(this.cutAndCopyEnable) 357 .onClick(() => { 358 if (!this.controller) { 359 return 360 } 361 let richEditorSelection = this.controller.getSelection() 362 if (this.onCopy) { 363 this.onCopy({ content: richEditorSelection }) 364 } else { 365 this.pushDataToPasteboard(richEditorSelection); 366 this.controller.closeSelectionMenu() 367 } 368 }) 369 MenuItem({ startIcon: this.theme.pasteIcon, content: "粘贴", labelInfo: "Ctrl+V" }) 370 .enabled(this.pasteEnable) 371 .onClick(() => { 372 if (!this.controller) { 373 return 374 } 375 let richEditorSelection = this.controller.getSelection() 376 if (this.onPaste) { 377 this.onPaste({ content: richEditorSelection }) 378 } else { 379 this.popDataFromPasteboard(richEditorSelection) 380 this.controller.closeSelectionMenu() 381 } 382 }) 383 MenuItem({ startIcon: this.theme.selectAllIcon, content: "全选", labelInfo: "Ctrl+A" }) 384 .visibility(this.visibilityValue) 385 .onClick(() => { 386 if (!this.controller) { 387 return 388 } 389 if (this.onSelectAll) { 390 let richEditorSelection = this.controller.getSelection() 391 this.onSelectAll({ content: richEditorSelection }) 392 } else { 393 this.controller.setSelection(-1, -1) 394 this.visibilityValue = Visibility.None 395 } 396 this.controller.closeSelectionMenu() 397 }) 398 if (this.showExpandedMenuOptions) { 399 MenuItem({ startIcon: this.theme.shareIcon, content: "分享", labelInfo: "" }) 400 .enabled(false) 401 MenuItem({ startIcon: this.theme.translateIcon, content: "翻译", labelInfo: "" }) 402 .enabled(false) 403 MenuItem({ startIcon: this.theme.searchIcon, content: "搜索", labelInfo: "" }) 404 .enabled(false) 405 } 406 } 407 } 408 if (this.controller && !this.showExpandedMenuOptions) { 409 MenuItem({ content: "更多", endIcon: this.theme.arrowDownIcon }) 410 .onClick(() => { 411 this.showExpandedMenuOptions = true 412 this.customMenuSize = '100%' 413 }) 414 } else if (this.showExpandedMenuOptions && this.expandedMenuOptions && this.expandedMenuOptions.length > 0) { 415 ForEach(this.expandedMenuOptions, (expandedMenuOptionItem: ExpandedMenuOptions, index) => { 416 MenuItem({ 417 startIcon: expandedMenuOptionItem.startIcon, 418 content: expandedMenuOptionItem.content, 419 endIcon: expandedMenuOptionItem.endIcon, 420 labelInfo: expandedMenuOptionItem.labelInfo, 421 builder: expandedMenuOptionItem.builder 422 }) 423 .onClick(() => { 424 if (expandedMenuOptionItem.action) { 425 expandedMenuOptionItem.action() 426 } 427 }) 428 }) 429 } 430 } 431 .onVisibleAreaChange([0.0, 1.0], () => { 432 if (!this.controller) { 433 return 434 } 435 let richEditorSelection = this.controller.getSelection() 436 let start = richEditorSelection.selection[0] 437 let end = richEditorSelection.selection[1] 438 if (start !== end) { 439 this.cutAndCopyEnable = true 440 } 441 if (start === 0 && this.controller.getSpans({ start: end + 1, end: end + 1 }).length === 0) { 442 this.visibilityValue = Visibility.None 443 } else { 444 this.visibilityValue = Visibility.Visible 445 } 446 }) 447 .radius(this.theme.containerBorderRadius) 448 .clip(true) 449 .width(this.theme.defaultMenuWidth) 450 } else if (this.showCustomerIndex > -1 && this.builder) { 451 if (this.customerChange) { 452 this.builder() 453 } else { 454 this.builder() 455 } 456 } 457 } 458 .width(this.theme.defaultMenuWidth) 459 } 460} 461 462@Builder 463export function SelectionMenu(options: SelectionMenuOptions) { 464 SelectionMenuComponent({ 465 editorMenuOptions: options.editorMenuOptions, 466 expandedMenuOptions: options.expandedMenuOptions, 467 controller: options.controller, 468 onPaste: options.onPaste, 469 onCopy: options.onCopy, 470 onCut: options.onCut, 471 onSelectAll: options.onSelectAll 472 }) 473}