diff --git a/app/assets/javascripts/lib/print_markdown_dom.js b/app/assets/javascripts/lib/print_markdown_dom.js new file mode 100644 index 0000000000000000000000000000000000000000..fb5ea09b6c8341b77703aa0f3c3953126453d3b7 --- /dev/null +++ b/app/assets/javascripts/lib/print_markdown_dom.js @@ -0,0 +1,50 @@ +function getPrintContent(target, ignoreSelectors) { + const cloneDom = target.cloneNode(true); + cloneDom.querySelectorAll('details').forEach((detail) => { + detail.setAttribute('open', ''); + }); + + if (Array.isArray(ignoreSelectors) && ignoreSelectors.length > 0) { + cloneDom.querySelectorAll(ignoreSelectors.join(',')).forEach((ignoredNode) => { + ignoredNode.remove(); + }); + } + + cloneDom.querySelectorAll('img').forEach((img) => { + img.setAttribute('loading', 'eager'); + }); + + return cloneDom.innerHTML; +} + +function getTitleContent(title) { + const titleElement = document.createElement('h2'); + titleElement.className = 'gl-mt-0 gl-mb-5'; + titleElement.innerText = title; + return titleElement.outerHTML; +} + +export default async function printMarkdownDom({ + target, + title, + ignoreSelectors = [], + stylesheet = [], +}) { + const printJS = (await import('print-js')).default; + + const printContent = getPrintContent(target, ignoreSelectors); + + const titleElement = title ? getTitleContent(title) : ''; + + const markdownElement = `
${printContent}
`; + + const printable = titleElement + markdownElement; + + printJS({ + printable, + type: 'raw-html', + documentTitle: title, + scanStyles: false, + css: stylesheet, + }); +} diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue new file mode 100644 index 0000000000000000000000000000000000000000..4d13f25c4cb71e4a741655565e33ebf0d5086678 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue @@ -0,0 +1,40 @@ + + diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js index 9906cb595f8054477d7ad51d478f785e0cd9ba79..9bc399d07b3682b466a1a1935537a175c2b465a3 100644 --- a/app/assets/javascripts/pages/shared/wikis/show.js +++ b/app/assets/javascripts/pages/shared/wikis/show.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Wikis from './wikis'; import WikiContent from './components/wiki_content.vue'; +import WikiExport from './components/wiki_export.vue'; const mountWikiContentApp = () => { const el = document.querySelector('.js-async-wiki-page-content'); @@ -20,8 +21,28 @@ const mountWikiContentApp = () => { } }; +const mountWikiExportApp = () => { + const el = document.querySelector('#js-export-actions'); + + if (!el) return false; + const { target, title, stylesheet } = JSON.parse(el.dataset.options); + + return new Vue({ + el, + provide: { + target, + title, + stylesheet, + }, + render(createElement) { + return createElement(WikiExport); + }, + }); +}; + export const mountApplications = () => { // eslint-disable-next-line no-new new Wikis(); mountWikiContentApp(); + mountWikiExportApp(); }; diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 84181a00f34b1f9a7572468aaeb0b77a3a38a06a..c3662c3e6ea81e3129d80543de23fe9953063f49 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -1,3 +1,9 @@ +@import 'framework/variables'; +@import 'framework/variables_overrides'; + +@import '@gitlab/ui/src/scss/variables'; +@import '@gitlab/ui/src/scss/utility-mixins/index'; + .md h1, .md h2, .md h3, @@ -20,6 +26,35 @@ font-weight: 600; } +.md { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + + // fix blockquote style in print + blockquote { + &::before { + position: absolute; + top: 0; + left: -4px; + content: ' '; + height: 100%; + width: 4px; + background-color: $white-dark; + } + + position: relative; + font-size: inherit; + @include gl-text-gray-700; + @include gl-py-3; + @include gl-pl-6; + @include gl-my-3; + @include gl-mx-0; + @include gl-inset-border-l-4-gray-100; + margin-left: 4px; + border: 0 !important; + } +} + header, nav, nav.navbar-collapse, @@ -40,6 +75,7 @@ ul.notes-form, .note-action-button, .right-sidebar, .flash-container, +copy-code, #js-peek { display: none !important; } diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 28699ca27f3eb58d02dbff01a74251f754c73a70..be1f43f44de01fd4a5853db58f14e41620f647a8 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -12,6 +12,8 @@ .nav-controls.pb-md-3.pb-lg-0 = render 'shared/wikis/main_links' + - if Feature.enabled?(:print_wiki, current_user) + #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } } - if @page.historical? = render Pajamas::AlertComponent.new(variant: :warning, diff --git a/config/feature_flags/development/print_wiki.yml b/config/feature_flags/development/print_wiki.yml new file mode 100644 index 0000000000000000000000000000000000000000..e04d7dd84bf7299f1e4240d5d44a50e9e7e620ca --- /dev/null +++ b/config/feature_flags/development/print_wiki.yml @@ -0,0 +1,8 @@ +--- +name: print_wiki +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125260 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/414691 +milestone: '16.3' +type: development +group: group::knowledge +default_enabled: false diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ce0ff813bee461af5ca53d8b87791823b790f358..b2cfb93d54217ac35bd41f8f7d0d21f2e94a12d7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -35419,6 +35419,9 @@ msgstr "" msgid "Primary Action" msgstr "" +msgid "Print as PDF" +msgstr "" + msgid "Print codes" msgstr "" diff --git a/package.json b/package.json index 9c80b30bb3d3f3b1132fbf49636a200760f2bbac..ecade4806f0e72de9e9f7739fd54dbc96082af1a 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "popper.js": "^1.16.1", "portal-vue": "^2.1.7", "postcss": "8.4.14", + "print-js": "^1.6.0", "prosemirror-markdown": "1.11.1", "raphael": "^2.2.7", "raw-loader": "^4.0.2", diff --git a/spec/frontend/lib/print_markdown_dom_spec.js b/spec/frontend/lib/print_markdown_dom_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..7f28417228e1d5fe9eec4d8e7b0eace7b51448ff --- /dev/null +++ b/spec/frontend/lib/print_markdown_dom_spec.js @@ -0,0 +1,102 @@ +import printJS from 'print-js'; +import printMarkdownDom from '~/lib/print_markdown_dom'; + +jest.mock('print-js', () => jest.fn()); + +describe('print util', () => { + describe('print markdown dom', () => { + beforeEach(() => { + document.body.innerHTML = `
`; + }); + + const getTarget = () => document.getElementById('target'); + + const contentValues = [ + { + title: 'test title', + expectedTitle: '

test title

', + content: '', + expectedContent: '
', + }, + { + title: 'test title', + expectedTitle: '

test title

', + content: '

test content

', + expectedContent: '

test content

', + }, + { + title: 'test title', + expectedTitle: '

test title

', + content: '
test detail

test detail content

', + expectedContent: + '
test detail

test detail content

', + }, + { + title: undefined, + expectedTitle: '', + content: '', + expectedContent: '
', + }, + { + title: undefined, + expectedTitle: '', + content: '

test content

', + expectedContent: '

test content

', + }, + { + title: undefined, + expectedTitle: '', + content: '
test detail

test detail content

', + expectedContent: + '
test detail

test detail content

', + }, + ]; + + it.each(contentValues)( + 'should print with title ($title) and content ($content)', + async ({ title, expectedTitle, content, expectedContent }) => { + const target = getTarget(); + target.innerHTML = content; + const stylesheet = 'test stylesheet'; + + await printMarkdownDom({ + target, + title, + stylesheet, + }); + + expect(printJS).toHaveBeenCalledWith({ + printable: expectedTitle + expectedContent, + type: 'raw-html', + documentTitle: title, + scanStyles: false, + css: stylesheet, + }); + }, + ); + }); + + describe('ignore selectors', () => { + beforeEach(() => { + document.body.innerHTML = `
`; + }); + + it('should ignore dom if ignoreSelectors', async () => { + const target = document.getElementById('target'); + const ignoreSelectors = ['.ignore-me']; + + await printMarkdownDom({ + target, + ignoreSelectors, + }); + + expect(printJS).toHaveBeenCalledWith({ + printable: '
', + type: 'raw-html', + documentTitle: undefined, + scanStyles: false, + css: [], + }); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b7002412561ba38876f3e836cb89e8a60bbc1830 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js @@ -0,0 +1,48 @@ +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import WikiExport from '~/pages/shared/wikis/components/wiki_export.vue'; +import printMarkdownDom from '~/lib/print_markdown_dom'; + +jest.mock('~/lib/print_markdown_dom'); + +describe('pages/shared/wikis/components/wiki_export', () => { + let wrapper; + + const createComponent = (provide) => { + wrapper = shallowMount(WikiExport, { + provide, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findPrintItem = () => + findDropdown() + .props('items') + .find((x) => x.text === 'Print as PDF'); + + describe('print', () => { + beforeEach(() => { + document.body.innerHTML = '
Content
'; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should print the content', () => { + createComponent({ + target: '#content-body', + title: 'test title', + stylesheet: [], + }); + + findPrintItem().action(); + + expect(printMarkdownDom).toHaveBeenCalledWith({ + target: document.querySelector('#content-body'), + title: 'test title', + stylesheet: [], + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index e4d728799fefac76038859f9db945e44c46a68a2..1fa6ddce695cfd9ed093061d13882c29a46f9c92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10487,6 +10487,11 @@ pretty@^2.0.0: extend-shallow "^2.0.1" js-beautify "^1.6.12" +print-js@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/print-js/-/print-js-1.6.0.tgz#692b046cf31992b46afa6c6d8a9db1c69d431d1f" + integrity sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"