├── .babelrc
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .npmignore
├── .npmrc
├── .travis.yml
├── CHANGELOG.md
├── LICENSE
├── README-zh_CN.md
├── README.md
├── bin
└── skeleton.js
├── demo
└── index.js
├── package.json
├── rollup.config.js
├── src
├── index.js
├── insertSkeleton.js
├── openPage.js
├── saveFile.js
├── script
│ ├── constants.js
│ ├── dist
│ │ └── index.js
│ ├── handler
│ │ ├── a.js
│ │ ├── before.js
│ │ ├── button.js
│ │ ├── empty.js
│ │ ├── img.js
│ │ ├── index.js
│ │ ├── input.js
│ │ ├── list.js
│ │ ├── pseudo.js
│ │ ├── script.js
│ │ ├── style.js
│ │ ├── svg.js
│ │ └── text.js
│ ├── main.js
│ └── util.js
└── util.js
└── test
├── lib
└── index.test.js
└── mocha.opts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["env"]
3 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | demo
2 | dist
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-egg",
3 | "parserOptions": {
4 | "sourceType": "module",
5 | "ecmaFeatures": {
6 | "experimentalObjectRestSpread": true
7 | }
8 | },
9 | "globals": {
10 | "window": true,
11 | "document": true,
12 | "getComputedStyle": true
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.gitignore.io/api/vim,node,code,webstorm,sublimetext
2 |
3 | ### Project ###
4 | demo
5 |
6 | ### Code ###
7 | # Visual Studio Code - https://code.visualstudio.com/
8 | .settings/
9 | .vscode/
10 | tsconfig.json
11 | jsconfig.json
12 |
13 | ### Node ###
14 | # Logs
15 | logs
16 | *.log
17 | npm-debug.log*
18 | yarn-debug.log*
19 | yarn-error.log*
20 |
21 | # Runtime data
22 | pids
23 | *.pid
24 | *.seed
25 | *.pid.lock
26 |
27 | # Directory for instrumented libs generated by jscoverage/JSCover
28 | lib-cov
29 |
30 | # Coverage directory used by tools like istanbul
31 | coverage
32 |
33 | # nyc test coverage
34 | .nyc_output
35 |
36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
37 | .grunt
38 |
39 | # Bower dependency directory (https://bower.io/)
40 | bower_components
41 |
42 | # node-waf configuration
43 | .lock-wscript
44 |
45 | # Compiled binary addons (https://nodejs.org/api/addons.html)
46 | build/Release
47 |
48 | # Dependency directories
49 | node_modules/
50 | jspm_packages/
51 |
52 | # TypeScript v1 declaration files
53 | typings/
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Optional REPL history
62 | .node_repl_history
63 |
64 | # Output of 'npm pack'
65 | *.tgz
66 |
67 | # Yarn Integrity file
68 | .yarn-integrity
69 |
70 | # dotenv environment variables file
71 | .env
72 |
73 | # parcel-bundler cache (https://parceljs.org/)
74 | .cache
75 |
76 | # next.js build output
77 | .next
78 |
79 | # nuxt.js build output
80 | .nuxt
81 |
82 | # vuepress build output
83 | .vuepress/dist
84 |
85 | # Serverless directories
86 | .serverless
87 |
88 | ### SublimeText ###
89 | # Cache files for Sublime Text
90 | *.tmlanguage.cache
91 | *.tmPreferences.cache
92 | *.stTheme.cache
93 |
94 | # Workspace files are user-specific
95 | *.sublime-workspace
96 |
97 | # Project files should be checked into the repository, unless a significant
98 | # proportion of contributors will probably not be using Sublime Text
99 | # *.sublime-project
100 |
101 | # SFTP configuration file
102 | sftp-config.json
103 |
104 | # Package control specific files
105 | Package Control.last-run
106 | Package Control.ca-list
107 | Package Control.ca-bundle
108 | Package Control.system-ca-bundle
109 | Package Control.cache/
110 | Package Control.ca-certs/
111 | Package Control.merged-ca-bundle
112 | Package Control.user-ca-bundle
113 | oscrypto-ca-bundle.crt
114 | bh_unicode_properties.cache
115 |
116 | # Sublime-github package stores a github token in this file
117 | # https://packagecontrol.io/packages/sublime-github
118 | GitHub.sublime-settings
119 |
120 | ### Vim ###
121 | # Swap
122 | [._]*.s[a-v][a-z]
123 | [._]*.sw[a-p]
124 | [._]s[a-rt-v][a-z]
125 | [._]ss[a-gi-z]
126 | [._]sw[a-p]
127 |
128 | # Session
129 | Session.vim
130 |
131 | # Temporary
132 | .netrwhist
133 | *~
134 | # Auto-generated tag files
135 | tags
136 | # Persistent undo
137 | [._]*.un~
138 |
139 | ### WebStorm ###
140 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
141 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
142 |
143 | # User-specific stuff
144 | .idea
145 | .idea/**/workspace.xml
146 | .idea/**/tasks.xml
147 | .idea/**/usage.statistics.xml
148 | .idea/**/dictionaries
149 | .idea/**/shelf
150 |
151 | # Generated files
152 | .idea/**/contentModel.xml
153 |
154 | # Sensitive or high-churn files
155 | .idea/**/dataSources/
156 | .idea/**/dataSources.ids
157 | .idea/**/dataSources.local.xml
158 | .idea/**/sqlDataSources.xml
159 | .idea/**/dynamic.xml
160 | .idea/**/uiDesigner.xml
161 | .idea/**/dbnavigator.xml
162 |
163 | # Gradle
164 | .idea/**/gradle.xml
165 | .idea/**/libraries
166 |
167 | # Gradle and Maven with auto-import
168 | # When using Gradle or Maven with auto-import, you should exclude module files,
169 | # since they will be recreated, and may cause churn. Uncomment if using
170 | # auto-import.
171 | # .idea/modules.xml
172 | # .idea/*.iml
173 | # .idea/modules
174 |
175 | # CMake
176 | cmake-build-*/
177 |
178 | # Mongo Explorer plugin
179 | .idea/**/mongoSettings.xml
180 |
181 | # File-based project format
182 | *.iws
183 |
184 | # IntelliJ
185 | out/
186 |
187 | # mpeltonen/sbt-idea plugin
188 | .idea_modules/
189 |
190 | # JIRA plugin
191 | atlassian-ide-plugin.xml
192 |
193 | # Cursive Clojure plugin
194 | .idea/replstate.xml
195 |
196 | # Crashlytics plugin (for Android Studio and IntelliJ)
197 | com_crashlytics_export_strings.xml
198 | crashlytics.properties
199 | crashlytics-build.properties
200 | fabric.properties
201 |
202 | # Editor-based Rest Client
203 | .idea/httpRequests
204 |
205 | # Android studio 3.1+ serialized cache file
206 | .idea/caches/build_file_checksums.ser
207 |
208 | ### WebStorm Patch ###
209 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
210 |
211 | # *.iml
212 | # modules.xml
213 | # .idea/misc.xml
214 | # *.ipr
215 |
216 | # Sonarlint plugin
217 | .idea/sonarlint
218 |
219 |
220 | # End of https://www.gitignore.io/api/vim,node,code,webstorm,sublimetext
221 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .*
2 | test/
3 | coverage/
4 | docs/
5 | docs_dist/
6 | logs/
7 | demo/
8 | *.md
9 | *.tgz
10 | *.yml
11 | *.sw*
12 | *.un~
13 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js:
4 | - '8'
5 | script:
6 | - npm run ci
7 | after_success:
8 | - npm i codecov && codecov
9 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 | ## [0.1.3](https://github.com/kaola-fed/awesome-skeleton/compare/v0.1.2...v0.1.3) (2019-09-23)
3 |
4 |
5 | ### Features
6 |
7 | * modify onload destory time ([ecb8b32](https://github.com/kaola-fed/awesome-skeleton/commit/ecb8b32))
8 |
9 |
10 |
11 |
12 | ## [0.1.2](https://github.com/kaola-fed/awesome-skeleton/compare/v0.1.1...v0.1.2) (2019-09-19)
13 |
14 |
15 | ### Features
16 |
17 | * remove delayTime param; handle dl dd dt list item ([7a827ca](https://github.com/kaola-fed/awesome-skeleton/commit/7a827ca))
18 | * add ci ([5b64be6](https://github.com/kaola-fed/awesome-skeleton/commit/5b64be6))
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 zivyangll
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-zh_CN.md:
--------------------------------------------------------------------------------
1 | # awesome-skeleton
2 |
3 | [![NPM version][npm-image]][npm-url]
4 | [![Build status][travis-image]][travis-url]
5 | [![Dependency status][daviddm-image]][daviddm-url]
6 |
7 | > 骨架屏生成工具
8 |
9 | [English](./README.md) | 简体中文
10 |
11 |
12 |
13 |
14 | ## Contributors
15 |
16 | |[
zivyangll](https://github.com/zivyangll)
|
17 | | :---: |
18 |
19 |
20 |
21 | ## 效果
22 |
23 | 查看线上效果:[考拉购物车](https://m-buy.kaola.com/cart.html)
24 |
25 | 
26 |
27 | ## 说明
28 | * 骨架图生成组件,仅限node端使用。该组件提供骨架图生成和骨架图模板注入两个能力。
29 | * 骨架图生成逻辑:通过传入页面地址,使用 Puppeteer 无头浏览器打开页面地址,对页面首屏图片和文本等节点进行灰色背景处理,然后对页面首屏进行截图,生成压缩后的 base64 png 图片。
30 |
31 | ## 安装
32 |
33 | ### 全局安装
34 |
35 | ```bash
36 | $ npm i awesome-skeleton -g
37 | ```
38 |
39 | ### 项目中安装
40 | ```bash
41 | $ npm i awesome-skeleton -D
42 | ```
43 |
44 | ## 使用方法
45 |
46 | ### 添加配置文件
47 |
48 | skeleton.config.json:
49 |
50 | ```json
51 | {
52 | "pageName": "baidu",
53 | "pageUrl": "https://www.baidu.com",
54 | "openRepeatList": false,
55 | "device": "iPhone X",
56 | "minGrayBlockWidth": 80,
57 | "minGrayPseudoWidth": 10,
58 | "debug": true,
59 | "debugTime": 3000,
60 | "cookies": [
61 | {
62 | "domain": ".baidu.com",
63 | "expirationDate": 1568267131.555328,
64 | "hostOnly": false,
65 | "httpOnly": false,
66 | "name": "BDORZ",
67 | "path": "/",
68 | "sameSite": "unspecified",
69 | "secure": false,
70 | "session": false,
71 | "storeId": "0",
72 | "value": "yyyyyyyyy",
73 | "id": 2
74 | }
75 | ]
76 | }
77 | ```
78 |
79 | ### 全局生成骨架屏
80 |
81 | ```bash
82 | $ skeleton -c ./skeleton.config.json
83 | ```
84 |
85 | 页面 DomReady 之后,会在页面顶部出现红色按钮:开始生成骨架屏。
86 |
87 | 生成完成后,会在运行目录生成 skeleton-output 文件件,里面包括骨架屏 png 图片、base64 文本、html 文件:
88 | - base64-baidu.png # 骨架图图片
89 | - base64-baidu.txt # 骨架图 Base64 编码
90 | - base64-baidu.html # 最终生成 HTML
91 |
92 | 其中 html 文件可以直接拿来用,复制下面位置:
93 |
94 | ```html
95 |
96 |
97 |
98 |
99 |
100 | ```
101 |
102 | 注意:
103 | - 骨架图默认在 onload 事件后销毁。
104 | - 手动销毁方式:
105 |
106 | ```js
107 | window.SKELETON && SKELETON.destroy();
108 | ```
109 |
110 | **当然,你也可以在项目中直接使用生成的 Base64 图片**
111 |
112 | ### 项目中生成骨架屏
113 |
114 | 在 package.json 中添加脚本:
115 |
116 | ```json
117 | "scripts": {
118 | "skeleton": "skeleton -c ./skeleton.config.json"
119 | }
120 | ```
121 |
122 | 生成骨架屏:
123 |
124 | ```bash
125 | $ npm run skeleton
126 | ```
127 |
128 | ### 解决登录态
129 |
130 | 如果页面需要登录,则需要下载 Chrome 插件 [EditThisCookie](https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg),将 Cookie 复制到配置参数中。
131 |
132 | ## 参数
133 |
134 | | 参数名称 | 必填 | 默认值 | 说明 |
135 | | --- | --- | --- | --- |
136 | | pageUrl | 是 | - | 页面地址(此地址必须可访问) |
137 | | pageName | 否 | output | 页面名称(仅限英文) |
138 | | cookies | 否 | | 页面 Cookies,用来解决登录态问题 |
139 | | outputPath | 否 | skeleton-output | 骨架图文件输出文件夹路径,默认到项目 skeleton-output 中 |
140 | | openRepeatList | 否 | true | 默认会将每个列表的第一项进行复制 |
141 | | device | 否 | 空为PC | 参考 puppeteer/DeviceDescriptors.js,可以设置为 'iPhone 6 Plus' |
142 | | debug | 否 | false | 是否开启调试开关 |
143 | | debugTime | 否 | 0 | 调试模式下,页面停留在骨架图的时间 |
144 | | minGrayBlockWidth | 否 | 0 | 最小处理灰色块的宽度 |
145 | | minGrayPseudoWidth | 否 | 0 | 最小处理伪类宽 |
146 |
147 | ## dom 节点属性
148 |
149 | 这是获取优质骨架图的要点,通过设置以下几个 dom 节点属性,在骨架图中对某些节点进行移除、忽略和指定背景色的操作,去除冗余节点的干扰,从而使得骨架图效果达到最佳。
150 |
151 | | 参数名称 | 说明 |
152 | | --- | --- |
153 | | data-skeleton-remove | 指定进行移除的 dom 节点属性 |
154 | | data-skeleton-bgcolor | 指定在某 dom 节点中添加的背景色 |
155 | | data-skeleton-ignore | 指定忽略不进行任何处理的 dom 节点属性 |
156 | | data-skeleton-empty | 将某dom的innerHTML置为空字符串 |
157 |
158 | 示例:
159 |
160 | ```html
161 | abc
162 | abc
163 | abc
164 | abc
165 | ```
166 |
167 | ## 本地开发
168 |
169 | ### 安装依赖
170 |
171 | ```bash
172 | $ git clone git@github.com:kaola-fed/awesome-skeleton.git
173 | $ cd awesome-skeleton && npm i
174 | ```
175 |
176 | ### 运行项目
177 |
178 | 由于生成骨架图的代码是通过动态脚本插入的,所以需要通过 rollup 将 src/script 中的代码打包到 src/script/dist/index.js 中。首先启动 rollup 打包
179 |
180 | ```bash
181 | $ npm run dev
182 | ```
183 |
184 | 修改 demo/index.js 中的配置,从而生成不同页面的骨架图:
185 |
186 | ```bash
187 | $ cd demo
188 | $ node index.js
189 | ```
190 |
191 | # 感谢
192 |
193 | - [puppeteer](https://github.com/GoogleChrome/puppeteer)
194 | - [page-skeleton-webpack-plugin](https://github.com/ElemeFE/page-skeleton-webpack-plugin)
195 |
196 | [npm-image]: https://img.shields.io/npm/v/awesome-skeleton.svg?style=flat-square&logo=npm
197 | [npm-url]: https://npmjs.org/package/awesome-skeleton
198 | [travis-image]: https://img.shields.io/travis/kaola-fed/awesome-skeleton/master.svg?style=flat-square&logo=travis
199 | [travis-url]: https://travis-ci.org/kaola-fed/awesome-skeleton
200 | [daviddm-image]: https://img.shields.io/david/kaola-fed/awesome-skeleton.svg?style=flat-square
201 | [daviddm-url]: https://david-dm.org/kaola-fed/awesome-skeleton
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # awesome-skeleton
2 |
3 | [![NPM version][npm-image]][npm-url]
4 | [![Build status][travis-image]][travis-url]
5 | [![Dependency status][daviddm-image]][daviddm-url]
6 |
7 | > Skeleton generation tool
8 |
9 | English | [简体中文](./README-zh_CN.md)
10 |
11 |
12 |
13 | ## Contributors
14 |
15 | |[
zivyangll](https://github.com/zivyangll)
|
16 | | :---: |
17 |
18 |
19 |
20 | ## Effect preview
21 |
22 | View online effects: [Kaola cart](https://m-buy.kaola.com/cart.html):
23 |
24 | 
25 |
26 | ## Description
27 | * skeleton generation component, only for the node side. This component provides two capabilities for skeleton generation and skeleton template injection.
28 | * Skeletal diagram generation logic: Open the page address by using the Puppeteer headless browser by passing in the page address, perform gray background processing on the first screen image and text of the page, and then take a screenshot of the first screen of the page to generate a compressed base64 png image.
29 |
30 | ## Installation
31 |
32 | ### Global installation
33 |
34 | ```bash
35 | $ npm i awesome-skeleton -g
36 | ```
37 |
38 | ### Installation in the project
39 | ```bash
40 | $ npm i awesome-skeleton -D
41 | ```
42 |
43 | ## Instructions
44 |
45 | ### Adding a configuration file
46 |
47 | skeleton.config.json:
48 |
49 | ```json
50 | {
51 | "pageName": "baidu",
52 | "pageUrl": "https://www.baidu.com",
53 | "openRepeatList": false,
54 | "device": "iPhone X",
55 | "minGrayBlockWidth": 80,
56 | "minGrayPseudoWidth": 10,
57 | "debug": true,
58 | "debugTime": 3000,
59 | "cookies": [
60 | {
61 | "domain": ".baidu.com",
62 | "expirationDate": 1568267131.555328,
63 | "hostOnly": false,
64 | "httpOnly": false,
65 | "name": "BDORZ",
66 | "path": "/",
67 | "sameSite": "unspecified",
68 | "secure": false,
69 | "session": false,
70 | "storeId": "0",
71 | "value": "yyyyyyyyy",
72 | "id": 2
73 | }
74 | ]
75 | }
76 | ```
77 |
78 | ### Globally generated skeleton
79 |
80 | ```bash
81 | $ skeleton -c ./skeleton.config.json
82 | ```
83 |
84 | After the page DomReady, a red button appears at the top of the page: Start generating the skeleton screen.
85 |
86 | After the build is complete, a skeleton-output file is generated in the run directory, which includes the skeleton screen png image, base64 text, and html file:
87 | - base64-baidu.png # skeleton picture
88 | - base64-baidu.txt # skeleton diagram Base64 encoding
89 | - base64-baidu.html # Final HTML generationL
90 |
91 | The html file can be used directly, copy the following location:
92 |
93 | ```html
94 |
95 |
96 |
97 |
98 |
99 | ```
100 |
101 | note:
102 | - The skeleton is destroyed by default after onload event.
103 | - Manual destruction method:
104 |
105 | ```js
106 | window.SKELETON && SKELETON.destroy();
107 | ```
108 |
109 | **Of course, you can also use the generated Base64 image directly in your project**
110 |
111 | ### Creating a skeleton screen in the project
112 |
113 | Add a script to package.json :
114 |
115 | ```
116 | "scripts": {
117 | "skeleton": "skeleton -c ./skeleton.config.json"
118 | }
119 | ```
120 |
121 | Generate skeleton screen::
122 |
123 | ```bash
124 | $ npm run skeleton
125 | ```
126 |
127 | ### Solve the login status
128 |
129 | If the page requires a login, you'll need to download the Chrome plugin [EditThisCookie] (https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg) to copy the cookie into the configuration parameters.
130 |
131 | ## Parameters
132 |
133 | | Parameter Name | Required | Default | Description |
134 | | --- | --- | --- | --- |
135 | | pageUrl | Yes | - | Page address (this address must be accessible) |
136 | | pageName | no | output | page name (English only) |
137 | | cookies | no | | page cookies to resolve login status issues |
138 | OutputPath | no | skeleton-output | skeleton file output folder path, default to project skeleton-output |
139 | | openRepeatList | no | true | by default will copy the first item of each list |
140 | | device | no | empty for PC | reference puppeteer/DeviceDescriptors.js, can be set to 'iPhone 6 Plus' |
141 | | debug | no | false | turn on debug switch |
142 | | debugTime | No | 0 | Time in the debug mode, the page stays in the skeleton |
143 | | minGrayBlockWidth | No | 0 | Minimum processing width of gray blocks |
144 | | minGrayPseudoWidth | No | 0 | Minimum processing pseudo-class width |
145 |
146 | ## dom node attribute
147 |
148 | This is the main point of obtaining a high-quality skeleton. By setting the following dom node attributes, some nodes are removed, ignored, and specified in the skeleton to remove the interference of redundant nodes, thus making the skeleton effect Get the best.
149 |
150 | | Parameter Name | Description |
151 | | --- | --- |
152 | | data-skeleton-remove | Specifies the dom node properties to remove |
153 | | data-skeleton-bgcolor | Specify the background color added in a dom node |
154 | | data-skeleton-ignore | Specifies to ignore dom node properties without any processing |
155 | | data-skeleton-empty | Set a dom's innerHTML to an empty string |
156 |
157 | Example:
158 |
159 | ```html
160 | abc
161 | abc
162 | abc
163 | abc
164 | ```
165 |
166 | ## development
167 |
168 | ### Installation dependencies
169 |
170 | ```bash
171 | $ git clone git@github.com:kaola-fed/awesome-skeleton.git
172 | $ cd awesome-skeleton && npm i
173 | ```
174 |
175 | ### Running the project
176 |
177 | Since the code that generates the skeleton is inserted through dynamic scripts, the code in src/script needs to be packaged into src/script/dist/index.js by Rollup.
178 |
179 | ```bash
180 | $ npm run dev
181 | ```
182 |
183 | Modify the configuration in demo/index.js to generate a skeleton of the different pages:
184 |
185 | ```bash
186 | $ cd demo
187 | $ node index.js
188 | ```
189 |
190 | # Thanks
191 |
192 | - [puppeteer](https://github.com/GoogleChrome/puppeteer)
193 | - [page-skeleton-webpack-plugin](https://github.com/ElemeFE/page-skeleton-webpack-plugin)
194 |
195 | [npm-image]: https://img.shields.io/npm/v/awesome-skeleton.svg?style=flat-square&logo=npm
196 | [npm-url]: https://npmjs.org/package/awesome-skeleton
197 | [travis-image]: https://img.shields.io/travis/kaola-fed/awesome-skeleton/master.svg?style=flat-square&logo=travis
198 | [travis-url]: https://travis-ci.org/kaola-fed/awesome-skeleton
199 | [daviddm-image]: https://img.shields.io/david/kaola-fed/awesome-skeleton.svg?style=flat-square
200 | [daviddm-url]: https://david-dm.org/kaola-fed/awesome-skeleton
201 |
--------------------------------------------------------------------------------
/bin/skeleton.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 |
4 | const {
5 | EOL,
6 | } = require('os');
7 | const path = require('path');
8 | const program = require('commander');
9 | const _ = require('xutil');
10 | const updateNotifier = require('update-notifier');
11 |
12 | const { chalk } = _;
13 | const getSkeleton = require('../src');
14 | const pkg = require('../package.json');
15 |
16 | updateNotifier({
17 | pkg,
18 | updateCheckInterval: 5000, // 5s
19 | }).notify();
20 |
21 |
22 | program
23 | .option('--verbose', 'show more debugging information')
24 | .option('-c, --config ', 'set configuration file')
25 | .parse(process.argv);
26 |
27 | let options = {};
28 |
29 | if (program.config) {
30 | const configFile = path.resolve(program.config);
31 |
32 | if (_.isExistedFile(configFile)) {
33 | console.log(`${EOL}configuration file: ${chalk.cyan(configFile)}`);
34 | options = Object.assign(options, require(configFile));
35 | }
36 | }
37 |
38 | (async () => {
39 | try {
40 | await getSkeleton(options);
41 |
42 | const resultDir = path.join(process.cwd(), 'skeleton-output');
43 | console.log('result files save in: ', chalk.cyan(resultDir));
44 | } catch (error) {
45 | console.log(chalk.red(`${EOL}awesome-skeleton start unsuccessfully: ${error}${EOL}`));
46 | return;
47 | }
48 | })();
49 |
50 |
--------------------------------------------------------------------------------
/demo/index.js:
--------------------------------------------------------------------------------
1 | const getSkeleton = require('../src/index');
2 |
3 | getSkeleton({
4 | pageName: 'baidu',
5 | pageUrl: 'https://www.baidu.com',
6 | openRepeatList: false,
7 | device: 'iPhone X', // 为空则使用默认 PC 页面打开
8 | minGrayBlockWidth: 80,
9 | minGrayPseudoWidth: 10,
10 | debug: true,
11 | debugTime: 3000,
12 | cookies: [{
13 | "domain": ".baidu.com",
14 | "expirationDate": 1568267131.555328,
15 | "hostOnly": false,
16 | "httpOnly": false,
17 | "name": "BDORZ",
18 | "path": "/",
19 | "sameSite": "unspecified",
20 | "secure": false,
21 | "session": false,
22 | "storeId": "0",
23 | "value": "yyyyyyyyy",
24 | "id": 2
25 | }],
26 | }).then(result => {
27 | console.log(result.html)
28 | })
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "awesome-skeleton",
3 | "version": "0.1.5",
4 | "description": "骨架图生成器",
5 | "author": "",
6 | "main": "src/index.js",
7 | "bin": {
8 | "skeleton": "bin/skeleton.js"
9 | },
10 | "scripts": {
11 | "dev": "rollup --config rollup.config.js --watch",
12 | "build": "rollup --config rollup.config.js",
13 | "lint": "npm run build && eslint . --fix",
14 | "test": "nyc mocha",
15 | "ci": "npm run lint && npm run test",
16 | "version": "conventional-changelog -p angular -i CHANGELOG.md -s && git add .",
17 | "contributor": "git-contributor",
18 | "prepublishOnly": "np --yolo --no-publish"
19 | },
20 | "dependencies": {
21 | "base64-img": "^1.0.4",
22 | "commander": "^3.0.1",
23 | "html-minifier": "^4.0.0",
24 | "images": "^3.0.2",
25 | "puppeteer": "^1.2.0",
26 | "update-notifier": "^3.0.1",
27 | "xutil": "^1.0.11"
28 | },
29 | "devDependencies": {
30 | "@commitlint/config-conventional": "^8.2.0",
31 | "commitlint": "^8.2.0",
32 | "conventional-changelog-cli": "^2.0.23",
33 | "eslint": "^6.3.0",
34 | "eslint-config-egg": "^7.5.1",
35 | "git-contributor": "^1.0.10",
36 | "husky": "^3.0.5",
37 | "lint-staged": "^9.2.5",
38 | "mocha": "^6.2.0",
39 | "nyc": "^14.1.1",
40 | "rollup": "^1.20.3"
41 | },
42 | "repository": {
43 | "type": "git",
44 | "url": "git@github.com:kaola-fed/awesome-skeleton.git"
45 | },
46 | "husky": {
47 | "hooks": {
48 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
49 | "pre-commit": "lint-staged"
50 | }
51 | },
52 | "lint-staged": {
53 | "*.js": [
54 | "eslint --fix"
55 | ]
56 | },
57 | "nyc": {
58 | "reporter": [
59 | "lcov",
60 | "text"
61 | ]
62 | },
63 | "commitlint": {
64 | "extends": [
65 | "@commitlint/config-conventional"
66 | ]
67 | },
68 | "bugs": {
69 | "url": "https://github.com/kaola-fed/awesome-skeleton/issues"
70 | },
71 | "engines": {
72 | "node": ">=8.9.0"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | input: 'src/script/main.js',
3 | output: {
4 | file: 'src/script/dist/index.js',
5 | format: 'iife',
6 | name: 'AwesomeSkeleton',
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | const {
5 | saveScreenShot,
6 | } = require('./saveFile');
7 | const openPage = require('./openPage');
8 | const insertSkeleton = require('./insertSkeleton');
9 |
10 | /**
11 | * Entry function
12 | * @param {Object} options Configuration
13 | * @param {String} options.pageName Page name (English only)
14 | * @param {String} options.pageUrl Page URL (must be accessible)
15 | * @param {String} options.outputPath Skeleton map file output folder path, output to the default project without filling
16 | * @param {Boolean} options.openRepeatList The first item of each list is copied by default, the default value is true
17 | * @param {Object} options.device reference puppeteer/DeviceDescriptors.js
18 | * @param {Number} options.minGrayBlockWidth Minimum processing width of gray blocks
19 | * @param {Number} options.minGrayPseudoWidth Minimum processing pseudo-class width
20 | * @param {Boolean} options.debug Whether to turn on debug mode
21 | * @param {Number} options.debugTime In debug mode, the time the page stays in the skeleton diagram
22 | */
23 | const getSkeleton = async function(options) {
24 | // Parameter check
25 | if (!options.pageUrl) {
26 | console.warn('页面地址不能为空!');
27 | return false;
28 | }
29 |
30 | // Set default parameters
31 | options.pageName = options.pageName ? options.pageName : 'output';
32 | options.outputPath = options.outputPath ? options.outputPath : path.join('skeleton-output');
33 |
34 | // Create directory if there is no output directory
35 | if (!fs.existsSync(options.outputPath)) {
36 | fs.mkdirSync(options.outputPath);
37 | }
38 |
39 | // Open URL
40 | const { page, browser } = await openPage(options);
41 |
42 | // Processing the page as a skeleton page
43 | await page.evaluate(async options => {
44 | await window.AwesomeSkeleton.genSkeleton(options);
45 | }, options);
46 |
47 | // Screenshot and save as png and base64 txt files
48 | const skeletonImageBase64 = await saveScreenShot(page, options);
49 |
50 | // Inject the skeleton into the desired page
51 | const result = insertSkeleton(skeletonImageBase64, options);
52 |
53 | // Close the browser
54 | await browser.close();
55 |
56 | return result;
57 | };
58 |
59 | module.exports = getSkeleton;
60 |
--------------------------------------------------------------------------------
/src/insertSkeleton.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const minify = require('html-minifier').minify;
4 |
5 | // Inject the skeleton into the template
6 | const insertSkeleton = (skeletonImageBase64, options) => {
7 | const skeletonHTMLPath = path.join(options.outputPath, `skeleton-${options.pageName}.html`);
8 |
9 | if (!skeletonImageBase64) {
10 | console.warn('The skeleton has not been generated yet');
11 | return false;
12 | }
13 |
14 | const skeletonClass = 'skeleton-remove-after-first-request';
15 |
16 | const content = `
17 |
30 |
42 |
55 | `;
73 |
74 | // Code compression
75 | const minifyContent = minify(content, {
76 | minifyCSS: true,
77 | minifyJS: true,
78 | removeComments: true,
79 | });
80 |
81 | // Write file
82 | fs.writeFileSync(skeletonHTMLPath, minifyContent, 'utf8', function(err) {
83 | if (err) return console.error(err);
84 | });
85 |
86 | return {
87 | minHtml: minifyContent,
88 | html: content,
89 | img: skeletonImageBase64,
90 | };
91 | };
92 |
93 | module.exports = insertSkeleton;
94 |
--------------------------------------------------------------------------------
/src/openPage.js:
--------------------------------------------------------------------------------
1 | const {
2 | sleep,
3 | genScriptContent,
4 | } = require('./util');
5 | const puppeteer = require('puppeteer');
6 | const devices = require('puppeteer/DeviceDescriptors');
7 |
8 | // puppeteer/DeviceDescriptors, If no device style, need to customize
9 | const desktopDevice = {
10 | name: 'Desktop 1920x1080',
11 | userAgent: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.75 Safari/537.36',
12 | viewport: {
13 | width: 1920,
14 | height: 1080,
15 | },
16 | };
17 |
18 | const openPage = async options => {
19 | const browser = await puppeteer.launch({
20 | headless: !options.debug,
21 | args: [ '--no-sandbox', '--disable-setuid-sandbox' ],
22 | });
23 | const page = await browser.newPage();
24 | const device = devices[options.device] || desktopDevice;
25 | await page.emulate(device);
26 |
27 | if (options.debug) {
28 | page.on('console', msg => console.log('PAGE LOG: ', msg.text()));
29 | page.on('warning', msg => console.log('PAGE WARN: ', JSON.stringify(msg)));
30 | page.on('error', msg => console.log('PAGE ERR: ', ...msg.args));
31 | }
32 |
33 | // Write cookies to solve the login status problem
34 | if (options.cookies && options.cookies.length) {
35 | await page.setCookie(...options.cookies);
36 | await page.cookies(options.pageUrl);
37 | await sleep(1000);
38 | }
39 |
40 | // open page
41 | await page.goto(options.pageUrl);
42 |
43 | // Get the packaged script in src/script
44 | const scriptContent = await genScriptContent();
45 |
46 | // Inject the script into the target page and get the global variable AwesomeSkeleton
47 | await page.addScriptTag({ content: scriptContent });
48 |
49 | // Waiting for page execution to complete
50 | await sleep(2000);
51 |
52 | return {
53 | page,
54 | browser,
55 | };
56 | };
57 |
58 | module.exports = openPage;
59 |
--------------------------------------------------------------------------------
/src/saveFile.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const images = require('images');
4 | const base64Img = require('base64-img');
5 |
6 | const saveScreenShot = async (page, options) => {
7 | const screenshotPath = path.join(options.outputPath, `skeleton-${options.pageName}.png`);
8 |
9 | // First screen skeleton screenshot
10 | await page.screenshot({
11 | path: screenshotPath,
12 | });
13 |
14 | const imgWidth = options.device ? 375 : 1920;
15 | // Use images for image compression
16 | await images(screenshotPath).size(imgWidth).save(screenshotPath);
17 |
18 | const skeletonImageBase64 = base64Img.base64Sync(screenshotPath);
19 |
20 | const skeletonBase64Path = options.outputPath ? path.join(options.outputPath, './base64-' + options.pageName + '.txt') : null;
21 | if (skeletonBase64Path) {
22 | // Write the skeleton base64 png to a txt file
23 | fs.writeFileSync(skeletonBase64Path, skeletonImageBase64, err => {
24 | if (err) throw err;
25 | console.log(`The base64-${options.pageName}.txt file has been saved in path '${options.outputPath}' !`);
26 | });
27 | }
28 | return skeletonImageBase64;
29 | };
30 |
31 | module.exports = {
32 | saveScreenShot,
33 | };
34 |
--------------------------------------------------------------------------------
/src/script/constants.js:
--------------------------------------------------------------------------------
1 | // Skeleton main color
2 | export const MAIN_COLOR = '#EEEEEE';
3 | export const MAIN_COLOR_RGB = 'rgb(238, 238, 238)';
4 |
5 | // Pseudo-class style
6 | export const PSEUDO_CLASS = 'sk-pseudo';
7 |
8 | // button style
9 | export const BUTTON_CLASS = 'sk-button';
10 |
11 | // Transparent style
12 | export const TRANSPARENT_CLASS = 'sk-transparent';
13 |
14 | // Transparent 1 pixel image
15 | export const SMALLEST_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
16 |
17 | // text class
18 | export const SKELETON_TEXT_CLASS = 'skeleton-text-block-mark';
19 |
20 | // List item Tag
21 | export const LIST_ITEM_TAG = [ 'LI', 'DT', 'DD' ];
22 |
--------------------------------------------------------------------------------
/src/script/dist/index.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | 'use strict';
3 |
4 | // sleep function
5 | const sleep = ms => {
6 | return new Promise(resolve => setTimeout(resolve, ms));
7 | };
8 |
9 | // Check if the node is in the first screen
10 | const inViewPort = ele => {
11 | try {
12 | const rect = ele.getBoundingClientRect();
13 | return rect.top < window.innerHeight &&
14 | rect.left < window.innerWidth;
15 |
16 | } catch (e) {
17 | return true;
18 | }
19 | };
20 |
21 | // Determine if the node has attributes
22 | const hasAttr = (ele, attr) => {
23 | try {
24 | return ele.hasAttribute(attr);
25 | } catch (e) {
26 | return false;
27 | }
28 | };
29 |
30 | // Set node transparency
31 | const setOpacity = ele => {
32 | if (ele.style) {
33 | ele.style.opacity = 0;
34 | }
35 | };
36 |
37 | // Unit conversion px -> rem
38 | const px2rem = px => {
39 | const pxValue = typeof px === 'string' ? parseInt(px, 10) : px;
40 | const htmlElementFontSize = getComputedStyle(document.documentElement).fontSize;
41 |
42 | return `${(pxValue / parseInt(htmlElementFontSize, 10))}rem`;
43 | };
44 |
45 | // Batch setting element properties
46 | const setAttributes = (ele, attrs) => {
47 | Object.keys(attrs).forEach(k => ele.setAttribute(k, attrs[k]));
48 | };
49 |
50 | // Delete element
51 | const removeElement = ele => {
52 | const parent = ele.parentNode;
53 | if (parent) {
54 | parent.removeChild(ele);
55 | }
56 | };
57 |
58 | // Check the element pseudo-class to return the corresponding element and width
59 | const checkHasPseudoEle = ele => {
60 | if (!ele) return false;
61 |
62 | const beforeComputedStyle = getComputedStyle(ele, '::before');
63 | const beforeContent = beforeComputedStyle.getPropertyValue('content');
64 | const beforeWidth = parseFloat(beforeComputedStyle.getPropertyValue('width'), 10) || 0;
65 | const hasBefore = beforeContent && beforeContent !== 'none';
66 |
67 | const afterComputedStyle = getComputedStyle(ele, '::after');
68 | const afterContent = afterComputedStyle.getPropertyValue('content');
69 | const afterWidth = parseFloat(afterComputedStyle.getPropertyValue('width'), 10) || 0;
70 | const hasAfter = afterContent && afterContent !== 'none';
71 |
72 | const width = Math.max(beforeWidth, afterWidth);
73 |
74 | if (hasBefore || hasAfter) {
75 | return { hasBefore, hasAfter, ele, width };
76 | }
77 | return false;
78 | };
79 |
80 | // Skeleton main color
81 | const MAIN_COLOR = '#EEEEEE';
82 | const MAIN_COLOR_RGB = 'rgb(238, 238, 238)';
83 |
84 | // Pseudo-class style
85 | const PSEUDO_CLASS = 'sk-pseudo';
86 |
87 | // button style
88 | const BUTTON_CLASS = 'sk-button';
89 |
90 | // Transparent style
91 | const TRANSPARENT_CLASS = 'sk-transparent';
92 |
93 | // Transparent 1 pixel image
94 | const SMALLEST_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
95 |
96 | // text class
97 | const SKELETON_TEXT_CLASS = 'skeleton-text-block-mark';
98 |
99 | // List item Tag
100 | const LIST_ITEM_TAG = [ 'LI', 'DT', 'DD' ];
101 |
102 | function aHandler(node) {
103 | node.removeAttribute('href');
104 | }
105 |
106 | function svgHandler(node) {
107 | const { width, height } = node.getBoundingClientRect();
108 |
109 | // Remove elements if they are not visible
110 | if (width === 0 || height === 0 || node.getAttribute('aria-hidden') === 'true') {
111 | return removeElement(node);
112 | }
113 |
114 | // Clear node centent
115 | node.innerHTML = '';
116 |
117 | // Set style
118 | Object.assign(node.style, {
119 | width: px2rem(parseInt(width)),
120 | height: px2rem(parseInt(height)),
121 | });
122 | }
123 |
124 | function imgHandler(node) {
125 | const { width, height } = node.getBoundingClientRect();
126 |
127 | setAttributes(node, {
128 | width,
129 | height,
130 | src: SMALLEST_BASE64,
131 | });
132 |
133 | node.style.backgroundColor = MAIN_COLOR;
134 | }
135 |
136 | function buttonHandler(node) {
137 | if (!node.tagName) return;
138 |
139 | node.classList.add(BUTTON_CLASS);
140 |
141 | let { backgroundColor: bgColor, width, height } = getComputedStyle(node);
142 |
143 | bgColor = bgColor === 'rgba(0, 0, 0, 0)' ? MAIN_COLOR : bgColor;
144 |
145 | node.style.backgroundColor = bgColor;
146 | node.style.color = bgColor;
147 | node.style.borderColor = bgColor;
148 | node.style.width = width;
149 | node.style.height = height;
150 |
151 | // Clear button content
152 | node.innerHTML = '';
153 | }
154 |
155 | function getTextWidth(ele, style) {
156 | const MOCK_TEXT_ID = 'skeleton-text-id';
157 | let offScreenParagraph = document.querySelector(`#${MOCK_TEXT_ID}`);
158 | if (!offScreenParagraph) {
159 | const wrapper = document.createElement('p');
160 | offScreenParagraph = document.createElement('span');
161 | Object.assign(wrapper.style, {
162 | width: '10000px',
163 | position: 'absolute',
164 | top: '0',
165 | });
166 | offScreenParagraph.id = MOCK_TEXT_ID;
167 | offScreenParagraph.style.visibility = 'hidden';
168 | wrapper.appendChild(offScreenParagraph);
169 | document.body.appendChild(wrapper);
170 | }
171 | Object.assign(offScreenParagraph.style, style);
172 | ele.childNodes && setStylesInNode(ele.childNodes);
173 | offScreenParagraph.innerHTML = ele.innerHTML;
174 | return offScreenParagraph.getBoundingClientRect().width;
175 | }
176 |
177 | function setStylesInNode(nodes) {
178 | Array.from(nodes).forEach(node => {
179 | if (!node || !node.tagName) return;
180 | const comStyle = getComputedStyle(node);
181 | Object.assign(node.style, {
182 | marginLeft: comStyle.marginLeft,
183 | marginRight: comStyle.marginRight,
184 | marginTop: comStyle.marginTop,
185 | marginBottom: comStyle.marginBottom,
186 | paddingLeft: comStyle.paddingLeft,
187 | paddingRight: comStyle.paddingRight,
188 | paddingTop: comStyle.paddingTop,
189 | paddingBottom: comStyle.paddingBottom,
190 | fontSize: comStyle.fontSize,
191 | lineHeight: comStyle.lineHeight,
192 | position: comStyle.position,
193 | textAlign: comStyle.textAlign,
194 | wordSpacing: comStyle.wordSpacing,
195 | wordBreak: comStyle.wordBreak,
196 | dispaly: comStyle.dispaly,
197 | boxSizing: comStyle.boxSizing,
198 | });
199 |
200 | if (node.childNodes) {
201 | setStylesInNode(node.childNodes);
202 | }
203 | });
204 | }
205 |
206 | function addTextMask(paragraph, {
207 | textAlign,
208 | lineHeight,
209 | paddingBottom,
210 | paddingLeft,
211 | paddingRight,
212 | }, maskWidthPercent = 0.5) {
213 |
214 | let left;
215 | let right;
216 | switch (textAlign) {
217 | case 'center':
218 | left = document.createElement('span');
219 | right = document.createElement('span');
220 | [ left, right ].forEach(mask => {
221 | Object.assign(mask.style, {
222 | display: 'inline-block',
223 | width: `${maskWidthPercent / 2 * 100}%`,
224 | height: lineHeight,
225 | background: '#fff',
226 | position: 'absolute',
227 | bottom: paddingBottom,
228 | });
229 | });
230 | left.style.left = paddingLeft;
231 | right.style.right = paddingRight;
232 | paragraph.appendChild(left);
233 | paragraph.appendChild(right);
234 | break;
235 | case 'right':
236 | left = document.createElement('span');
237 | Object.assign(left.style, {
238 | display: 'inline-block',
239 | width: `${maskWidthPercent * 100}%`,
240 | height: lineHeight,
241 | background: '#fff',
242 | position: 'absolute',
243 | bottom: paddingBottom,
244 | left: paddingLeft,
245 | });
246 | paragraph.appendChild(left);
247 | break;
248 | case 'left':
249 | default:
250 | right = document.createElement('span');
251 | Object.assign(right.style, {
252 | display: 'inline-block',
253 | width: `${maskWidthPercent * 100}%`,
254 | height: lineHeight,
255 | background: '#fff',
256 | position: 'absolute',
257 | bottom: paddingBottom,
258 | right: paddingRight,
259 | });
260 | paragraph.appendChild(right);
261 | break;
262 | }
263 | }
264 |
265 | function handleTextStyle(ele, width) {
266 | const comStyle = getComputedStyle(ele);
267 | let {
268 | lineHeight,
269 | paddingTop,
270 | paddingRight,
271 | paddingBottom,
272 | paddingLeft,
273 | position: pos,
274 | fontSize,
275 | textAlign,
276 | wordSpacing,
277 | wordBreak,
278 | } = comStyle;
279 | if (!/\d/.test(lineHeight)) {
280 | const fontSizeNum = parseInt(fontSize, 10) || 14;
281 | lineHeight = `${fontSizeNum * 1.4}px`;
282 | }
283 |
284 | const position = [ 'fixed', 'absolute', 'flex' ].find(p => p === pos) ? pos : 'relative';
285 |
286 | const height = ele.offsetHeight;
287 | // Round down
288 | let lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) || 0;
289 |
290 | lineCount = lineCount < 1.5 ? 1 : lineCount;
291 |
292 | const textHeightRatio = 0.6; // Default
293 |
294 | // Add text block class name tag
295 | ele.classList.add(SKELETON_TEXT_CLASS);
296 |
297 | Object.assign(ele.style, {
298 | backgroundImage: `linear-gradient(
299 | transparent ${(1 - textHeightRatio) / 2 * 100}%,
300 | ${MAIN_COLOR} 0%,
301 | ${MAIN_COLOR} ${((1 - textHeightRatio) / 2 + textHeightRatio) * 100}%,
302 | transparent 0%
303 | )`,
304 | backgroundSize: `100% ${px2rem(parseInt(lineHeight) * 1.1)}`,
305 | position,
306 | });
307 |
308 | // add white mask
309 | if (lineCount > 1) {
310 | addTextMask(ele, Object.assign(JSON.parse(JSON.stringify(comStyle)), {
311 | lineHeight,
312 | }));
313 | } else {
314 | const textWidth = getTextWidth(ele, {
315 | fontSize,
316 | lineHeight,
317 | wordBreak,
318 | wordSpacing,
319 | });
320 | const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10));
321 | ele.style.backgroundSize = `${textWidthPercent * 100}% 100%`;
322 | switch (textAlign) {
323 | case 'left':
324 | break;
325 | case 'right':
326 | ele.style.backgroundPositionX = '100%';
327 | break;
328 | default: // center
329 | ele.style.backgroundPositionX = '50%';
330 | break;
331 | }
332 | }
333 | }
334 |
335 | function textHandler(ele, options) {
336 | const {
337 | width,
338 | } = ele.getBoundingClientRect();
339 |
340 | // Elements with a width less than N are not handled
341 | const minGrayBlockWidth = options.minGrayBlockWidth || 30;
342 | if (width <= minGrayBlockWidth) {
343 | return setOpacity(ele);
344 | }
345 |
346 | // If it is a button, it ends early
347 | const isBtn = /(btn)|(button)/g.test(ele.getAttribute('class'));
348 | if (isBtn) {
349 | return buttonHandler(ele);
350 | }
351 |
352 | // Handling text styles
353 | handleTextStyle(ele, width);
354 | }
355 |
356 | const listHandler = (node, options) => {
357 | if (!options.openRepeatList || !node.children.length) return;
358 |
359 | const children = node.children;
360 | const len = Array.from(children).filter(child => LIST_ITEM_TAG.indexOf(child.tagName) > -1).length;
361 |
362 | if (len === 0) return false;
363 |
364 | const firstChild = children[0];
365 | // Solve the bug that sometimes the ul element child element is not a specified list element.
366 | if (LIST_ITEM_TAG.indexOf(firstChild.tagName) === -1) {
367 | return listHandler(firstChild, options);
368 | }
369 |
370 | // Keep only the first list element
371 | Array.from(children).forEach((li, index) => {
372 | if (index > 0) {
373 | removeElement(li);
374 | }
375 | });
376 |
377 | // Set all sibling elements of LI to the same element to ensure that the generated page skeleton is neat
378 | for (let i = 1; i < len; i++) {
379 | node.appendChild(firstChild.cloneNode(true));
380 | }
381 | };
382 |
383 | function emptyHandler(node) {
384 | node.innerHTML = '';
385 |
386 | let classNameArr = node.className && node.className.split(' ');
387 | classNameArr = classNameArr.map(item => {
388 | return '.' + item;
389 | });
390 | const className = classNameArr.join('');
391 | const id = node.id ? '#' + node.id : '';
392 | const query = className || id;
393 |
394 | if (!query) return;
395 |
396 | let styleSheet;
397 |
398 | for (const item of document.styleSheets) {
399 | if (!item.href) {
400 | styleSheet = item;
401 | return;
402 | }
403 | }
404 |
405 | try {
406 | styleSheet && styleSheet.insertRule(`${query}::before{content:'' !important;background:none !important;}`, 0);
407 | styleSheet && styleSheet.insertRule(`${query}::after{content:'' !important;background:none !important;}`, 0);
408 | } catch (e) {
409 | console.log('handleEmptyNode Error: ', JSON.stringify(e));
410 | }
411 | }
412 |
413 | function styleHandler() {
414 | const skeletonBlockStyleEle = document.createElement('style');
415 |
416 | skeletonBlockStyleEle.innerText = `
417 | .${SKELETON_TEXT_CLASS},
418 | .${SKELETON_TEXT_CLASS} * {
419 | background-origin: content-box;
420 | background-clip: content-box;
421 | background-color: transparent !important;
422 | color: transparent !important;
423 | background-repeat: repeat-y;
424 | }
425 |
426 | .${PSEUDO_CLASS}::before,
427 | .${PSEUDO_CLASS}::after {
428 | background: ${MAIN_COLOR} !important;
429 | background-image: none !important;
430 | color: transparent !important;
431 | border-color: transparent !important;
432 | border-radius: 0 !important;
433 | }
434 |
435 | .${BUTTON_CLASS} {
436 | box-shadow: none !important;
437 | }
438 |
439 | .${TRANSPARENT_CLASS}::before,
440 | .${TRANSPARENT_CLASS}::after {
441 | opacity: 0 !important;
442 | }
443 | `.replace(/\n/g, '');
444 |
445 | document.body.prepend(skeletonBlockStyleEle);
446 | }
447 |
448 | function inputHandler(node) {
449 | node.removeAttribute('placeholder');
450 | node.value = '';
451 | }
452 |
453 | function scriptHandler(node) {
454 | removeElement(node);
455 | }
456 |
457 | function pseudoHandler(node, options) {
458 | if (!node.tagName) return;
459 |
460 | const pseudo = checkHasPseudoEle(node);
461 |
462 | if (!pseudo || !pseudo.ele) return;
463 |
464 | const { ele, width } = pseudo;
465 |
466 | // Width is less than the hiding threshold
467 | if (width < options.minGrayPseudoWidth) {
468 | return ele.classList.add(TRANSPARENT_CLASS);
469 | }
470 |
471 | ele.classList.add(PSEUDO_CLASS);
472 | }
473 |
474 | function beforeHandler(node, options) {
475 | if (!node.tagName) return;
476 |
477 | // Handling empty elements of user tags
478 | if (hasAttr(node, 'data-skeleton-empty')) {
479 | emptyHandler(node);
480 | }
481 |
482 | // Width is less than the hiding threshold
483 | const { width } = node.getBoundingClientRect();
484 | if (width < options.minGrayBlockWidth) {
485 | setOpacity(node);
486 | }
487 |
488 | const ComputedStyle = getComputedStyle(node);
489 |
490 | // The background image is changed to the main color
491 | if (ComputedStyle.backgroundImage !== 'none') {
492 | node.style.backgroundImage = 'none';
493 | node.style.background = MAIN_COLOR;
494 | }
495 |
496 | // The Shadow is changed to the main color
497 | if (ComputedStyle.boxShadow !== 'none') {
498 | const oldBoxShadow = ComputedStyle.boxShadow;
499 | const newBoxShadow = oldBoxShadow.replace(/^rgb.*\)/, MAIN_COLOR_RGB);
500 | node.style.boxShadow = newBoxShadow;
501 | }
502 |
503 | // The border is changed to the main color
504 | if (ComputedStyle.borderColor) {
505 | node.style.borderColor = MAIN_COLOR;
506 | }
507 |
508 | // Set the background color of the user class
509 | const bgColor = node.getAttribute('data-skeleton-bgcolor');
510 | if (bgColor) {
511 | node.style.backgroundColor = bgColor;
512 | node.style.color = 'transparent';
513 | }
514 | }
515 |
516 | window.AwesomeSkeleton = {
517 | // Entry function
518 | async genSkeleton(options) {
519 | this.options = options;
520 | if (options.debug) {
521 | await this.debugGenSkeleton(options);
522 | } else {
523 | await this.startGenSkeleton();
524 | }
525 | },
526 |
527 | // Start generating the skeleton
528 | async startGenSkeleton() {
529 | this.init();
530 | try {
531 | this.handleNode(document.body);
532 | } catch (e) {
533 | console.log('==genSkeleton Error==\n', e.message, e.stack);
534 | }
535 | },
536 |
537 | // The Debug mode generates a skeleton diagram for debugging.
538 | // There will be a button at the top of the page, and click to generate a skeleton map.
539 | async debugGenSkeleton(options) {
540 | const switchWrapElement = document.createElement('div');
541 | switchWrapElement.style.height = '100px';
542 | const switchElement = document.createElement('button');
543 | switchElement.innerHTML = '开始生成骨架图';
544 | Object.assign(switchElement.style, {
545 | position: 'fixed',
546 | top: 0,
547 | left: 0,
548 | width: '100%',
549 | zIndex: 9999,
550 | color: '#FFFFFF',
551 | background: 'red',
552 | fontSize: '30px',
553 | height: '100px',
554 | });
555 | switchWrapElement.appendChild(switchElement);
556 | document.body.prepend(switchWrapElement);
557 |
558 | // Need to wait for event processing, so use Promise for packaging
559 | return new Promise((resolve, reject) => {
560 | try {
561 | switchElement.onclick = async () => {
562 | removeElement(switchWrapElement);
563 | await this.startGenSkeleton();
564 | await sleep(options.debugTime || 0);
565 | resolve();
566 | };
567 | } catch (e) {
568 | console.error('==startGenSkeleton Error==', e);
569 | reject(e);
570 | }
571 | });
572 | },
573 |
574 | // Initialization processing DOM
575 | init() {
576 | this.cleanSkeletonContainer();
577 | styleHandler();
578 | },
579 |
580 | // Remove skeleton image html and style from the page to avoid interference
581 | cleanSkeletonContainer() {
582 | const skeletonWrap = document.body.querySelector('#nozomi-skeleton-html-style-container');
583 | if (skeletonWrap) {
584 | removeElement(skeletonWrap);
585 | }
586 | },
587 |
588 | /**
589 | * Processing text nodes
590 | * @param {*} node Node
591 | * @return {Boolean} True means that processing has been completed, false means that processing still needs to be continued
592 | */
593 | handleText(node) {
594 | const tagName = node.tagName && node.tagName.toUpperCase();
595 |
596 | // Processing xxx
or xxx
597 | if (node.childNodes && node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) {
598 | textHandler(node, this.options);
599 | return true;
600 | }
601 |
602 | // Processing xxx,change to xxx
603 | if (node && node.nodeType === 3 && node.textContent) {
604 | const parent = node.parentNode;
605 | // Determine if it has been processed
606 | if (!parent.classList.contains(SKELETON_TEXT_CLASS)) {
607 | // It is plain text itself and needs to be replaced with a node
608 | const textContent = node.textContent.replace(/[\r\n]/g, '').trim();
609 | if (textContent) {
610 | const tmpNode = document.createElement('i');
611 | tmpNode.classList.add(SKELETON_TEXT_CLASS);
612 | tmpNode.innerText = textContent;
613 | node.parentNode.replaceChild(tmpNode, node);
614 | textHandler(tmpNode, this.options);
615 | return true;
616 | }
617 | }
618 | }
619 |
620 | // Processing 111222 111
621 | if (tagName === 'SPAN' && node.innerHTML) {
622 | // Process image and background image first
623 | this.handleImages(node.childNodes);
624 |
625 | textHandler(node, this.options);
626 | return true;
627 | }
628 |
629 | return false;
630 | },
631 |
632 | // The text nodes are processed uniformly, and the background image, IMG, SVG, etc. need to be processed again.
633 | handleImages(nodes) {
634 | if (!nodes) return;
635 |
636 | Array.from(nodes).forEach(node => {
637 | if (hasAttr(node, 'data-skeleton-ignore')) return;
638 |
639 | beforeHandler(node, this.options);
640 | pseudoHandler(node, this.options);
641 | const tagName = node.tagName && node.tagName.toUpperCase();
642 | if (tagName === 'IMG') {
643 | imgHandler(node);
644 | } else if (tagName === 'SVG') {
645 | svgHandler(node);
646 | } else {
647 | this.handleImages(node.childNodes);
648 | }
649 | });
650 | },
651 |
652 | // Processing node list
653 | handleNodes(nodes) {
654 | if (!nodes.length) return;
655 |
656 | Array.from(nodes).forEach(node => {
657 | this.handleNode(node);
658 | });
659 | },
660 |
661 | // Processing a single node
662 | handleNode(node) {
663 | if (!node) return;
664 |
665 | // Delete elements that are not in first screen, or marked for deletion
666 | if (!inViewPort(node) || hasAttr(node, 'data-skeleton-remove')) {
667 | return removeElement(node);
668 | }
669 |
670 | // Handling elements that are ignored by user tags -> End
671 | const ignore = hasAttr(node, 'data-skeleton-ignore') || node.tagName === 'STYLE';
672 | if (ignore) return;
673 |
674 | // Preprocessing some styles
675 | beforeHandler(node, this.options);
676 |
677 | // Preprocessing pseudo-class style
678 | pseudoHandler(node, this.options);
679 |
680 | const tagName = node.tagName && node.tagName.toUpperCase();
681 | const isBtn = tagName && (tagName === 'BUTTON' || /(btn)|(button)/g.test(node.getAttribute('class')));
682 |
683 | let isCompleted = false;
684 | switch (tagName) {
685 | case 'SCRIPT':
686 | scriptHandler(node);
687 | break;
688 | case 'IMG':
689 | imgHandler(node);
690 | break;
691 | case 'SVG':
692 | svgHandler(node);
693 | break;
694 | case 'INPUT':
695 | inputHandler(node);
696 | break;
697 | case 'BUTTON': // Button processing ends once
698 | buttonHandler(node);
699 | break;
700 | case 'UL':
701 | case 'OL':
702 | case 'DL':
703 | listHandler(node, this.options);
704 | break;
705 | case 'A': // A label processing is placed behind to prevent IMG from displaying an exception
706 | aHandler(node);
707 | break;
708 | }
709 |
710 | if (isBtn) {
711 | // Handle button styles, end directly after processing
712 | buttonHandler(node);
713 | } else {
714 | // Other nodes are processed as TEXT
715 | isCompleted = this.handleText(node);
716 | }
717 |
718 | // If it is a button and has not been processed by handleText, then the child node is processed
719 | if (!isBtn && !isCompleted) {
720 | this.handleNodes(node.childNodes);
721 | }
722 | },
723 | };
724 |
725 | }());
726 |
--------------------------------------------------------------------------------
/src/script/handler/a.js:
--------------------------------------------------------------------------------
1 | function aHandler(node) {
2 | node.removeAttribute('href');
3 | }
4 |
5 | export default aHandler;
6 |
--------------------------------------------------------------------------------
/src/script/handler/before.js:
--------------------------------------------------------------------------------
1 | import {
2 | MAIN_COLOR,
3 | MAIN_COLOR_RGB,
4 | } from '../constants';
5 |
6 | import {
7 | hasAttr,
8 | setOpacity,
9 | } from '../util';
10 |
11 | import handlerEmpty from './empty.js';
12 |
13 | function beforeHandler(node, options) {
14 | if (!node.tagName) return;
15 |
16 | // Handling empty elements of user tags
17 | if (hasAttr(node, 'data-skeleton-empty')) {
18 | handlerEmpty(node);
19 | }
20 |
21 | // Width is less than the hiding threshold
22 | const { width } = node.getBoundingClientRect();
23 | if (width < options.minGrayBlockWidth) {
24 | setOpacity(node);
25 | }
26 |
27 | const ComputedStyle = getComputedStyle(node);
28 |
29 | // The background image is changed to the main color
30 | if (ComputedStyle.backgroundImage !== 'none') {
31 | node.style.backgroundImage = 'none';
32 | node.style.background = MAIN_COLOR;
33 | }
34 |
35 | // The Shadow is changed to the main color
36 | if (ComputedStyle.boxShadow !== 'none') {
37 | const oldBoxShadow = ComputedStyle.boxShadow;
38 | const newBoxShadow = oldBoxShadow.replace(/^rgb.*\)/, MAIN_COLOR_RGB);
39 | node.style.boxShadow = newBoxShadow;
40 | }
41 |
42 | // The border is changed to the main color
43 | if (ComputedStyle.borderColor) {
44 | node.style.borderColor = MAIN_COLOR;
45 | }
46 |
47 | // Set the background color of the user class
48 | const bgColor = node.getAttribute('data-skeleton-bgcolor');
49 | if (bgColor) {
50 | node.style.backgroundColor = bgColor;
51 | node.style.color = 'transparent';
52 | }
53 | }
54 |
55 | export default beforeHandler;
56 |
--------------------------------------------------------------------------------
/src/script/handler/button.js:
--------------------------------------------------------------------------------
1 | import {
2 | BUTTON_CLASS,
3 | MAIN_COLOR,
4 | } from '../constants';
5 |
6 | function buttonHandler(node) {
7 | if (!node.tagName) return;
8 |
9 | node.classList.add(BUTTON_CLASS);
10 |
11 | let { backgroundColor: bgColor, width, height } = getComputedStyle(node);
12 |
13 | bgColor = bgColor === 'rgba(0, 0, 0, 0)' ? MAIN_COLOR : bgColor;
14 |
15 | node.style.backgroundColor = bgColor;
16 | node.style.color = bgColor;
17 | node.style.borderColor = bgColor;
18 | node.style.width = width;
19 | node.style.height = height;
20 |
21 | // Clear button content
22 | node.innerHTML = '';
23 | }
24 |
25 | export default buttonHandler;
26 |
--------------------------------------------------------------------------------
/src/script/handler/empty.js:
--------------------------------------------------------------------------------
1 | function emptyHandler(node) {
2 | node.innerHTML = '';
3 |
4 | let classNameArr = node.className && node.className.split(' ');
5 | classNameArr = classNameArr.map(item => {
6 | return '.' + item;
7 | });
8 | const className = classNameArr.join('');
9 | const id = node.id ? '#' + node.id : '';
10 | const query = className || id;
11 |
12 | if (!query) return;
13 |
14 | let styleSheet;
15 |
16 | for (const item of document.styleSheets) {
17 | if (!item.href) {
18 | styleSheet = item;
19 | return;
20 | }
21 | }
22 |
23 | try {
24 | styleSheet && styleSheet.insertRule(`${query}::before{content:'' !important;background:none !important;}`, 0);
25 | styleSheet && styleSheet.insertRule(`${query}::after{content:'' !important;background:none !important;}`, 0);
26 | } catch (e) {
27 | console.log('handleEmptyNode Error: ', JSON.stringify(e));
28 | }
29 | }
30 |
31 | export default emptyHandler;
32 |
--------------------------------------------------------------------------------
/src/script/handler/img.js:
--------------------------------------------------------------------------------
1 | import {
2 | MAIN_COLOR,
3 | SMALLEST_BASE64,
4 | } from '../constants';
5 |
6 | import {
7 | setAttributes,
8 | } from '../util';
9 |
10 | function imgHandler(node) {
11 | const { width, height } = node.getBoundingClientRect();
12 |
13 | setAttributes(node, {
14 | width,
15 | height,
16 | src: SMALLEST_BASE64,
17 | });
18 |
19 | node.style.backgroundColor = MAIN_COLOR;
20 | }
21 |
22 | export default imgHandler;
23 |
--------------------------------------------------------------------------------
/src/script/handler/index.js:
--------------------------------------------------------------------------------
1 | import a from './a';
2 | import svg from './svg';
3 | import img from './img';
4 | import text from './text';
5 | import list from './list';
6 | import empty from './empty';
7 | import style from './style';
8 | import input from './input';
9 | import button from './button';
10 | import script from './script';
11 | import pseudo from './pseudo';
12 | import before from './before';
13 |
14 | export {
15 | a,
16 | svg,
17 | img,
18 | text,
19 | list,
20 | empty,
21 | style,
22 | input,
23 | button,
24 | script,
25 | pseudo,
26 | before,
27 | };
28 |
--------------------------------------------------------------------------------
/src/script/handler/input.js:
--------------------------------------------------------------------------------
1 | function inputHandler(node) {
2 | node.removeAttribute('placeholder');
3 | node.value = '';
4 | }
5 |
6 | export default inputHandler;
7 |
--------------------------------------------------------------------------------
/src/script/handler/list.js:
--------------------------------------------------------------------------------
1 | import {
2 | removeElement,
3 | } from '../util';
4 |
5 | import {
6 | LIST_ITEM_TAG,
7 | } from '../constants';
8 |
9 | const listHandler = (node, options) => {
10 | if (!options.openRepeatList || !node.children.length) return;
11 |
12 | const children = node.children;
13 | const len = Array.from(children).filter(child => LIST_ITEM_TAG.indexOf(child.tagName) > -1).length;
14 |
15 | if (len === 0) return false;
16 |
17 | const firstChild = children[0];
18 | // Solve the bug that sometimes the ul element child element is not a specified list element.
19 | if (LIST_ITEM_TAG.indexOf(firstChild.tagName) === -1) {
20 | return listHandler(firstChild, options);
21 | }
22 |
23 | // Keep only the first list element
24 | Array.from(children).forEach((li, index) => {
25 | if (index > 0) {
26 | removeElement(li);
27 | }
28 | });
29 |
30 | // Set all sibling elements of LI to the same element to ensure that the generated page skeleton is neat
31 | for (let i = 1; i < len; i++) {
32 | node.appendChild(firstChild.cloneNode(true));
33 | }
34 | };
35 |
36 | export default listHandler;
37 |
--------------------------------------------------------------------------------
/src/script/handler/pseudo.js:
--------------------------------------------------------------------------------
1 | import {
2 | checkHasPseudoEle,
3 | } from '../util';
4 |
5 | import {
6 | PSEUDO_CLASS,
7 | TRANSPARENT_CLASS,
8 | } from '../constants';
9 |
10 | function pseudoHandler(node, options) {
11 | if (!node.tagName) return;
12 |
13 | const pseudo = checkHasPseudoEle(node);
14 |
15 | if (!pseudo || !pseudo.ele) return;
16 |
17 | const { ele, width } = pseudo;
18 |
19 | // Width is less than the hiding threshold
20 | if (width < options.minGrayPseudoWidth) {
21 | return ele.classList.add(TRANSPARENT_CLASS);
22 | }
23 |
24 | ele.classList.add(PSEUDO_CLASS);
25 | }
26 |
27 | export default pseudoHandler;
28 |
--------------------------------------------------------------------------------
/src/script/handler/script.js:
--------------------------------------------------------------------------------
1 | import {
2 | removeElement,
3 | } from '../util';
4 |
5 | function scriptHandler(node) {
6 | removeElement(node);
7 | }
8 |
9 | export default scriptHandler;
10 |
--------------------------------------------------------------------------------
/src/script/handler/style.js:
--------------------------------------------------------------------------------
1 | import {
2 | MAIN_COLOR,
3 | PSEUDO_CLASS,
4 | BUTTON_CLASS,
5 | TRANSPARENT_CLASS,
6 | SKELETON_TEXT_CLASS,
7 | } from '../constants';
8 |
9 | function styleHandler() {
10 | const skeletonBlockStyleEle = document.createElement('style');
11 |
12 | skeletonBlockStyleEle.innerText = `
13 | .${SKELETON_TEXT_CLASS},
14 | .${SKELETON_TEXT_CLASS} * {
15 | background-origin: content-box;
16 | background-clip: content-box;
17 | background-color: transparent !important;
18 | color: transparent !important;
19 | background-repeat: repeat-y;
20 | }
21 |
22 | .${PSEUDO_CLASS}::before,
23 | .${PSEUDO_CLASS}::after {
24 | background: ${MAIN_COLOR} !important;
25 | background-image: none !important;
26 | color: transparent !important;
27 | border-color: transparent !important;
28 | border-radius: 0 !important;
29 | }
30 |
31 | .${BUTTON_CLASS} {
32 | box-shadow: none !important;
33 | }
34 |
35 | .${TRANSPARENT_CLASS}::before,
36 | .${TRANSPARENT_CLASS}::after {
37 | opacity: 0 !important;
38 | }
39 | `.replace(/\n/g, '');
40 |
41 | document.body.prepend(skeletonBlockStyleEle);
42 | }
43 |
44 | export default styleHandler;
45 |
--------------------------------------------------------------------------------
/src/script/handler/svg.js:
--------------------------------------------------------------------------------
1 | import {
2 | px2rem,
3 | removeElement,
4 | } from '../util';
5 |
6 | function svgHandler(node) {
7 | const { width, height } = node.getBoundingClientRect();
8 |
9 | // Remove elements if they are not visible
10 | if (width === 0 || height === 0 || node.getAttribute('aria-hidden') === 'true') {
11 | return removeElement(node);
12 | }
13 |
14 | // Clear node centent
15 | node.innerHTML = '';
16 |
17 | // Set style
18 | Object.assign(node.style, {
19 | width: px2rem(parseInt(width)),
20 | height: px2rem(parseInt(height)),
21 | });
22 | }
23 |
24 | export default svgHandler;
25 |
--------------------------------------------------------------------------------
/src/script/handler/text.js:
--------------------------------------------------------------------------------
1 | import {
2 | px2rem,
3 | setOpacity,
4 | } from '../util';
5 | import {
6 | MAIN_COLOR,
7 | SKELETON_TEXT_CLASS,
8 | } from '../constants';
9 | import handlerButton from './button';
10 |
11 | function getTextWidth(ele, style) {
12 | const MOCK_TEXT_ID = 'skeleton-text-id';
13 | let offScreenParagraph = document.querySelector(`#${MOCK_TEXT_ID}`);
14 | if (!offScreenParagraph) {
15 | const wrapper = document.createElement('p');
16 | offScreenParagraph = document.createElement('span');
17 | Object.assign(wrapper.style, {
18 | width: '10000px',
19 | position: 'absolute',
20 | top: '0',
21 | });
22 | offScreenParagraph.id = MOCK_TEXT_ID;
23 | offScreenParagraph.style.visibility = 'hidden';
24 | wrapper.appendChild(offScreenParagraph);
25 | document.body.appendChild(wrapper);
26 | }
27 | Object.assign(offScreenParagraph.style, style);
28 | ele.childNodes && setStylesInNode(ele.childNodes);
29 | offScreenParagraph.innerHTML = ele.innerHTML;
30 | return offScreenParagraph.getBoundingClientRect().width;
31 | }
32 |
33 | function setStylesInNode(nodes) {
34 | Array.from(nodes).forEach(node => {
35 | if (!node || !node.tagName) return;
36 | const comStyle = getComputedStyle(node);
37 | Object.assign(node.style, {
38 | marginLeft: comStyle.marginLeft,
39 | marginRight: comStyle.marginRight,
40 | marginTop: comStyle.marginTop,
41 | marginBottom: comStyle.marginBottom,
42 | paddingLeft: comStyle.paddingLeft,
43 | paddingRight: comStyle.paddingRight,
44 | paddingTop: comStyle.paddingTop,
45 | paddingBottom: comStyle.paddingBottom,
46 | fontSize: comStyle.fontSize,
47 | lineHeight: comStyle.lineHeight,
48 | position: comStyle.position,
49 | textAlign: comStyle.textAlign,
50 | wordSpacing: comStyle.wordSpacing,
51 | wordBreak: comStyle.wordBreak,
52 | dispaly: comStyle.dispaly,
53 | boxSizing: comStyle.boxSizing,
54 | });
55 |
56 | if (node.childNodes) {
57 | setStylesInNode(node.childNodes);
58 | }
59 | });
60 | }
61 |
62 | function addTextMask(paragraph, {
63 | textAlign,
64 | lineHeight,
65 | paddingBottom,
66 | paddingLeft,
67 | paddingRight,
68 | }, maskWidthPercent = 0.5) {
69 |
70 | let left;
71 | let right;
72 | switch (textAlign) {
73 | case 'center':
74 | left = document.createElement('span');
75 | right = document.createElement('span');
76 | [ left, right ].forEach(mask => {
77 | Object.assign(mask.style, {
78 | display: 'inline-block',
79 | width: `${maskWidthPercent / 2 * 100}%`,
80 | height: lineHeight,
81 | background: '#fff',
82 | position: 'absolute',
83 | bottom: paddingBottom,
84 | });
85 | });
86 | left.style.left = paddingLeft;
87 | right.style.right = paddingRight;
88 | paragraph.appendChild(left);
89 | paragraph.appendChild(right);
90 | break;
91 | case 'right':
92 | left = document.createElement('span');
93 | Object.assign(left.style, {
94 | display: 'inline-block',
95 | width: `${maskWidthPercent * 100}%`,
96 | height: lineHeight,
97 | background: '#fff',
98 | position: 'absolute',
99 | bottom: paddingBottom,
100 | left: paddingLeft,
101 | });
102 | paragraph.appendChild(left);
103 | break;
104 | case 'left':
105 | default:
106 | right = document.createElement('span');
107 | Object.assign(right.style, {
108 | display: 'inline-block',
109 | width: `${maskWidthPercent * 100}%`,
110 | height: lineHeight,
111 | background: '#fff',
112 | position: 'absolute',
113 | bottom: paddingBottom,
114 | right: paddingRight,
115 | });
116 | paragraph.appendChild(right);
117 | break;
118 | }
119 | }
120 |
121 | function handleTextStyle(ele, width) {
122 | const comStyle = getComputedStyle(ele);
123 | let {
124 | lineHeight,
125 | paddingTop,
126 | paddingRight,
127 | paddingBottom,
128 | paddingLeft,
129 | position: pos,
130 | fontSize,
131 | textAlign,
132 | wordSpacing,
133 | wordBreak,
134 | } = comStyle;
135 | if (!/\d/.test(lineHeight)) {
136 | const fontSizeNum = parseInt(fontSize, 10) || 14;
137 | lineHeight = `${fontSizeNum * 1.4}px`;
138 | }
139 |
140 | const position = [ 'fixed', 'absolute', 'flex' ].find(p => p === pos) ? pos : 'relative';
141 |
142 | const height = ele.offsetHeight;
143 | // Round down
144 | let lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) || 0;
145 |
146 | lineCount = lineCount < 1.5 ? 1 : lineCount;
147 |
148 | const textHeightRatio = 0.6; // Default
149 |
150 | // Add text block class name tag
151 | ele.classList.add(SKELETON_TEXT_CLASS);
152 |
153 | Object.assign(ele.style, {
154 | backgroundImage: `linear-gradient(
155 | transparent ${(1 - textHeightRatio) / 2 * 100}%,
156 | ${MAIN_COLOR} 0%,
157 | ${MAIN_COLOR} ${((1 - textHeightRatio) / 2 + textHeightRatio) * 100}%,
158 | transparent 0%
159 | )`,
160 | backgroundSize: `100% ${px2rem(parseInt(lineHeight) * 1.1)}`,
161 | position,
162 | });
163 |
164 | // add white mask
165 | if (lineCount > 1) {
166 | addTextMask(ele, Object.assign(JSON.parse(JSON.stringify(comStyle)), {
167 | lineHeight,
168 | }));
169 | } else {
170 | const textWidth = getTextWidth(ele, {
171 | fontSize,
172 | lineHeight,
173 | wordBreak,
174 | wordSpacing,
175 | });
176 | const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10));
177 | ele.style.backgroundSize = `${textWidthPercent * 100}% 100%`;
178 | switch (textAlign) {
179 | case 'left':
180 | break;
181 | case 'right':
182 | ele.style.backgroundPositionX = '100%';
183 | break;
184 | default: // center
185 | ele.style.backgroundPositionX = '50%';
186 | break;
187 | }
188 | }
189 | }
190 |
191 | function textHandler(ele, options) {
192 | const {
193 | width,
194 | } = ele.getBoundingClientRect();
195 |
196 | // Elements with a width less than N are not handled
197 | const minGrayBlockWidth = options.minGrayBlockWidth || 30;
198 | if (width <= minGrayBlockWidth) {
199 | return setOpacity(ele);
200 | }
201 |
202 | // If it is a button, it ends early
203 | const isBtn = /(btn)|(button)/g.test(ele.getAttribute('class'));
204 | if (isBtn) {
205 | return handlerButton(ele);
206 | }
207 |
208 | // Handling text styles
209 | handleTextStyle(ele, width);
210 | }
211 |
212 | export default textHandler;
213 |
--------------------------------------------------------------------------------
/src/script/main.js:
--------------------------------------------------------------------------------
1 | import {
2 | sleep,
3 | hasAttr,
4 | inViewPort,
5 | removeElement,
6 | } from './util';
7 |
8 | import {
9 | SKELETON_TEXT_CLASS,
10 | } from './constants';
11 |
12 | import * as handler from './handler/index';
13 |
14 | window.AwesomeSkeleton = {
15 | // Entry function
16 | async genSkeleton(options) {
17 | this.options = options;
18 | if (options.debug) {
19 | await this.debugGenSkeleton(options);
20 | } else {
21 | await this.startGenSkeleton();
22 | }
23 | },
24 |
25 | // Start generating the skeleton
26 | async startGenSkeleton() {
27 | this.init();
28 | try {
29 | this.handleNode(document.body);
30 | } catch (e) {
31 | console.log('==genSkeleton Error==\n', e.message, e.stack);
32 | }
33 | },
34 |
35 | // The Debug mode generates a skeleton diagram for debugging.
36 | // There will be a button at the top of the page, and click to generate a skeleton map.
37 | async debugGenSkeleton(options) {
38 | const switchWrapElement = document.createElement('div');
39 | switchWrapElement.style.height = '100px';
40 | const switchElement = document.createElement('button');
41 | switchElement.innerHTML = '开始生成骨架图';
42 | Object.assign(switchElement.style, {
43 | position: 'fixed',
44 | top: 0,
45 | left: 0,
46 | width: '100%',
47 | zIndex: 9999,
48 | color: '#FFFFFF',
49 | background: 'red',
50 | fontSize: '30px',
51 | height: '100px',
52 | });
53 | switchWrapElement.appendChild(switchElement);
54 | document.body.prepend(switchWrapElement);
55 |
56 | // Need to wait for event processing, so use Promise for packaging
57 | return new Promise((resolve, reject) => {
58 | try {
59 | switchElement.onclick = async () => {
60 | removeElement(switchWrapElement);
61 | await this.startGenSkeleton();
62 | await sleep(options.debugTime || 0);
63 | resolve();
64 | };
65 | } catch (e) {
66 | console.error('==startGenSkeleton Error==', e);
67 | reject(e);
68 | }
69 | });
70 | },
71 |
72 | // Initialization processing DOM
73 | init() {
74 | this.cleanSkeletonContainer();
75 | handler.style();
76 | },
77 |
78 | // Remove skeleton image html and style from the page to avoid interference
79 | cleanSkeletonContainer() {
80 | const skeletonWrap = document.body.querySelector('#nozomi-skeleton-html-style-container');
81 | if (skeletonWrap) {
82 | removeElement(skeletonWrap);
83 | }
84 | },
85 |
86 | /**
87 | * Processing text nodes
88 | * @param {*} node Node
89 | * @return {Boolean} True means that processing has been completed, false means that processing still needs to be continued
90 | */
91 | handleText(node) {
92 | const tagName = node.tagName && node.tagName.toUpperCase();
93 |
94 | // Processing xxx
or xxx
95 | if (node.childNodes && node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) {
96 | handler.text(node, this.options);
97 | return true;
98 | }
99 |
100 | // Processing xxx,change to xxx
101 | if (node && node.nodeType === 3 && node.textContent) {
102 | const parent = node.parentNode;
103 | // Determine if it has been processed
104 | if (!parent.classList.contains(SKELETON_TEXT_CLASS)) {
105 | // It is plain text itself and needs to be replaced with a node
106 | const textContent = node.textContent.replace(/[\r\n]/g, '').trim();
107 | if (textContent) {
108 | const tmpNode = document.createElement('i');
109 | tmpNode.classList.add(SKELETON_TEXT_CLASS);
110 | tmpNode.innerText = textContent;
111 | node.parentNode.replaceChild(tmpNode, node);
112 | handler.text(tmpNode, this.options);
113 | return true;
114 | }
115 | }
116 | }
117 |
118 | // Processing 111222 111
119 | if (tagName === 'SPAN' && node.innerHTML) {
120 | // Process image and background image first
121 | this.handleImages(node.childNodes);
122 |
123 | handler.text(node, this.options);
124 | return true;
125 | }
126 |
127 | return false;
128 | },
129 |
130 | // The text nodes are processed uniformly, and the background image, IMG, SVG, etc. need to be processed again.
131 | handleImages(nodes) {
132 | if (!nodes) return;
133 |
134 | Array.from(nodes).forEach(node => {
135 | if (hasAttr(node, 'data-skeleton-ignore')) return;
136 |
137 | handler.before(node, this.options);
138 | handler.pseudo(node, this.options);
139 | const tagName = node.tagName && node.tagName.toUpperCase();
140 | if (tagName === 'IMG') {
141 | handler.img(node);
142 | } else if (tagName === 'SVG') {
143 | handler.svg(node);
144 | } else {
145 | this.handleImages(node.childNodes);
146 | }
147 | });
148 | },
149 |
150 | // Processing node list
151 | handleNodes(nodes) {
152 | if (!nodes.length) return;
153 |
154 | Array.from(nodes).forEach(node => {
155 | this.handleNode(node);
156 | });
157 | },
158 |
159 | // Processing a single node
160 | handleNode(node) {
161 | if (!node) return;
162 |
163 | // Delete elements that are not in first screen, or marked for deletion
164 | if (!inViewPort(node) || hasAttr(node, 'data-skeleton-remove')) {
165 | return removeElement(node);
166 | }
167 |
168 | // Handling elements that are ignored by user tags -> End
169 | const ignore = hasAttr(node, 'data-skeleton-ignore') || node.tagName === 'STYLE';
170 | if (ignore) return;
171 |
172 | // Preprocessing some styles
173 | handler.before(node, this.options);
174 |
175 | // Preprocessing pseudo-class style
176 | handler.pseudo(node, this.options);
177 |
178 | const tagName = node.tagName && node.tagName.toUpperCase();
179 | const isBtn = tagName && (tagName === 'BUTTON' || /(btn)|(button)/g.test(node.getAttribute('class')));
180 |
181 | let isCompleted = false;
182 | switch (tagName) {
183 | case 'SCRIPT':
184 | handler.script(node);
185 | break;
186 | case 'IMG':
187 | handler.img(node);
188 | break;
189 | case 'SVG':
190 | handler.svg(node);
191 | break;
192 | case 'INPUT':
193 | handler.input(node);
194 | break;
195 | case 'BUTTON': // Button processing ends once
196 | handler.button(node);
197 | break;
198 | case 'UL':
199 | case 'OL':
200 | case 'DL':
201 | handler.list(node, this.options);
202 | break;
203 | case 'A': // A label processing is placed behind to prevent IMG from displaying an exception
204 | handler.a(node);
205 | break;
206 | default:
207 | break;
208 | }
209 |
210 | if (isBtn) {
211 | // Handle button styles, end directly after processing
212 | handler.button(node);
213 | } else {
214 | // Other nodes are processed as TEXT
215 | isCompleted = this.handleText(node);
216 | }
217 |
218 | // If it is a button and has not been processed by handleText, then the child node is processed
219 | if (!isBtn && !isCompleted) {
220 | this.handleNodes(node.childNodes);
221 | }
222 | },
223 | };
224 |
--------------------------------------------------------------------------------
/src/script/util.js:
--------------------------------------------------------------------------------
1 | // sleep function
2 | export const sleep = ms => {
3 | return new Promise(resolve => setTimeout(resolve, ms));
4 | };
5 |
6 | // Check if the node is in the first screen
7 | export const inViewPort = ele => {
8 | try {
9 | const rect = ele.getBoundingClientRect();
10 | return rect.top < window.innerHeight &&
11 | rect.left < window.innerWidth;
12 |
13 | } catch (e) {
14 | return true;
15 | }
16 | };
17 |
18 | // Determine if the node has attributes
19 | export const hasAttr = (ele, attr) => {
20 | try {
21 | return ele.hasAttribute(attr);
22 | } catch (e) {
23 | return false;
24 | }
25 | };
26 |
27 | // Set node transparency
28 | export const setOpacity = ele => {
29 | if (ele.style) {
30 | ele.style.opacity = 0;
31 | }
32 | };
33 |
34 | // Unit conversion px -> rem
35 | export const px2rem = px => {
36 | const pxValue = typeof px === 'string' ? parseInt(px, 10) : px;
37 | const htmlElementFontSize = getComputedStyle(document.documentElement).fontSize;
38 |
39 | return `${(pxValue / parseInt(htmlElementFontSize, 10))}rem`;
40 | };
41 |
42 | // Batch setting element properties
43 | export const setAttributes = (ele, attrs) => {
44 | Object.keys(attrs).forEach(k => ele.setAttribute(k, attrs[k]));
45 | };
46 |
47 | // Delete element
48 | export const removeElement = ele => {
49 | const parent = ele.parentNode;
50 | if (parent) {
51 | parent.removeChild(ele);
52 | }
53 | };
54 |
55 | // Check the element pseudo-class to return the corresponding element and width
56 | export const checkHasPseudoEle = ele => {
57 | if (!ele) return false;
58 |
59 | const beforeComputedStyle = getComputedStyle(ele, '::before');
60 | const beforeContent = beforeComputedStyle.getPropertyValue('content');
61 | const beforeWidth = parseFloat(beforeComputedStyle.getPropertyValue('width'), 10) || 0;
62 | const hasBefore = beforeContent && beforeContent !== 'none';
63 |
64 | const afterComputedStyle = getComputedStyle(ele, '::after');
65 | const afterContent = afterComputedStyle.getPropertyValue('content');
66 | const afterWidth = parseFloat(afterComputedStyle.getPropertyValue('width'), 10) || 0;
67 | const hasAfter = afterContent && afterContent !== 'none';
68 |
69 | const width = Math.max(beforeWidth, afterWidth);
70 |
71 | if (hasBefore || hasAfter) {
72 | return { hasBefore, hasAfter, ele, width };
73 | }
74 | return false;
75 | };
76 |
--------------------------------------------------------------------------------
/src/util.js:
--------------------------------------------------------------------------------
1 | const { promisify } = require('util');
2 | const path = require('path');
3 | const fs = require('fs');
4 |
5 | // sleep function
6 | const sleep = ms => {
7 | return new Promise(resolve => setTimeout(resolve, ms));
8 | };
9 |
10 | // Get the contents of the scr/script packaged script for injecting into the target page
11 | const genScriptContent = async function() {
12 | const sourcePath = path.resolve(__dirname, './script/dist/index.js');
13 | const result = await promisify(fs.readFile)(sourcePath, 'utf-8');
14 | return result;
15 | };
16 |
17 | module.exports = {
18 | sleep,
19 | genScriptContent,
20 | };
21 |
--------------------------------------------------------------------------------
/test/lib/index.test.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 |
3 | describe('lib/index.test.js', function() {
4 | it('works', () => {
5 | assert(true);
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --recursive
2 |
--------------------------------------------------------------------------------