Repository: fritx/vue-at
Branch: vue3
Commit: 43680e2102ef
Files: 18
Total size: 45.2 KB
Directory structure:
gitextract_8hv1zyjq/
├── .eslintignore
├── .eslintrc.js
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── index.html
├── package.json
├── src/
│ ├── App.vue
│ ├── At.scss
│ ├── At.vue
│ ├── AtTemplate.vue
│ ├── AtTextarea.vue
│ ├── main.js
│ └── util.js
├── test_umd.html
└── vue.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintignore
================================================
/dist/
/package/dist/
================================================
FILE: .eslintrc.js
================================================
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-recommended',
'@vue/standard'
],
rules: {
'vue/multiline-html-element-content-newline': 'off',
'vue/first-attribute-linebreak': 'off',
'vue/max-attributes-per-line': 'off',
'vue/order-in-components': 'off',
'vue/attributes-order': 'off',
'vue/html-indent': 'off',
'no-irregular-whitespace': 'off',
'no-mixed-operators': 'off',
'no-unused-vars': 'off',
'prefer-const': 'off',
'comma-dangle': 'off',
'max-len': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
}
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: vue-at # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
custom: # Replace with a single custom sponsorship URL
================================================
FILE: .gitignore
================================================
.DS_Store
node_modules*/
dist/
dist_demo/
package/
*.tgz
npm-debug.log
yarn-error.log
================================================
FILE: .npmrc
================================================
package-lock=false
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2016-present, Fritz Lin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# vue-at
<a href="https://we-demo.github.io/vue-at-vite-app/"><img width="76" height="20" src="https://img.shields.io/website?url=https%3A%2F%2Fwe-demo.github.io%2Fvue-at-vite-app%2F"></a> <a href="https://www.npmjs.com/package/vue-at"><img height="20" src="https://img.shields.io/npm/dm/vue-at.svg"></a> <a href="https://github.com/fritx/vue-at"><img width="90" height="20" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" /></a> <a href="https://github.com/fritx/vue-at"><img width="84" height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"></a> <a href="https://gitter.im/fritx/vue-at?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"><img width="92" src="https://badges.gitter.im/fritx/vue-at.svg" alt="Join the chat at https://gitter.im/fritx/vue-at"></a>
<img width="262" height="218" src="https://raw.githubusercontent.com/fritx/vue-at/master/WechatIMG1.jpeg"> <img width="262" height="218" src="https://raw.githubusercontent.com/fritx/vue-at/master/WechatIMG2.jpeg">
- [x] Chrome / Firefox / Edge / IE9~IE11
- [x] Plain-text based, no jQuery, no extra nodes
- [x] Content-Editable / Textarea
- [x] Avatars, custom templates
- [x] Vite / Vue3 / Vue2 / Vue1
- [x] Vuetify / Element UI / Element Plus
- [x] Vue-CLI build migration
- [ ] Vite build migration
- [x] CommonJS / UMD Support
Playground: https://we-demo.github.io/vue-at-vite-app/<br>
Vue2 Docs: https://github.com/fritx/vue-at/tree/vue2#readme<br>
Vue3 Docs: See below<br>
See also: [react-at](https://github.com/fritx/react-at)
**If you're using Vue2, read [branch vue2](https://github.com/fritx/vue-at/tree/vue2#readme) instead.**
```plain
npm i vue-at@next # for Vue3 (branch vue3)
npm i vue-at@2.x # for Vue2 (branch vue2)
npm i vue-at@1.x # for Vue1 (branch vue1-legacy)
npm i vue1-at # for Vue1 (branch vue1-new)
```
```vue
<template>
<at :members="members">
<div :contenteditable="true"></div>
</at>
<at-ta :members="members">
<textarea></textarea>
</at-ta>
</template>
<script>
import At from 'vue-at' // for content-editable
import AtTa from 'vue-at/dist/vue-at-textarea' // for textarea
export default {
components: { At, AtTa },
data () {
return {
members: ['Roxie Miles', 'grace.carroll', '小浩']
}
}
}
</script>
<style>
#app .atwho-view { /* more */ }
#app .atwho-ul { /* more */ }
</style>
```
## UMD Also Supported
```html
<!-- for Vue2 -->
<script src="//unpkg.com/vue@2"></script>
<script src="//unpkg.com/vue-at@2/dist/vue-at.umd.js"></script>
<script src="//unpkg.com/vue-at@2/dist/vue-at-textarea.umd.js"></script>
<!-- ...-->
<!-- for Vue3 -->
<script src="//unpkg.com/vue@3"></script>
<script src="//unpkg.com/vue-at@next/dist/vue-at.umd.js"></script>
<script src="//unpkg.com/vue-at@next/dist/vue-at-textarea.umd.js"></script>
<div id="app">
<at v-model:value="html">
<div contenteditable></div>
</at>
<at-textarea>
<textarea v-model="text"></textarea>
</at-textarea>
</div>
<script>
Vue.createApp({
components: { At, AtTextarea },
// ...
}).mount('#app')
</script>
```
## Using V-Model (Recommended)
With Content-Editable, use `<at v-model:value="v">`<br>
With Textarea, you can use either `<at-ta v-model:value="v">` or `<textarea v-model="v">`
```vue
<at v-model:value="html">
<div :contenteditable="true"></div>
</at>
<at-ta v-model:value="text">
<textarea></textarea>
</at-ta>
<at-ta>
<textarea v-model="text"></textarea>
</at-ta>
```
## Custom Templates
### Custom List
```vue
<template>
<at :members="members" name-key="name">
<template slot="item" slot-scope="s">
<img :src="s.item.avatar">
<span v-text="s.item.name"></span>
</template>
<div :contenteditable="true"></div>
</at>
</template>
<script>
// ...
members: [{
avatar: 'https://randomuser.me/api/portraits/men/2.jpg',
name: 'myrtie.green'
}, {
avatar: 'https://randomuser.me/api/portraits/men/8.jpg',
name: '椿木'
}]
</script>
<style>
#app .atwho-li { /* more */ }
#app .atwho-li img { /* more */ }
#app .atwho-li span { /* more */ }
</style>
```
#### Custom List with Vue 1.x
There is no "scoped slot" feature in Vue 1.<br>
Use a "normal slot" with `data-` attribute instead.
```vue
<!-- vue1-at for vue@1.x -->
<template slot="item">
<img data-src="item.avatar">
<span data-text="item.name"></span>
</template>
```
### Custom Tags
This gives you the option of changing the style of inserted tagged items. It is only supported for ContentEditable version, not Textarea.
```vue
<span slot="embeddedItem" slot-scope="s">
<span class="tag"><img :src="s.current.avatar">{{ s.current.name }}</span>
</span>
<!-- with Vue 2.6+ 'v-slot' / '#slot' directive -->
<!-- note at least two '<span>' wrapper are required to work -->
<template #embeddedItem="s">
<span><span class="tag"><img class="avatar" :src="s.current.avatar">{{ s.current.name }}</span></span>
</template>
```
## Used with 3rd-party libraries
### Vuetify v-textarea
```vue
<at-ta :members="members">
<!-- slots -->
<v-textarea v-model="text"></v-textarea>
</at-ta>
```
### Element UI / Element-Plus el-input
```vue
<at-ta :members="members">
<!-- slots -->
<el-input v-model="text" type="textarea"></el-input>
</at-ta>
```
================================================
FILE: index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>vue-at</title>
</head>
<body>
<div id="app"></div>
<script src="dist/demo.js"></script>
</body>
</html>
================================================
FILE: package.json
================================================
{
"name": "vue-at",
"description": "At.js for Vue",
"version": "3.0.0-alpha.3",
"author": "Fritz Lin <uxfritz@163.com>",
"repository": "https://github.com/fritx/vue-at",
"scripts": {
"lint:fix": "vue-cli-service lint",
"lint": "vue-cli-service lint --no-fix",
"dev:dist": "vue-cli-service serve --skip-plugins eslint",
"dev": "vue-cli-service serve",
"demo": "vue-cli-service build --dest dist_demo",
"build:at": "vue-cli-service build --no-clean ./src/At.vue --target lib --name At --filename vue-at && shx mv dist/vue-at.common.js dist/vue-at.js",
"build:at-ta": "vue-cli-service build --no-clean ./src/AtTextarea.vue --target lib --name AtTextarea --filename vue-at-textarea && shx mv dist/vue-at-textarea.common.js dist/vue-at-textarea.js",
"build": "shx rm -rf dist && run-p build:at build:at-ta && shx rm dist/demo.html",
"prepublish": "npm run build"
},
"main": "dist/vue-at.js",
"files": [
"dist"
],
"engines": {
"node": ">= 14.x"
},
"dependencies": {
"textarea-caret": "^3.1.0"
},
"peerDependencies": {
"vue": "3.x"
},
"devDependencies": {
"@babel/core": "^7.18.9",
"@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"@vue/compat": "^3.1.0",
"@vue/compiler-sfc": "^3.1.0",
"@vue/eslint-config-standard": "^8.0.1",
"element-plus": "^2.2.12",
"eslint": "^8.21.0",
"eslint-plugin-vue": "^9.3.0",
"npm-run-all": "^4.1.5",
"sass": "^1.53.0",
"sass-loader": "^13.0.2",
"shx": "^0.3.4",
"vue": "^3.1.0",
"vue-loader": "^16.0.0",
"vuetify": "3.0.0",
"webpack": "^5.73.0"
}
}
================================================
FILE: src/App.vue
================================================
<template>
<div id="app">
<!--
vue3 migration.2
fix: [Vue warn]: (deprecation COMPONENT_V_MODEL) v-model usage on component has changed in Vue 3. Component that expects to work with v-model should now use the "modelValue" prop and emit the "update:modelValue" event. You can update the usage and then opt-in to Vue 3 behavior on a per-component basis with `compatConfig: { COMPONENT_V_MODEL: false }`.
Details: https://v3-migration.vuejs.org/breaking-changes/v-model.html
-->
<at :members="members" name-key="name" v-model:value="html">
<!-- custom: same as default slot -->
<!-- <template #item="s">
<span v-text="s.item"></span>
</template> -->
<!-- custom: with avatars -->
<template #item="s">
<img :src="s.item.avatar">
<span v-text="s.item.name" />
</template>
<!--
// vue3 migration.4
fix: [Vue warn]: (deprecation ATTR_ENUMERATED_COERCION) Enumerated attribute "contenteditable" with v-bind value `` will render the value as-is instead of coercing the value to "true" in Vue 3. Always use explicit "true" or "false" values for enumerated attributes. If the usage is intended, you can disable the compat behavior and suppress this warning with:
configureCompat({ ATTR_ENUMERATED_COERCION: false })
Details: https://v3-migration.vuejs.org/breaking-changes/attribute-coercion.html
-->
<div class="editor" :contenteditable="true" />
</at>
<at :members="members" name-key="name" v-model:value="html2">
<template #embeddedItem="s">
<span><span class="tag"><img class="avatar" :src="s.current.avatar">{{ s.current.name }}</span></span>
</template>
<!-- custom: with avatars -->
<template #item="s">
<img :src="s.item.avatar">
<span v-text="s.item.name" />
</template>
<div class="editor" :contenteditable="true" />
</at>
<br>
<at-ta :members="members" name-key="name" v-model:value="text">
<!-- custom: with avatars -->
<template #item="s">
<img :src="s.item.avatar">
<span v-text="s.item.name" />
</template>
<textarea class="editor" />
</at-ta>
<at-ta :members="members" name-key="name">
<!-- custom: with avatars -->
<template #item="s">
<img :src="s.item.avatar">
<span v-text="s.item.name" />
</template>
<v-textarea class="vuetify-editor" v-model="text2" />
</at-ta>
<br>
<at-ta :members="members" name-key="name">
<!-- custom: with avatars -->
<template #item="s">
<img :src="s.item.avatar">
<span v-text="s.item.name" />
</template>
<el-input type="textarea" v-model="text3" class="element-editor" />
</at-ta>
</div>
</template>
<script>
import At from './At.vue'
import AtTa from './AtTextarea.vue'
// testing dist
// import At from '../dist/vue-at'
// import AtTa from '../dist/vue-at-textarea'
// testing npm_pack
// import At from '../package/'
// import AtTa from '../package/dist/vue-at-textarea'
// testing node_modules
// import At from 'vue-at'
// import AtTa from 'vue-at/dist/vue-at-textarea'
let members = [
/* eslint-disable */
"Roxie Miles","grace.carroll",
"小浩",
"Helena Perez","melvin.miller",
"椿木",
"myrtie.green","elsie.graham","Elva Neal",
"肖逵",
"amy.sandoval","katie.leonard","lottie.hamilton",
/* eslint-enable */
]
members = members.map((v, i) => {
return {
avatar: `https://randomuser.me/api/portraits/men/${i % 5}.jpg`,
name: v
}
})
export default {
components: { At, AtTa },
name: 'App',
data () {
const data = {
members,
text: `
<<< Textarea >>>
Awesome Electron
Useful resources for creating apps with Electron
Inspired by the awesome list thing. You might also like awesome-nodejs.
Example apps
Some good apps written with Electron.
Open Source
Atom - Code editor.
Nuclide - Unified IDE.
Playback - Video player.
Awesome Electron
Useful resources for creating apps with Electron
Inspired by the awesome list thing. You might also like awesome-nodejs.
Example apps
Some good apps written with Electron.
Open Source
Atom - Code editor.
Nuclide - Unified IDE.
Playback - Video player.
`.trim(), // fix trailing abnormal nodes
html: `
<div><<< Content Editable Div >>></div><div>Awesome Electron
<img src="awesome.svg"></div><div><img style="max-width: 50px;" src="electron.svg"></div><div>Useful resources for creating apps with Electron</div><div>Inspired by the awesome list thing. You might also like awesome-nodejs.</div><div>Example apps</div><div>Some good apps written with Electron.</div><div>Open Source</div><div>Atom - Code editor.</div><div>Nuclide - Unified IDE.</div><div>Playback - Video player.</div>
<div><<< Content Editable Div >>></div><div>Awesome Electron <img style="max-width: 50px;" src="awesome.svg"></div><div><img style="max-width: 50px;" src="electron.svg"></div><div>Useful resources for creating apps with Electron</div><div>Inspired by the awesome list thing. You might also like awesome-nodejs.</div><div>Example apps</div><div>Some good apps written with Electron.</div><div>Open Source</div><div>Atom - Code editor.</div><div>Nuclide - Unified IDE.</div><div>Playback - Video player.</div>
`.trim() // fix trailing abnormal nodes
}
data.text2 = data.text
data.text3 = data.text
data.html2 = data.html
return data
}
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
margin-top: 30px;
}
.tag {
border-radius: 5px;
background: beige;
border: 1ps outset yellow;
}
.editor {
width: 400px;
height: 200px;
overflow: auto;
white-space: pre-wrap;
border: solid 2px rgba(0,0,0,.5);
}
textarea {
display: block;
}
.vuetify-editor {
width: 400px;
}
.vuetify-editor textarea {
height: 200px;
}
.v-text-field__details {
display: none;
}
.element-editor {
width: 400px;
}
.element-editor textarea {
height: 200px;
}
.editor img {
max-width: 10em;
vertical-align: bottom;
}
.tag .avatar {
max-width: 1em;
vertical-align: middle;
}
textarea {
padding: 0;
font-size: inherit;
resize: none;
}
/* override styles */
#app .atwho-li {
padding: 0 4px;
}
#app .atwho-li img {
height: 100%;
width: auto;
transform: scale(.8);
-webkit-transform: scale(.8);
}
#app .atwho-li span {
padding-left: 8px;
}
#app .atwho-wrap {
display: inline-block;
vertical-align: top;
margin-left: 40px;
margin-top: 30px;
}
</style>
================================================
FILE: src/At.scss
================================================
// atwho.css https://github.com/ichord/At.js
.atwho-view {
// position:absolute;
// top: 0;
// left: 0;
// display: none;
// margin-top: 18px;
// background: white;
color: black;
// border: 1px solid #DDD;
border-radius: 3px;
box-shadow: 0 0 5px rgba(0,0,0,0.1);
min-width: 120px;
z-index: 11110 !important;
}
.atwho-ul {
/* width: 100px; */
list-style:none;
// padding:0;
// margin:auto;
// max-height: 200px;
// overflow-y: auto;
}
.atwho-li {
display: block;
// padding: 5px 10px;
// border-bottom: 1px solid #DDD;
// cursor: pointer;
/* border-top: 1px solid #C8C8C8; */
}
////// added 1
.atwho-view {
// font-size: 14px;
// min-width: 140px;
// max-width: 180px;
border-radius: 6px;
// overflow: hidden;
box-shadow: 0 0 10px 0 rgba(101, 111, 122, .5);
}
.atwho-ul {
max-height: 135px;
padding: 0;
margin: 0;
}
.atwho-li {
box-sizing: border-box;
height: 27px;
padding: 0 12px;
white-space: nowrap;
display: flex;
align-items: center;
span {
overflow: hidden;
text-overflow: ellipsis;
}
}
.atwho-cur {
// background: #44a8f2;
background: #5BB8FF;
color: white;
}
////// added 2
.atwho-wrap {
position: relative;
}
.atwho-panel {
position: absolute;
}
.atwho-inner {
position: relative;
}
.atwho-view {
position: absolute;
bottom: 0;
left: -0.8em; // 抵消左边距
cursor: default;
background-color: rgba(255,255,255,.94);
min-width: 140px;
max-width: 180px;
max-height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 11px;
height: 11px;
}
&::-webkit-scrollbar-track {
// background-color: rgba(127, 127, 127, .1);
background-color: #F5F5F5;
}
&::-webkit-scrollbar-thumb {
min-height: 36px;
border: 2px solid transparent;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
background-clip: padding-box;
border-radius: 7px;
// background-color: rgba(0, 0, 0, 0.2);
background-color: #C4C4C4;
}
}
================================================
FILE: src/At.vue
================================================
<script>
import {
closest, getOffset, getPrecedingRange,
getRange, applyRange,
scrollIntoView, getAtAndIndex
} from './util'
import AtTemplate from './AtTemplate.vue'
export default {
name: 'VueAt',
mixins: [AtTemplate],
emits: ['update:value', 'at', 'insert'],
props: {
value: {
type: String, // value not required
default: null
},
at: {
type: String,
default: null
},
ats: {
type: Array,
default: () => ['@']
},
suffix: {
type: String,
default: ' '
},
loop: {
type: Boolean,
default: true
},
allowSpaces: {
type: Boolean,
default: true
},
tabSelect: {
type: Boolean,
default: false
},
avoidEmail: {
type: Boolean,
default: true
},
showUnique: {
type: Boolean,
default: true
},
hoverSelect: {
type: Boolean,
default: true
},
members: {
type: Array,
default: () => []
},
nameKey: {
type: String,
default: ''
},
filterMatch: {
type: Function,
default: (name, chunk, at) => {
// match at lower-case
return name.toLowerCase()
.indexOf(chunk.toLowerCase()) > -1
}
},
deleteMatch: {
type: Function,
default: (name, chunk, suffix) => {
return chunk === name + suffix
}
},
scrollRef: {
type: String,
default: ''
}
},
data () {
return {
// at[v-model] mode should be on only when
// initial :value/v-model is present (not nil)
bindsValue: this.value != null,
customsEmbedded: false,
hasComposition: false,
atwho: null
}
},
computed: {
atItems () {
return this.at ? [this.at] : this.ats
},
currentItem () {
if (this.atwho) {
return this.atwho.list[this.atwho.cur]
}
return ''
},
style () {
if (this.atwho) {
const { list, cur, x, y } = this.atwho
const { wrap } = this.$refs
if (wrap) {
const offset = getOffset(wrap)
const scrollLeft = this.scrollRef ? document.querySelector(this.scrollRef).scrollLeft : 0
const scrollTop = this.scrollRef ? document.querySelector(this.scrollRef).scrollTop : 0
const left = x + scrollLeft + window.pageXOffset - offset.left + 'px'
const top = y + scrollTop + window.pageYOffset - offset.top + 'px'
return { left, top }
}
}
return null
}
},
watch: {
'atwho.cur' (index) {
if (index != null) { // cur index exists
this.$nextTick(() => {
this.scrollToCur()
})
}
},
members () {
this.handleInput(true)
},
value (value, oldValue) {
if (this.bindsValue) {
this.handleValueUpdate(value)
}
}
},
mounted () {
// vue3 migration.5
// [Vue warn]: (deprecation INSTANCE_SCOPED_SLOTS) vm.$scopedSlots has been removed. Use vm.$slots instead.
// Details: https://v3-migration.vuejs.org/breaking-changes/slots-unification.html
if (this.$slots.embeddedItem) {
this.customsEmbedded = true
}
if (this.bindsValue) {
this.handleValueUpdate(this.value)
}
},
methods: {
itemName (v) {
const { nameKey } = this
return nameKey ? v[nameKey] : v
},
isCur (index) {
return index === this.atwho.cur
},
handleValueUpdate (value) {
const el = this.$el.querySelector('[contenteditable]')
if (value !== el.innerHTML) { // avoid range reset
el.innerHTML = value
this.dispatchInput()
}
},
dispatchInput () {
let el = this.$el.querySelector('[contenteditable]')
let ev = new Event('input', { bubbles: true })
el.dispatchEvent(ev)
},
handleItemHover (e) {
if (this.hoverSelect) {
this.selectByMouse(e)
}
},
handleItemClick (e) {
this.selectByMouse(e)
this.insertItem()
},
handleDelete (e) {
const range = getPrecedingRange()
if (range) {
// fixme: Very bad code from me
if (this.customsEmbedded && range.endOffset >= 1) {
let a = range.endContainer.childNodes[range.endOffset] ||
range.endContainer.childNodes[range.endOffset - 1]
if (!a || a.nodeType === Node.TEXT_NODE && !/^\s?$/.test(a.data)) {
return
} else if (a.nodeType === Node.TEXT_NODE) {
if (a.previousSibling) a = a.previousSibling
} else {
if (a.previousElementSibling) a = a.previousElementSibling
}
let ch = [].slice.call(a.childNodes)
ch = [].reverse.call(ch)
ch.unshift(a)
let last
;[].some.call(ch, c => {
if (c.getAttribute && c.getAttribute('data-at-embedded') != null) {
last = c
return true
}
})
if (last) {
e.preventDefault()
e.stopPropagation()
const r = getRange()
if (r) {
r.setStartBefore(last)
r.deleteContents()
applyRange(r)
this.handleInput()
}
}
return
}
const { atItems, members, suffix, deleteMatch, itemName } = this
const text = range.toString()
const { at, index } = getAtAndIndex(text, atItems)
if (index > -1) {
const chunk = text.slice(index + at.length)
const has = members.some(v => {
const name = itemName(v)
return deleteMatch(name, chunk, suffix)
})
if (has) {
e.preventDefault()
e.stopPropagation()
const r = getRange()
if (r) {
r.setStart(r.endContainer, index)
r.deleteContents()
applyRange(r)
this.handleInput()
}
}
}
}
},
handleKeyDown (e) {
const { atwho } = this
if (atwho) {
if (e.keyCode === 38 || e.keyCode === 40) { // ↑/↓
if (!(e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
this.selectByKeyboard(e)
}
return
}
if (e.keyCode === 13 || (this.tabSelect && e.keyCode === 9)) { // enter or tab
e.preventDefault()
e.stopPropagation()
this.insertItem()
return
}
if (e.keyCode === 27) { // esc
this.closePanel()
return
}
}
// 为了兼容ie ie9~11 editable无input事件 只能靠keydown触发 textarea正常
// 另 ie9 textarea的delete不触发input
const isValid = e.keyCode >= 48 && e.keyCode <= 90 || e.keyCode === 8
if (isValid) {
setTimeout(() => {
this.handleInput()
}, 50)
}
if (e.keyCode === 8) {
this.handleDelete(e)
}
},
// compositionStart -> input -> compositionEnd
handleCompositionStart () {
this.hasComposition = true
},
handleCompositionEnd () {
this.hasComposition = false
this.handleInput()
},
handleInput (keep) {
if (this.hasComposition) return
const el = this.$el.querySelector('[contenteditable]')
// vue3 migration.2.1
// https://vuejs.org/guide/components/events.html#usage-with-v-model
// https://laracasts.com/discuss/channels/vue/how-do-emit-to-v-model-in-vue-3
// this.$emit('input', el.innerHTML)
this.$emit('update:value', el.innerHTML)
const range = getPrecedingRange()
if (range) {
if (keep) {
// exit the function if the range is not inside this.$el
let container = range.commonAncestorContainer;
while (container) {
if (container === this.$el) break;
container = container.parentElement;
}
if (!container) return;
}
const { atItems, avoidEmail, allowSpaces, showUnique } = this
let show = true
const text = range.toString()
const { at, index } = getAtAndIndex(text, atItems)
if (index < 0) show = false
const prev = text[index - 1]
const chunk = text.slice(index + at.length, text.length)
if (avoidEmail) {
// 上一个字符不能为字母数字 避免与邮箱冲突
// 微信则是避免 所有字母数字及半角符号
if (/^[a-z0-9]$/i.test(prev)) show = false
}
if (!allowSpaces && /\s/.test(chunk)) {
show = false
}
// chunk以空白字符开头不匹配 避免`@ `也匹配
if (/^\s/.test(chunk)) show = false
if (!show) {
this.closePanel()
} else {
const { members, filterMatch, itemName } = this
if (!keep && chunk) { // fixme: should be consistent with AtTextarea.vue
this.$emit('at', chunk)
}
const matched = members.filter(v => {
const name = itemName(v)
return filterMatch(name, chunk, at, v)
})
show = false
if (matched.length) {
show = true
if (!showUnique) {
let item = matched[0]
if (chunk === itemName(item)) {
show = false
}
}
}
if (show) {
this.openPanel(matched, range, index, at)
} else {
this.closePanel()
}
}
}
},
closePanel () {
if (this.atwho) {
this.atwho = null
}
},
openPanel (list, range, offset, at) {
const fn = () => {
const r = range.cloneRange()
r.setStart(r.endContainer, offset + at.length) // 从@后第一位开始
// todo: 根据窗口空间 判断向上或是向下展开
const rect = r.getClientRects()[0]
this.atwho = {
range,
offset,
list,
x: rect.left,
y: rect.top - 4,
cur: 0 // todo: 尽可能记录
}
}
if (this.atwho) {
fn()
} else { // 焦点超出了显示区域 需要提供延时以移动指针 再计算位置
setTimeout(fn, 10)
}
},
scrollToCur () {
// vue3 migration.6
// fix: [Vue warn]: Missing ref owner context. ref cannot be used on hoisted vnodes.
// A vnode with ref must be created inside the render function.
// at selectByMouse
// at handleItemHover
// const curEl = this.$refs.cur[0]
let { wrap } = this.$refs
let { cur } = this.atwho
const curEl = wrap.querySelector(`.atwho-li[data-index="${cur}"]`)
const scrollParent = curEl.parentElement.parentElement // .atwho-view
scrollIntoView(curEl, scrollParent)
},
selectByMouse (e) {
const el = closest(e.target, d => {
return d.getAttribute('data-index')
})
const cur = +el.getAttribute('data-index')
this.atwho = {
...this.atwho,
cur
}
},
selectByKeyboard (e) {
const offset = e.keyCode === 38 ? -1 : 1
const { cur, list } = this.atwho
const nextCur = this.loop
? (cur + offset + list.length) % list.length
: Math.max(0, Math.min(cur + offset, list.length - 1))
this.atwho = {
...this.atwho,
cur: nextCur
}
},
// todo: 抽离成库并测试
insertText (text, r) {
r.deleteContents()
const node = r.endContainer
if (node.nodeType === Node.TEXT_NODE) {
const cut = r.endOffset
node.data = node.data.slice(0, cut) +
text + node.data.slice(cut)
r.setEnd(node, cut + text.length)
} else {
const t = document.createTextNode(text)
r.insertNode(t)
r.setEndAfter(t)
}
r.collapse(false) // 参数在IE下必传
applyRange(r)
this.dispatchInput()
},
insertHtml (html, r) {
r.deleteContents()
const node = r.endContainer
const newElement = document.createElement('span')
// Seems `contentediable=false` should includes spaces,
// otherwise, caret can't be placed well across them
newElement.appendChild(document.createTextNode(' '))
newElement.appendChild(this.htmlToElement(html))
newElement.appendChild(document.createTextNode(' '))
newElement.setAttribute('data-at-embedded', '')
newElement.setAttribute('contenteditable', false)
if (node.nodeType === Node.TEXT_NODE) {
const cut = r.endOffset
let secondPart = node.splitText(cut)
node.parentNode.insertBefore(newElement, secondPart)
r.setEndBefore(secondPart)
} else {
const t = document.createTextNode(this.suffix)
r.insertNode(newElement)
r.setEndAfter(newElement)
r.insertNode(t)
r.setEndAfter(t)
}
r.collapse(false) // 参数在IE下必传
applyRange(r)
},
insertItem () {
const { range, offset, list, cur } = this.atwho
const { suffix, atItems, itemName, customsEmbedded } = this
const r = range.cloneRange()
const text = range.toString()
const { at, index } = getAtAndIndex(text, atItems)
// Leading `@` is automatically dropped as `customsEmbedded=true`
// You can fully custom the output inside the embedded slot
const start = customsEmbedded ? index : index + at.length
r.setStart(r.endContainer, start)
// hack: 连续两次 可以确保click后 focus回来 range真正生效
applyRange(r)
applyRange(r)
const curItem = list[cur]
if (customsEmbedded) {
// `suffix` is ignored as `customsEmbedded=true` has to be
// wrapped around by spaces
// vue3 migration.7
// fix: Uncaught TypeError: Cannot read properties of undefined (reading 'innerHTML')
// at Proxy.insertItem (At.vue?075e:490:1)
// at Proxy.handleItemClick (At.vue?075e:184:1)
// const html = this.$refs.embeddedItem.firstChild.innerHTML
const html = this.$refs.embeddedItem.firstElementChild.innerHTML
this.insertHtml(html, r)
} else {
const t = itemName(curItem) + suffix
this.insertText(t, r)
}
scrollIntoView(window.getSelection())
this.$emit('insert', curItem)
this.handleInput()
// fix safari: use `r` instead of `window.getSelection()`
// scrollIntoView(window.getSelection())
scrollIntoView(r)
},
htmlToElement (html) {
const template = document.createElement('template')
html = html.trim() // Never return a text node of whitespace as the result
template.innerHTML = html
return template.content.firstChild
}
}
}
</script>
================================================
FILE: src/AtTemplate.vue
================================================
<template>
<div
ref="wrap"
class="atwho-wrap"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput()"
@keydown.capture="handleKeyDown"
>
<div
v-if="atwho"
class="atwho-panel"
:style="style"
>
<div class="atwho-inner">
<div class="atwho-view">
<ul class="atwho-ul">
<li
v-for="(item, index) in atwho.list"
class="atwho-li"
:key="index"
:class="isCur(index) && 'atwho-cur'"
:data-index="index"
@mouseenter="handleItemHover"
@click="handleItemClick"
>
<slot
name="item"
:item="item"
>
<span v-text="itemName(item)" />
</slot>
</li>
</ul>
</div>
</div>
</div>
<span
v-show="false"
ref="embeddedItem"
>
<slot
name="embeddedItem"
:current="currentItem"
/>
</span>
<slot />
</div>
</template>
<style lang="scss" src="./At.scss"></style>
================================================
FILE: src/AtTextarea.vue
================================================
<script>
import At from './At.vue'
import getCaretCoordinates from 'textarea-caret'
import { getAtAndIndex, getPrecedingRange } from './util'
export default {
extends: At,
name: 'AtTextarea',
emits: ['update:value', 'at', 'insert'],
computed: {
style () {
if (this.atwho) {
const { list, cur, x, y } = this.atwho
const { wrap } = this.$refs
const el = this.$el.querySelector('textarea')
if (wrap) {
const left = x + el.offsetLeft - el.scrollLeft + 'px'
const top = y + el.offsetTop - el.scrollTop + 'px'
return { left, top }
}
}
return null
}
},
methods: {
handleValueUpdate (value) {
const el = this.$el.querySelector('textarea')
if (value !== el.value) { // avoid range reset
el.value = value
this.dispatchInput()
}
},
dispatchInput () {
let el = this.$el.querySelector('textarea')
let ev = new Event('input', { bubbles: true })
el.dispatchEvent(ev)
},
handleDelete (e) {
const el = this.$el.querySelector('textarea')
// fix https://github.com/fritx/vue-at/issues/139
const hasSelection = el.selectionEnd - el.selectionStart > 0
if (hasSelection) return
const text = el.value.slice(0, el.selectionEnd)
if (text) {
const { atItems, members, suffix, deleteMatch, itemName } = this
const { at, index } = getAtAndIndex(text, atItems)
if (index > -1) {
const chunk = text.slice(index + at.length)
const has = members.some(v => {
const name = itemName(v)
return deleteMatch(name, chunk, suffix)
})
if (has) {
el.value = el.value.slice(0, index) +
el.value.slice(el.selectionEnd - 1)
el.selectionStart = index + 1
el.selectionEnd = index + 1
this.handleInput()
}
}
}
},
handleInput (keep) {
if (this.hasComposition) return
const el = this.$el.querySelector('textarea')
// vue3 migration.2.1
// https://vuejs.org/guide/components/events.html#usage-with-v-model
// https://laracasts.com/discuss/channels/vue/how-do-emit-to-v-model-in-vue-3
// this.$emit('input', el.value)
this.$emit('update:value', el.value)
if (keep) {
// exit the function if the range is not inside this.$el
const range = getPrecedingRange()
let container = range && range.commonAncestorContainer;
while (container) {
if (container === this.$el) break;
container = container.parentElement;
}
if (!container) return;
}
const text = el.value.slice(0, el.selectionEnd)
if (text) {
const { atItems, avoidEmail, allowSpaces } = this
let show = true
const { at, index } = getAtAndIndex(text, atItems)
if (index < 0) show = false
const prev = text[index - 1]
const chunk = text.slice(index + at.length, text.length)
if (avoidEmail) {
// 上一个字符不能为字母数字 避免与邮箱冲突
// 微信则是避免 所有字母数字及半角符号
if (/^[a-z0-9]$/i.test(prev)) show = false
}
if (!allowSpaces && /\s/.test(chunk)) {
show = false
}
// chunk以空白字符开头不匹配 避免`@ `也匹配
if (/^\s/.test(chunk)) show = false
if (!show) {
this.closePanel()
} else {
const { members, filterMatch, itemName } = this
if (!keep) { // fixme: should be consistent with At.vue
this.$emit('at', chunk)
}
const matched = members.filter(v => {
const name = itemName(v)
return filterMatch(name, chunk, at, v)
})
if (matched.length) {
this.openPanel(matched, chunk, index, at, keep)
} else {
this.closePanel()
}
}
} else {
this.closePanel()
}
},
openPanel (list, chunk, offset, at) {
const fn = () => {
const el = this.$el.querySelector('textarea')
const atEnd = offset + at.length // 从@后第一位开始
const rect = getCaretCoordinates(el, atEnd)
this.atwho = {
chunk,
offset,
list,
atEnd,
x: rect.left,
y: rect.top - 4,
cur: 0, // todo: 尽可能记录
}
}
if (this.atwho) {
fn()
} else { // 焦点超出了显示区域 需要提供延时以移动指针 再计算位置
setTimeout(fn, 10)
}
},
// todo: 抽离成库并测试
insertText (text, ta) {
const start = ta.selectionStart
const end = ta.selectionEnd
ta.value = ta.value.slice(0, start) +
text + ta.value.slice(end)
const newEnd = start + text.length
ta.selectionStart = newEnd
ta.selectionEnd = newEnd
this.dispatchInput()
},
insertItem () {
const { chunk, offset, list, cur, atEnd } = this.atwho
const { suffix, atItems, itemName } = this
const el = this.$el.querySelector('textarea')
const text = el.value.slice(0, atEnd)
const { at, index } = getAtAndIndex(text, atItems)
const start = index + at.length // 从@后第一位开始
el.selectionStart = start
el.focus() // textarea必须focus回来
const curItem = list[cur]
const t = itemName(curItem) + suffix
this.insertText(t, el)
this.$emit('insert', curItem)
this.handleInput()
}
}
}
</script>
================================================
FILE: src/main.js
================================================
// Element-Plus x Vue3
// import 'element-ui/lib/theme-chalk/index.css'
import 'element-plus/dist/index.css'
import ElementPlus from 'element-plus'
// Vuetify x Vue3
// https://next.vuetifyjs.com/en/getting-started/installation/
import 'vuetify/styles'
import { VTextarea } from 'vuetify/components'
import { createVuetify } from 'vuetify'
// import { createApp, configureCompat } from 'vue'
import { createApp } from 'vue'
import App from './App.vue'
let configureCompat = () => {}
configureCompat({
// vue3 migration.3
// fix: [Vue warn]: (deprecation WATCH_ARRAY) "watch" option or vm.$watch on an array value will no longer trigger on array mutation unless the "deep" option is specified. If current usage is intended, you can disable the compat behavior and suppress this warning with:
// configureCompat({ WATCH_ARRAY: false })
// Details: https://v3-migration.vuejs.org/breaking-changes/watch.html
WATCH_ARRAY: false,
// vue3 migration.4
// fix: [Vue warn]: (deprecation ATTR_ENUMERATED_COERCION) Enumerated attribute "contenteditable" with v-bind value `` will render the value as-is instead of coercing the value to "true" in Vue 3. Always use explicit "true" or "false" values for enumerated attributes. If the usage is intended, you can disable the compat behavior and suppress this warning with:
// configureCompat({ ATTR_ENUMERATED_COERCION: false })
// Details: https://v3-migration.vuejs.org/breaking-changes/attribute-coercion.html
ATTR_ENUMERATED_COERCION: false,
})
// vue3 migration.1
// fix: [Vue warn]: (deprecation GLOBAL_MOUNT) The global app bootstrapping API has changed: vm.$mount() and the "el" option have been removed. Use createApp(RootComponent).mount() instead.
// Details: https://v3-migration.vuejs.org/breaking-changes/global-api.html#mounting-app-instance
let app = createApp(App)
let vuetify = createVuetify({
components: { VTextarea }
})
app.use(vuetify)
app.use(ElementPlus)
app.mount('#app')
================================================
FILE: src/util.js
================================================
// Scrolling The Selection Into View
// http://roysharon.com/blog/37
export function scrollIntoView (t, scrollParent) {
if (typeof (t) !== 'object') return
if (t.getRangeAt) {
// we have a Selection object
if (t.rangeCount === 0) return
t = t.getRangeAt(0)
}
if (t.cloneRange) {
// we have a Range object
let r = t.cloneRange() // do not modify the source range
r.collapse(true) // collapse to start
t = r.startContainer
// if start is an element, then startOffset is the child number
// in which the range starts
if (t.nodeType === 1) t = t.childNodes[r.startOffset]
}
if (!t) return
// if t is not an element node, then we need to skip back until we find the
// previous element with which we can call scrollIntoView()
let o = t
while (o && o.nodeType !== 1) o = o.previousSibling
t = o || t.parentNode
if (t) scrollIntoViewElement(t, scrollParent)
}
// bug report: https://github.com/vuejs/awesome-vue/pull/1028
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded
export function scrollIntoViewElement (el, scrollParent) {
if (el.scrollIntoViewIfNeeded) {
el.scrollIntoViewIfNeeded(false) // alignToCenter=false
} else {
// should not use `el.scrollIntoView(false)` // alignToTop=false
// bug report: https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
scrollParent = scrollParent || el.parentElement
const diff = el.offsetTop - scrollParent.scrollTop
if (diff < 0 || diff > scrollParent.offsetHeight - el.offsetHeight) {
scrollParent.scrollTop = el.offsetTop
}
}
}
export function applyRange (range) {
const selection = window.getSelection()
if (selection) { // 容错
selection.removeAllRanges()
selection.addRange(range)
}
}
export function getRange () {
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
return selection.getRangeAt(0)
}
}
export function getAtAndIndex (text, ats) {
return ats.map((at) => {
return { at, index: text.lastIndexOf(at) }
}).reduce((a, b) => {
return a.index > b.index ? a : b
})
}
/* eslint-disable */
// http://stackoverflow.com/questions/26747240/plain-javascript-replication-to-offset-and-position
export function getOffset(element, target) {
// var element = document.getElementById(element),
// target = target ? document.getElementById(target) : window;
target = target || window
var offset = {top: element.offsetTop, left: element.offsetLeft},
parent = element.offsetParent;
while (parent != null && parent != target) {
offset.left += parent.offsetLeft;
offset.top += parent.offsetTop;
parent = parent.offsetParent;
}
return offset;
}
// http://stackoverflow.com/questions/3972014/get-caret-position-in-contenteditable-div
export function closest (el, predicate) {
/* eslint-disable */
do if (predicate(el)) return el;
while (el = el && el.parentNode);
}
// http://stackoverflow.com/questions/15157435/get-last-character-before-caret-position-in-javascript
// 修复 "空格+表情+空格+@" range报错 应设(endContainer, 0)
// stackoverflow上的这段代码有bug
export function getPrecedingRange() {
const r = getRange()
if (r) {
const range = r.cloneRange()
range.collapse(true)
// var el = closest(range.endContainer, d => d.contentEditable)
// range.setStart(el, 0)
range.setStart(range.endContainer, 0)
return range
}
}
/* eslint-enable */
================================================
FILE: test_umd.html
================================================
<meta charset="utf8">
<!-- for Vue2 -->
<!-- <script src="//unpkg.com/vue@2"></script> -->
<!-- for Vue3 -->
<script src="//unpkg.com/vue@3"></script>
<script src="dist/vue-at.umd.js"></script>
<script src="dist/vue-at-textarea.umd.js"></script>
<div id="app">
<h2>UMD: Vue3 x Vue-At</h2>
<at v-model:value="html">
<div contenteditable></div>
</at>
<at-ta v-model:value="text">
<textarea></textarea>
</at-ta>
<at-ta>
<textarea v-model="text1"></textarea>
</at-ta>
<br>
html: {{html}}
<br>
text: {{text}}
<br>
text1: {{text1}}
</div>
<script>
Vue.createApp({
components: { At, AtTa: AtTextarea },
data() {
return {
html: 'content-editable',
text: 'textarea',
text1: 'textarea'
}
}
}).mount('#app')
</script>
================================================
FILE: vue.config.js
================================================
module.exports = {
publicPath: './',
css: {
extract: false, // inline css into js
loaderOptions: {
sass: {},
scss: {}
}
},
chainWebpack: config => {
// config.resolve.alias.set('vue', '@vue/compat')
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
return {
...options,
compilerOptions: {
compatConfig: {
MODE: 2
}
}
}
})
}
}
gitextract_8hv1zyjq/ ├── .eslintignore ├── .eslintrc.js ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── index.html ├── package.json ├── src/ │ ├── App.vue │ ├── At.scss │ ├── At.vue │ ├── AtTemplate.vue │ ├── AtTextarea.vue │ ├── main.js │ └── util.js ├── test_umd.html └── vue.config.js
SYMBOL INDEX (8 symbols across 1 files)
FILE: src/util.js
function scrollIntoView (line 3) | function scrollIntoView (t, scrollParent) {
function scrollIntoViewElement (line 33) | function scrollIntoViewElement (el, scrollParent) {
function applyRange (line 47) | function applyRange (range) {
function getRange (line 54) | function getRange () {
function getAtAndIndex (line 61) | function getAtAndIndex (text, ats) {
function getOffset (line 71) | function getOffset(element, target) {
function closest (line 85) | function closest (el, predicate) {
function getPrecedingRange (line 93) | function getPrecedingRange() {
Condensed preview — 18 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (50K chars).
[
{
"path": ".eslintignore",
"chars": 22,
"preview": "/dist/\n/package/dist/\n"
},
{
"path": ".eslintrc.js",
"chars": 693,
"preview": "module.exports = {\n root: true,\n extends: [\n 'plugin:vue/vue3-recommended',\n '@vue/standard'\n ],\n rules: {\n "
},
{
"path": ".github/FUNDING.yml",
"chars": 444,
"preview": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [u"
},
{
"path": ".gitignore",
"chars": 86,
"preview": ".DS_Store\nnode_modules*/\ndist/\ndist_demo/\npackage/\n*.tgz\nnpm-debug.log\nyarn-error.log\n"
},
{
"path": ".npmrc",
"chars": 19,
"preview": "package-lock=false\n"
},
{
"path": "LICENSE",
"chars": 1085,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2016-present, Fritz Lin\n\nPermission is hereby granted, free of charge, to any perso"
},
{
"path": "README.md",
"chars": 5293,
"preview": "# vue-at\n\n<a href=\"https://we-demo.github.io/vue-at-vite-app/\"><img width=\"76\" height=\"20\" src=\"https://img.shields.io/w"
},
{
"path": "index.html",
"chars": 254,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-wid"
},
{
"path": "package.json",
"chars": 1647,
"preview": "{\n \"name\": \"vue-at\",\n \"description\": \"At.js for Vue\",\n \"version\": \"3.0.0-alpha.3\",\n \"author\": \"Fritz Lin <uxfritz@16"
},
{
"path": "src/App.vue",
"chars": 6777,
"preview": "<template>\n <div id=\"app\">\n\n <!--\n vue3 migration.2\n fix: [Vue warn]: (deprecation COMPONENT_V_MODEL) v-mo"
},
{
"path": "src/At.scss",
"chars": 2046,
"preview": "// atwho.css https://github.com/ichord/At.js\n.atwho-view {\n // position:absolute;\n // top: 0;\n // left: 0;\n "
},
{
"path": "src/At.vue",
"chars": 14598,
"preview": "<script>\nimport {\n closest, getOffset, getPrecedingRange,\n getRange, applyRange,\n scrollIntoView, getAtAndIndex\n} fro"
},
{
"path": "src/AtTemplate.vue",
"chars": 1168,
"preview": "<template>\n <div\n ref=\"wrap\"\n class=\"atwho-wrap\"\n @compositionstart=\"handleCompositionStart\"\n @compositione"
},
{
"path": "src/AtTextarea.vue",
"chars": 5458,
"preview": "<script>\nimport At from './At.vue'\nimport getCaretCoordinates from 'textarea-caret'\nimport { getAtAndIndex, getPreceding"
},
{
"path": "src/main.js",
"chars": 1967,
"preview": "// Element-Plus x Vue3\n// import 'element-ui/lib/theme-chalk/index.css'\nimport 'element-plus/dist/index.css'\nimport Elem"
},
{
"path": "src/util.js",
"chars": 3506,
"preview": "// Scrolling The Selection Into View\n// http://roysharon.com/blog/37\nexport function scrollIntoView (t, scrollParent) {\n"
},
{
"path": "test_umd.html",
"chars": 781,
"preview": "<meta charset=\"utf8\">\n\n<!-- for Vue2 -->\n<!-- <script src=\"//unpkg.com/vue@2\"></script> -->\n<!-- for Vue3 -->\n<script sr"
},
{
"path": "vue.config.js",
"chars": 492,
"preview": "\nmodule.exports = {\n publicPath: './',\n css: {\n extract: false, // inline css into js\n loaderOptions: {\n sa"
}
]
About this extraction
This page contains the full source code of the fritx/vue-at GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 18 files (45.2 KB), approximately 13.0k tokens, and a symbol index with 8 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.