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}