├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── index.html ├── rollup.config.js ├── src ├── App.vue ├── components │ └── Clamp.js ├── main.js └── vue-lang.js └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parserOptions: { 6 | parser: 'babel-eslint', 7 | sourceType: 'module' 8 | }, 9 | extends: [ 10 | // https://github.com/vuejs/eslint-plugin-vue#bulb-rules 11 | 'plugin:vue/essential', 12 | 'plugin:vue/recommended', 13 | 'plugin:vue/strongly-recommended', 14 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 15 | 'standard', 16 | 'prettier/standard', 17 | 'prettier/vue' 18 | ], 19 | // required to lint *.vue files 20 | plugins: ['vue'], 21 | // add your custom rules here 22 | rules: { 23 | // allow paren-less arrow functions 24 | 'arrow-parens': 0, 25 | // allow async-await 26 | 'generator-star-spacing': 0, 27 | // allow debugger during development 28 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 29 | 'no-multi-spaces': ['error', { ignoreEOLComments: true }], 30 | 'no-template-curly-in-string': 0, 31 | // to many false positives 32 | 'vue/no-side-effects-in-computed-properties': 0, 33 | // fix unused var error for JSX custom tags 34 | 'vue/jsx-uses-vars': 2, 35 | 'vue/require-default-prop': 0, 36 | 'vue/name-property-casing': ['error', 'kebab-case'], 37 | 'vue/component-name-in-template-casing': ['error', 'kebab-case'], 38 | 'vue/html-indent': [ 39 | 'error', 40 | 2, 41 | { 42 | attribute: 1, 43 | baseIndent: 0, 44 | closeBracket: 0, 45 | alignAttributesVertically: true 46 | } 47 | ], 48 | 'vue/html-self-closing': [ 49 | 'error', 50 | { 51 | html: { 52 | void: 'never', 53 | normal: 'always', 54 | component: 'always' 55 | }, 56 | svg: 'always', 57 | math: 'always' 58 | } 59 | ], 60 | 'vue/html-closing-bracket-spacing': [ 61 | 'error', 62 | { 63 | startTag: 'never', 64 | endTag: 'never', 65 | selfClosingTag: 'never' 66 | } 67 | ], 68 | 'vue/no-v-html': 0 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /demo 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw* 23 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.config.js 3 | /demo 4 | /public 5 | /src 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.4.1 2 | 3 | * Fix clamped text for `location="start"`. 4 | * Text content is now always be trimmed. 5 | 6 | ## 0.4.0 7 | 8 | * Add `location` prop. (#66) 9 | * Add ESM output and no longer require users to transpile. 10 | * Externalize `resize-detector` to reduce bundle size. 11 | 12 | ## 0.3.2 13 | 14 | * Fix the problem caused by array spread. (#47) 15 | 16 | ## 0.3.1 17 | 18 | * Fix SSR support. 19 | * Fix RTL support. 20 | 21 | ## 0.3.0 22 | 23 | * Add `clampchange` event. 24 | 25 | ## 0.2.2 26 | 27 | * Preserve at lease a single line of content when even a single line would exceeds `max-height`. 28 | 29 | ## 0.2.1 30 | 31 | * Update layout when clamp status has been changed. 32 | 33 | ## 0.2.0 34 | 35 | * Add `clamped: boolean` and `expanded: boolean` to scoped slot `before`/`after`. 36 | * Fix content extraction. 37 | 38 | ## 0.1.0 39 | 40 | * First release. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 GU Yiling 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <vue-clamp> 2 | 3 | Clamping multiline text with ease. 4 | 5 | See more in our [docs & demo](https://vue-clamp.vercel.app). 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-clamp", 3 | "version": "0.4.1", 4 | "description": "Clamping multiline text with ease.", 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "NODE_ENV=production rollup -c", 8 | "lint": "vue-cli-service lint", 9 | "build:demo": "vue-cli-service build", 10 | "prepublishOnly": "npm run build" 11 | }, 12 | "main": "dist/vue-clamp.js", 13 | "module": "dist/vue-clamp.esm.js", 14 | "dependencies": { 15 | "resize-detector": "^0.3.0" 16 | }, 17 | "devDependencies": { 18 | "@rollup/plugin-buble": "^0.21.3", 19 | "@rollup/plugin-node-resolve": "^7.1.3", 20 | "@vue/cli-plugin-babel": "^4.5.13", 21 | "@vue/cli-plugin-eslint": "^4.5.13", 22 | "@vue/cli-service": "^4.4.6", 23 | "@vue/eslint-config-standard": "^4.0.0", 24 | "babel-eslint": "^10.1.0", 25 | "core-js": "^3.6.5", 26 | "eslint": "^6.8.0", 27 | "eslint-config-prettier": "^6.11.0", 28 | "eslint-config-standard": "^14.1.1", 29 | "eslint-plugin-vue": "^6.2.2", 30 | "highlight.js": "^9.18.3", 31 | "prettier": "^2.0.5", 32 | "prettier-eslint": "^9.0.2", 33 | "qs": "^6.9.4", 34 | "rollup": "^2.23.0", 35 | "rollup-plugin-terser": "^5.3.0", 36 | "rollup-plugin-vue": "^5.1.9", 37 | "spectre.css": "^0.5.9", 38 | "stylus": "^0.54.8", 39 | "stylus-loader": "^3.0.2", 40 | "vue": "^2.6.11", 41 | "vue-template-compiler": "^2.6.11" 42 | }, 43 | "peerDependencies": { 44 | "vue": "^2.5.17" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/Justineo/vue-clamp/issues" 48 | }, 49 | "homepage": "https://justineo.github.io/vue-clamp/demo/", 50 | "license": "MIT", 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/Justineo/vue-clamp.git" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Justineo/vue-clamp/cc04a308e060cd5f230b23b7569cc07707112e4b/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | vue-clamp 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import vue from 'rollup-plugin-vue' 2 | import buble from '@rollup/plugin-buble' 3 | import { terser } from 'rollup-plugin-terser' 4 | import resolve from '@rollup/plugin-node-resolve' 5 | 6 | export default [{ 7 | input: 'src/components/Clamp.js', 8 | output: { 9 | file: 'dist/vue-clamp.js', 10 | name: 'VueClamp', 11 | format: 'umd', 12 | globals: { 13 | vue: 'Vue', 14 | 'resize-detector': 'resizeDetector' 15 | } 16 | }, 17 | external: [ 18 | 'vue', 19 | 'resize-detector' 20 | ], 21 | plugins: [ 22 | resolve(), 23 | vue(), 24 | buble(), 25 | terser() 26 | ] 27 | }, { 28 | input: 'src/components/Clamp.js', 29 | output: { 30 | file: 'dist/vue-clamp.esm.js', 31 | format: 'es' 32 | }, 33 | external: [ 34 | 'vue', 35 | 'resize-detector' 36 | ], 37 | plugins: [ 38 | resolve(), 39 | vue(), 40 | buble(), 41 | terser() 42 | ] 43 | }] 44 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 530 | 531 | 616 | 617 | 618 | 619 | 620 | 621 | 722 | -------------------------------------------------------------------------------- /src/components/Clamp.js: -------------------------------------------------------------------------------- 1 | import { addListener, removeListener } from 'resize-detector' 2 | 3 | export default { 4 | name: 'vue-clamp', 5 | props: { 6 | tag: { 7 | type: String, 8 | default: 'div' 9 | }, 10 | autoresize: { 11 | type: Boolean, 12 | default: false 13 | }, 14 | maxLines: Number, 15 | maxHeight: [String, Number], 16 | ellipsis: { 17 | type: String, 18 | default: '…' 19 | }, 20 | location: { 21 | type: String, 22 | default: 'end', 23 | validator (value) { 24 | return ['start', 'middle', 'end'].indexOf(value) !== -1 25 | } 26 | }, 27 | expanded: Boolean 28 | }, 29 | data () { 30 | return { 31 | offset: null, 32 | text: this.getText(), 33 | localExpanded: !!this.expanded 34 | } 35 | }, 36 | computed: { 37 | clampedText () { 38 | if (this.location === 'start') { 39 | return this.ellipsis + (this.text.slice(-this.offset) || '').trim() 40 | } else if (this.location === 'middle') { 41 | const split = Math.floor(this.offset / 2) 42 | return (this.text.slice(0, split) || '').trim() + this.ellipsis + (this.text.slice(-split) || '').trim() 43 | } 44 | 45 | return (this.text.slice(0, this.offset) || '').trim() + this.ellipsis 46 | }, 47 | isClamped () { 48 | if (!this.text) { 49 | return false 50 | } 51 | return this.offset !== this.text.length 52 | }, 53 | realText () { 54 | return this.isClamped ? this.clampedText : this.text 55 | }, 56 | realMaxHeight () { 57 | if (this.localExpanded) { 58 | return null 59 | } 60 | const { maxHeight } = this 61 | if (!maxHeight) { 62 | return null 63 | } 64 | return typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight 65 | } 66 | }, 67 | watch: { 68 | expanded (val) { 69 | this.localExpanded = val 70 | }, 71 | localExpanded (val) { 72 | if (val) { 73 | this.clampAt(this.text.length) 74 | } else { 75 | this.update() 76 | } 77 | if (this.expanded !== val) { 78 | this.$emit('update:expanded', val) 79 | } 80 | }, 81 | isClamped: { 82 | handler (val) { 83 | this.$nextTick(() => this.$emit('clampchange', val)) 84 | }, 85 | immediate: true 86 | } 87 | }, 88 | mounted () { 89 | this.init() 90 | 91 | this.$watch( 92 | (vm) => [vm.maxLines, vm.maxHeight, vm.ellipsis, vm.isClamped, vm.location].join(), 93 | this.update 94 | ) 95 | this.$watch((vm) => [vm.tag, vm.text, vm.autoresize].join(), this.init) 96 | }, 97 | updated () { 98 | this.text = this.getText() 99 | this.applyChange() 100 | }, 101 | beforeDestroy () { 102 | this.cleanUp() 103 | }, 104 | methods: { 105 | init () { 106 | const contents = this.$slots.default 107 | if (!contents) { 108 | return 109 | } 110 | 111 | this.offset = this.text.length 112 | 113 | this.cleanUp() 114 | 115 | if (this.autoresize) { 116 | addListener(this.$el, this.update) 117 | this.unregisterResizeCallback = () => { 118 | removeListener(this.$el, this.update) 119 | } 120 | } 121 | this.update() 122 | }, 123 | update () { 124 | if (this.localExpanded) { 125 | return 126 | } 127 | this.applyChange() 128 | if (this.isOverflow() || this.isClamped) { 129 | this.search() 130 | } 131 | }, 132 | expand () { 133 | this.localExpanded = true 134 | }, 135 | collapse () { 136 | this.localExpanded = false 137 | }, 138 | toggle () { 139 | this.localExpanded = !this.localExpanded 140 | }, 141 | getLines () { 142 | return Object.keys( 143 | Array.prototype.slice.call(this.$refs.content.getClientRects()).reduce( 144 | (prev, { top, bottom }) => { 145 | const key = `${top}/${bottom}` 146 | if (!prev[key]) { 147 | prev[key] = true 148 | } 149 | return prev 150 | }, 151 | {} 152 | ) 153 | ).length 154 | }, 155 | isOverflow () { 156 | if (!this.maxLines && !this.maxHeight) { 157 | return false 158 | } 159 | 160 | if (this.maxLines) { 161 | if (this.getLines() > this.maxLines) { 162 | return true 163 | } 164 | } 165 | 166 | if (this.maxHeight) { 167 | if (this.$el.scrollHeight > this.$el.offsetHeight) { 168 | return true 169 | } 170 | } 171 | return false 172 | }, 173 | getText () { 174 | // Look for the first non-empty text node 175 | const [content] = (this.$slots.default || []).filter( 176 | (node) => !node.tag && !node.isComment 177 | ) 178 | return content ? content.text.trim() : '' 179 | }, 180 | moveEdge (steps) { 181 | this.clampAt(this.offset + steps) 182 | }, 183 | clampAt (offset) { 184 | this.offset = offset 185 | this.applyChange() 186 | }, 187 | applyChange () { 188 | this.$refs.text.textContent = this.realText 189 | }, 190 | stepToFit () { 191 | this.fill() 192 | this.clamp() 193 | }, 194 | fill () { 195 | while ( 196 | (!this.isOverflow() || this.getLines() < 2) && 197 | this.offset < this.text.length 198 | ) { 199 | this.moveEdge(1) 200 | } 201 | }, 202 | clamp () { 203 | while (this.isOverflow() && this.getLines() > 1 && this.offset > 0) { 204 | this.moveEdge(-1) 205 | } 206 | }, 207 | search (...range) { 208 | const [from = 0, to = this.offset] = range 209 | if (to - from <= 3) { 210 | this.stepToFit() 211 | return 212 | } 213 | const target = Math.floor((to + from) / 2) 214 | this.clampAt(target) 215 | if (this.isOverflow()) { 216 | this.search(from, target) 217 | } else { 218 | this.search(target, to) 219 | } 220 | }, 221 | cleanUp () { 222 | if (this.unregisterResizeCallback) { 223 | this.unregisterResizeCallback() 224 | } 225 | } 226 | }, 227 | render (h) { 228 | const contents = [ 229 | h( 230 | 'span', 231 | this.$isServer 232 | ? {} 233 | : { 234 | ref: 'text', 235 | attrs: { 236 | 'aria-label': this.text.trim() 237 | } 238 | }, 239 | this.$isServer ? this.text : this.realText 240 | ) 241 | ] 242 | 243 | const { expand, collapse, toggle } = this 244 | const scope = { 245 | expand, 246 | collapse, 247 | toggle, 248 | clamped: this.isClamped, 249 | expanded: this.localExpanded 250 | } 251 | const before = this.$scopedSlots.before 252 | ? this.$scopedSlots.before(scope) 253 | : this.$slots.before 254 | if (before) { 255 | contents.unshift(...(Array.isArray(before) ? before : [before])) 256 | } 257 | const after = this.$scopedSlots.after 258 | ? this.$scopedSlots.after(scope) 259 | : this.$slots.after 260 | if (after) { 261 | contents.push(...(Array.isArray(after) ? after : [after])) 262 | } 263 | const lines = [ 264 | h( 265 | 'span', 266 | { 267 | style: { 268 | boxShadow: 'transparent 0 0' 269 | }, 270 | ref: 'content' 271 | }, 272 | contents 273 | ) 274 | ] 275 | return h( 276 | this.tag, 277 | { 278 | style: { 279 | maxHeight: this.realMaxHeight, 280 | overflow: 'hidden' 281 | } 282 | }, 283 | lines 284 | ) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | Vue.config.productionTip = false 5 | 6 | new Vue({ 7 | render: h => h(App) 8 | }).$mount('#app') 9 | -------------------------------------------------------------------------------- /src/vue-lang.js: -------------------------------------------------------------------------------- 1 | export default function (hljs) { 2 | const XML_IDENT_RE = '[A-Za-z0-9\\._:-]+' 3 | const TAG_INTERNALS = { 4 | endsWithParent: true, 5 | illegal: /`]+/ } 24 | ] 25 | } 26 | ] 27 | } 28 | ] 29 | } 30 | return { 31 | case_insensitive: true, 32 | contains: [ 33 | hljs.COMMENT('', { 34 | relevance: 10 35 | }), 36 | { 37 | className: 'tag', 38 | /* 39 | The lookahead pattern (?=...) ensures that 'begin' only matches 40 | '|$)', 45 | end: '>', 46 | keywords: { name: 'style' }, 47 | contains: [TAG_INTERNALS], 48 | starts: { 49 | end: '', 50 | returnEnd: true, 51 | subLanguage: ['css', 'less', 'scss', 'stylus'] 52 | } 53 | }, 54 | { 55 | className: 'tag', 56 | // See the comment in the