diff --git a/client/components/admin/admin-theme.vue b/client/components/admin/admin-theme.vue index 1803ab1b6c..273bf24dd6 100644 --- a/client/components/admin/admin-theme.vue +++ b/client/components/admin/admin-theme.vue @@ -68,6 +68,75 @@ hint='Select whether the table of contents is shown on the left, right or not at all.' disabled ) + v-radio-group( + row + outlined + persistent-hint + prepend-icon='mdi-serial-port' + v-model='config.tocLevel' + label='Max Heading Level' + hint='The table of contents will show headings up to the selected level. By default, only heading levels up to H2 are shown.' + ) + v-spacer + v-radio( + label='H1' + v-bind:value='1' + ) + v-radio( + label='H2' + v-bind:value='2' + ) + v-radio( + label='H3' + v-bind:value='3' + ) + v-radio( + label='H4' + v-bind:value='4' + ) + v-radio( + label='H5' + v-bind:value='5' + ) + v-radio( + label='H6' + v-bind:value='6' + ) + v-radio-group( + row + outlined + persistent-hint + prepend-icon='mdi-serial-port' + v-model='config.tocCollapseLevel' + label='Collapse Heading Level' + hint='The table of contents will collapse headings starting from the selected level. By default, only heading levels from H2 are collapsed.' + ) + v-spacer + v-radio( + label='H1' + v-bind:value='1' + ) + v-radio( + label='H2' + v-bind:value='2' + ) + v-radio( + label='H3' + v-bind:value='3' + ) + v-radio( + label='H4' + v-bind:value='4' + ) + v-radio( + label='H5' + v-bind:value='5' + ) + v-radio( + label='H6' + v-bind:value='6' + ) + v-flex(lg6 xs12) v-card.animated.fadeInUp.wait-p2s @@ -154,6 +223,8 @@ export default { config: { theme: 'default', darkMode: false, + tocLevel: 2, + tocCollapseLevel: 2, iconset: '', injectCSS: '', injectHead: '', @@ -209,6 +280,8 @@ export default { theme: this.config.theme, iconset: this.config.iconset, darkMode: this.darkMode, + tocLevel: parseInt(this.config.tocLevel, 10), + tocCollapseLevel: parseInt(this.config.tocCollapseLevel, 10), injectCSS: this.config.injectCSS, injectHead: this.config.injectHead, injectBody: this.config.injectBody diff --git a/client/components/editor.vue b/client/components/editor.vue index 15311e9bed..e7bc260dd5 100644 --- a/client/components/editor.vue +++ b/client/components/editor.vue @@ -144,6 +144,14 @@ export default { type: Number, default: 0 }, + tocLevel: { + type: Number, + default: 0 + }, + tocCollapseLevel: { + type: Number, + default: 0 + }, checkoutDate: { type: String, default: new Date().toISOString() @@ -190,6 +198,8 @@ export default { this.path !== this.$store.get('page/path'), this.savedState.title !== this.$store.get('page/title'), this.savedState.description !== this.$store.get('page/description'), + this.savedState.tocLevel !== this.$store.get('page/tocLevel'), + this.savedState.tocCollapseLevel !== this.$store.get('page/tocCollapseLevel'), this.savedState.tags !== this.$store.get('page/tags'), this.savedState.isPublished !== this.$store.get('page/isPublished'), this.savedState.publishStartDate !== this.$store.get('page/publishStartDate'), @@ -223,6 +233,8 @@ export default { this.$store.set('page/title', this.title) this.$store.set('page/scriptCss', this.scriptCss) this.$store.set('page/scriptJs', this.scriptJs) + this.$store.set('page/tocLevel', this.tocLevel) + this.$store.set('page/tocCollapseLevel', this.tocCollapseLevel) this.$store.set('page/mode', 'edit') @@ -303,6 +315,8 @@ export default { $publishStartDate: Date $scriptCss: String $scriptJs: String + $tocLevel: Int! + $tocCollapseLevel: Int! $tags: [String]! $title: String! ) { @@ -319,6 +333,8 @@ export default { publishStartDate: $publishStartDate scriptCss: $scriptCss scriptJs: $scriptJs + tocLevel: $tocLevel + tocCollapseLevel: $tocCollapseLevel tags: $tags title: $title ) { @@ -348,6 +364,8 @@ export default { publishStartDate: this.$store.get('page/publishStartDate') || '', scriptCss: this.$store.get('page/scriptCss'), scriptJs: this.$store.get('page/scriptJs'), + tocLevel: this.$store.get('page/tocLevel'), + tocCollapseLevel: this.$store.get('page/tocCollapseLevel'), tags: this.$store.get('page/tags'), title: this.$store.get('page/title') } @@ -407,6 +425,8 @@ export default { $publishStartDate: Date $scriptCss: String $scriptJs: String + $tocLevel: Int + $tocCollapseLevel: Int $tags: [String] $title: String ) { @@ -424,6 +444,8 @@ export default { publishStartDate: $publishStartDate scriptCss: $scriptCss scriptJs: $scriptJs + tocLevel: $tocLevel + tocCollapseLevel: $tocCollapseLevel tags: $tags title: $title ) { @@ -453,6 +475,8 @@ export default { publishStartDate: this.$store.get('page/publishStartDate') || '', scriptCss: this.$store.get('page/scriptCss'), scriptJs: this.$store.get('page/scriptJs'), + tocLevel: this.$store.get('page/tocLevel'), + tocCollapseLevel: this.$store.get('page/tocCollapseLevel'), tags: this.$store.get('page/tags'), title: this.$store.get('page/title') } @@ -535,7 +559,9 @@ export default { tags: this.$store.get('page/tags'), title: this.$store.get('page/title'), css: this.$store.get('page/scriptCss'), - js: this.$store.get('page/scriptJs') + js: this.$store.get('page/scriptJs'), + tocLevel: this.$store.get('page/tocLevel'), + tocCollapseLevel: this.$store.get('page/tocCollapseLevel') } }, injectCustomCss: _.debounce(css => { diff --git a/client/components/editor/editor-modal-properties.vue b/client/components/editor/editor-modal-properties.vue index 819c3299b6..0b192794c2 100644 --- a/client/components/editor/editor-modal-properties.vue +++ b/client/components/editor/editor-modal-properties.vue @@ -66,6 +66,85 @@ @click:append='showPathSelector' ) v-divider + v-card-text.grey.pt-5(:class='$vuetify.theme.dark ? `darken-3-d3` : `lighten-5`') + .overline.pb-5 Theme Options + v-radio-group( + row + outlined + persistent-hint + prepend-icon='mdi-serial-port' + v-model='tocLevel' + label='Max Heading Level' + hint='The table of contents will show headings up to the selected level. By default, only heading levels up to H2 are shown.' + ) + v-spacer + v-radio( + label='Global' + v-bind:value='0' + ) + v-radio( + label='H1' + v-bind:value='1' + ) + v-radio( + label='H2' + v-bind:value='2' + ) + v-radio( + label='H3' + v-bind:value='3' + ) + v-radio( + label='H4' + v-bind:value='4' + ) + v-radio( + label='H5' + v-bind:value='5' + ) + v-radio( + label='H6' + v-bind:value='6' + ) + v-radio-group( + row + outlined + persistent-hint + prepend-icon='mdi-serial-port' + v-model='tocCollapseLevel' + label='Collapse Heading Level' + hint='The table of contents will collapse headings starting from the selected level. By default, only heading levels from H2 are collapsed.' + ) + v-spacer + v-radio( + label='Global' + v-bind:value='0' + ) + v-radio( + label='H1' + v-bind:value='1' + ) + v-radio( + label='H2' + v-bind:value='2' + ) + v-radio( + label='H3' + v-bind:value='3' + ) + v-radio( + label='H4' + v-bind:value='4' + ) + v-radio( + label='H5' + v-bind:value='5' + ) + v-radio( + label='H6' + v-bind:value='6' + ) + v-divider v-card-text.grey.pt-5(:class='$vuetify.theme.dark ? `darken-3-d5` : `lighten-4`') .overline.pb-5 {{$t('editor:props.categorization')}} v-chip-group.radius-5.mb-5(column, v-if='tags && tags.length > 0') @@ -289,6 +368,8 @@ export default { isPublished: sync('page/isPublished'), publishStartDate: sync('page/publishStartDate'), publishEndDate: sync('page/publishEndDate'), + tocLevel: sync('page/tocLevel'), + tocCollapseLevel: sync('page/tocCollapseLevel'), scriptJs: sync('page/scriptJs'), scriptCss: sync('page/scriptCss'), hasScriptPermission: get('page/effectivePermissions@pages.script'), diff --git a/client/graph/admin/theme/theme-mutation-save.gql b/client/graph/admin/theme/theme-mutation-save.gql index 856442ce90..b986a0369f 100644 --- a/client/graph/admin/theme/theme-mutation-save.gql +++ b/client/graph/admin/theme/theme-mutation-save.gql @@ -1,6 +1,6 @@ -mutation($theme: String!, $iconset: String!, $darkMode: Boolean!, $injectCSS: String, $injectHead: String, $injectBody: String) { +mutation($theme: String!, $iconset: String!, $darkMode: Boolean!, $tocLevel: Int!, $tocCollapseLevel: Int!, $injectCSS: String, $injectHead: String, $injectBody: String) { theming { - setConfig(theme: $theme, iconset: $iconset, darkMode: $darkMode, injectCSS: $injectCSS, injectHead: $injectHead, injectBody: $injectBody) { + setConfig(theme: $theme, iconset: $iconset, darkMode: $darkMode, tocLevel: $tocLevel, tocCollapseLevel: $tocCollapseLevel, injectCSS: $injectCSS, injectHead: $injectHead, injectBody: $injectBody) { responseResult { succeeded errorCode diff --git a/client/graph/admin/theme/theme-query-config.gql b/client/graph/admin/theme/theme-query-config.gql index 360cb2fa57..8126fe9eb2 100644 --- a/client/graph/admin/theme/theme-query-config.gql +++ b/client/graph/admin/theme/theme-query-config.gql @@ -4,6 +4,8 @@ query { theme iconset darkMode + tocLevel + tocCollapseLevel injectCSS injectHead injectBody diff --git a/client/store/page.js b/client/store/page.js index e6b0992e81..d1c9f1e5af 100644 --- a/client/store/page.js +++ b/client/store/page.js @@ -16,6 +16,8 @@ const state = { updatedAt: '', mode: '', scriptJs: '', + tocLevel: 2, + tocCollapseLevel: 2, scriptCss: '', effectivePermissions: { comments: { diff --git a/client/themes/default/components/page-toc-item.vue b/client/themes/default/components/page-toc-item.vue new file mode 100644 index 0000000000..4fa2d7e7a0 --- /dev/null +++ b/client/themes/default/components/page-toc-item.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/client/themes/default/components/page.vue b/client/themes/default/components/page.vue index 8d5b222dc1..c2ed2bdd41 100644 --- a/client/themes/default/components/page.vue +++ b/client/themes/default/components/page.vue @@ -59,18 +59,9 @@ v-flex.page-col-sd(lg3, xl2, v-if='$vuetify.breakpoint.lgAndUp') v-card.mb-5(v-if='tocDecoded.length') .overline.pa-5.pb-0(:class='$vuetify.theme.dark ? `blue--text text--lighten-2` : `primary--text`') {{$t('common:page.toc')}} - v-list.pb-3(dense, nav, :class='$vuetify.theme.dark ? `darken-3-d3` : ``') - template(v-for='(tocItem, tocIdx) in tocDecoded') - v-list-item(@click='$vuetify.goTo(tocItem.anchor, scrollOpts)') - v-icon(color='grey', small) {{ $vuetify.rtl ? `mdi-chevron-left` : `mdi-chevron-right` }} - v-list-item-title.px-3 {{tocItem.title}} - //- v-divider(v-if='tocIdx < toc.length - 1 || tocItem.children.length') - template(v-for='tocSubItem in tocItem.children') - v-list-item(@click='$vuetify.goTo(tocSubItem.anchor, scrollOpts)') - v-icon.px-3(color='grey lighten-1', small) {{ $vuetify.rtl ? `mdi-chevron-left` : `mdi-chevron-right` }} - v-list-item-title.px-3.caption.grey--text(:class='$vuetify.theme.dark ? `text--lighten-1` : `text--darken-1`') {{tocSubItem.title}} - //- v-divider(inset, v-if='tocIdx < toc.length - 1') - + v-list.py-0(dense, nav, :class='$vuetify.theme.dark ? `darken-3-d3` : ``') + template(v-for='item in tocDecoded') + page-toc-item(:item='item', :tocLevel='tocLevel', :tocCollapseLevel='tocCollapseLevel') v-card.mb-5(v-if='tags.length > 0') .pa-5 .overline.teal--text.pb-2(:class='$vuetify.theme.dark ? `text--lighten-3` : ``') {{$t('common:page.tags')}} @@ -307,6 +298,7 @@ import { StatusIndicator } from 'vue-status-indicator' import Tabset from './tabset.vue' import NavSidebar from './nav-sidebar.vue' +import PageTocItem from './page-toc-item.vue' import Prism from 'prismjs' import mermaid from 'mermaid' import { get, sync } from 'vuex-pathify' @@ -354,6 +346,7 @@ Prism.plugins.toolbar.registerButton('copy-to-clipboard', (env) => { export default { components: { NavSidebar, + PageTocItem, StatusIndicator }, props: { @@ -424,6 +417,14 @@ export default { commentsExternal: { type: Boolean, default: false + }, + tocLevel: { + type: Number, + default: 2 + }, + tocCollapseLevel: { + type: Number, + default: 2 } }, data() { diff --git a/server/app/data.yml b/server/app/data.yml index f428bdf4ad..1c10a575cc 100644 --- a/server/app/data.yml +++ b/server/app/data.yml @@ -53,6 +53,8 @@ defaults: theme: 'default' iconset: 'md' darkMode: false + tocLevel: 2 + tocCollapseLevel: 2 auth: autoLogin: false enforce2FA: false diff --git a/server/controllers/common.js b/server/controllers/common.js index d1879dfe28..172945c8fd 100644 --- a/server/controllers/common.js +++ b/server/controllers/common.js @@ -464,6 +464,9 @@ router.get('/*', async (req, res, next) => { injectCode.body = `${injectCode.body}\n${page.extra.js}` } + const tocLevel = page.tocLevel || WIKI.config.theming.tocLevel + const tocCollapseLevel = page.tocCollapseLevel || WIKI.config.theming.tocCollapseLevel + if (req.query.legacy || req.get('user-agent').indexOf('Trident') >= 0) { // -> Convert page TOC if (_.isString(page.toc)) { @@ -474,6 +477,8 @@ router.get('/*', async (req, res, next) => { res.render('legacy/page', { page, sidebar, + tocLevel, + tocCollapseLevel, injectCode, isAuthenticated: req.user && req.user.id !== 2 }) @@ -499,6 +504,8 @@ router.get('/*', async (req, res, next) => { res.render('page', { page, sidebar, + tocLevel, + tocCollapseLevel, injectCode, comments: WIKI.data.commentProvider, effectivePermissions diff --git a/server/db/migrations-sqlite/2.5.13.js b/server/db/migrations-sqlite/2.5.13.js new file mode 100644 index 0000000000..75fca01794 --- /dev/null +++ b/server/db/migrations-sqlite/2.5.13.js @@ -0,0 +1,9 @@ +exports.up = async knex => { + await knex.schema + .alterTable('pages', table => { + table.integer('tocLevel').notNullable().defaultTo(0) + table.integer('tocCollapseLevel').notNullable().defaultTo(0) + }) +} + +exports.down = knex => { } diff --git a/server/db/migrations/2.5.13.js b/server/db/migrations/2.5.13.js new file mode 100644 index 0000000000..75fca01794 --- /dev/null +++ b/server/db/migrations/2.5.13.js @@ -0,0 +1,9 @@ +exports.up = async knex => { + await knex.schema + .alterTable('pages', table => { + table.integer('tocLevel').notNullable().defaultTo(0) + table.integer('tocCollapseLevel').notNullable().defaultTo(0) + }) +} + +exports.down = knex => { } diff --git a/server/graph/resolvers/theming.js b/server/graph/resolvers/theming.js index cfd6367038..872d50169e 100644 --- a/server/graph/resolvers/theming.js +++ b/server/graph/resolvers/theming.js @@ -24,6 +24,8 @@ module.exports = { theme: WIKI.config.theming.theme, iconset: WIKI.config.theming.iconset, darkMode: WIKI.config.theming.darkMode, + tocLevel: WIKI.config.theming.tocLevel, + tocCollapseLevel: WIKI.config.theming.tocCollapseLevel, injectCSS: new CleanCSS({ format: 'beautify' }).minify(WIKI.config.theming.injectCSS).styles, injectHead: WIKI.config.theming.injectHead, injectBody: WIKI.config.theming.injectBody @@ -44,6 +46,8 @@ module.exports = { theme: args.theme, iconset: args.iconset, darkMode: args.darkMode, + tocLevel: args.tocLevel, + tocCollapseLevel: args.tocCollapseLevel, injectCSS: args.injectCSS || '', injectHead: args.injectHead || '', injectBody: args.injectBody || '' diff --git a/server/graph/schemas/page.graphql b/server/graph/schemas/page.graphql index f6f43f2a3a..b327a9345e 100644 --- a/server/graph/schemas/page.graphql +++ b/server/graph/schemas/page.graphql @@ -93,6 +93,8 @@ type PageMutation { scriptJs: String tags: [String]! title: String! + tocLevel: Int + tocCollapseLevel: Int ): PageResponse @auth(requires: ["write:pages", "manage:pages", "manage:system"]) update( @@ -110,6 +112,8 @@ type PageMutation { scriptJs: String tags: [String] title: String + tocLevel: Int + tocCollapseLevel: Int ): PageResponse @auth(requires: ["write:pages", "manage:pages", "manage:system"]) move( @@ -180,6 +184,8 @@ type Page { content: String! @auth(requires: ["read:source", "write:pages", "manage:system"]) render: String toc: String + tocLevel: Int! + tocCollapseLevel: Int! contentType: String! createdAt: Date! updatedAt: Date! diff --git a/server/graph/schemas/theming.graphql b/server/graph/schemas/theming.graphql index eddebad42e..976aab5edb 100644 --- a/server/graph/schemas/theming.graphql +++ b/server/graph/schemas/theming.graphql @@ -28,6 +28,8 @@ type ThemingMutation { theme: String! iconset: String! darkMode: Boolean! + tocLevel: Int! + tocCollapseLevel: Int! injectCSS: String injectHead: String injectBody: String @@ -42,6 +44,8 @@ type ThemingConfig { theme: String! iconset: String! darkMode: Boolean! + tocLevel: Int! + tocCollapseLevel: Int! injectCSS: String injectHead: String injectBody: String diff --git a/server/helpers/page.js b/server/helpers/page.js index 67d37663f8..38f37ecb3d 100644 --- a/server/helpers/page.js +++ b/server/helpers/page.js @@ -74,6 +74,12 @@ module.exports = { ['tags', page.tags ? page.tags.map(t => t.tag).join(', ') : ''], ['editor', page.editorKey] ] + if (page.tocLevel) { + meta.push(['tocLevel', page.tocLevel]) + } + if (page.tocCollapseLevel) { + meta.push(['tocCollapseLevel', page.tocCollapseLevel]) + } switch (page.contentType) { case 'markdown': return '---\n' + meta.map(mt => `${mt[0]}: ${mt[1]}`).join('\n') + '\n---\n\n' + page.content diff --git a/server/models/pages.js b/server/models/pages.js index 37d0cc11c6..d3ff888b9b 100644 --- a/server/models/pages.js +++ b/server/models/pages.js @@ -44,7 +44,8 @@ module.exports = class Page extends Model { publishEndDate: {type: 'string'}, content: {type: 'string'}, contentType: {type: 'string'}, - + tocLevel: {type: 'integer'}, + tocCollapseLevel: {type: 'integer'}, createdAt: {type: 'string'}, updatedAt: {type: 'string'} } @@ -149,6 +150,8 @@ module.exports = class Page extends Model { }, title: 'string', toc: 'string', + tocLevel: 'uint', + tocCollapseLevel: 'uint', updatedAt: 'string' }) } @@ -295,6 +298,8 @@ module.exports = class Page extends Model { publishStartDate: opts.publishStartDate || '', title: opts.title, toc: '[]', + tocLevel: opts.tocLevel || 0, + tocCollapseLevel: opts.tocCollapseLevel || 0, extra: JSON.stringify({ js: scriptJs, css: scriptCss @@ -414,6 +419,8 @@ module.exports = class Page extends Model { publishEndDate: opts.publishEndDate || '', publishStartDate: opts.publishStartDate || '', title: opts.title, + tocLevel: opts.tocLevel || 0, + tocCollapseLevel: opts.tocCollapseLevel || 0, extra: JSON.stringify({ ...ogPage.extra, js: scriptJs, @@ -567,7 +574,7 @@ module.exports = class Page extends Model { path: opts.destinationPath, mode: 'move' }) - + // -> Reconnect Links : Validate invalid links to the new path await WIKI.models.pages.reconnectLinks({ locale: opts.destinationLocale, @@ -795,6 +802,8 @@ module.exports = class Page extends Model { 'pages.content', 'pages.render', 'pages.toc', + 'pages.tocLevel', + 'pages.tocCollapseLevel', 'pages.contentType', 'pages.createdAt', 'pages.updatedAt', @@ -874,6 +883,8 @@ module.exports = class Page extends Model { tags: page.tags.map(t => _.pick(t, ['tag', 'title'])), title: page.title, toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc), + tocLevel: page.tocLevel, + tocCollapseLevel: page.tocCollapseLevel, updatedAt: page.updatedAt })) } diff --git a/server/modules/storage/disk/common.js b/server/modules/storage/disk/common.js index 9ed0b9863d..9caf30a7b1 100644 --- a/server/modules/storage/disk/common.js +++ b/server/modules/storage/disk/common.js @@ -92,6 +92,8 @@ module.exports = { isPublished: _.get(pageData, 'isPublished', currentPage.isPublished), isPrivate: false, content: pageData.content, + tocLevel: pageData.tocLevel, + tocCollapseLevel: pageData.tocCollapseLevel, user: user, skipStorage: true }) @@ -110,7 +112,9 @@ module.exports = { content: pageData.content, user: user, editor: pageEditor, - skipStorage: true + skipStorage: true, + tocLevel: pageData.tocLevel, + tocCollapseLevel: pageData.tocCollapseLevel }) } }, diff --git a/server/setup.js b/server/setup.js index c22c0f30ad..8929d94288 100644 --- a/server/setup.js +++ b/server/setup.js @@ -126,6 +126,8 @@ module.exports = () => { _.set(WIKI.config, 'theming', { theme: 'default', darkMode: false, + tocLevel: 2, + tocCollapseLevel: 2, iconset: 'mdi', injectCSS: '', injectHead: '', diff --git a/server/views/editor.pug b/server/views/editor.pug index 0f0b64c026..34222f90c0 100644 --- a/server/views/editor.pug +++ b/server/views/editor.pug @@ -19,6 +19,8 @@ block body script-css=page.extra.css script-js=page.extra.js init-mode=page.mode + :toc-level=page.tocLevel + :toc-collapse-level=page.tocCollapseLevel init-editor=page.editorKey init-content=page.content checkout-date=page.updatedAt diff --git a/server/views/page.pug b/server/views/page.pug index 2096a7c814..2604b4f990 100644 --- a/server/views/page.pug +++ b/server/views/page.pug @@ -28,6 +28,8 @@ block body comments-enabled=config.features.featurePageComments effective-permissions=Buffer.from(JSON.stringify(effectivePermissions)).toString('base64') comments-external=comments.codeTemplate + :toc-level=tocLevel + :toc-collapse-level=tocCollapseLevel ) template(slot='contents') div!= page.render