├── README.md ├── atuser.gif ├── atuser3 ├── atuser.vue ├── index.html ├── index.js └── index.vue ├── edit.gif ├── vue-at ├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── WechatIMG1.jpeg ├── WechatIMG2.jpeg ├── index.html ├── package.json ├── src │ ├── App.vue │ ├── At.scss │ ├── At.vue │ ├── AtTemplate.vue │ ├── AtTextarea.vue │ ├── main.js │ └── util.js ├── static │ ├── awesome.svg │ └── electron.svg └── webpack │ ├── base.js │ ├── demo.js │ └── prod.js ├── vue-edit ├── atuser.vue ├── edit.vue ├── index.html ├── index.js ├── index.vue ├── src │ ├── chooseImg.js │ ├── cursorPosition.js │ ├── emoji.js │ └── paste.js └── 复制图片测试.html ├── 聊天底部.html └── 聊天底部2.html /README.md: -------------------------------------------------------------------------------- 1 | # vue-atuser 2 | Vue@某人,At某人,仿新浪微博@某人,@user 3 | 4 | ![](https://raw.githubusercontent.com/libin1991/vue-atuser/master/atuser.gif) 5 | 6 | # vue-edit 7 | ### [Vue实现渲染数据后控制滚动条位置📜](https://juejin.im/post/5ded14d3e51d4557ff13fe56) 8 | ### [Element.scrollIntoView()](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollIntoView) 9 | ``` 10 | Element.scrollIntoView() // 如果为false,元素的底端将和其所在滚动区的可视区域的底端对齐。 11 | ``` 12 | ### [Web聊天工具的富文本输入框](https://juejin.im/post/5c79f249e51d457ab52e67e1) 13 | ![](https://raw.githubusercontent.com/libin1991/vue-atuser/master/edit.gif) 14 | ### [div+contenteditable 实现富文本发布框的小结](https://juejin.im/post/5c851ce8f265da2dc0068c14) 15 | ### [实现高度“听话”的多行文本输入框](https://juejin.im/post/5c9a1645e51d4559bb5c666f) 16 | ### [原生js 实现输入框emoji表情发布](https://juejin.im/post/5c9cd1ecf265da60d0005235) 17 | ### [Twitter和微博都在用的 @ 人的功能是如何设计与实现的?](https://juejin.cn/post/7036965252428202021) 18 | # 获取光标位置,设置光标位置 19 | ### [Vue实现字符串中自定义标识符的解析渲染🎩](https://juejin.im/post/5de6715ff265da33f11ab400) 20 | ``` 21 | /** 22 | * 获取光标位置 23 | * @param {DOMElement} element 输入框的dom节点 24 | * @return {Number} 光标位置 25 | */ 26 | export const getCursorPosition = (element) => { 27 | let caretOffset = 0 28 | const doc = element.ownerDocument || element.document 29 | const win = doc.defaultView || doc.parentWindow 30 | const sel = win.getSelection() 31 | if (sel.rangeCount > 0) { 32 | const range = win.getSelection().getRangeAt(0) 33 | const preCaretRange = range.cloneRange() 34 | preCaretRange.selectNodeContents(element) 35 | preCaretRange.setEnd(range.endContainer, range.endOffset) 36 | caretOffset = preCaretRange.toString().length 37 | } 38 | return caretOffset 39 | } 40 | 41 | /** 42 | * 设置光标位置 43 | * @param {DOMElement} element 输入框的dom节点 44 | * @param {Number} cursorPosition 光标位置的值 45 | */ 46 | export const setCursorPosition = (element, cursorPosition) => { 47 | const range = document.createRange() 48 | range.setStart(element.firstChild, cursorPosition) 49 | range.setEnd(element.firstChild, cursorPosition) 50 | const sel = window.getSelection() 51 | sel.removeAllRanges() 52 | sel.addRange(range) 53 | } 54 | ``` 55 | ### [Vue实现图片与文字混输🔥](https://juejin.im/post/5de26d39e51d455da17be1e3) 56 | # DEMO 57 | > atuser.vue 58 | 59 | ```js 60 | 76 | 77 | 375 | 376 | 449 | ``` 450 | > index.vue 451 | 452 | ```js 453 | 460 | 461 | 481 | 482 | 498 | ``` 499 | -------------------------------------------------------------------------------- /atuser.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libin1991/vue-atuser/5f870efb54b40c89be0cbd1fee7260dd0d4dd355/atuser.gif -------------------------------------------------------------------------------- /atuser3/atuser.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 393 | 394 | -------------------------------------------------------------------------------- /atuser3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /atuser3/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './index.vue' 3 | import '@/assets/css/reset.css' 4 | 5 | new Vue({ 6 | render: h => h(App) 7 | }).$mount('#app') -------------------------------------------------------------------------------- /atuser3/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 32 | 33 | -------------------------------------------------------------------------------- /edit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libin1991/vue-atuser/5f870efb54b40c89be0cbd1fee7260dd0d4dd355/edit.gif -------------------------------------------------------------------------------- /vue-at/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "stage-0", 4 | ["es2015", { "modules": false }] 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /vue-at/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules*/ 3 | dist/ 4 | npm-debug.log 5 | yarn-error.log 6 | yarn.lock 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /vue-at/.npmignore: -------------------------------------------------------------------------------- 1 | dist/demo.js 2 | -------------------------------------------------------------------------------- /vue-at/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present, Fritz Lin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /vue-at/README.md: -------------------------------------------------------------------------------- 1 | # vue-at 2 | 3 |      4 | 5 |    6 | 7 | Live Demo & Documentation: https://fritx.github.io/vue-at 8 | 9 | *Docs is powered by [At-UI](https://github.com/AT-UI/at-ui).* 10 | 11 | - [x] Chrome/Firefox/Edge/IE9~IE11 12 | - [x] Plain-text based, no jQuery, no extra nodes 13 | - [x] ContentEditable/Textarea 14 | - [x] Avatars, custom templates 15 | - [x] Vue2/Vue1 16 | 17 | See also: [react-at](https://github.com/fritx/react-at) 18 | 19 | ## Motivation 20 | 21 | [At.js](https://github.com/ichord/At.js) is awesome, but: 22 | 23 | - It is based on jQuery and jQuery-Caret. 24 | - It introduces extra node wrappers. 25 | - It could be unstable on content edit/copy/paste. 26 | 27 | Finally I ended up creating this. 28 | 29 | ```plain 30 | npm i vue-at@2.x # for Vue2 <---- 31 | npm i vue-at@1.x # for Vue1 (branch vue1-legacy) 32 | npm i vue1-at # for Vue1 (branch vue1-new) 33 | ``` 34 | 35 | ```vue 36 | 41 | 42 | 54 | 55 | 59 | ``` 60 | 61 | ## Using V-Model (Recommended) 62 | 63 | Notice that `` could be buggy,
64 | and should be like `` instead. 65 | 66 | ```vue 67 | 68 |
69 |
70 | 71 | 72 | 73 | 74 | ``` 75 | 76 | ## Textarea 77 | 78 | ```vue 79 | 84 | 85 | 93 | ``` 94 | 95 | ```plain 96 | npm i -S textarea-caret # also, for textarea 97 | ``` 98 | 99 | ## Custom Templates 100 | 101 | ### Custom List 102 | 103 | ```vue 104 | 113 | 114 | 124 | 125 | 130 | ``` 131 | 132 | #### Custom List with Vue 1.x 133 | 134 | There is no "scoped slot" feature in Vue 1.
135 | Use a "normal slot" with `data-` attribute instead. 136 | 137 | ```vue 138 | 139 | 143 | ``` 144 | 145 | ### Custom Tags 146 | 147 | This gives you the option of changing the style of inserted tagged items. It is only supported for ContentEditable version, not Textarea. 148 | 149 | ```vue 150 | 151 | {{ s.current.name }} 152 | 153 | ``` 154 | -------------------------------------------------------------------------------- /vue-at/WechatIMG1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libin1991/vue-atuser/5f870efb54b40c89be0cbd1fee7260dd0d4dd355/vue-at/WechatIMG1.jpeg -------------------------------------------------------------------------------- /vue-at/WechatIMG2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libin1991/vue-atuser/5f870efb54b40c89be0cbd1fee7260dd0d4dd355/vue-at/WechatIMG2.jpeg -------------------------------------------------------------------------------- /vue-at/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-at 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /vue-at/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-at", 3 | "description": "At.js for Vue", 4 | "version": "2.5.0-beta.1", 5 | "author": "Fritz Lin ", 6 | "repository": "https://github.com/fritx/vue-at", 7 | "scripts": { 8 | "dev": "webpack-dev-server --config webpack/demo --open --inline --hot", 9 | "demo": "webpack --config webpack/demo --progress --hide-modules", 10 | "build": "webpack --config webpack/prod --progress --hide-modules", 11 | "prepublish": "npm run build" 12 | }, 13 | "main": "dist/vue-at.js", 14 | "dependencies": {}, 15 | "peerDependencies": { 16 | "vue": "2.x" 17 | }, 18 | "devDependencies": { 19 | "babel-core": "^6.0.0", 20 | "babel-loader": "^6.0.0", 21 | "babel-preset-es2015": "^6.0.0", 22 | "babel-preset-stage-0": "^6.16.0", 23 | "cross-env": "^3.0.0", 24 | "css-loader": "^0.25.0", 25 | "file-loader": "^0.9.0", 26 | "node-sass": "^4.0.0", 27 | "sass-loader": "^4.1.0", 28 | "textarea-caret": "^3.0.2", 29 | "uglifyjs-webpack-plugin": "^1.2.2", 30 | "vue": "^2.1.0", 31 | "vue-loader": "^10.3.0", 32 | "vue-style-loader": "^1.0.0", 33 | "vue-template-compiler": "^2.2.4", 34 | "webpack": "^2.1.0-beta.25", 35 | "webpack-dev-server": "^2.1.0-beta.9" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /vue-at/src/App.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 116 | 117 | 168 | -------------------------------------------------------------------------------- /vue-at/src/At.scss: -------------------------------------------------------------------------------- 1 | // atwho.css https://github.com/ichord/At.js 2 | .atwho-view { 3 | // position:absolute; 4 | // top: 0; 5 | // left: 0; 6 | // display: none; 7 | // margin-top: 18px; 8 | // background: white; 9 | color: black; 10 | // border: 1px solid #DDD; 11 | border-radius: 3px; 12 | box-shadow: 0 0 5px rgba(0,0,0,0.1); 13 | min-width: 120px; 14 | z-index: 11110 !important; 15 | } 16 | .atwho-ul { 17 | /* width: 100px; */ 18 | list-style:none; 19 | // padding:0; 20 | // margin:auto; 21 | // max-height: 200px; 22 | // overflow-y: auto; 23 | } 24 | .atwho-li { 25 | display: block; 26 | // padding: 5px 10px; 27 | // border-bottom: 1px solid #DDD; 28 | // cursor: pointer; 29 | /* border-top: 1px solid #C8C8C8; */ 30 | } 31 | 32 | ////// added 1 33 | .atwho-view { 34 | // font-size: 14px; 35 | // min-width: 140px; 36 | // max-width: 180px; 37 | border-radius: 6px; 38 | // overflow: hidden; 39 | box-shadow: 0 0 10px 0 rgba(101, 111, 122, .5); 40 | } 41 | .atwho-ul { 42 | max-height: 135px; 43 | padding: 0; 44 | margin: 0; 45 | } 46 | .atwho-li { 47 | box-sizing: border-box; 48 | height: 27px; 49 | padding: 0 12px; 50 | white-space: nowrap; 51 | display: flex; 52 | align-items: center; 53 | span { 54 | overflow: hidden; 55 | text-overflow: ellipsis; 56 | } 57 | } 58 | .atwho-cur { 59 | // background: #44a8f2; 60 | background: #5BB8FF; 61 | color: white; 62 | } 63 | 64 | ////// added 2 65 | .atwho-wrap { 66 | position: relative; 67 | } 68 | .atwho-panel { 69 | position: absolute; 70 | } 71 | .atwho-inner { 72 | position: relative; 73 | } 74 | .atwho-view { 75 | position: absolute; 76 | bottom: 0; 77 | left: -0.8em; // 抵消左边距 78 | cursor: default; 79 | background-color: rgba(255,255,255,.94); 80 | min-width: 140px; 81 | max-width: 180px; 82 | max-height: 200px; 83 | overflow-y: auto; 84 | &::-webkit-scrollbar { 85 | width: 11px; 86 | height: 11px; 87 | } 88 | &::-webkit-scrollbar-track { 89 | // background-color: rgba(127, 127, 127, .1); 90 | background-color: #F5F5F5; 91 | } 92 | &::-webkit-scrollbar-thumb { 93 | min-height: 36px; 94 | border: 2px solid transparent; 95 | border-top: 3px solid transparent; 96 | border-bottom: 3px solid transparent; 97 | background-clip: padding-box; 98 | border-radius: 7px; 99 | // background-color: rgba(0, 0, 0, 0.2); 100 | background-color: #C4C4C4; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /vue-at/src/At.vue: -------------------------------------------------------------------------------- 1 | 464 | -------------------------------------------------------------------------------- /vue-at/src/AtTemplate.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /vue-at/src/AtTextarea.vue: -------------------------------------------------------------------------------- 1 | 151 | -------------------------------------------------------------------------------- /vue-at/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | new Vue({ 5 | el: '#app', 6 | render: h => h(App) 7 | }) 8 | -------------------------------------------------------------------------------- /vue-at/src/util.js: -------------------------------------------------------------------------------- 1 | // bug report: https://github.com/vuejs/awesome-vue/pull/1028 2 | // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded 3 | export function scrollIntoView(el, scrollParent) { 4 | if (el.scrollIntoViewIfNeeded) { 5 | el.scrollIntoViewIfNeeded(false) // alignToCenter=false 6 | } else { 7 | // should not use `el.scrollIntoView(false)` // alignToTop=false 8 | // bug report: https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move 9 | const diff = el.offsetTop - scrollParent.scrollTop 10 | if (diff < 0 || diff > scrollParent.offsetHeight - el.offsetHeight) { 11 | scrollParent = scrollParent || el.parentElement 12 | scrollParent.scrollTop = el.offsetTop 13 | } 14 | } 15 | } 16 | 17 | export function applyRange(range) { 18 | const selection = window.getSelection() 19 | if (selection) { // 容错 20 | selection.removeAllRanges() 21 | selection.addRange(range) 22 | } 23 | } 24 | export function getRange() { 25 | const selection = window.getSelection() 26 | if (selection && selection.rangeCount > 0) { 27 | return selection.getRangeAt(0) 28 | } 29 | } 30 | 31 | export function getAtAndIndex(text, ats) { 32 | return ats.map((at) => { 33 | return { at, index: text.lastIndexOf(at) } 34 | }).reduce((a, b) => { 35 | return a.index > b.index ? a : b 36 | }) 37 | } 38 | 39 | /* eslint-disable */ 40 | // http://stackoverflow.com/questions/26747240/plain-javascript-replication-to-offset-and-position 41 | export function getOffset(element, target) { 42 | // var element = document.getElementById(element), 43 | // target = target ? document.getElementById(target) : window; 44 | target = target || window 45 | var offset = {top: element.offsetTop, left: element.offsetLeft}, 46 | parent = element.offsetParent; 47 | while (parent != null && parent != target) { 48 | offset.left += parent.offsetLeft; 49 | offset.top += parent.offsetTop; 50 | parent = parent.offsetParent; 51 | } 52 | return offset; 53 | } 54 | // http://stackoverflow.com/questions/3972014/get-caret-position-in-contenteditable-div 55 | export function closest (el, predicate) { 56 | /* eslint-disable */ 57 | do if (predicate(el)) return el; 58 | while (el = el && el.parentNode); 59 | } 60 | // http://stackoverflow.com/questions/15157435/get-last-character-before-caret-position-in-javascript 61 | // 修复 "空格+表情+空格+@" range报错 应设(endContainer, 0) 62 | // stackoverflow上的这段代码有bug 63 | export function getPrecedingRange() { 64 | const r = getRange() 65 | if (r) { 66 | const range = r.cloneRange() 67 | range.collapse(true) 68 | // var el = closest(range.endContainer, d => d.contentEditable) 69 | // range.setStart(el, 0) 70 | range.setStart(range.endContainer, 0) 71 | return range 72 | } 73 | } 74 | /* eslint-enable */ 75 | -------------------------------------------------------------------------------- /vue-at/static/awesome.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /vue-at/static/electron.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vue-at/webpack/base.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | 4 | module.exports = { 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.vue$/, 9 | loader: 'vue-loader', 10 | options: { 11 | loaders: { 12 | // Since sass-loader (weirdly) has SCSS as its default parse mode, we map 13 | // the "scss" and "sass" values for the lang attribute to the right configs here. 14 | // other preprocessors should work out of the box, no loader config like this nessessary. 15 | 'scss': 'vue-style-loader!css-loader!sass-loader', 16 | 'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax' 17 | } 18 | // other vue-loader options go here 19 | } 20 | }, 21 | { 22 | test: /\.js$/, 23 | loader: 'babel-loader', 24 | exclude: /node_modules/ 25 | }, 26 | { 27 | test: /\.(png|jpg|gif|svg)$/, 28 | loader: 'file-loader', 29 | options: { 30 | name: '[name].[ext]?[hash]' 31 | } 32 | } 33 | ] 34 | }, 35 | resolve: { 36 | alias: { 37 | // https://vuejs.org/v2/guide/installation.html#Standalone-vs-Runtime-only-Build 38 | // 'vue$': 'vue/dist/vue.common.js' 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /vue-at/webpack/demo.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | var base = require('./base') 4 | var config = module.exports = Object.assign({}, base) 5 | 6 | const isProd = process.env.NODE_ENV === 'production' 7 | console.log('isProd', isProd) 8 | 9 | Object.assign(config, { 10 | entry: './src/main.js', 11 | output: { 12 | path: path.resolve(__dirname, '../dist'), 13 | publicPath: '/dist/', 14 | filename: 'demo.js' 15 | }, 16 | devServer: { 17 | historyApiFallback: true, 18 | noInfo: true 19 | }, 20 | devtool: isProd ? false : '#eval-source-map', 21 | plugins: [ 22 | new webpack.DefinePlugin({ 23 | 'process.env': { 24 | NODE_ENV: '"development"' 25 | } 26 | }), 27 | isProd 28 | ? new webpack.optimize.UglifyJsPlugin({ 29 | compress: { 30 | warnings: false 31 | } 32 | }) 33 | : { apply: () => {} } 34 | ] 35 | }) 36 | -------------------------------------------------------------------------------- /vue-at/webpack/prod.js: -------------------------------------------------------------------------------- 1 | var UglifyJsPlugin = require('uglifyjs-webpack-plugin') 2 | var path = require('path') 3 | var webpack = require('webpack') 4 | var base = require('./base') 5 | var config = module.exports = Object.assign({}, base) 6 | 7 | Object.assign(config, { 8 | entry: { 9 | 'vue-at': './src/At.vue', 10 | 'vue-at-textarea': './src/AtTextarea.vue' 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, '../dist'), 14 | filename: '[name].js', 15 | libraryTarget: 'commonjs2' 16 | }, 17 | devtool: '#source-map', 18 | plugins: [ 19 | new webpack.DefinePlugin({ 20 | 'process.env': { 21 | NODE_ENV: '"production"' 22 | } 23 | }), 24 | new webpack.LoaderOptionsPlugin({ 25 | minimize: true 26 | }), 27 | new webpack.ExternalsPlugin('commonjs2', [ 28 | 'vue', 29 | 'textarea-caret' 30 | ]), 31 | // todo: upgrade webpack to 3.x 32 | // switched to uglifyjs-webpack-plugin 33 | // https://github.com/vuejs-templates/webpack/blob/cd4d7d957c9af3d37092c79bf490b56b8d88b108/template/build/webpack.prod.conf.js#L37 34 | new UglifyJsPlugin({ 35 | uglifyOptions: { 36 | compress: { 37 | warnings: false 38 | } 39 | }, 40 | sourceMap: true, 41 | parallel: true 42 | }) 43 | // http://vue-loader.vuejs.org/en/workflow/production.html 44 | // new webpack.optimize.UglifyJsPlugin({ 45 | // sourceMap: true, 46 | // compress: { 47 | // warnings: false 48 | // } 49 | // }) 50 | ] 51 | }) 52 | -------------------------------------------------------------------------------- /vue-edit/atuser.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 393 | 394 | -------------------------------------------------------------------------------- /vue-edit/edit.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 80 | 81 | -------------------------------------------------------------------------------- /vue-edit/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /vue-edit/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './index.vue' 3 | import '@/assets/css/reset.css' 4 | 5 | new Vue({ 6 | render: h => h(App) 7 | }).$mount('#app') 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /vue-edit/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 35 | 36 | -------------------------------------------------------------------------------- /vue-edit/src/chooseImg.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 预览函数 3 | * 4 | * @param {*} dataUrl base64字符串 5 | * @param {*} cb 回调函数 6 | */ 7 | function toPreviewer (dataUrl, cb) { 8 | cb && cb(dataUrl) 9 | } 10 | 11 | /** 12 | * 图片压缩函数 13 | * 14 | * @param {*} img 图片对象 15 | * @param {*} fileType 图片类型 16 | * @param {*} maxWidth 图片最大宽度 17 | * @returns base64字符串 18 | */ 19 | function compress (img, fileType, maxWidth) { 20 | let canvas = document.createElement('canvas') 21 | let ctx = canvas.getContext('2d') 22 | 23 | const proportion = img.width / img.height 24 | const width = maxWidth 25 | const height = maxWidth / proportion 26 | 27 | canvas.width = width 28 | canvas.height = height 29 | 30 | ctx.fillStyle = '#fff' 31 | ctx.fillRect(0, 0, canvas.width, canvas.height) 32 | ctx.drawImage(img, 0, 0, width, height) 33 | 34 | const base64data = canvas.toDataURL(fileType, 0.75) 35 | canvas = ctx = null 36 | 37 | return base64data 38 | } 39 | 40 | /** 41 | * 选择图片函数 42 | * 43 | * @param {*} e input.onchange事件对象 44 | * @param {*} cb 回调函数 45 | * @param {number} [maxsize=200 * 1024] 图片最大体积 46 | */ 47 | function chooseImg (e, cb, maxsize = 200 * 1024) { 48 | const file = e.target.files[0] 49 | 50 | if (!file || !/\/(?:jpeg|jpg|png)/i.test(file.type)) { 51 | return 52 | } 53 | 54 | const reader = new FileReader() 55 | reader.onload = function () { 56 | const result = this.result 57 | let img = new Image() 58 | 59 | if (result.length <= maxsize) { 60 | toPreviewer(result, cb) 61 | return 62 | } 63 | 64 | img.onload = function () { 65 | const compressedDataUrl = compress(img, file.type, maxsize / 1024) 66 | toPreviewer(compressedDataUrl, cb) 67 | img = null 68 | } 69 | 70 | img.src = result 71 | } 72 | 73 | reader.readAsDataURL(file) 74 | } 75 | 76 | export default chooseImg 77 | -------------------------------------------------------------------------------- /vue-edit/src/cursorPosition.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 获取光标位置 4 | * @param {DOMElement} element 输入框的dom节点 5 | * @return {Number} 光标位置 6 | */ 7 | export const getCursorPosition = (element) => { 8 | let caretOffset = 0 9 | const doc = element.ownerDocument || element.document 10 | const win = doc.defaultView || doc.parentWindow 11 | const sel = win.getSelection() 12 | if (sel.rangeCount > 0) { 13 | const range = win.getSelection().getRangeAt(0) 14 | const preCaretRange = range.cloneRange() 15 | preCaretRange.selectNodeContents(element) 16 | preCaretRange.setEnd(range.endContainer, range.endOffset) 17 | caretOffset = preCaretRange.toString().length 18 | } 19 | return caretOffset 20 | } 21 | 22 | /** 23 | * 设置光标位置 24 | * @param {DOMElement} element 输入框的dom节点 25 | * @param {Number} cursorPosition 光标位置的值 26 | */ 27 | export const setCursorPosition = (element, cursorPosition) => { 28 | const range = document.createRange() 29 | range.setStart(element.firstChild, cursorPosition) 30 | range.setEnd(element.firstChild, cursorPosition) 31 | const sel = window.getSelection() 32 | sel.removeAllRanges() 33 | sel.addRange(range) 34 | } 35 | -------------------------------------------------------------------------------- /vue-edit/src/emoji.js: -------------------------------------------------------------------------------- 1 | export default { 2 | smiles: '😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍'.split(' ') 3 | } 4 | -------------------------------------------------------------------------------- /vue-edit/src/paste.js: -------------------------------------------------------------------------------- 1 | import chooseImg from './chooseImg.js' 2 | 3 | const onPaste = (e) => { 4 | if (!(e.clipboardData && e.clipboardData.items)) { 5 | return 6 | } 7 | return new Promise((resolve, reject) => { 8 | for (let i = 0, len = e.clipboardData.items.length; i < len; i++) { 9 | const item = e.clipboardData.items[i] 10 | if (item.kind === 'string') { 11 | item.getAsString((str) => { 12 | resolve(str) 13 | }) 14 | } else if (item.kind === 'file') { 15 | const pasteFile = item.getAsFile() 16 | const imgEvent = { 17 | target: { 18 | files: [pasteFile] 19 | } 20 | } 21 | chooseImg(imgEvent, (url) => { 22 | resolve(url) 23 | }) 24 | } else { 25 | reject(new Error('Not allow to paste this type!')) 26 | } 27 | } 28 | }) 29 | } 30 | 31 | export default onPaste 32 | -------------------------------------------------------------------------------- /vue-edit/复制图片测试.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /聊天底部.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 将滚动条(scrollbar)保持在最底部的方法 - 滚动条, scrollbar, 页面底部, 聊天窗口, 10 | 11 | 12 | 13 |
14 |

将滚动条(scrollbar)保持在最底部的方法

15 |
16 | 17 | 25 | 请点击“插入一行”按钮,插入最新信息,当出现滚动条时,滚动条将自动保持在底部。
26 | 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /聊天底部2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 将滚动条(scrollbar)保持在最底部的方法 - 滚动条, scrollbar, 页面底部, 聊天窗口, 10 | 11 | 12 | 13 |
14 |

将滚动条(scrollbar)保持在最底部的方法

15 |
16 | 请点击“插入一行”按钮,插入最新信息,当出现滚动条时,滚动条将自动保持在底部。
17 |
18 |
19 |
20 |
21 | 22 | 23 | 31 |
32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | --------------------------------------------------------------------------------