├── .eslintrc ├── .gitattributes ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── commitlint.config.js ├── lib ├── adminsockets.js ├── controllers.js ├── migrator.js └── websockets.js ├── library.js ├── package.json ├── plugin.json ├── renovate.json ├── screenshots ├── desktop.png └── mobile.png ├── static ├── lib │ ├── .eslintrc │ ├── admin.js │ ├── client.js │ ├── emoji.js │ └── quill-nbb.js ├── scss │ ├── overrides.scss │ ├── post.scss │ └── quill.scss └── templates │ ├── admin │ └── plugins │ │ └── composer-quill.tpl │ ├── composer.tpl │ ├── modals │ └── topic-scheduler.tpl │ ├── partials │ ├── composer-formatting.tpl │ ├── composer-tags.tpl │ ├── composer-title-container.tpl │ └── composer-write-preview.tpl │ └── plugins │ └── composer-quill │ └── modals │ ├── migrate-in.tpl │ └── migrate-out.tpl └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "nodebb" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | x64/ 49 | build/ 50 | [Bb]in/ 51 | [Oo]bj/ 52 | 53 | # MSTest test Results 54 | [Tt]est[Rr]esult*/ 55 | [Bb]uild[Ll]og.* 56 | 57 | *_i.c 58 | *_p.c 59 | *.ilk 60 | *.meta 61 | *.obj 62 | *.pch 63 | *.pdb 64 | *.pgc 65 | *.pgd 66 | *.rsp 67 | *.sbr 68 | *.tlb 69 | *.tli 70 | *.tlh 71 | *.tmp 72 | *.tmp_proj 73 | *.log 74 | *.vspscc 75 | *.vssscc 76 | .builds 77 | *.pidb 78 | *.log 79 | *.scc 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | *.ncrunch* 109 | .*crunch*.local.xml 110 | 111 | # Installshield output folder 112 | [Ee]xpress/ 113 | 114 | # DocProject is a documentation generator add-in 115 | DocProject/buildhelp/ 116 | DocProject/Help/*.HxT 117 | DocProject/Help/*.HxC 118 | DocProject/Help/*.hhc 119 | DocProject/Help/*.hhk 120 | DocProject/Help/*.hhp 121 | DocProject/Help/Html2 122 | DocProject/Help/html 123 | 124 | # Click-Once directory 125 | publish/ 126 | 127 | # Publish Web Output 128 | *.Publish.xml 129 | *.pubxml 130 | 131 | # NuGet Packages Directory 132 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 133 | #packages/ 134 | 135 | # Windows Azure Build Output 136 | csx 137 | *.build.csdef 138 | 139 | # Windows Store app package directory 140 | AppPackages/ 141 | 142 | # Others 143 | sql/ 144 | *.Cache 145 | ClientBin/ 146 | [Ss]tyle[Cc]op.* 147 | ~$* 148 | *~ 149 | *.dbmdl 150 | *.[Pp]ublish.xml 151 | *.pfx 152 | *.publishsettings 153 | 154 | # RIA/Silverlight projects 155 | Generated_Code/ 156 | 157 | # Backup & report files from converting an old project file to a newer 158 | # Visual Studio version. Backup files are not needed, because we have git ;-) 159 | _UpgradeReport_Files/ 160 | Backup*/ 161 | UpgradeLog*.XML 162 | UpgradeLog*.htm 163 | 164 | # SQL Server files 165 | App_Data/*.mdf 166 | App_Data/*.ldf 167 | 168 | ############# 169 | ## Windows detritus 170 | ############# 171 | 172 | # Windows image file caches 173 | Thumbs.db 174 | ehthumbs.db 175 | 176 | # Folder config file 177 | Desktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Mac crap 183 | .DS_Store 184 | 185 | 186 | ############# 187 | ## Python 188 | ############# 189 | 190 | *.py[co] 191 | 192 | # Packages 193 | *.egg 194 | *.egg-info 195 | dist/ 196 | build/ 197 | eggs/ 198 | parts/ 199 | var/ 200 | sdist/ 201 | develop-eggs/ 202 | .installed.cfg 203 | 204 | # Installer logs 205 | pip-log.txt 206 | 207 | # Unit test / coverage reports 208 | .coverage 209 | .tox 210 | 211 | #Translations 212 | *.mo 213 | 214 | #Mr Developer 215 | .mr.developer.cfg 216 | 217 | sftp-config.json 218 | node_modules/ 219 | package-lock.json 220 | 221 | .idea/ 222 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | sftp-config.json 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 NodeBB Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quill composer for NodeBB 2 | 3 | This plugin activates the WYSIWYG Quill composer for NodeBB. Please ensure that: 4 | 5 | * The markdown plugin is disabled (see note below, re: markdown) 6 | * Any other composers (i.e. nodebb-plugin-composer-default) are disabled 7 | * **Warning** This composer saves its data in a unique format that is only compatible with Quill. If you switch to Quill, any posts made with Quill cannot be migrated back to Markdown. 8 | 9 | ## For developers 10 | 11 | You may encounter a LESS build error when this module is not installed via npm: 12 | 13 | ``` 14 | error: [build] Encountered error during build step 15 | Error: FileError: './quill/dist/quill.bubble.css' wasn't found. Tried - /some,/directories,/here 16 | /quill.bubble.css,quill/dist/quill.bubble.css in /path/to/nodebb/node_modules/nodebb-plugin-composer-quill/static/less/quill.less on line 2, column 1: 17 | ``` 18 | 19 | This is due to npm/yarn's flattening of dependencies. Quill expects these css files to be at root level, so to get around this: 20 | 21 | `cd /path/to/nodebb/node_modules && ln -s nodebb-plugin-composer-quill/node_modules/quill .` 22 | 23 | ## Migration concerns 24 | 25 | ### nodebb-plugin-composer-default/nodebb-plugin-markdown 26 | 27 | If you used the default composer, chances are you also had the markdown plugin active. If that is the case, any posts made before the switch are still in markdown format, and are not saved into html (in a manner than quill can digest). A migrator tool has been bundled with v1.1 of the Quill Composer, which you can use to migrate posts in markdown and html into the Quill-compatible format. 28 | 29 | ### nodebb-plugin-redactor 30 | 31 | Your posts should automatically work with Quill. Redactor saves HTML into the database, and the Quill plugin is set up so it can digest that html and backport it to Quill's internal format if the post is edited. That said, when you edit a post originally made in Redactor, you'll see the html tags, which are now extraneous. You can remove them as part of your edit. Alternatively, you can use the bundled migrator to convert posts to the Quill-compatible format. 32 | 33 | ## Contributors Welcome 34 | This plugin is considered _production ready_. Please report any bugs to our issue tracker and we will take a look. 35 | 36 | If you'd like to look at the [documentation](https://quilljs.com/docs/) and add a feature, or take a look at the GitHub Issues and do something from there then please do. All pull requests lovingly reviewed. 37 | 38 | ## Screenshots 39 | 40 | ### Desktop 41 | 42 | ![Desktop](/screenshots/desktop.png) 43 | 44 | ### Mobile 45 | 46 | ![Mobile](/screenshots/mobile.png) 47 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: ['@commitlint/config-angular'], 5 | rules: { 6 | 'header-max-length': [1, 'always', 72], 7 | 'type-enum': [ 8 | 2, 9 | 'always', 10 | [ 11 | 'breaking', 12 | 'build', 13 | 'chore', 14 | 'ci', 15 | 'docs', 16 | 'feat', 17 | 'fix', 18 | 'perf', 19 | 'refactor', 20 | 'revert', 21 | 'style', 22 | 'test', 23 | ], 24 | ], 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /lib/adminsockets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const async = require('async'); 4 | const util = require('util'); 5 | 6 | const batch = require.main.require('./src/batch'); 7 | const db = require.main.require('./src/database'); 8 | 9 | const migrator = require('./migrator'); 10 | var Sockets = module.exports; 11 | 12 | Sockets.migrateIn = async function (socket) { 13 | const process = util.promisify(batch.processSortedSet); 14 | const numPosts = await db.sortedSetCard('posts:pid'); 15 | let current = 0; 16 | await process('posts:pid', function (ids, next) { 17 | async.eachSeries(ids, async function (id) { 18 | let postData = await db.getObjectFields('post:' + id, ['content', 'quillDelta']); 19 | if (!postData || postData.quillDelta !== null) { 20 | socket.emit('event:composer-quill.migrateUpdate', { 21 | current: current += 1, 22 | total: numPosts, 23 | }); 24 | 25 | return; 26 | } 27 | 28 | delete postData.quillDelta; 29 | postData = await migrator.toQuill(postData); 30 | await db.setObject('post:' + id, postData); 31 | socket.emit('event:composer-quill.migrateUpdate', { 32 | current: current += 1, 33 | total: numPosts, 34 | }); 35 | }, next); 36 | }, {}); 37 | 38 | return true; 39 | }; 40 | 41 | Sockets.migrateOut = async function (socket) { 42 | const process = util.promisify(batch.processSortedSet); 43 | const numPosts = await db.sortedSetCard('posts:pid'); 44 | let current = 0; 45 | await process('posts:pid', function (ids, next) { 46 | async.eachSeries(ids, async function (id) { 47 | let postData = await db.getObjectFields('post:' + id, ['quillBackup']); 48 | if (!postData || postData.quillBackup === null) { 49 | socket.emit('event:composer-quill.migrateUpdate', { 50 | current: current += 1, 51 | total: numPosts, 52 | }); 53 | return; 54 | } 55 | 56 | postData = { 57 | content: postData.quillBackup, 58 | }; 59 | await db.setObject('post:' + id, postData); 60 | await db.deleteObjectFields('post:' + id, ['quillDelta', 'quillBackup']); 61 | socket.emit('event:composer-quill.migrateUpdate', { 62 | current: current += 1, 63 | total: numPosts, 64 | }); 65 | }, next); 66 | }, {}); 67 | 68 | return true; 69 | }; 70 | -------------------------------------------------------------------------------- /lib/controllers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Controllers = module.exports; 4 | 5 | Controllers.renderAdminPage = function (req, res, next) { 6 | var quill = module.parent.exports; 7 | 8 | quill.checkCompatibility(function (err, checks) { 9 | if (err) { 10 | return next(err); 11 | } 12 | 13 | res.render('admin/plugins/composer-quill', { 14 | title: 'Quill Composer', 15 | checks: checks, 16 | }); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /lib/migrator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const posts = require.main.require('./src/posts'); 4 | 5 | const MarkdownIt = require('markdown-it'); 6 | 7 | const markdown = new MarkdownIt(); 8 | const { QuillDeltaToHtmlConverter } = require('quill-delta-to-html'); 9 | const isHtml = require('is-html'); 10 | 11 | const winston = require.main.require('winston'); 12 | 13 | const Migrator = module.exports; 14 | 15 | Migrator.detect = (postObj) => { 16 | const isHtml = Migrator.isHtml(postObj); 17 | 18 | return Object.freeze({ 19 | quill: Migrator.isQuill(postObj), 20 | html: isHtml, 21 | markdown: !isHtml, 22 | }); 23 | }; 24 | 25 | Migrator.isQuill = postObj => postObj.hasOwnProperty('quillDelta'); 26 | 27 | Migrator.isDelta = (content) => { 28 | try { 29 | content = JSON.parse(content); 30 | return content.hasOwnProperty('ops') && Array.isArray(content.ops); 31 | } catch (e) { 32 | return false; 33 | } 34 | }; 35 | 36 | Migrator.isHtml = postObj => isHtml(postObj.content); 37 | 38 | Migrator.isMarkdown = postObj => !Migrator.isHTML(postObj); 39 | 40 | Migrator.toHtml = (content) => { 41 | try { 42 | content = JSON.parse(content); 43 | const converter = new QuillDeltaToHtmlConverter(content.ops, {}); 44 | 45 | // Quill plugin should fire a hook here, passing converter.renderCustomWith 46 | // Emoji plugin should take that method and register a listener. 47 | // Also toHtml is probably going to end up being asynchronous, then... awaited? 48 | converter.renderCustomWith((customOp) => { 49 | if (customOp.insert.type === 'emoji') { 50 | return `${customOp.attributes.alt}`; 51 | } 52 | }); 53 | 54 | return posts.sanitize(converter.convert()); 55 | } catch (e) { 56 | // Do nothing 57 | winston.verbose('[plugin/composer-quill (toHtml)] Input not in expected format, skipping.'); 58 | return false; 59 | } 60 | }; 61 | 62 | Migrator.toQuill = (postObj) => { 63 | const currently = Migrator.detect(postObj); 64 | 65 | if (currently.quill) { 66 | // Delta already available, no action needed 67 | return postObj; 68 | } 69 | 70 | // Preserve existing content for backup purposes 71 | postObj.quillBackup = postObj.content; 72 | 73 | if (currently.markdown) { 74 | // Convert to HTML 75 | postObj.content = markdown.render(postObj.content); 76 | } 77 | 78 | // Finally, convert to delta 79 | postObj.quillDelta = JSON.stringify(require('node-quill-converter').convertHtmlToDelta(postObj.content)); 80 | 81 | return postObj; 82 | }; 83 | -------------------------------------------------------------------------------- /lib/websockets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Sockets = module.exports; 4 | 5 | Sockets.getEmojiTable = function (socket, data, callback) { 6 | try { 7 | const table = require.main.require('nodebb-plugin-emoji/build/emoji/table.json'); 8 | callback(null, table); 9 | } catch (err) { 10 | callback(null, null); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /library.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SocketPlugins = require.main.require('./src/socket.io/plugins'); 4 | const SocketAdmin = require.main.require('./src/socket.io/admin').plugins; 5 | SocketAdmin['composer-quill'] = require('./lib/adminsockets'); 6 | 7 | const defaultComposer = require.main.require('nodebb-plugin-composer-default'); 8 | const plugins = module.parent.exports; 9 | const meta = require.main.require('./src/meta'); 10 | const posts = require.main.require('./src/posts'); 11 | const messaging = require.main.require('./src/messaging'); 12 | const helpers = require.main.require('./src/controllers/helpers'); 13 | 14 | const async = require('async'); 15 | 16 | const winston = require.main.require('winston'); 17 | const nconf = require.main.require('nconf'); 18 | 19 | const controllers = require('./lib/controllers'); 20 | const migrator = require('./lib/migrator'); 21 | 22 | const plugin = {}; 23 | 24 | plugin.init = function (data, callback) { 25 | const { router } = data; 26 | const hostMiddleware = data.middleware; 27 | 28 | router.get('/admin/plugins/composer-quill', hostMiddleware.admin.buildHeader, controllers.renderAdminPage); 29 | router.get('/api/admin/plugins/composer-quill', controllers.renderAdminPage); 30 | 31 | // Expose the default composer's socket method calls for this composer as well 32 | plugin.checkCompatibility((err, checks) => { 33 | if (err) { 34 | return winston.error(`[plugin/composer-quill] Error initialising plugin: ${err.message}`); 35 | } 36 | 37 | if (checks.composer) { 38 | SocketPlugins.composer = defaultComposer.socketMethods; 39 | SocketPlugins['composer-quill'] = require('./lib/websockets'); 40 | } else { 41 | winston.warn('[plugin/composer-quill] Another composer plugin is active! Please disable all other composers.'); 42 | } 43 | }); 44 | 45 | callback(); 46 | }; 47 | 48 | plugin.checkCompatibility = function (callback) { 49 | async.parallel({ 50 | active: async.apply(plugins.getActive), 51 | markdown: async.apply(meta.settings.get, 'markdown'), 52 | }, (err, data) => { 53 | callback(err, { 54 | markdown: data.active.indexOf('nodebb-plugin-markdown') === -1, // plugin disabled 55 | composer: data.active.filter(plugin => plugin.startsWith('nodebb-plugin-composer-') && plugin !== 'nodebb-plugin-composer-quill').length === 0, 56 | }); 57 | }); 58 | }; 59 | 60 | plugin.addAdminNavigation = function (header, callback) { 61 | header.plugins.push({ 62 | route: '/plugins/composer-quill', 63 | icon: 'fa-edit', 64 | name: 'Quill (Composer)', 65 | }); 66 | 67 | callback(null, header); 68 | }; 69 | 70 | plugin.build = function (data, callback) { 71 | // No plans for a standalone composer route, so handle redirection on f5 72 | const { req } = data; 73 | const { res } = data; 74 | 75 | if (req.query.p) { 76 | if (!res.locals.isAPI) { 77 | if (req.query.p.startsWith(nconf.get('relative_path'))) { 78 | req.query.p = req.query.p.replace(nconf.get('relative_path'), ''); 79 | } 80 | 81 | return helpers.redirect(res, req.query.p); 82 | } 83 | return res.render('', {}); 84 | } else if (!req.query.pid && !req.query.tid && !req.query.cid) { 85 | return helpers.redirect(res, '/'); 86 | } 87 | 88 | callback(null, data); 89 | }; 90 | 91 | plugin.savePost = async (data, path = 'post') => { 92 | if (typeof path === 'function') { 93 | path = 'post'; 94 | } 95 | 96 | if (migrator.isDelta(data[path].content)) { 97 | // Optimistic case: regular post via quill composer 98 | data[path].quillDelta = data[path].content; 99 | data[path].content = migrator.toHtml(data[path].content); 100 | } else { 101 | // Fallback to handle write-api and such 102 | data[path] = migrator.toQuill(data[path]); 103 | } 104 | 105 | return data; 106 | }; 107 | 108 | plugin.savePostQueue = async (data) => { 109 | data = await plugin.savePost(data, 'data'); 110 | return data; 111 | }; 112 | 113 | plugin.saveChat = (data, callback) => { 114 | if (data.system) { 115 | return callback(null, data); 116 | } 117 | 118 | data.quillDelta = data.content; 119 | data.content = migrator.toHtml(data.content); 120 | callback(null, data); 121 | }; 122 | 123 | plugin.append = async (data) => { 124 | const delta = await posts.getPostField(data.pid, 'quillDelta'); 125 | if (delta) { 126 | data.body = delta; 127 | } 128 | return data; 129 | }; 130 | 131 | plugin.handleRawPost = async (data) => { 132 | const delta = await posts.getPostField(data.postData.pid, 'quillDelta'); 133 | if (delta) { 134 | data.postData.content = delta; 135 | } 136 | return data; 137 | }; 138 | 139 | plugin.handleMessageEdit = async (data) => { 140 | // Only handle situations where message content is requested 141 | if (data.fields.length > 1 || data.fields[0] !== 'content') { 142 | return data; 143 | } 144 | 145 | const delta = await messaging.getMessageField(data.mid, 'quillDelta'); 146 | data.message.content = delta; 147 | return data; 148 | }; 149 | 150 | plugin.handleMessageCheck = async ({ content, length }) => { 151 | try { 152 | const delta = JSON.parse(content); 153 | if (!delta.ops) { 154 | throw new Error(); 155 | } 156 | 157 | content = delta.ops.reduce((memo, cur) => { 158 | memo += cur.insert ? cur.insert : ''; 159 | return memo; 160 | }, ''); 161 | length = String(content).trim().length; 162 | } catch (e) { 163 | winston.warn('[plugins/quill/handleMessageCheck] Did not receive a delta, ignoring...'); 164 | } 165 | 166 | return { content, length }; 167 | }; 168 | 169 | module.exports = plugin; 170 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodebb-plugin-composer-quill", 3 | "version": "4.0.1", 4 | "description": "Quill Composer for NodeBB", 5 | "main": "library.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/NodeBB/nodebb-plugin-composer-quill" 9 | }, 10 | "keywords": [ 11 | "nodebb", 12 | "plugin", 13 | "composer", 14 | "quill" 15 | ], 16 | "author": { 17 | "name": "NodeBB Team", 18 | "email": "sales@nodebb.org" 19 | }, 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/NodeBB/nodebb-plugin-composer-quill/issues" 23 | }, 24 | "readmeFilename": "README.md", 25 | "nbbpm": { 26 | "compatibility": "^3.2.0" 27 | }, 28 | "dependencies": { 29 | "async": "^3.2.0", 30 | "is-html": "^2.0.0", 31 | "markdown-it": "^12.0.0", 32 | "node-quill-converter": "^0.3.2", 33 | "quill": "^1.3.6", 34 | "quill-delta-to-html": "^0.12.0", 35 | "quill-magic-url": "^4.0.0", 36 | "screenfull": "^5.0.0" 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "npx lint-staged", 41 | "commit-msg": "npx commitlint -E HUSKY_GIT_PARAMS" 42 | } 43 | }, 44 | "lint-staged": { 45 | "*.js": [ 46 | "eslint --fix", 47 | "git add" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "@commitlint/cli": "19.8.1", 52 | "@commitlint/config-angular": "19.8.1", 53 | "eslint": "9.28.0", 54 | "eslint-config-airbnb-base": "15.0.0", 55 | "eslint-plugin-import": "2.31.0", 56 | "husky": "9.1.7", 57 | "lint-staged": "16.1.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nodebb-plugin-composer-quill", 3 | "url": "https://github.com/NodeBB/nodebb-plugin-composer-quill", 4 | "hooks": [ 5 | { "hook": "static:app.load", "method": "init" }, 6 | { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }, 7 | { "hook": "filter:composer.build", "method": "build" }, 8 | { "hook": "filter:post.create", "method": "savePost" }, 9 | { "hook": "filter:post.edit", "method": "savePost" }, 10 | { "hook": "filter:post-queue.save", "method": "savePostQueue" }, 11 | { "hook": "filter:composer.push", "method": "append" }, 12 | { "hook": "filter:messaging.save", "method": "saveChat" }, 13 | { "hook": "filter:messaging.edit", "method": "saveChat" }, 14 | { "hook": "filter:post.getRawPost", "method": "handleRawPost" }, 15 | { "hook": "filter:messaging.getFields", "method": "handleMessageEdit" }, 16 | { "hook": "filter:messaging.checkContent", "method": "handleMessageCheck" } 17 | ], 18 | "scss": [ 19 | "../nodebb-plugin-composer-default/static/scss/composer.scss", 20 | "./static/scss/quill.scss", 21 | "./static/scss/post.scss", 22 | "./static/scss/overrides.scss" 23 | ], 24 | "modules": { 25 | "quill.js": "./node_modules/quill/dist/quill.js", 26 | "quill-magic-url.js": "./node_modules/quill-magic-url/dist/index.js", 27 | "quill-emoji.js": "./static/lib/emoji.js", 28 | "composer.js": "../nodebb-plugin-composer-default/static/lib/composer.js", 29 | "composer/categoryList.js": "../nodebb-plugin-composer-default/static/lib/composer/categoryList.js", 30 | "composer/controls.js": "../nodebb-plugin-composer-default/static/lib/composer/controls.js", 31 | "composer/drafts.js": "../nodebb-plugin-composer-default/static/lib/composer/drafts.js", 32 | "composer/formatting.js": "../nodebb-plugin-composer-default/static/lib/composer/formatting.js", 33 | "composer/preview.js": "../nodebb-plugin-composer-default/static/lib/composer/preview.js", 34 | "composer/resize.js": "../nodebb-plugin-composer-default/static/lib/composer/resize.js", 35 | "composer/scheduler.js": "../nodebb-plugin-composer-default/static/lib/composer/scheduler.js", 36 | "composer/tags.js": "../nodebb-plugin-composer-default/static/lib/composer/tags.js", 37 | "composer/uploads.js": "../nodebb-plugin-composer-default/static/lib/composer/uploads.js", 38 | "composer/autocomplete.js": "../nodebb-plugin-composer-default/static/lib/composer/autocomplete.js", 39 | "composer/post-queue.js": "../nodebb-plugin-composer-default/static/lib/composer/post-queue.js", 40 | "../admin/plugins/composer-quill.js": "./static/lib/admin.js" 41 | }, 42 | "scripts": [ 43 | "./static/lib/quill-nbb.js", 44 | "./static/lib/client.js", 45 | "./node_modules/screenfull/dist/screenfull.js" 46 | ], 47 | "templates": "static/templates" 48 | } 49 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /screenshots/desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeBB/nodebb-plugin-composer-quill/50e3e77d1f1081d5744b6ab8bd38d812a1a6f1d4/screenshots/desktop.png -------------------------------------------------------------------------------- /screenshots/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeBB/nodebb-plugin-composer-quill/50e3e77d1f1081d5744b6ab8bd38d812a1a6f1d4/screenshots/mobile.png -------------------------------------------------------------------------------- /static/lib/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "nodebb/public" 3 | } -------------------------------------------------------------------------------- /static/lib/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals app, $, socket, define, bootbox */ 4 | 5 | define('admin/plugins/composer-quill', ['benchpress'], function (Benchpress) { 6 | var ACP = {}; 7 | 8 | ACP.init = function () { 9 | $('button[data-action="migrate/in"]').on('click', ACP.migrateIn); 10 | $('button[data-action="migrate/out"]').on('click', ACP.migrateOut); 11 | }; 12 | 13 | ACP.migrateIn = function () { 14 | Benchpress.parse('plugins/composer-quill/modals/migrate-in', {}, function (html) { 15 | var start = function () { 16 | var modal = this; 17 | var progressEl = modal.find('.progress .progress-bar'); 18 | var current = 0; 19 | $(modal).find('.modal-footer button').prop('disabled', true); 20 | 21 | socket.emit('admin.plugins.composer-quill.migrateIn', function (err, ok) { 22 | if (err) { 23 | return app.alertError(err.message); 24 | } 25 | 26 | if (ok) { 27 | // Close modal on migration completion 28 | modal.modal('hide'); 29 | } 30 | }); 31 | 32 | // Update progress bar on server notice 33 | socket.on('event:composer-quill.migrateUpdate', function (payload) { 34 | var percentage = Math.floor((payload.current / payload.total) * 100); 35 | if (percentage > current) { 36 | progressEl.css('width', percentage + '%'); 37 | progressEl.attr('aria-valuenow', percentage); 38 | current = percentage; 39 | } 40 | }); 41 | 42 | return false; 43 | }; 44 | 45 | bootbox.dialog({ 46 | title: 'Migrate Content to Quill', 47 | message: html, 48 | buttons: { 49 | submit: { 50 | label: 'Begin Migration', 51 | className: 'btn-primary', 52 | callback: start, 53 | }, 54 | }, 55 | }); 56 | }); 57 | }; 58 | 59 | ACP.migrateOut = function () { 60 | Benchpress.parse('plugins/composer-quill/modals/migrate-out', {}, function (html) { 61 | var start = function () { 62 | var modal = this; 63 | var progressEl = modal.find('.progress .progress-bar'); 64 | var current = 0; 65 | $(modal).find('.modal-footer button').prop('disabled', true); 66 | 67 | socket.emit('admin.plugins.composer-quill.migrateOut', function (err, ok) { 68 | if (err) { 69 | return app.alertError(err.message); 70 | } 71 | 72 | if (ok) { 73 | // Close modal on migration completion 74 | modal.modal('hide'); 75 | } 76 | }); 77 | 78 | // Update progress bar on server notice 79 | socket.on('event:composer-quill.migrateUpdate', function (payload) { 80 | var percentage = Math.floor((payload.current / payload.total) * 100); 81 | if (percentage > current) { 82 | progressEl.css('width', percentage + '%'); 83 | progressEl.attr('aria-valuenow', percentage); 84 | current = percentage; 85 | } 86 | }); 87 | 88 | return false; 89 | }; 90 | 91 | bootbox.dialog({ 92 | title: 'Reverse previous Quill Migration', 93 | message: html, 94 | buttons: { 95 | submit: { 96 | label: 'Begin Migration', 97 | className: 'btn-primary', 98 | callback: start, 99 | }, 100 | }, 101 | }); 102 | }); 103 | }; 104 | 105 | return ACP; 106 | }); 107 | -------------------------------------------------------------------------------- /static/lib/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals $, document, window */ 4 | 5 | $(document).ready(() => { 6 | const wrapWithBlockquote = function (delta) { 7 | // Validate the delta 8 | try { 9 | const parsed = JSON.parse(delta); 10 | parsed.ops = parsed.ops.map((op) => { 11 | // eslint-disable-next-line prefer-object-spread 12 | op.attributes = Object.assign({ blockquote: true }, op.attributes || {}); 13 | return op; 14 | }); 15 | return JSON.stringify(parsed); 16 | } catch (e) { 17 | // It is probably just a text string, make your own delta(tm) 18 | return JSON.stringify({ ops: [{ insert: `${delta}\n`, attributes: { blockquote: true } }] }); 19 | } 20 | }; 21 | $(window).on('action:app.load', () => { 22 | require(['composer', 'quill-nbb'], (composer) => { 23 | $(window).on('action:composer.topic.new', (ev, data) => { 24 | composer.newTopic({ 25 | cid: data.cid, 26 | title: data.title || '', 27 | body: data.body || '', 28 | tags: data.tags || [], 29 | }); 30 | }); 31 | 32 | $(window).on('action:composer.post.edit', (ev, data) => { 33 | composer.editPost({ pid: data.pid }); 34 | }); 35 | 36 | $(window).on('action:composer.post.new', (ev, data) => { 37 | data.body = data.body || data.text; 38 | data.title = data.title || data.topicName; 39 | composer.newReply({ 40 | tid: data.tid, 41 | toPid: data.pid, 42 | title: data.title, 43 | body: data.body, 44 | }); 45 | }); 46 | 47 | $(window).on('action:composer.addQuote', (ev, data) => { 48 | data.title = data.title || data.topicName; 49 | data.body = data.body || data.text; 50 | composer.newReply({ 51 | tid: data.tid, 52 | toPid: data.pid, 53 | title: data.title, 54 | body: wrapWithBlockquote(data.body), 55 | }); 56 | }); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /static/lib/emoji.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals define, socket, app, config */ 4 | 5 | /** 6 | * DEVELOPER NOTE 7 | * 8 | * It isn't particularly scalable to have a separate file for integration with 9 | * each individual plugin. Eventually, it is expected that Quill will fire off 10 | * hooks that plugins can listen for and invoke, therefore the code contained 11 | * here would be better located in the emoji plugin instead. 12 | * 13 | * .enable() is called from quill-nbb.js but it could very well be listening 14 | * for action:quill.load 15 | * 16 | * .convert() is called during composer autocomplete, which could be listening 17 | * for a hook to be fired by autocomplete, of which there is none right now. 18 | * 19 | * 2 June 2021 -- core now has client-side hooks, which will allow this to happen. 20 | */ 21 | 22 | define('quill-emoji', ['quill'], (quill) => { 23 | const Emoji = { 24 | table: {}, 25 | }; 26 | 27 | // Emoji Blot 28 | const imageBlot = quill.import('formats/image'); 29 | const emojiAttributes = ['src', 'alt', 'class']; 30 | 31 | class EmojiBlot extends imageBlot { 32 | static create(value) { 33 | const node = super.create(value.src); 34 | node.setAttribute('class', value.class); 35 | return node; 36 | } 37 | 38 | static formats(domNode) { 39 | return emojiAttributes.reduce((formats, attribute) => { 40 | if (domNode.hasAttribute(attribute)) { 41 | formats[attribute] = domNode.getAttribute(attribute); 42 | } 43 | return formats; 44 | }, {}); 45 | } 46 | 47 | static format(name, value) { 48 | if (emojiAttributes.includes(name)) { 49 | if (value) { 50 | this.domNode.setAttribute(name, value); 51 | } else { 52 | this.domNode.removeAttribute(name); 53 | } 54 | } else { 55 | super.format(name, value); 56 | } 57 | } 58 | 59 | static value(domNode) { 60 | return { 61 | src: domNode.getAttribute('src'), 62 | class: domNode.getAttribute('class'), 63 | }; 64 | } 65 | } 66 | EmojiBlot.blotName = 'emoji'; 67 | quill.register(EmojiBlot); 68 | 69 | Emoji.enable = function (quill) { 70 | if (!Object.keys(Emoji.table).length) { 71 | socket.emit('plugins.composer-quill.getEmojiTable', {}, (err, table) => { 72 | if (err) { 73 | app.alertError(err.message); 74 | } 75 | 76 | if (table !== null) { 77 | Emoji.table = table; 78 | quill.on('text-change', Emoji.convert.bind(quill)); 79 | } 80 | }); 81 | } 82 | }; 83 | 84 | Emoji.convert = function (delta) { 85 | const quill = this; 86 | const contents = quill.getContents(); 87 | const emojiRegex = /:([\w+-]+):/g; 88 | 89 | // Special handling for emoji plugin 90 | if (!delta || delta.ops.some(command => command.insert && (command.insert === ':' || String(command.insert).endsWith(':') || String(command.insert).endsWith(': \n')))) { 91 | // Check all nodes for emoji shorthand and replace with image 92 | contents.reduce((retain, cur) => { 93 | let match = emojiRegex.exec(cur.insert); 94 | let contents; 95 | let emojiObj; 96 | while (match !== null) { 97 | emojiObj = Emoji.table[match[1]]; 98 | if (emojiObj) { 99 | contents = [{ 100 | insert: { 101 | emoji: { 102 | src: `${config.relative_path}/plugins/nodebb-plugin-emoji/emoji/${emojiObj.pack}/${emojiObj.image}?${app.cacheBuster}`, 103 | class: `not-responsive emoji emoji-${emojiObj.pack} emoji--${emojiObj.name}`, 104 | }, 105 | }, 106 | attributes: { 107 | alt: emojiObj.character, 108 | }, 109 | }]; 110 | if (match[0].length) { 111 | contents.unshift({ delete: match[0].length }); 112 | } 113 | if (retain + match.index) { 114 | contents.unshift({ retain: retain + match.index }); 115 | } 116 | 117 | quill.updateContents({ 118 | ops: contents, 119 | }); 120 | } 121 | 122 | // Reset search and continue 123 | emojiRegex.lastIndex = retain + match.index + 1; 124 | match = emojiRegex.exec(cur.insert); 125 | } 126 | 127 | retain += cur.insert.length || 1; 128 | return retain; 129 | }, 0); 130 | } 131 | }; 132 | 133 | return Emoji; 134 | }); 135 | -------------------------------------------------------------------------------- /static/lib/quill-nbb.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals document, $, window, define, socket, app, ajaxify, config */ 4 | 5 | window.quill = { 6 | uploads: {}, 7 | }; 8 | 9 | define('quill-nbb', [ 10 | 'quill', 11 | 'composer/resize', 12 | 'components', 13 | 'slugify', 14 | ], (Quill, resize, components, slugify) => { 15 | $(window).on('action:composer.loaded', (ev, data) => { 16 | const postContainer = $(`.composer[data-uuid="${data.post_uuid}"]`); 17 | const targetEl = postContainer.find('.write-container div'); 18 | 19 | window.quill.init(targetEl, data); 20 | 21 | const cidEl = postContainer.find('.category-list'); 22 | if (cidEl.length) { 23 | cidEl.attr('id', `cmp-cid-${data.post_uuid}`); 24 | } else { 25 | postContainer.append(``); 26 | } 27 | 28 | // if (config.allowTopicsThumbnail && data.composerData.isMain) { 29 | // var thumbToggleBtnEl = postContainer.find('.re-topic_thumb'); 30 | // var url = data.composerData.topic_thumb || ''; 31 | 32 | // postContainer.find('input#topic-thumb-url').val(url); 33 | // postContainer.find('img.topic-thumb-preview').attr('src', url); 34 | 35 | // if (url) { 36 | // postContainer.find('.topic-thumb-clear-btn').removeClass('hide'); 37 | // } 38 | // thumbToggleBtnEl.addClass('show'); 39 | // thumbToggleBtnEl.off('click').on('click', function() { 40 | // var container = postContainer.find('.topic-thumb-container'); 41 | // container.toggleClass('hide', !container.hasClass('hide')); 42 | // }); 43 | // } 44 | 45 | resize.reposition(postContainer); 46 | }); 47 | 48 | $(window).on('action:composer.check', (ev, data) => { 49 | // Update bodyLen for length checking purposes 50 | const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill'); 51 | data.bodyLen = quill.getLength() - 1; 52 | }); 53 | 54 | $(window).on('action:chat.sent', (evt, data) => { 55 | // Empty chat input 56 | const quill = $(`.chat-modal[data-roomid="${data.roomId}"] .ql-container, .expanded-chat[data-roomid="${data.roomId}"] .ql-container`).data('quill'); 57 | quill.deleteText(0, quill.getLength()); 58 | 59 | // Reset text direction 60 | const textDirection = $('html').attr('data-dir'); 61 | quill.format('direction', textDirection); 62 | quill.format('align', textDirection === 'rtl' ? 'right' : 'left'); 63 | }); 64 | 65 | $(window).on('action:chat.prepEdit', (evt, data) => { 66 | let value = data.inputEl.val(); 67 | const quill = data.inputEl.siblings('.ql-container').data('quill'); 68 | 69 | try { 70 | value = JSON.parse(value); 71 | quill.setContents(value, 'user'); 72 | } catch (e) { 73 | app.alertError('[[error:invalid-json]]'); 74 | } 75 | }); 76 | 77 | $(window).on('action:composer.uploadUpdate', (evt, data) => { 78 | const filename = data.filename.replace(/^\d+_\d+_/, ''); 79 | const alertId = generateAlertId(data.post_uuid, filename); 80 | if (!window.quill.uploads[filename]) { 81 | console.warn(`[quill/uploads] Unable to find file (${filename}).`); 82 | app.removeAlert(alertId); 83 | return; 84 | } 85 | 86 | if (!data.text.startsWith('/')) { 87 | app.alert({ 88 | alert_id: alertId, 89 | title: data.filename.replace(/\d_\d+_/, ''), 90 | message: data.text, 91 | timeout: 1000, 92 | }); 93 | } 94 | }); 95 | 96 | $(window).on('action:composer.upload', (evt, data) => { 97 | const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill'); 98 | data.files.forEach((file) => { 99 | const alertId = generateAlertId(data.post_uuid, file.filename); 100 | app.removeAlert(alertId); 101 | 102 | // Image vs. file upload 103 | if (file.isImage) { 104 | quill.insertEmbed(quill.getSelection().index, 'image', file.url); 105 | } else { 106 | const selection = quill.getSelection(); 107 | 108 | if (selection.length) { 109 | const linkText = quill.getText(selection.index, selection.length); 110 | quill.deleteText(selection.index, selection.length); 111 | quill.insertText(selection.index, linkText, { 112 | link: file.url, 113 | }); 114 | } else { 115 | quill.insertText(selection.index, file.filename, { 116 | link: file.url, 117 | }); 118 | } 119 | } 120 | }); 121 | }); 122 | 123 | $(window).on('action:composer.uploadError', (evt, data) => { 124 | const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill'); 125 | const textareaEl = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('textarea'); 126 | textareaEl.val(!window.quill.isEmpty(quill) ? JSON.stringify(quill.getContents()) : ''); 127 | textareaEl.trigger('change'); 128 | textareaEl.trigger('keyup'); 129 | }); 130 | 131 | $(window).on('action:composer.uploadStart', (evt, data) => { 132 | data.files.forEach((file) => { 133 | app.alert({ 134 | alert_id: generateAlertId(data.post_uuid, file.filename), 135 | title: file.filename.replace(/\d_\d+_/, ''), 136 | message: data.text, 137 | }); 138 | }); 139 | }); 140 | 141 | $(window).on('action:composer.insertIntoTextarea', (evt, data) => { 142 | const quill = $(data.textarea).siblings('.ql-container').data('quill'); 143 | const selection = quill.getSelection(true); 144 | quill.insertText(selection.index, data.value); 145 | data.preventDefault = true; 146 | 147 | // hack to convert emoji's inserted text into... an emoji 148 | require(['quill-emoji'], (Emoji) => { 149 | Emoji.convert.call(quill); 150 | }); 151 | }); 152 | 153 | $(window).on('action:composer.updateTextareaSelection', (evt, data) => { 154 | const quill = $(data.textarea).siblings('.ql-container').data('quill'); 155 | quill.setSelection(data.start, data.end - data.start); 156 | data.preventDefault = true; 157 | }); 158 | 159 | $(window).on('action:composer.wrapSelectionInTextareaWith', (evt, data) => { 160 | const Delta = Quill.import('delta'); 161 | const quill = $(data.textarea).siblings('.ql-container').data('quill'); 162 | 163 | const range = quill.getSelection(); 164 | let insertionDelta; 165 | 166 | if (range.length) { 167 | insertionDelta = quill.getContents(range.index, range.length); 168 | } else { 169 | insertionDelta = new Delta(); 170 | } 171 | 172 | // Wrap selection in spoiler tags 173 | quill.updateContents(new Delta() 174 | .retain(range.index) 175 | .delete(range.length) 176 | .insert(data.leading) 177 | .concat(insertionDelta) 178 | .insert(data.trailing)); 179 | 180 | if (range.length) { 181 | // Update selection 182 | quill.setSelection(range.index + (data.leading.length), range.length); 183 | } 184 | 185 | data.preventDefault = true; 186 | }); 187 | 188 | $(window).on('action:chat.updateRemainingLength', (evt, data) => { 189 | const quill = data.parent.find('.ql-container').data('quill'); 190 | const length = quill.getText().length - 1; 191 | data.parent.find('[component="chat/message/length"]').text(length); 192 | data.parent.find('[component="chat/message/remaining"]').text(config.maximumChatMessageLength - length); 193 | }); 194 | 195 | function generateAlertId(uuid, filename) { 196 | return slugify([uuid, filename].join('-')); 197 | } 198 | }); 199 | 200 | // Window events that must be attached immediately 201 | 202 | $(window).on('action:chat.loaded', (evt, containerEl) => { 203 | require([ 204 | 'composer', 205 | 'composer/autocomplete', 206 | 'components', 207 | ], (composer, autocomplete, components) => { 208 | // Create div element for composer 209 | const targetEl = $('
').insertBefore(components.get('chat/input')); 210 | 211 | const onInit = function () { 212 | autocomplete.init($(containerEl)); 213 | }; 214 | 215 | // Load formatting options into DOM on-demand 216 | if (composer.formatting) { 217 | window.quill.init(targetEl, { 218 | formatting: composer.formatting, 219 | theme: 'bubble', 220 | bounds: containerEl, 221 | }, onInit); 222 | } else { 223 | socket.emit('plugins.composer.getFormattingOptions', (err, options) => { 224 | if (err) { 225 | app.alertError(err.message); 226 | } 227 | 228 | composer.formatting = options; 229 | window.quill.init(targetEl, { 230 | formatting: composer.formatting, 231 | theme: 'bubble', 232 | bounds: containerEl, 233 | }, onInit); 234 | }); 235 | } 236 | }); 237 | }); 238 | 239 | // Internal methods 240 | 241 | window.quill.init = function (targetEl, data, callback) { 242 | require([ 243 | 'quill', 'quill-magic-url', 'quill-emoji', 244 | 'composer/autocomplete', 'composer/drafts', 245 | ], (Quill, MagicUrl, Emoji, autocomplete, drafts) => { 246 | const textDirection = $('html').attr('data-dir'); 247 | const textareaEl = targetEl.siblings('textarea'); 248 | 249 | window.quill.configureToolbar(targetEl, data).then(({ toolbar }) => { 250 | // Quill... 251 | Quill.register('modules/magicUrl', MagicUrl.default); 252 | const quill = new Quill(targetEl.get(0), { 253 | theme: data.theme || 'snow', 254 | modules: { 255 | toolbar, 256 | magicUrl: { 257 | normalizeUrlOptions: { 258 | sortQueryParameters: false, 259 | defaultProtocol: 'https:', 260 | }, 261 | }, 262 | }, 263 | bounds: data.bounds || document.body, 264 | }); 265 | targetEl.data('quill', quill); 266 | targetEl.find('.ql-editor').addClass('write'); 267 | 268 | // Configure toolbar icons (must be done after quill itself is instantiated) 269 | const toolbarEl = targetEl.siblings('.ql-toolbar').length ? targetEl.siblings('.ql-toolbar') : targetEl.find('.ql-toolbar'); 270 | data.formatting.forEach((option) => { 271 | const buttonEl = toolbarEl.find(`.ql-${option.name}`); 272 | buttonEl.html(``); 273 | if (option.mobile) { 274 | buttonEl.addClass('visible-xs'); 275 | } 276 | }); 277 | ['upload:post:image', 'upload:post:file'].forEach((privilege) => { 278 | if (app.user.privileges[privilege]) { 279 | const className = privilege === 'upload:post:image' ? 'picture' : 'upload'; 280 | const buttonEl = toolbarEl.find(`.ql-${className}`); 281 | if (className === 'picture') { 282 | buttonEl.html(''); 283 | } else { 284 | buttonEl.html(''); 285 | } 286 | } 287 | }); 288 | 289 | $(window).trigger('action:quill.load', quill); 290 | 291 | // Restore text if contained in composerData or drafts 292 | const draft = data.composerData && drafts.get(data.composerData.save_id); 293 | if (data.composerData && data.composerData.body) { 294 | try { 295 | const unescaped = data.composerData.body.replace(/"/g, '"'); 296 | const delta = JSON.parse(unescaped); 297 | delta.ops.push({ 298 | insert: '\n', 299 | attributes: { 300 | direction: textDirection, 301 | align: textDirection === 'rtl' ? 'right' : 'left', 302 | }, 303 | }); 304 | quill.setContents(delta, 'api'); 305 | } catch (e) { 306 | quill.setContents({ 307 | ops: [{ 308 | insert: data.composerData.body.toString(), 309 | attributes: { 310 | direction: textDirection, 311 | align: textDirection === 'rtl' ? 'right' : 'left', 312 | }, 313 | }], 314 | }, 'api'); 315 | } 316 | 317 | // Move cursor to the very end 318 | const length = quill.getLength(); 319 | quill.setSelection(length); 320 | } else if (draft && draft.text) { 321 | // Set title 322 | targetEl.parents('.composer').find('.title').val(draft.title); 323 | const delta = JSON.parse(draft.text); 324 | delta.ops.push({ 325 | insert: '\n', 326 | attributes: { 327 | direction: textDirection, 328 | align: textDirection === 'rtl' ? 'right' : 'left', 329 | }, 330 | }); 331 | quill.setContents(delta, 'api'); 332 | } 333 | 334 | // Automatic RTL support 335 | quill.format('direction', textDirection); 336 | quill.format('align', textDirection === 'rtl' ? 'right' : 'left'); 337 | 338 | autocomplete.init(targetEl, data.post_uuid); 339 | Emoji.enable(quill); 340 | 341 | // Update textarea on editor-change event. This allows compatibility with 342 | // how NodeBB handles things like drafts, etc. 343 | quill.on('editor-change', () => { 344 | textareaEl.val(JSON.stringify(quill.getContents())); 345 | textareaEl.trigger('change'); 346 | textareaEl.trigger('keyup'); 347 | }); 348 | 349 | // Special handling on text-change 350 | quill.on('text-change', () => { 351 | if (window.quill.isEmpty(quill)) { 352 | quill.deleteText(0, quill.getLength()); 353 | textareaEl.val(''); 354 | } 355 | }); 356 | 357 | // Handle tab/enter for autocomplete 358 | const doAutocomplete = function () { 359 | setTimeout(Emoji.convert.bind(quill), 0); 360 | return !$(`.composer-autocomplete-dropdown-${data.post_uuid}:visible`).length; 361 | }; 362 | [9, 13].forEach((keyCode) => { 363 | quill.keyboard.addBinding({ 364 | key: keyCode, 365 | }, doAutocomplete); 366 | quill.keyboard.bindings[keyCode].unshift(quill.keyboard.bindings[keyCode].pop()); 367 | }); 368 | 369 | if (!data.composerData || data.composerData.action !== 'topics.post') { 370 | // Oddly, a 0ms timeout is required here otherwise .focus() does not work 371 | setTimeout(quill.focus.bind(quill), 0); 372 | } 373 | 374 | if (typeof callback === 'function') { 375 | callback(); 376 | } 377 | }); 378 | }); 379 | 380 | return window.quill; 381 | }; 382 | 383 | window.quill.configureToolbar = async (targetEl, data) => { 384 | const textareaEl = targetEl.siblings('textarea'); 385 | const [formatting, hooks] = await new Promise((resolve) => { 386 | require(['composer/formatting', 'hooks'], (...libs) => resolve(libs)); 387 | }); 388 | const toolbar = { 389 | container: [ 390 | [{ header: [1, 2, 3, 4, 5, 6, false] }], // h1..h6 391 | [{ font: [] }], 392 | ['bold', 'italic', 'underline', 'strike'], // toggled buttons 393 | ['link', 'blockquote', 'code-block'], 394 | [{ list: 'ordered' }, { list: 'bullet' }], 395 | [{ script: 'sub' }, { script: 'super' }], // superscript/subscript 396 | [{ color: [] }, { background: [] }], // dropdown with defaults from theme 397 | [{ align: [] }], 398 | ['clean'], 399 | ], 400 | handlers: {}, 401 | }; 402 | 403 | // Configure toolbar 404 | const toolbarHandlers = formatting.getDispatchTable(); 405 | const group = []; 406 | data.formatting.forEach((option) => { 407 | group.push(option.name); 408 | toolbar.handlers[option.name] = function () { 409 | // Chicken-wrapper to pass additional values to handlers (to match composer-default behaviour) 410 | const quill = targetEl.data('quill'); 411 | const selection = quill.getSelection(true); 412 | toolbarHandlers[option.name].apply( 413 | data.postContainer, 414 | [textareaEl.get(0), selection.index, selection.index + selection.length] 415 | ); 416 | }; 417 | }); 418 | // -- upload privileges 419 | ['upload:post:file', 'upload:post:image'].forEach((privilege) => { 420 | if (app.user.privileges[privilege]) { 421 | const name = privilege === 'upload:post:image' ? 'picture' : 'upload'; 422 | group.unshift(name); 423 | toolbar.handlers[name] = toolbarHandlers[name].bind($('.formatting-bar')); 424 | } 425 | }); 426 | toolbar.container.push(group); 427 | 428 | // Allow plugins to modify toolbar 429 | return await hooks.fire('filter:quill.toolbar', { toolbar }); 430 | }; 431 | 432 | window.quill.isEmpty = function (quill) { 433 | const contents = quill.getContents(); 434 | 435 | if (contents.ops.length === 1) { 436 | const value = contents.ops[0].insert.replace(/[\s\n]/g, ''); 437 | return value === ''; 438 | } 439 | 440 | return false; 441 | }; 442 | -------------------------------------------------------------------------------- /static/scss/overrides.scss: -------------------------------------------------------------------------------- 1 | .composer { 2 | .formatting-bar { 3 | display: none!important; 4 | } 5 | 6 | .write-container { 7 | width: 100%!important; 8 | min-width: 0px; // fixes negation of overflow-wrap due this being a flex-item 9 | 10 | textarea { 11 | display: none; 12 | } 13 | } 14 | } 15 | 16 | .ql-toolbar { 17 | button > i { 18 | float: left; 19 | height: 100%; 20 | } 21 | } 22 | 23 | [component="chat/composer"] { 24 | .ql-editor { 25 | padding: 0; 26 | } 27 | 28 | [component="chat/input"] { 29 | display: none; 30 | } 31 | } -------------------------------------------------------------------------------- /static/scss/post.scss: -------------------------------------------------------------------------------- 1 | [component="post/content"], [component="chat/messages"] { 2 | .ql-align-center { 3 | text-align: center; 4 | } 5 | .ql-align-right { 6 | text-align: right; 7 | } 8 | .ql-align-justify { 9 | text-align: justify; 10 | } 11 | 12 | // Fonts 13 | .ql-font-serif { 14 | font-family: Georgia, "Times New Roman", serif; 15 | } 16 | .ql-font-monospace { 17 | font-family: Monaco, "Courier New", monospace; 18 | } 19 | } -------------------------------------------------------------------------------- /static/scss/quill.scss: -------------------------------------------------------------------------------- 1 | @import 'quill/dist/quill.snow'; 2 | @import 'quill/dist/quill.bubble'; -------------------------------------------------------------------------------- /static/templates/admin/plugins/composer-quill.tpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |
7 |

8 | Quill is a free, open source WYSIWYG editor built for the modern web. With its modular architecture and expressive API, it is completely customizable to fit any need. 9 |

10 | 11 |
12 | 13 |
14 |
Migration
15 | 16 |

17 | If you are switching to Quill from a different composer (i.e. composer-default/markdown), you will need to convert your existing posts to Quill's format. You may use the utilities below to do so. 18 |

19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 |
27 |
Compatibility Checks
28 | 29 |
    30 |
  • 31 | Markdown Compatibility 32 | {{{ if checks.markdown }}} 33 | 34 |

    The Markdown plugin is either disabled, or HTML sanitization is disabled

    35 | {{{ else }}} 36 | 37 |

    38 | In order to render post content correctly, the Markdown plugin needs to have HTML sanitization disabled, 39 | or the entire plugin should be disabled altogether. 40 |

    41 | {{{ end }}} 42 |
  • 43 |
  • 44 | Composer Conflicts 45 | {{{ if checks.composer }}} 46 | 47 |

    Great! Looks like Quill is the only composer active

    48 | {{{ else }}} 49 | 50 |

    Quill must be the only composer active. Please disable other composers and reload NodeBB.

    51 | {{{ end }}} 52 |
  • 53 |
54 |
55 |
56 | 57 | 58 |
59 |
60 | 61 | -------------------------------------------------------------------------------- /static/templates/composer.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | {{{ if isTopicOrMain }}} 32 | 33 | {{{ end }}} 34 | 35 |
[[topic:composer.drag-and-drop-images]]
36 | 37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 |
46 |
47 | -------------------------------------------------------------------------------- /static/templates/modals/topic-scheduler.tpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
-------------------------------------------------------------------------------- /static/templates/partials/composer-formatting.tpl: -------------------------------------------------------------------------------- 1 |
2 | 60 |
61 | 62 | 67 | {{{ if composer:showHelpTab }}} 68 | 72 | {{{ end }}} 73 |
74 |
75 | 76 | -------------------------------------------------------------------------------- /static/templates/partials/composer-tags.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 8 | 9 | 14 |
15 | 16 |
17 |
-------------------------------------------------------------------------------- /static/templates/partials/composer-title-container.tpl: -------------------------------------------------------------------------------- 1 |
2 | {{{ if isTopic }}} 3 |
4 | 5 |
6 | {{{ end }}} 7 | 8 | {{{ if showHandleInput }}} 9 |
10 | 11 |
12 | {{{ end }}} 13 | 14 |
15 | {{{ if isTopicOrMain }}} 16 | 17 | {{{ else }}} 18 | {{{ if isEditing }}}[[topic:composer.editing-in, "{topicTitle}"]]{{{ else }}}[[topic:composer.replying-to, "{topicTitle}"]]{{{ end }}} 19 | {{{ end }}} 20 | 24 |
25 | 26 |
27 | 28 | 29 |
30 | 31 |
32 | 36 | 43 |
44 |
45 |
46 |
47 | -------------------------------------------------------------------------------- /static/templates/partials/composer-write-preview.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 8 | 9 |
10 | 11 |
12 |
13 |
14 | 15 |
-------------------------------------------------------------------------------- /static/templates/plugins/composer-quill/modals/migrate-in.tpl: -------------------------------------------------------------------------------- 1 |

Use this utility to migrate pre-existing post content to a Quill-compatible format

2 |

3 | This plugin will convert Markdown and HTML in post content to Quill deltas, the internal format used by the Quill library. The old content will be saved in case you ever want to migrate back (via the "Migrate from Quill" option), but any posts made in Quill will be left as-is. 4 |

5 |

6 | Important! → Once begun, the migration process cannot be stopped. 7 |

8 | 9 |
10 |
11 | 0% Complete 12 |
13 |
-------------------------------------------------------------------------------- /static/templates/plugins/composer-quill/modals/migrate-out.tpl: -------------------------------------------------------------------------------- 1 |

Use this utility to reverse any previously migrated content back to its old format

2 |

3 | If there were posts that were migrated to Quill via the bundled utility, a backup of the old content was saved. This migrator will restore the old format. Any posts made in Quill will be left as-is. 4 |

5 |

6 | Important! → Once begun, the migration process cannot be stopped. 7 |

8 | 9 |
10 |
11 | 0% Complete 12 |
13 |
--------------------------------------------------------------------------------