• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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}