Repository: IduxFE/components (ts)

Issue:
[cdk]support clipboard
- [x] I have searched the [issues](https://github.com/IduxFE/components/issues) of this repository and believe that this is not a duplicate.

### What problem does this feature solve?

https://material.angular.io/cdk/clipboard/overview

### What does the proposed API look like?

```ts
  const value = 'copy value'
  const { copy } = useClipboard()
  copy(value).then(successful => console.log(successful))
```

Pull request description:
feat(cdk:clipboard): support useClipboard
fix #14

## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] The commit message follows [our guidelines](https://github.com/IduxFE/components/blob/main/docs/contributing.zh-CN.md#commit)
- [ ] Tests for the changes have been added/updated or not needed
- [ ] Docs and demo have been added/updated or not needed


## PR Type
What kind of change does this PR introduce?

- [ ] Bugfix
- [x] Feature
- [ ] Component style update
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Documentation content changes
- [ ] Application (the showcase website) / infrastructure changes
- [ ] Other... Please describe:


## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->


## What is the new behavior?


## Other information



Patch:
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 6814394c..944013d8 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -38,7 +38,7 @@ stages:
               command: 'install'
           - script: |
               npm run test
-              npm run codecov
+              npm run codecov $(CODECOV_TOKEN)
 
           - task: PublishCodeCoverageResults@1
             displayName: 'publish code coverage results'
diff --git a/package.json b/package.json
index 01373c94..602b2a2f 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "cz": "git-cz",
     "gen": "ts-node --project scripts/gen/tsconfig.json scripts/gen/index.ts",
     "postinstall": "lerna bootstrap",
-    "codecov": "codecov",
+    "codecov": "codecov -t",
     "test": "jest",
     "lint": "npm run eslint && npm run stylelint && npm run markdownlint",
     "lint-fix": "npm run eslint-fix && npm run stylelint-fix && npm run markdownlint-fix",
diff --git a/packages/cdk/clipboard/docs/index.zh-CN.md b/packages/cdk/clipboard/docs/index.zh-CN.md
new file mode 100644
index 00000000..d3fb18b4
--- /dev/null
+++ b/packages/cdk/clipboard/docs/index.zh-CN.md
@@ -0,0 +1,28 @@
+---
+category: Cdk
+type: 
+title: Clipboard
+subtitle: 剪贴板
+cover: 
+---
+
+对系统剪贴板进行了简单封装，方便使用。
+
+## 何时使用
+
+用于对文本的复制。
+
+对于简单的文本可以直接复制，但是对于较长的文本，浏览器需要时间来填充中间textarea元素并复制内容。在这种情况下，直接调用可能会失败，因此可以通过传入 `attempts` 进行多次尝试。
+
+## API
+
+### `useClipboard`
+
+```ts
+export const useClipboard: () => { copy: (text: string, attempts?: number) => Promise<boolean> }
+```
+
+| 属性 | 说明 | 类型  | 默认值 |
+| --- | --- | --- | --- |
+| `text` | 需要复制的文本 | `string` | - |
+| `attempts` | 尝试次数 | `number` | `1` |
diff --git a/packages/cdk/clipboard/index.ts b/packages/cdk/clipboard/index.ts
new file mode 100644
index 00000000..b465259d
--- /dev/null
+++ b/packages/cdk/clipboard/index.ts
@@ -0,0 +1,1 @@
+export * from './src/useClipboard'
diff --git a/packages/cdk/clipboard/src/clipboard.ts b/packages/cdk/clipboard/src/clipboard.ts
new file mode 100644
index 00000000..af158a97
--- /dev/null
+++ b/packages/cdk/clipboard/src/clipboard.ts
@@ -0,0 +1,27 @@
+import { PendingCopy } from './pendingCopy'
+
+export class Clipboard {
+  public static getInstance(): Clipboard {
+    if (!Clipboard.instance) {
+      Clipboard.instance = new Clipboard()
+    }
+    return Clipboard.instance
+  }
+
+  private static instance: Clipboard | null = null
+  private constructor() {
+    // private
+  }
+
+  copy(text: string): boolean {
+    const pendingCopy = this.beginCopy(text)
+    const successful = pendingCopy.copy()
+    pendingCopy.destroy()
+
+    return successful
+  }
+
+  beginCopy(text: string): PendingCopy {
+    return new PendingCopy(text)
+  }
+}
diff --git a/packages/cdk/clipboard/src/pendingCopy.ts b/packages/cdk/clipboard/src/pendingCopy.ts
new file mode 100644
index 00000000..19235119
--- /dev/null
+++ b/packages/cdk/clipboard/src/pendingCopy.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+/**
+ * A pending copy-to-clipboard operation.
+ *
+ * The implementation of copying text to the clipboard modifies the DOM and
+ * forces a relayout. This relayout can take too long if the string is large,
+ * causing the execCommand('copy') to happen too long after the user clicked.
+ * This results in the browser refusing to copy. This object lets the
+ * relayout happen in a separate tick from copying by providing a copy function
+ * that can be called later.
+ *
+ * Destroy must be called when no longer in use, regardless of whether `copy` is
+ * called.
+ */
+export class PendingCopy {
+  private _textarea: HTMLTextAreaElement | undefined
+
+  constructor(text: string) {
+    const textarea = (this._textarea = document.createElement('textarea'))
+    const styles = textarea.style
+
+    // Hide the element for display and accessibility. Set an
+    // absolute position so the page layout isn't affected.
+    styles.opacity = '0'
+    styles.position = 'absolute'
+    styles.left = styles.top = '-999em'
+    textarea.setAttribute('aria-hidden', 'true')
+    textarea.value = text
+    document.body.appendChild(textarea)
+  }
+
+  /** Finishes copying the text. */
+  copy(): boolean {
+    const textarea = this._textarea
+    let successful = false
+
+    try {
+      // Older browsers could throw if copy is not supported.
+      if (textarea) {
+        const currentFocus = (document.activeElement as unknown) as HTMLOrSVGElement | null
+
+        textarea.select()
+        textarea.setSelectionRange(0, textarea.value.length)
+        successful = document.execCommand('copy')
+
+        if (currentFocus) {
+          currentFocus.focus()
+        }
+      }
+    } catch {
+      // Discard error.
+      // Initial setting of {@code successful} will represent failure here.
+    }
+
+    return successful
+  }
+
+  /** Cleans up DOM changes used to perform the copy operation. */
+  destroy(): void {
+    const textarea = this._textarea
+
+    if (textarea) {
+      if (textarea.parentNode) {
+        textarea.parentNode.removeChild(textarea)
+      }
+
+      this._textarea = undefined
+    }
+  }
+}
diff --git a/packages/cdk/clipboard/src/useClipboard.ts b/packages/cdk/clipboard/src/useClipboard.ts
new file mode 100644
index 00000000..453ab1e0
--- /dev/null
+++ b/packages/cdk/clipboard/src/useClipboard.ts
@@ -0,0 +1,54 @@
+import { onUnmounted } from 'vue'
+import { Clipboard } from './clipboard'
+import { PendingCopy } from './pendingCopy'
+
+export interface ClipboardConfig {
+  attempts: number
+}
+
+export const useClipboard = (): { copy: (text: string, attempts?: number) => Promise<boolean> } => {
+  const clipboard = Clipboard.getInstance()
+  const pendingSet = new Set<PendingCopy>()
+  let unmounted = false
+  let currentTimeout: NodeJS.Timeout | null = null
+
+  onUnmounted(() => {
+    if (currentTimeout !== null) {
+      clearTimeout(currentTimeout)
+    }
+
+    pendingSet.forEach(copy => copy.destroy())
+    pendingSet.clear()
+    unmounted = true
+  })
+
+  function copy(text: string, attempts = 1): Promise<boolean> {
+    const promise = new Promise<boolean>((resolve): void => {
+      if (attempts > 1) {
+        let remainingAttempts = attempts
+        const pending = clipboard.beginCopy(text)
+        pendingSet.add(pending)
+
+        const attempt = () => {
+          const successful = pending.copy()
+          if (!successful && --remainingAttempts && !unmounted) {
+            currentTimeout = setTimeout(attempt, 1)
+          } else {
+            currentTimeout = null
+            pendingSet.delete(pending)
+            pending.destroy()
+            resolve(successful)
+          }
+        }
+        attempt()
+      } else {
+        const successful = clipboard.copy(text)
+        resolve(successful)
+      }
+    })
+
+    return promise
+  }
+
+  return { copy }
+}
diff --git a/packages/cdk/index.ts b/packages/cdk/index.ts
new file mode 100644
index 00000000..57c4f83b
--- /dev/null
+++ b/packages/cdk/index.ts
@@ -0,0 +1,1 @@
+export * from './clipboard'


Test patch:
diff --git a/packages/cdk/clipboard/__tests__/useClipboard.spec.ts b/packages/cdk/clipboard/__tests__/useClipboard.spec.ts
new file mode 100644
index 00000000..8c02185f
--- /dev/null
+++ b/packages/cdk/clipboard/__tests__/useClipboard.spec.ts
@@ -0,0 +1,60 @@
+import { mount } from '@vue/test-utils'
+import { useClipboard } from '../src/useClipboard'
+
+const copyText = 'copy text'
+
+const getComp = () => {
+  return {
+    template: `<div></div>`,
+    setup() {
+      const { copy } = useClipboard()
+      return { copy }
+    },
+  }
+}
+
+describe('useClipboard.ts', () => {
+  test('copy work', async () => {
+    const wrapper = mount(getComp())
+
+    document.execCommand = jest.fn().mockReturnValue(true)
+    await wrapper.vm.copy(copyText)
+    wrapper.unmount()
+
+    expect(document.execCommand).toHaveBeenCalledWith('copy')
+  })
+
+  test('copy fail', async () => {
+    const wrapper = mount(getComp())
+
+    document.execCommand = jest.fn().mockReturnValue(false)
+    const successful = await wrapper.vm.copy(copyText)
+
+    expect(successful).toBeFalsy()
+  })
+
+  test('attempts work', async () => {
+    const wrapper = mount(getComp())
+
+    document.execCommand = jest.fn().mockReturnValue(false)
+    await wrapper.vm.copy(copyText, 3)
+
+    expect(document.execCommand).toHaveBeenCalledTimes(3)
+
+    document.execCommand = jest.fn().mockReturnValue(true)
+    await wrapper.vm.copy(copyText, 3)
+
+    expect(document.execCommand).toHaveBeenCalledTimes(1)
+  })
+
+  test('unmounted work', async () => {
+    const wrapper = mount(getComp())
+
+    window.clearTimeout = jest.fn().mockReturnValue(true)
+    document.execCommand = jest.fn().mockReturnValue(false)
+    wrapper.vm.copy(copyText, 3)
+    wrapper.unmount()
+
+    expect(window.clearTimeout).toHaveBeenCalledTimes(1)
+  })
+})