• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# 使用Web组件菜单处理网页内容
2<!--Kit: ArkWeb-->
3<!--Subsystem: Web-->
4<!--Owner: @zourongchun-->
5<!--Designer: @zhufenghao-->
6<!--Tester: @ghiker-->
7<!--Adviser: @HelloCrease-->
8菜单作为用户交互的关键组件,其作用是构建清晰的导航体系,通过结构化布局展示功能入口,使用户能够迅速找到目标内容或执行操作。作为人机交互的重要枢纽,它显著提升了Web组件的可访问性和用户体验,是应用设计中必不可少的部分。Web组件菜单类型包括[文本选中菜单](./web_menu.md#文本选中菜单)、[上下文菜单](./web_menu.md#上下文菜单)和[自定义菜单](./web_menu.md#自定义菜单),应用可根据具体需求灵活选择。
9|菜单类型|目标元素|响应类型|是否支持自定义|
10|----|----|----|----|
11|[文本选中菜单](./web_menu.md#文本选中菜单)|文本|手势长按|可增减菜单项,菜单样式不可自定义|
12|[上下文菜单](./web_menu.md#上下文菜单)|超链接、图片、文字|手势长按、鼠标右键|支持通过菜单组件自定义|
13|[自定义菜单](./web_menu.md#自定义菜单)|图片|手势长按|支持通过菜单组件自定义|
14## 文本选中菜单
15Web组件的文本选中菜单是一种通过自定义元素实现的上下文交互组件,当用户选中文本时会动态显示,提供复制、分享、标注等语义化操作,具备标准化功能与可扩展性,是移动端文本操作的核心功能。文本选中菜单在用户长按选中文本或编辑状态下长按出现单手柄时弹出,菜单项横向排列。系统提供默认的菜单实现。应用可通过[editMenuOptions](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#editmenuoptions12)接口对文本选中菜单进行自定义操作。
161. 通过onCreateMenu方法自定义菜单项,通过操作Array<[TextMenuItem](../reference/apis-arkui/arkui-ts/ts-text-common.md#textmenuitem12对象说明)>数组可对显示菜单项进行增减操作,在[TextMenuItem](../reference/apis-arkui/arkui-ts/ts-text-common.md#textmenuitem12对象说明)中定义菜单项名称、图标、ID等内容。
172. 通过onMenuItemClick方法处理菜单项点击事件,当返回false时会执行系统默认逻辑。
183. 创建一个[EditMenuOptions](../reference/apis-arkui/arkui-ts/ts-text-common.md#editmenuoptions)对象,包含onCreateMenu和onMenuItemClick两个方法,通过Web组件的[editMenuOptions](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#editmenuoptions12)方法与Web组件绑定。
19
20  ```ts
21  // xxx.ets
22  import { webview } from '@kit.ArkWeb';
23  @Entry
24  @Component
25  struct WebComponent {
26    controller: webview.WebviewController = new webview.WebviewController();
27
28    onCreateMenu(menuItems: Array<TextMenuItem>): Array<TextMenuItem> {
29      let items = menuItems.filter((menuItem) => {
30        // 过滤用户需要的系统按键
31        return (
32          menuItem.id.equals(TextMenuItemId.CUT) ||
33          menuItem.id.equals(TextMenuItemId.COPY) ||
34          menuItem.id.equals(TextMenuItemId.PASTE)
35        );
36      });
37      let customItem1: TextMenuItem = {
38        content: 'customItem1',
39        id: TextMenuItemId.of('customItem1'),
40        icon: $r('app.media.startIcon')
41      };
42      let customItem2: TextMenuItem = {
43        content: $r('app.string.EntryAbility_label'),
44        id: TextMenuItemId.of('customItem2'),
45        icon: $r('app.media.startIcon')
46      };
47      items.push(customItem1);// 在选项列表后添加新选项
48      items.unshift(customItem2);// 在选项列表前添加选项
49      items.push(customItem1);
50      items.push(customItem1);
51      items.push(customItem1);
52      items.push(customItem1);
53      items.push(customItem1);
54      return items;
55    }
56
57    onMenuItemClick(menuItem: TextMenuItem, textRange: TextRange): boolean {
58      if (menuItem.id.equals(TextMenuItemId.CUT)) {
59        // 用户自定义行为
60        console.log("拦截 id:CUT")
61        return true; // 返回true不执行系统回调
62      } else if (menuItem.id.equals(TextMenuItemId.COPY)) {
63        // 用户自定义行为
64        console.log("不拦截 id:COPY")
65        return false; // 返回false执行系统回调
66      } else if (menuItem.id.equals(TextMenuItemId.of('customItem1'))) {
67        // 用户自定义行为
68        console.log("拦截 id:customItem1")
69        return true;// 用户自定义菜单选项返回true时点击后不关闭菜单,返回false时关闭菜单
70      } else if (menuItem.id.equals(TextMenuItemId.of('customItem2'))){
71        // 用户自定义行为
72        console.log("拦截 id:customItem2")
73        return true;
74      }
75      return false;// 返回默认值false
76    }
77
78    @State EditMenuOptions: EditMenuOptions = { onCreateMenu: this.onCreateMenu, onMenuItemClick: this.onMenuItemClick }
79
80    build() {
81      Column() {
82        Web({ src: $rawfile("index.html"), controller: this.controller })
83          .editMenuOptions(this.EditMenuOptions)
84      }
85    }
86  }
87  ```
88
89  ```html
90  <!--index.html-->
91  <!DOCTYPE html>
92  <html>
93    <head>
94        <title>测试网页</title>
95    </head>
96    <body>
97      <h1>editMenuOptions Demo</h1>
98      <span>edit menu options</span>
99    </body>
100  </html>
101  ```
102  ![editMenuOption](./figures/editMenuOption.gif)
103## 上下文菜单
104上下文菜单是用户通过特定操作(如右键点击或长按富文本)触发的快捷菜单,用于提供与当前操作对象或界面元素相关的功能选项。菜单项纵向排列。系统未提供默认实现,若应用未实现,则不显示上下文菜单。应用需要创建一个[Menu](../reference/apis-arkui/arkui-ts/ts-basic-components-menu.md)组件并与Web绑定,在菜单弹出时可通过Web组件的[onContextMenuShow](../reference/apis-arkweb/arkts-basic-components-web-events.md#oncontextmenushow9)回调接口获取上下文菜单的详细信息,包括点击位置的HTML元素信息及点击位置信息。
105
1061. [Menu](../reference/apis-arkui/arkui-ts/ts-basic-components-menu.md)组件作为弹出的菜单,包含所有菜单项行为与样式。
1072. 使用bindPopup方法将Menu组件与Web组件绑定。当上下文菜单弹出时,将显示创建的Menu组件。
1083. 在onContextMenuShow回调中获取上下文菜单事件信息[onContextMenuShowEvent](../reference/apis-arkweb/arkts-basic-components-web-i.md#oncontextmenushowevent12)。其中param为[WebContextMenuParam](../reference/apis-arkweb/arkts-basic-components-web-WebContextMenuParam.md)类型,包含点击位置对应HTML元素信息和位置信息,result为[WebContextMenuResult](../reference/apis-arkweb/arkts-basic-components-web-WebContextMenuResult.md)类型,提供常见的菜单能力。
109
110```ts
111// xxx.ets
112import { webview } from '@kit.ArkWeb';
113import { pasteboard } from '@kit.BasicServicesKit';
114
115const TAG = 'ContextMenu';
116
117@Entry
118@Component
119struct WebComponent {
120  controller: webview.WebviewController = new webview.WebviewController();
121  private result: WebContextMenuResult | undefined = undefined;
122  @State linkUrl: string = '';
123  @State offsetX: number = 0;
124  @State offsetY: number = 0;
125  @State showMenu: boolean = false;
126  uiContext: UIContext = this.getUIContext();
127
128  @Builder
129  // 构建自定义菜单及触发功能接口
130  MenuBuilder() {
131    // 以垂直列表形式显示的菜单。
132    Menu() {
133      // 展示菜单Menu中具体的item菜单项。
134      MenuItem({
135        content: '复制图片',
136      })
137        .width(100)
138        .height(50)
139        .onClick(() => {
140          this.result?.copyImage();
141          this.showMenu = false;
142        })
143      MenuItem({
144        content: '剪切',
145      })
146        .width(100)
147        .height(50)
148        .onClick(() => {
149          this.result?.cut();
150          this.showMenu = false;
151        })
152      MenuItem({
153        content: '复制',
154      })
155        .width(100)
156        .height(50)
157        .onClick(() => {
158          this.result?.copy();
159          this.showMenu = false;
160        })
161      MenuItem({
162        content: '粘贴',
163      })
164        .width(100)
165        .height(50)
166        .onClick(() => {
167          this.result?.paste();
168          this.showMenu = false;
169        })
170      MenuItem({
171        content: '复制链接',
172      })
173        .width(100)
174        .height(50)
175        .onClick(() => {
176          let pasteData = pasteboard.createData('text/plain', this.linkUrl);
177          pasteboard.getSystemPasteboard().setData(pasteData, (error) => {
178            if (error) {
179              return;
180            }
181          })
182          this.showMenu = false;
183        })
184      MenuItem({
185        content: '全选',
186      })
187        .width(100)
188        .height(50)
189        .onClick(() => {
190          this.result?.selectAll();
191          this.showMenu = false;
192        })
193    }
194    .width(150)
195    .height(300)
196  }
197
198  build() {
199    Column() {
200      Web({ src: $rawfile("index.html"), controller: this.controller })
201        // 触发自定义弹窗
202        .onContextMenuShow((event) => {
203          if (event) {
204            this.result = event.result
205            console.info("x coord = " + event.param.x());
206            console.info("link url = " + event.param.getLinkUrl());
207            this.linkUrl = event.param.getLinkUrl();
208          }
209          console.info(TAG, `x: ${this.offsetX}, y: ${this.offsetY}`);
210          this.showMenu = true;
211          this.offsetX = 0;
212          this.offsetY = Math.max(this.uiContext!.px2vp(event?.param.y() ?? 0) - 0, 0);
213          return true;
214        })
215        .bindPopup(this.showMenu,
216          {
217            builder: this.MenuBuilder(),
218            enableArrow: false,
219            placement: Placement.LeftTop,
220            offset: { x: this.offsetX, y: this.offsetY },
221            mask: false,
222            onStateChange: (e) => {
223              if (!e.isVisible) {
224                this.showMenu = false;
225                this.result!.closeContextMenu();
226              }
227            }
228          })
229    }
230  }
231}
232```
233```html
234<!-- index.html -->
235<!DOCTYPE html>
236<html lang="en">
237<body>
238  <h1>onContextMenuShow</h1>
239  <a href="http://www.example.com" style="font-size:27px">超链接www.example.com</a>
240  <!--example.png为html同目录下图片-->
241  <div><img src="example.png"></div>
242  <p>选中文字鼠标右键弹出菜单</p>
243</body>
244</html>
245```
246![onContextMenuShow](./figures/onContextMenuShow.gif)
247## 自定义菜单
248自定义菜单赋予开发者调整菜单触发时机与视觉展现的能力,使应用能够依据用户操作场景动态匹配功能入口,简化开发流程中的界面适配工作,同时使应用交互更符合用户直觉。应用可通过[bindSelectionMenu](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#bindselectionmenu13)接口,实现自定义菜单。目前已额外支持通过长按图片和链接响应自定义菜单。
2491. 创建[Menu](../reference/apis-arkui/arkui-ts/ts-basic-components-menu.md)组件作为菜单弹窗。
2502. 通过Web组件的[bindSelectionMenu](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#bindselectionmenu13)方法绑定MenuBuilder菜单弹窗。将[WebElementType](../reference/apis-arkweb/arkts-basic-components-web-e.md#webelementtype13)设置为WebElementType.IMAGE,[responseType](../reference/apis-arkweb/arkts-basic-components-web-e.md#webresponsetype13)设置为WebResponseType.LONG_PRESS,表示长按图片时弹出菜单。在[options](../reference/apis-arkweb/arkts-basic-components-web-i.md#selectionmenuoptionsext13)中定义菜单显示回调onAppear、菜单消失回调onDisappear、预览窗口preview和菜单类型menuType。
251```ts
252// xxx.ets
253import { webview } from '@kit.ArkWeb';
254
255interface PreviewBuilderParam {
256  previewImage: Resource | string | undefined;
257  width: number;
258  height: number;
259}
260
261@Builder function PreviewBuilderGlobal($$: PreviewBuilderParam) {
262  Column() {
263    Image($$.previewImage)
264      .objectFit(ImageFit.Fill)
265      .autoResize(true)
266  }.width($$.width).height($$.height)
267}
268
269@Entry
270@Component
271struct WebComponent {
272  controller: webview.WebviewController = new webview.WebviewController();
273
274  private result: WebContextMenuResult | undefined = undefined;
275  @State previewImage: Resource | string | undefined = undefined;
276  @State previewWidth: number = 0;
277  @State previewHeight: number = 0;
278  uiContext: UIContext = this.getUIContext();
279
280  @Builder
281  MenuBuilder() {
282    Menu() {
283      MenuItem({ content: '复制', })
284        .onClick(() => {
285          this.result?.copy();
286          this.result?.closeContextMenu();
287        })
288      MenuItem({ content: '全选', })
289        .onClick(() => {
290          this.result?.selectAll();
291          this.result?.closeContextMenu();
292        })
293    }
294  }
295  build() {
296    Column() {
297      Web({ src: $rawfile("index.html"), controller: this.controller })
298        .bindSelectionMenu(WebElementType.IMAGE, this.MenuBuilder, WebResponseType.LONG_PRESS,
299          {
300            onAppear: () => {},
301            onDisappear: () => {
302              this.result?.closeContextMenu();
303            },
304            preview: PreviewBuilderGlobal({
305              previewImage: this.previewImage,
306              width: this.previewWidth,
307              height: this.previewHeight
308            }),
309            menuType: MenuType.PREVIEW_MENU
310          })
311        .onContextMenuShow((event) => {
312            if (event) {
313              this.result = event.result;
314              if (event.param.getLinkUrl()) {
315                return false;
316              }
317              this.previewWidth = this.uiContext!.px2vp(event.param.getPreviewWidth());
318              this.previewHeight = this.uiContext!.px2vp(event.param.getPreviewHeight());
319              if (event.param.getSourceUrl().indexOf("resource://rawfile/") == 0) {
320                this.previewImage = $rawfile(event.param.getSourceUrl().substr(19));
321              } else {
322                this.previewImage = event.param.getSourceUrl();
323              }
324              return true;
325            }
326            return false;
327          })
328    }
329  }
330}
331```
332```html
333<!--index.html-->
334<!DOCTYPE html>
335<html>
336  <head>
337      <title>测试网页</title>
338  </head>
339  <body>
340    <h1>bindSelectionMenu Demo</h1>
341    <!--img.png为html同目录下图片-->
342    <img src="./img.png" >
343  </body>
344</html>
345```
346![bindSelectionMenu](./figures/bindSelectionMenu.gif)
347
348自API version 20起,支持绑定长按超链接菜单。可以为图片和链接绑定不同的自定义菜单。
349
350以下示例中,PreviewBuilder定义了超链接对应菜单的弹出内容,用Web组件加载了超链接内容,使用[Progress组件](../ui/arkts-common-components-progress-indicator.md)展示了加载进度。
351
352```ts
353import { webview } from '@kit.ArkWeb';
354import { pasteboard } from '@kit.BasicServicesKit';
355
356interface PreviewBuilderParam {
357  width: number;
358  height: number;
359  url:Resource | string | undefined;
360}
361
362interface PreviewBuilderParamForImage {
363  previewImage: Resource | string | undefined;
364  width: number;
365  height: number;
366}
367
368
369@Builder function PreviewBuilderGlobalForImage($$: PreviewBuilderParamForImage) {
370  Column() {
371    Image($$.previewImage)
372      .objectFit(ImageFit.Fill)
373      .autoResize(true)
374  }.width($$.width).height($$.height)
375}
376
377@Entry
378@Component
379struct SelectionMenuLongPress {
380  controller: webview.WebviewController = new webview.WebviewController();
381  previewController: webview.WebviewController = new webview.WebviewController();
382  @Builder PreviewBuilder($$: PreviewBuilderParam){
383    Column() {
384      Stack(){
385        Text("") // 可选择是否展示url
386          .padding(5)
387          .width('100%')
388          .textAlign(TextAlign.Start)
389          .backgroundColor(Color.White)
390          .copyOption(CopyOptions.LocalDevice)
391          .maxLines(1)
392          .textOverflow({overflow:TextOverflow.Ellipsis})
393        Progress({ value: this.progressValue, total: 100, type: ProgressType.Linear }) // 展示进度条
394          .style({ strokeWidth: 3, enableSmoothEffect: true })
395          .backgroundColor(Color.White)
396          .opacity(this.progressVisible?1:0)
397          .backgroundColor(Color.White)
398      }.alignContent(Alignment.Bottom)
399      Web({src:$$.url,controller: new webview.WebviewController()})
400        .javaScriptAccess(true)
401        .fileAccess(true)
402        .onlineImageAccess(true)
403        .imageAccess(true)
404        .domStorageAccess(true)
405        .onPageBegin(()=>{
406          this.progressValue = 0;
407          this.progressVisible = true;
408        })
409        .onProgressChange((event)=>{
410          this.progressValue = event.newProgress;
411        })
412        .onPageEnd(()=>{
413          this.progressVisible = false;
414        })
415        .hitTestBehavior(HitTestMode.None) // 使预览Web不响应手势
416    }.width($$.width).height($$.height) // 设置预览宽高
417  }
418
419  private result: WebContextMenuResult | undefined = undefined;
420  @State previewImage: Resource | string | undefined = undefined;
421  @State previewWidth: number = 1;
422  @State previewHeight: number = 1;
423  @State previewWidthImage: number = 1;
424  @State previewHeightImage: number = 1;
425  @State linkURL:string = "";
426  @State progressValue:number = 0;
427  @State progressVisible:boolean = true;
428  uiContext: UIContext = this.getUIContext();
429
430  @Builder
431  LinkMenuBuilder() {
432    Menu() {
433      MenuItem({ content: '复制链接', })
434        .onClick(() => {
435          const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.linkURL);
436          const systemPasteboard = pasteboard.getSystemPasteboard();
437          systemPasteboard.setData(pasteboardData);
438        })
439      MenuItem({content:'打开链接'})
440        .onClick(()=>{
441          this.controller.loadUrl(this.linkURL);
442        })
443    }
444  }
445  @Builder
446  ImageMenuBuilder() {
447    Menu() {
448      MenuItem({ content: '复制图片', })
449        .onClick(() => {
450          this.result?.copyImage();
451          this.result?.closeContextMenu();
452        })
453    }
454  }
455  build() {
456    Column() {
457      Web({ src: $rawfile("index.html"), controller: this.controller })
458        .javaScriptAccess(true)
459        .fileAccess(true)
460        .onlineImageAccess(true)
461        .imageAccess(true)
462        .domStorageAccess(true)
463        .bindSelectionMenu(WebElementType.LINK, this.LinkMenuBuilder, WebResponseType.LONG_PRESS,
464          {
465            onAppear: () => {},
466            onDisappear: () => {
467              this.result?.closeContextMenu();
468            },
469            preview: this.PreviewBuilder({
470              width: 500,
471              height: 400,
472              url:this.linkURL
473            }),
474            menuType: MenuType.PREVIEW_MENU,
475          })
476        .bindSelectionMenu(WebElementType.IMAGE, this.ImageMenuBuilder, WebResponseType.LONG_PRESS,
477          {
478            onAppear: () => {},
479            onDisappear: () => {
480              this.result?.closeContextMenu();
481            },
482            preview: PreviewBuilderGlobalForImage({
483              previewImage: this.previewImage,
484              width: this.previewWidthImage,
485              height: this.previewHeightImage,
486            }),
487            menuType: MenuType.PREVIEW_MENU,
488          })
489        .zoomAccess(true)
490        .onContextMenuShow((event) => {
491          if (event) {
492            this.result = event.result;
493            this.previewWidthImage = this.uiContext!.px2vp(event.param.getPreviewWidth());
494            this.previewHeightImage = this.uiContext!.px2vp(event.param.getPreviewHeight());
495            if (event.param.getSourceUrl().indexOf("resource://rawfile/") == 0) {
496              this.previewImage = $rawfile(event.param.getSourceUrl().substring(19));
497            } else {
498              this.previewImage = event.param.getSourceUrl();
499            }
500            this.linkURL = event.param.getLinkUrl()
501            return true;
502          }
503          return false;
504        })
505    }
506
507  }
508  // 侧滑返回
509  onBackPress(): boolean | void {
510    if (this.controller.accessStep(-1)) {
511      this.controller.backward();
512      return true;
513    } else {
514      return false;
515    }
516  }
517}
518```
519html示例
520```html
521<html lang="zh-CN"><head>
522    <meta charset="UTF-8">
523    <meta name="viewport" content="width=device-width, initial-scale=1.0">
524    <title>综合信息页面</title>
525</head>
526<body>
527<div>
528    <h1>综合信息与联系详情</h1>
529    <section>
530        <a href="https://www.example.com">EXAMPLE</a>
531        <br>
532        <a href="https://www.example1.com/">EXAMPLE1</a>
533    </section>
534</div>
535<footer>
536    <p>请注意,以上提供的所有网址仅供演示之用。</p>
537</footer>
538</body>
539</html>
540```
541![bindSelectionMenu_link](./figures/web-menu-bindselectionmenu-link.gif)
542
543## Web菜单保存图片
5441. 创建MenuBuilder组件作为菜单弹窗,使用[SaveButton](../reference/apis-arkui/arkui-ts/ts-security-components-savebutton.md)组件实现图片保存,通过bindContextMenu将MenuBuilder与Web绑定。
5452. 在onContextMenuShow中获取图片url,通过copyLocalPicToDir或copyUrlPicToDir将图片保存至应用沙箱。
5463. 通过photoAccessHelper将应用沙箱中的图片保存至图库。
547
548  ```ts
549import { webview } from '@kit.ArkWeb';
550import { common } from '@kit.AbilityKit';
551import { fileIo as fs} from '@kit.CoreFileKit';
552import { systemDateTime } from '@kit.BasicServicesKit';
553import { http } from '@kit.NetworkKit';
554import { photoAccessHelper } from '@kit.MediaLibraryKit';
555
556@Entry
557@Component
558struct WebComponent {
559  saveButtonOptions: SaveButtonOptions = {
560    icon: SaveIconStyle.FULL_FILLED,
561    text: SaveDescription.SAVE_IMAGE,
562    buttonType: ButtonType.Capsule
563  }
564  controller: webview.WebviewController = new webview.WebviewController();
565  @State showMenu: boolean = false;
566  @State imgUrl: string = '';
567  context = this.getUIContext().getHostContext() as common.UIAbilityContext;
568
569  copyLocalPicToDir(rawfilePath: string, newFileName: string): string {
570    let srcFileDes = this.context.resourceManager.getRawFdSync(rawfilePath);
571    let dstPath = this.context.filesDir + "/" +newFileName;
572    let dest: fs.File = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
573    let bufsize = 4096;
574    let buf = new ArrayBuffer(bufsize);
575    let off = 0, len = 0, readedLen = 0;
576    while (len = fs.readSync(srcFileDes.fd, buf, { offset: srcFileDes.offset + off, length: bufsize })) {
577      readedLen += len;
578      fs.writeSync(dest.fd, buf, { offset: off, length: len });
579      off = off + len;
580      if ((srcFileDes.length - readedLen) < bufsize) {
581        bufsize = srcFileDes.length - readedLen;
582      }
583    }
584    fs.close(dest.fd);
585    return dest.path;
586  }
587
588  async copyUrlPicToDir(picUrl: string, newFileName: string): Promise<string> {
589    let uri = '';
590    let httpRequest = http.createHttp();
591    let data: http.HttpResponse = await(httpRequest.request(picUrl) as Promise<http.HttpResponse>);
592    if (data?.responseCode == http.ResponseCode.OK) {
593      let dstPath = this.context.filesDir + "/" + newFileName;
594      let dest: fs.File = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
595      let writeLen: number = fs.writeSync(dest.fd, data.result as ArrayBuffer);
596      uri = dest.path;
597    }
598    return uri;
599  }
600
601  @Builder
602  MenuBuilder() {
603    Column() {
604      Row() {
605        SaveButton(this.saveButtonOptions)
606          .onClick(async (event, result: SaveButtonOnClickResult) => {
607            if (result == SaveButtonOnClickResult.SUCCESS) {
608              try {
609                let context = this.context;
610                let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
611                let uri = '';
612                if (this.imgUrl?.includes('rawfile')) {
613                  let rawFileName: string = this.imgUrl.substring(this.imgUrl.lastIndexOf('/') + 1);
614                  uri = this.copyLocalPicToDir(rawFileName, 'copyFile.png');
615                } else if (this.imgUrl?.includes('http') || this.imgUrl?.includes('https')) {
616                  uri = await this.copyUrlPicToDir(this.imgUrl, `onlinePic${systemDateTime.getTime()}.png`);
617                }
618                let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest = photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, uri);
619                await phAccessHelper.applyChanges(assetChangeRequest);
620              }
621              catch (err) {
622                console.error(`create asset failed with error: ${err.code}, ${err.message}`);
623              }
624            } else {
625              console.error(`SaveButtonOnClickResult create asset failed`);
626            }
627            this.showMenu = false;
628          })
629      }
630      .margin({ top: 20, bottom: 20 })
631      .justifyContent(FlexAlign.Center)
632    }
633    .width('80')
634    .backgroundColor(Color.White)
635    .borderRadius(10)
636  }
637
638  build() {
639    Column() {
640      Web({src: $rawfile("index.html"), controller: this.controller})
641        .onContextMenuShow((event) => {
642          if (event) {
643            let hitValue = this.controller.getLastHitTest();
644            this.imgUrl = hitValue.extra;
645          }
646          this.showMenu = true;
647          return true;
648        })
649        .bindContextMenu(this.MenuBuilder, ResponseType.LongPress)
650        .fileAccess(true)
651        .javaScriptAccess(true)
652        .domStorageAccess(true)
653    }
654  }
655}
656  ```
657  ```html
658<!--index.html-->
659<!DOCTYPE html>
660<html>
661<head>
662    <title>SavePicture</title>
663</head>
664<body>
665<h1>SavePicture</h1>
666<br>
667<br>
668<br>
669<br>
670<br>
671<!--startIcon.png为html同目录下图片-->
672<img src="./startIcon.png">
673</body>
674</html>
675  ```
676![emptyEditMenuOption](./figures/web-menu-savePic.gif)
677
678## Web菜单获取选中文本
679Web组件的[editMenuOptions](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#editmenuoptions12)接口中没有提供获取选中文本的方式。开发者可通过[javaScriptProxy](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#javascriptproxy)获取到JavaScript的选中文本,实现自定义菜单的逻辑。
6801. 创建SelectClass类,通过[javaScriptProxy](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#javascriptproxy)将SelectClass对象注册到Web组件中。
6812. 在Html侧注册选区变更监听器,在选区变更时通过SelectClass对象将选区设置到ArkTS侧。
682  ```ts
683import { webview } from '@kit.ArkWeb';
684let selectText = '';
685
686class SelectClass {
687  constructor() {
688  }
689
690  setSelectText(param: String) {
691    selectText = param.toString();
692  }
693}
694
695@Entry
696@Component
697struct WebComponent {
698  webController: webview.WebviewController = new webview.WebviewController();
699  @State selectObj: SelectClass = new SelectClass();
700  @State textStr: string = '';
701
702  build() {
703    Column() {
704      Web({ src: $rawfile('index.html'), controller: this.webController})
705        .javaScriptProxy({
706          object: this.selectObj,
707          name: 'selectObjName',
708          methodList: ['setSelectText'],
709          controller: this.webController
710        })
711        .height('40%')
712      Text('Click here to get the selected text.')
713        .fontSize(20)
714        .onClick(() => {
715          this.textStr = selectText;
716        })
717        .height('10%')
718      Text('Selected text is ' + this.textStr)
719        .fontSize(20)
720        .height('10%')
721    }
722  }
723}
724  ```
725  ```html
726<!DOCTYPE html>
727<html>
728<head>
729    <title>Test Get Select</title>
730    <style>
731        body {
732          margin: 40px;
733          background-color: #f4f4f4;
734        }
735        .edit-container {
736          padding: 20px;
737          background-color: #fff;
738          border-radius: 8px;
739          box-shadow: 0 0 10px rgba(0,0,0,0.1);
740          margin: auto;
741        }
742        textarea {
743          width: 100%;
744          height: 400px;
745          font-size: 16px;
746          padding: 10px;
747          border: 1px solid #ccc;
748          border-radius: 4px;
749        }
750    </style>
751</head>
752<body>
753<div class="edit-container">
754    <textarea placeholder="Enter the text here and select it by long pressing."></textarea>
755</div>
756<script>
757    document.addEventListener('selectionchange', () => {
758      var selection = window.getSelection();
759      if(selection.rangeCount > 0) {
760        var selectedText = selection.toString();
761        selectObjName.setSelectText(selectedText);
762      }
763    })
764</script>
765</body>
766</html>
767  ```
768![web-menu-get-select](./figures/web-menu-get-select.gif)
769
770## Web菜单识别图片二维码
771在二维码跳转页面或者付款场景中,开发者可通过实现上下文菜单,获取到[onContextMenuShow](../reference/apis-arkweb/arkts-basic-components-web-events.md#oncontextmenushow9)接口中的二维码图片信息进行处理,提供给用户扫描二维码入口。
7721. 创建MenuBuilder组件作为菜单弹窗,通过bindContextMenu将MenuBuilder与Web绑定。
7732. 在onContextMenuShow中获取图片url,通过copyLocalPicToDir或copyUrlPicToDir将图片保存至应用沙箱。
7743. 通过detectBarcode.decode解析保存在沙箱中的图片,获取到结果。
775  ```ts
776import { webview } from '@kit.ArkWeb';
777import { common } from '@kit.AbilityKit';
778import { fileIo as fs } from '@kit.CoreFileKit';
779import { systemDateTime } from '@kit.BasicServicesKit';
780import { http } from '@kit.NetworkKit';
781import { scanCore, scanBarcode, detectBarcode } from '@kit.ScanKit';
782import { BusinessError } from '@kit.BasicServicesKit';
783
784@Entry
785@Component
786struct WebComponent {
787  saveButtonOptions: SaveButtonOptions = {
788    icon: SaveIconStyle.FULL_FILLED,
789    text: SaveDescription.SAVE_IMAGE,
790    buttonType: ButtonType.Capsule
791  }
792  controller: webview.WebviewController = new webview.WebviewController();
793  private result: WebContextMenuResult | undefined = undefined;
794  @State showMenu: boolean = false;
795  @State imgUrl: string = '';
796  @State decodeResult: string = '';
797  context = this.getUIContext().getHostContext() as common.UIAbilityContext;
798
799  copyLocalPicToDir(rawfilePath: string, newFileName: string): string {
800    let srcFileDes = this.context.resourceManager.getRawFdSync(rawfilePath);
801    let dstPath = this.context.filesDir + "/" +newFileName;
802    let dest: fs.File = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
803    let bufsize = 4096;
804    let buf = new ArrayBuffer(bufsize);
805    let off = 0, len = 0, readedLen = 0;
806    while (len = fs.readSync(srcFileDes.fd, buf, { offset: srcFileDes.offset + off, length: bufsize })) {
807      readedLen += len;
808      fs.writeSync(dest.fd, buf, { offset: off, length: len });
809      off = off + len;
810      if ((srcFileDes.length - readedLen) < bufsize) {
811        bufsize = srcFileDes.length - readedLen;
812      }
813    }
814    fs.close(dest.fd);
815    return dest.path;
816  }
817
818  async copyUrlPicToDir(picUrl: string, newFileName: string): Promise<string> {
819    let uri = '';
820    let httpRequest = http.createHttp();
821    let data: http.HttpResponse = await(httpRequest.request(picUrl) as Promise<http.HttpResponse>);
822    if (data?.responseCode == http.ResponseCode.OK) {
823      let dstPath = this.context.filesDir + "/" + newFileName;
824      let dest: fs.File = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
825      let writeLen: number = fs.writeSync(dest.fd, data.result as ArrayBuffer);
826      uri = dest.path;
827    }
828    return uri;
829  }
830
831  @Builder
832  MenuBuilder() {
833    Menu() {
834      MenuItem({
835        content: "Scan QR Code",
836      })
837        .width(200)
838        .height(50)
839        .onClick(async () => {
840          try {
841            let uri = '';
842            if (this.imgUrl?.includes('rawfile')) {
843              let rawFileName: string = this.imgUrl.substring(this.imgUrl.lastIndexOf('/') + 1);
844              uri = this.copyLocalPicToDir(rawFileName, 'copyFile.png');
845            } else if (this.imgUrl?.includes('http') || this.imgUrl?.includes('https')) {
846              uri = await this.copyUrlPicToDir(this.imgUrl, `onlinePic${systemDateTime.getTime()}.png`);
847            }
848            let options: scanBarcode.ScanOptions = { scanTypes: [scanCore.ScanType.ALL], enableMultiMode: true, enableAlbum: true }
849            let inputImage: detectBarcode.InputImage = { uri: uri };
850            try {
851              // 调用图片识码接口
852              detectBarcode.decode(inputImage, options, (error: BusinessError, result: Array<scanBarcode.ScanResult>) => {
853                if (error && error.code) {
854                  console.error(`create asset failed with error: ${error.code}, ${error.message}`);
855                  return;
856                }
857              this.decodeResult = JSON.stringify(result);
858              });
859            } catch (err) {
860              console.error(`Failed to detect Barcode. Code: ${err.code}, ${err.message}`);
861            }
862          }
863          catch (err) {
864            console.error(`create asset failed with error: ${err.code}, ${err.message}`);
865          }
866        })
867    }
868  }
869
870  build() {
871    Column() {
872      Web({src: $rawfile("index.html"), controller: this.controller})
873        .onContextMenuShow((event) => {
874          if (event) {
875            let hitValue = this.controller.getLastHitTest();
876            this.imgUrl = hitValue.extra;
877          }
878          this.showMenu = true;
879          return true;
880        })
881        .bindContextMenu(this.MenuBuilder, ResponseType.LongPress)
882        .fileAccess(true)
883        .javaScriptAccess(true)
884        .domStorageAccess(true)
885        .height('40%')
886      Text('Decode result is ' + this.decodeResult)
887        .fontSize(20)
888        .height('10%')
889    }
890  }
891}
892  ```
893  ```html
894<!--index.html-->
895<!DOCTYPE html>
896<html>
897<head>
898    <title>test QR code</title>
899</head>
900<body>
901<h1>Long press and click to scan the QR code</h1>
902<!--img.png为二维码图片-->
903<img src="img.png" >
904</body>
905</html>
906  ```
907![web-menu-scan-qr-code](./figures/web-menu-scan-qrcode.gif)
908
909## 常见问题
910### 如何禁用长按选择时弹出菜单
911可通过[editMenuOptions](../reference/apis-arkweb/arkts-basic-components-web-attributes.md#editmenuoptions12)接口将系统默认菜单全部过滤,此时无菜单项,则不会显示菜单。
912  ```ts
913// xxx.ets
914import { webview } from '@kit.ArkWeb';
915@Entry
916@Component
917struct WebComponent {
918  controller: webview.WebviewController = new webview.WebviewController();
919
920  onCreateMenu(menuItems: Array<TextMenuItem>): Array<TextMenuItem> {
921    let items = menuItems.filter((menuItem) => {
922      // 过滤用户需要的系统按键
923      return false;
924    });
925    return items;
926  }
927
928  onMenuItemClick(menuItem: TextMenuItem, textRange: TextRange): boolean {
929    return false;// 返回默认值false
930  }
931
932  @State EditMenuOptions: EditMenuOptions = { onCreateMenu: this.onCreateMenu, onMenuItemClick: this.onMenuItemClick }
933
934  build() {
935    Column() {
936      Web({ src: $rawfile("index.html"), controller: this.controller })
937        .editMenuOptions(this.EditMenuOptions)
938    }
939  }
940}
941  ```
942  ```html
943<!--index.html-->
944<!DOCTYPE html>
945<html>
946  <head>
947      <title>测试网页</title>
948  </head>
949  <body>
950    <h1>editMenuOptions Demo</h1>
951    <span>edit menu options</span>
952  </body>
953</html>
954  ```
955![emptyEditMenuOption](./figures/emptyEditMenuOption.gif)
956
957### 出现选区时手柄菜单不显示
958可排查是否通过JS的[selection-api](https://www.w3.org/TR/selection-api/)对选区进行了操作,目前通过这种方式改变选区会导致手柄菜单不显示。
959
960### 如何修改文本选中菜单的样式
961目前暂不支持修改文本选中菜单的具体样式。