diff --git a/cypress/tests/core/blocks/block-anchors.js b/cypress/tests/core/blocks/block-anchors.js new file mode 100644 index 0000000000..1166b0ce26 --- /dev/null +++ b/cypress/tests/core/blocks/block-anchors.js @@ -0,0 +1,71 @@ +import { slateBeforeEach } from '../../../support/volto-slate'; + +describe('Block Tests: Anchors', () => { + beforeEach(slateBeforeEach); + + it('Add Block: Links', () => { + // Change page title + cy.clearSlateTitle(); + cy.getSlateTitle().type('Slate Heading Anchors'); + cy.getSlate().click(); + + // Add TOC block + cy.get('.ui.basic.icon.button.block-add-button').first().click(); + cy.get(".blocks-chooser .ui.form .field.searchbox input[type='text']").type( + 'table of contents', + ); + cy.get('.button.toc').click(); + + // Save page + cy.get('#toolbar-save').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/my-page'); + cy.get('h1.documentFirstHeading') + .trigger('mouseover', { eventConstructor: 'MouseEvent' }) + .children() + .should('have.length', 1); + }); + + it('Add Block: add content to TOC', () => { + // Change page title + cy.clearSlateTitle(); + cy.getSlateTitle().type('Slate Heading Anchors'); + cy.getSlate().click(); + + // Add TOC block + cy.get('.ui.basic.icon.button.block-add-button').first().click(); + cy.get(".blocks-chooser .ui.form .field.searchbox input[type='text']").type( + 'table of contents', + ); + cy.get('.button.toc').click(); + + // Add headings + cy.get('.ui.drag.block.inner.slate').click().type('Title 1').click(); + cy.get('.ui.drag.block.inner.slate span span span').setSelection('Title 1'); + cy.get('.slate-inline-toolbar .button-wrapper a[title="Title"]').click({ + force: true, + }); + cy.get('.ui.drag.block.inner.slate').click().type('{enter}'); + + cy.get('.ui.drag.block.inner.slate').eq(1).click().type('Title 2').click(); + cy.get('.ui.drag.block.inner.slate span span span') + .eq(1) + .setSelection('Title 2'); + cy.get('.slate-inline-toolbar .button-wrapper a[title="Title"]').click({ + force: true, + }); + cy.get('.ui.drag.block.inner.slate').eq(1).click().type('{enter}'); + + // Save page + cy.get('#toolbar-save').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/my-page'); + + // Check if the page contains the TOC and scrolls to each entry on click + cy.contains('Slate Heading Anchors'); + cy.get('h2[id="title-1"]').contains('Title 1'); + cy.get('h2[id="title-2"]').contains('Title 2'); + cy.get('a[href="#title-1"]').click(); + cy.get('h2[id="title-1"]').scrollIntoView().should('be.visible'); + cy.get('a[href="#title-2"]').click(); + cy.get('h2[id="title-2"]').scrollIntoView().should('be.visible'); + }); +}); diff --git a/cypress/tests/core/guillotina/blocks-text.js b/cypress/tests/core/guillotina/blocks-text.js index 8ca7b815b8..334c3b3c32 100644 --- a/cypress/tests/core/guillotina/blocks-text.js +++ b/cypress/tests/core/guillotina/blocks-text.js @@ -53,7 +53,7 @@ describe('Text Block Tests', () => { // then the page view should contain a link cy.contains('Colorless green ideas sleep furiously.'); - cy.get('#page-document a') + cy.get('#page-document p a') .should('have.attr', 'href') .and('include', 'https://google.com'); }); @@ -81,7 +81,7 @@ describe('Text Block Tests', () => { // then the page view should contain a mailto link cy.contains('Colorless green ideas sleep furiously.'); - cy.get('#page-document a') + cy.get('#page-document p a') .should('have.attr', 'href') .and('include', 'mailto:hello@example.com'); }); diff --git a/cypress/tests/core/volto-slate/05-block-slate-format-basics.js b/cypress/tests/core/volto-slate/05-block-slate-format-basics.js index 413d43a21b..3d32b822e4 100644 --- a/cypress/tests/core/volto-slate/05-block-slate-format-basics.js +++ b/cypress/tests/core/volto-slate/05-block-slate-format-basics.js @@ -135,7 +135,7 @@ describe('Block Tests: Basic text format', () => { cy.toolbarSave(); // then the page view should contain our changes - cy.get('[id="page-document"] h2').children().should('have.length', 0); + cy.get('[id="page-document"] h2').children().should('have.length', 1); cy.get('[id="page-document"] h2').contains('Colorless'); }); @@ -177,7 +177,7 @@ describe('Block Tests: Basic text format', () => { cy.toolbarSave(); // then the page view should contain our changes - cy.get('[id="page-document"] h3').children().should('have.length', 0); + cy.get('[id="page-document"] h3').children().should('have.length', 1); cy.get('[id="page-document"] h3').contains('Colorless'); }); diff --git a/cypress/tests/guillotina/blocks-text.js b/cypress/tests/guillotina/blocks-text.js index 8ca7b815b8..334c3b3c32 100644 --- a/cypress/tests/guillotina/blocks-text.js +++ b/cypress/tests/guillotina/blocks-text.js @@ -53,7 +53,7 @@ describe('Text Block Tests', () => { // then the page view should contain a link cy.contains('Colorless green ideas sleep furiously.'); - cy.get('#page-document a') + cy.get('#page-document p a') .should('have.attr', 'href') .and('include', 'https://google.com'); }); @@ -81,7 +81,7 @@ describe('Text Block Tests', () => { // then the page view should contain a mailto link cy.contains('Colorless green ideas sleep furiously.'); - cy.get('#page-document a') + cy.get('#page-document p a') .should('have.attr', 'href') .and('include', 'mailto:hello@example.com'); }); diff --git a/docs/source/configuration/volto-slate/configuration-settings.md b/docs/source/configuration/volto-slate/configuration-settings.md index a6da06c3b2..97276b804f 100644 --- a/docs/source/configuration/volto-slate/configuration-settings.md +++ b/docs/source/configuration/volto-slate/configuration-settings.md @@ -239,6 +239,19 @@ They are not persisted in the final value, so they are useful, for example, to h slate.runtimeDecorators = [([node, path], ranges) => ranges]; ``` +(editor-configuration-slate-useLinkedHeadings-label)= + +## `slate.useLinkedHeadings` + +The setting `slate.useLinkedHeadings` controls whether `volto-slate` creates anchors for headings, such as `h1` and `h2`, in the editor. + +The default setting is `true`. + +You can opt out of this feature by setting its value to `false`. + +```js +slate.useLinkedHeadings = false +``` (editor-configuration-blocks-initialBlocksFocus-label)= diff --git a/locales/ca/LC_MESSAGES/volto.po b/locales/ca/LC_MESSAGES/volto.po index 4bf4b148d1..db73f21323 100644 --- a/locales/ca/LC_MESSAGES/volto.po +++ b/locales/ca/LC_MESSAGES/volto.po @@ -1903,6 +1903,11 @@ msgstr "" msgid "Link" msgstr "Enllaç" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/de/LC_MESSAGES/volto.po b/locales/de/LC_MESSAGES/volto.po index f316a86a30..4d37f47225 100644 --- a/locales/de/LC_MESSAGES/volto.po +++ b/locales/de/LC_MESSAGES/volto.po @@ -1900,6 +1900,11 @@ msgstr "" msgid "Link" msgstr "Link" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/en/LC_MESSAGES/volto.po b/locales/en/LC_MESSAGES/volto.po index 08c9c94371..7783ecbea7 100644 --- a/locales/en/LC_MESSAGES/volto.po +++ b/locales/en/LC_MESSAGES/volto.po @@ -1894,6 +1894,11 @@ msgstr "" msgid "Link" msgstr "" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/es/LC_MESSAGES/volto.po b/locales/es/LC_MESSAGES/volto.po index 6d18f20d8c..0f8a0bd273 100644 --- a/locales/es/LC_MESSAGES/volto.po +++ b/locales/es/LC_MESSAGES/volto.po @@ -1905,6 +1905,11 @@ msgstr "" msgid "Link" msgstr "Enlace" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/eu/LC_MESSAGES/volto.po b/locales/eu/LC_MESSAGES/volto.po index 6f2565e6d5..85aa21e868 100644 --- a/locales/eu/LC_MESSAGES/volto.po +++ b/locales/eu/LC_MESSAGES/volto.po @@ -1901,6 +1901,11 @@ msgstr "" msgid "Link" msgstr "Esteka" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/fi/LC_MESSAGES/volto.po b/locales/fi/LC_MESSAGES/volto.po index 791c210c1e..e3d3b2cc3f 100644 --- a/locales/fi/LC_MESSAGES/volto.po +++ b/locales/fi/LC_MESSAGES/volto.po @@ -1905,6 +1905,11 @@ msgstr "" msgid "Link" msgstr "Linkki" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/fr/LC_MESSAGES/volto.po b/locales/fr/LC_MESSAGES/volto.po index c4a007b100..8862a6e1ac 100644 --- a/locales/fr/LC_MESSAGES/volto.po +++ b/locales/fr/LC_MESSAGES/volto.po @@ -1911,6 +1911,11 @@ msgstr "" msgid "Link" msgstr "Lien" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/it/LC_MESSAGES/volto.po b/locales/it/LC_MESSAGES/volto.po index d63d8a20f0..c081ae1612 100644 --- a/locales/it/LC_MESSAGES/volto.po +++ b/locales/it/LC_MESSAGES/volto.po @@ -1894,6 +1894,11 @@ msgstr "" msgid "Link" msgstr "Link" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/ja/LC_MESSAGES/volto.po b/locales/ja/LC_MESSAGES/volto.po index a8a6fbea17..81b4228ce9 100644 --- a/locales/ja/LC_MESSAGES/volto.po +++ b/locales/ja/LC_MESSAGES/volto.po @@ -1902,6 +1902,11 @@ msgstr "" msgid "Link" msgstr "リンク" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/nl/LC_MESSAGES/volto.po b/locales/nl/LC_MESSAGES/volto.po index e575532f1a..24d58061a5 100644 --- a/locales/nl/LC_MESSAGES/volto.po +++ b/locales/nl/LC_MESSAGES/volto.po @@ -1913,6 +1913,11 @@ msgstr "" msgid "Link" msgstr "" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/pt/LC_MESSAGES/volto.po b/locales/pt/LC_MESSAGES/volto.po index 2f445ad4fd..b5ecc4dd7d 100644 --- a/locales/pt/LC_MESSAGES/volto.po +++ b/locales/pt/LC_MESSAGES/volto.po @@ -1902,6 +1902,11 @@ msgstr "" msgid "Link" msgstr "" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/pt_BR/LC_MESSAGES/volto.po b/locales/pt_BR/LC_MESSAGES/volto.po index bd0ee20da5..19cfd33d8d 100644 --- a/locales/pt_BR/LC_MESSAGES/volto.po +++ b/locales/pt_BR/LC_MESSAGES/volto.po @@ -1904,6 +1904,11 @@ msgstr "Menos filtros" msgid "Link" msgstr "Link" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/ro/LC_MESSAGES/volto.po b/locales/ro/LC_MESSAGES/volto.po index f2411286fc..e3e7c2ae0c 100644 --- a/locales/ro/LC_MESSAGES/volto.po +++ b/locales/ro/LC_MESSAGES/volto.po @@ -1894,6 +1894,11 @@ msgstr "" msgid "Link" msgstr "Legătură" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/volto.pot b/locales/volto.pot index 7cb0cc5731..4ef602b388 100644 --- a/locales/volto.pot +++ b/locales/volto.pot @@ -1896,6 +1896,11 @@ msgstr "" msgid "Link" msgstr "" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/locales/zh_CN/LC_MESSAGES/volto.po b/locales/zh_CN/LC_MESSAGES/volto.po index 98779199d8..0355a55f65 100644 --- a/locales/zh_CN/LC_MESSAGES/volto.po +++ b/locales/zh_CN/LC_MESSAGES/volto.po @@ -1900,6 +1900,11 @@ msgstr "" msgid "Link" msgstr "链接" +#: helpers/MessageLabels/MessageLabels +# defaultMessage: Link copied to clipboard +msgid "Link copied to clipboard" +msgstr "" + #: components/manage/Blocks/HeroImageLeft/schema #: components/manage/Blocks/Listing/schema # defaultMessage: Link more diff --git a/news/4287.feature b/news/4287.feature new file mode 100644 index 0000000000..e676eb12b7 --- /dev/null +++ b/news/4287.feature @@ -0,0 +1 @@ +Added slug-based linked headings in `volto-slate`. @tiberiuichim, @nileshgulia1 diff --git a/package.json b/package.json index d09b21cb0c..8882758ccf 100644 --- a/package.json +++ b/package.json @@ -289,6 +289,7 @@ "eslint-plugin-react-hooks": "4.0.2", "express": "4.17.3", "filesize": "6", + "github-slugger": "1.4.0", "glob": "7.1.6", "history": "4.10.1", "hoist-non-react-statics": "3.3.2", diff --git a/packages/volto-slate/src/blocks/Text/TextBlockView.jsx b/packages/volto-slate/src/blocks/Text/TextBlockView.jsx index 5e8accb82c..7f9f623fbd 100644 --- a/packages/volto-slate/src/blocks/Text/TextBlockView.jsx +++ b/packages/volto-slate/src/blocks/Text/TextBlockView.jsx @@ -1,26 +1,30 @@ -import { serializeNodes } from '@plone/volto-slate/editor/render'; +import { + serializeNodes, + serializeNodesToText, +} from '@plone/volto-slate/editor/render'; import config from '@plone/volto/registry'; +import { isEqual } from 'lodash'; +import Slugger from 'github-slugger'; const TextBlockView = (props) => { const { id, data, styling = {} } = props; const { value, override_toc } = data; const metadata = props.metadata || props.properties; - return serializeNodes( - value, - (node, path) => { - const res = { ...styling }; - if (node.type) { - if ( - config.settings.slate.topLevelTargetElements.includes(node.type) || - override_toc - ) { - res.id = id; - } + const { topLevelTargetElements } = config.settings.slate; + + const getAttributes = (node, path) => { + const res = { ...styling }; + if (node.type && isEqual(path, [0])) { + if (topLevelTargetElements.includes(node.type) || override_toc) { + const text = serializeNodesToText(node?.children || []); + const slug = Slugger.slug(text); + res.id = slug || id; } - return res; - }, - { metadata: metadata }, - ); + } + return res; + }; + + return serializeNodes(value, getAttributes, { metadata: metadata }); }; export default TextBlockView; diff --git a/packages/volto-slate/src/editor/config.jsx b/packages/volto-slate/src/editor/config.jsx index d97bb021d7..cdacc04daa 100644 --- a/packages/volto-slate/src/editor/config.jsx +++ b/packages/volto-slate/src/editor/config.jsx @@ -43,6 +43,7 @@ import { bTagDeserializer, codeTagDeserializer, } from './deserialize'; +import { renderLinkElement } from './render'; // Registry of available buttons export const buttons = { @@ -234,10 +235,10 @@ export const defaultBlockType = 'p'; export const elements = { default: ({ attributes, children }) =>

{children}

, - h1: ({ attributes, children }) =>

{children}

, - h2: ({ attributes, children }) =>

{children}

, - h3: ({ attributes, children }) =>

{children}

, - h4: ({ attributes, children }) =>

{children}

, + h1: renderLinkElement('h1'), + h2: renderLinkElement('h2'), + h3: renderLinkElement('h3'), + h4: renderLinkElement('h4'), li: ({ attributes, children }) =>
  • {children}
  • , ol: ({ attributes, children }) =>
      {children}
    , diff --git a/packages/volto-slate/src/editor/less/slate.less b/packages/volto-slate/src/editor/less/slate.less new file mode 100644 index 0000000000..954ee09ab2 --- /dev/null +++ b/packages/volto-slate/src/editor/less/slate.less @@ -0,0 +1,28 @@ +h1, +h2, +h3, +h4 { + &:hover { + a.anchor { + svg { + opacity: 1; + transform: rotate(15deg); + } + } + } + + a.anchor { + position: absolute; + display: inline-block; + margin-left: 5px; + vertical-align: middle; + + svg { + width: 1.6ch; + fill: #42526e; + opacity: 0; + transform: rotate(15deg) translate(-8px, 2px); + transition: opacity 0.2s ease 0s, transform 0.2s ease 0s; + } + } +} diff --git a/packages/volto-slate/src/editor/render.jsx b/packages/volto-slate/src/editor/render.jsx index bfc34cb66e..45ab42ca75 100644 --- a/packages/volto-slate/src/editor/render.jsx +++ b/packages/volto-slate/src/editor/render.jsx @@ -1,9 +1,18 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { useLocation } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { useIntl } from 'react-intl'; import { Node, Text } from 'slate'; import cx from 'classnames'; -import { isEmpty, isEqual, omit } from 'lodash'; +import { isEmpty, omit } from 'lodash'; +import { UniversalLink, Toast } from '@plone/volto/components'; +import { messages, addAppURL } from '@plone/volto/helpers'; +import useClipboard from '@plone/volto/hooks/clipboard/useClipboard'; import config from '@plone/volto/registry'; +import linkSVG from '@plone/volto/icons/link.svg'; + +import './less/slate.less'; const OMITTED = ['editor', 'path']; @@ -106,13 +115,7 @@ export const serializeNodes = (nodes, getAttributes, extras = {}) => { mode="view" key={path} data-slate-data={node.data ? serializeData(node) : null} - attributes={ - isEqual(path, [0]) - ? getAttributes - ? getAttributes(node, path) - : null - : null - } + attributes={getAttributes ? getAttributes(node, path) : null} extras={extras} > {_serializeNodes(Array.from(Node.children(editor, path)))} @@ -153,3 +156,60 @@ export const serializeNodesToText = (nodes) => { export const serializeNodesToHtml = (nodes) => renderToStaticMarkup(serializeNodes(nodes)); + +export const renderLinkElement = (tagName) => { + function LinkElement({ + attributes, + children, + mode = 'edit', + className = null, + }) { + const { slate = {} } = config.settings; + const Tag = tagName; + const slug = attributes.id || ''; + const location = useLocation(); + const appPathname = addAppURL(location.pathname); + // eslint-disable-next-line no-unused-vars + const [copied, copy, setCopied] = useClipboard( + appPathname.concat(`#${slug}`), + ); + const intl = useIntl(); + + return slate.useLinkedHeadings === false ? ( + + {children} + + ) : ( + + {children} + {mode === 'view' && slug && ( + + )} + + ); + } + LinkElement.displayName = `${tagName}LinkElement`; + return LinkElement; +}; diff --git a/src/components/manage/Blocks/Listing/ListingBody.jsx b/src/components/manage/Blocks/Listing/ListingBody.jsx index 0d8e54003f..227b473c8f 100644 --- a/src/components/manage/Blocks/Listing/ListingBody.jsx +++ b/src/components/manage/Blocks/Listing/ListingBody.jsx @@ -1,14 +1,35 @@ -import React, { createRef } from 'react'; +import React, { createRef, useMemo } from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import cx from 'classnames'; import { Pagination, Dimmer, Loader } from 'semantic-ui-react'; +import Slugger from 'github-slugger'; import { Icon } from '@plone/volto/components'; +import { renderLinkElement } from '@plone/volto-slate/editor/render'; import config from '@plone/volto/registry'; import withQuerystringResults from './withQuerystringResults'; import paginationLeftSVG from '@plone/volto/icons/left-key.svg'; import paginationRightSVG from '@plone/volto/icons/right-key.svg'; +const Headline = ({ headlineTag, id, data = {}, listingItems, isEditMode }) => { + let attr = { id }; + const slug = Slugger.slug(data.headline); + attr.id = slug || id; + const LinkedHeadline = useMemo(() => renderLinkElement(headlineTag), [ + headlineTag, + ]); + return ( + 0, + })} + /> + ); +}; + const ListingBody = withQuerystringResults((props) => { const { data = {}, @@ -22,6 +43,7 @@ const ListingBody = withQuerystringResults((props) => { nextBatch, isFolderContentsListing, hasLoaded, + id, } = props; let ListingBodyTemplate; @@ -50,13 +72,13 @@ const ListingBody = withQuerystringResults((props) => { return ( <> {data.headline && ( - 0, - })} - > - {data.headline} - + )} {listingItems?.length > 0 ? (
    diff --git a/src/components/manage/Blocks/Title/View.jsx b/src/components/manage/Blocks/Title/View.jsx index b5836f8109..8486785fe7 100644 --- a/src/components/manage/Blocks/Title/View.jsx +++ b/src/components/manage/Blocks/Title/View.jsx @@ -3,19 +3,29 @@ * @module volto-slate/blocks/Title/TitleBlockView */ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; +import Slugger from 'github-slugger'; +import { renderLinkElement } from '@plone/volto-slate/editor/render'; /** * View title block component. * @class View * @extends Component */ -const TitleBlockView = ({ properties, metadata }) => { +const TitleBlockView = ({ properties, metadata, id, children }) => { + let attr = { id }; + const title = (properties || metadata)['title']; + const slug = Slugger.slug(title); + attr.id = slug || id; + const LinkedTitle = useMemo(() => renderLinkElement('h1'), []); return ( -

    - {(metadata || properties)['title'] || ''} -

    + ); }; diff --git a/src/components/manage/Blocks/Title/View.test.jsx b/src/components/manage/Blocks/Title/View.test.jsx index 80683d24c9..9c2c53d29e 100644 --- a/src/components/manage/Blocks/Title/View.test.jsx +++ b/src/components/manage/Blocks/Title/View.test.jsx @@ -1,10 +1,25 @@ import React from 'react'; import renderer from 'react-test-renderer'; +import configureStore from 'redux-mock-store'; +import { Provider } from 'react-intl-redux'; +import { MemoryRouter } from 'react-router-dom'; import View from './View'; +const mockStore = configureStore(); + test('renders a view title component', () => { + const store = mockStore({ + intl: { + locale: 'en', + messages: {}, + }, + }); const component = renderer.create( - , + + + + + , ); const json = component.toJSON(); expect(json).toMatchSnapshot(); diff --git a/src/components/manage/Blocks/Title/__snapshots__/View.test.jsx.snap b/src/components/manage/Blocks/Title/__snapshots__/View.test.jsx.snap index 4e31d1dc1b..cc62ae1b9c 100644 --- a/src/components/manage/Blocks/Title/__snapshots__/View.test.jsx.snap +++ b/src/components/manage/Blocks/Title/__snapshots__/View.test.jsx.snap @@ -3,7 +3,29 @@ exports[`renders a view title component 1`] = `

    My Title +

    `; diff --git a/src/components/manage/Blocks/ToC/View.jsx b/src/components/manage/Blocks/ToC/View.jsx index 92f0a5f033..a0cd337f53 100644 --- a/src/components/manage/Blocks/ToC/View.jsx +++ b/src/components/manage/Blocks/ToC/View.jsx @@ -56,7 +56,14 @@ const View = (props) => { const items = []; if (!level || !levels.includes(level)) return; tocEntriesLayout.push(id); - tocEntries[id] = { level, title: title || block.plaintext, items, id }; + tocEntries[id] = { + level, + title: title || block.plaintext, + items, + id, + override_toc: block.override_toc, + plaintext: block.plaintext, + }; if (level < rootLevel) { rootLevel = level; } diff --git a/src/components/manage/Blocks/ToC/variations/DefaultTocRenderer.jsx b/src/components/manage/Blocks/ToC/variations/DefaultTocRenderer.jsx index 97fdbbcd10..148a8d88e3 100644 --- a/src/components/manage/Blocks/ToC/variations/DefaultTocRenderer.jsx +++ b/src/components/manage/Blocks/ToC/variations/DefaultTocRenderer.jsx @@ -8,15 +8,27 @@ import PropTypes from 'prop-types'; import { map } from 'lodash'; import { List } from 'semantic-ui-react'; import { FormattedMessage, injectIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; import AnchorLink from 'react-anchor-link-smooth-scroll'; +import Slugger from 'github-slugger'; -const RenderListItems = ({ items, data }) => { +const RenderListItems = ({ items, data, history }) => { return map(items, (item) => { - const { id, level, title } = item; + const { id, level, title, override_toc, plaintext } = item; + const slug = override_toc + ? Slugger.slug(plaintext) + : Slugger.slug(title) || id; return ( item && ( - {title} + { + history.push({ hash: slug }); + }} + > + {title} + {item.items?.length > 0 && ( { * @extends Component */ const View = ({ data, tocEntries }) => { + const history = useHistory(); return ( <> {data.title && !data.hide_title ? ( @@ -57,7 +70,7 @@ const View = ({ data, tocEntries }) => { bulleted={!data.ordered} as={data.ordered ? 'ol' : 'ul'} > - + ); diff --git a/src/components/manage/Blocks/ToC/variations/HorizontalMenu.jsx b/src/components/manage/Blocks/ToC/variations/HorizontalMenu.jsx index 8239a96e97..0bd1ccf8b0 100644 --- a/src/components/manage/Blocks/ToC/variations/HorizontalMenu.jsx +++ b/src/components/manage/Blocks/ToC/variations/HorizontalMenu.jsx @@ -9,15 +9,19 @@ import { map } from 'lodash'; import { Menu } from 'semantic-ui-react'; import { FormattedMessage, injectIntl } from 'react-intl'; import AnchorLink from 'react-anchor-link-smooth-scroll'; +import Slugger from 'github-slugger'; const RenderMenuItems = ({ items }) => { return map(items, (item) => { - const { id, level, title } = item; + const { id, level, title, override_toc, plaintext } = item; + const slug = override_toc + ? Slugger.slug(plaintext) + : Slugger.slug(title) || id; return ( item && ( - {title} + {title} {item.items?.length > 0 && } diff --git a/src/helpers/MessageLabels/MessageLabels.js b/src/helpers/MessageLabels/MessageLabels.js index a341f329b7..2e1c4f6b90 100644 --- a/src/helpers/MessageLabels/MessageLabels.js +++ b/src/helpers/MessageLabels/MessageLabels.js @@ -260,6 +260,10 @@ export const messages = defineMessages({ id: 'Show groups of users below', defaultMessage: 'Show groups of users below', }, + urlClipboardCopy: { + id: 'Link copied to clipboard', + defaultMessage: 'Link copied to clipboard', + }, inspectRelations: { id: 'Inspect relations', defaultMessage: 'Inspect relations', diff --git a/src/helpers/ScrollToTop/ScrollToTop.jsx b/src/helpers/ScrollToTop/ScrollToTop.jsx index 6d4a87bcc7..8122fbea63 100644 --- a/src/helpers/ScrollToTop/ScrollToTop.jsx +++ b/src/helpers/ScrollToTop/ScrollToTop.jsx @@ -28,15 +28,17 @@ class ScrollToTop extends React.Component { * @memberof ScrollToTop */ componentDidUpdate(prevProps) { + const { location } = this.props; const noInitialBlocksFocus = // Do not scroll on /edit config.blocks?.initialBlocksFocus === null ? this.props.location?.pathname.slice(-5) !== '/edit' : true; + + const isHash = location?.hash || location?.pathname.hash; if ( - !this.props.location?.hash && - !this.props.location?.pathname.hash && + !isHash && noInitialBlocksFocus && - this.props.location?.pathname !== prevProps.location?.pathname + location?.pathname !== prevProps.location?.pathname ) { window.scrollTo(0, 0); } diff --git a/src/hooks/clipboard/useClipboard.js b/src/hooks/clipboard/useClipboard.js new file mode 100644 index 0000000000..eaabbf7a15 --- /dev/null +++ b/src/hooks/clipboard/useClipboard.js @@ -0,0 +1,26 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +export default function useClipboard(clipboardText = '') { + const stringToCopy = useRef(clipboardText); + const [copied, setCopied] = useState(false); + + //synchronous: window.clipboardData.setData(options.format || "text", text); + const copyToClipboard = async (text) => { + if ('clipboard' in navigator) { + return await navigator.clipboard.writeText(text); + } else { + return document.execCommand('copy', true, text); + } + }; + + const copyAction = useCallback(() => { + const copiedString = copyToClipboard(stringToCopy.current); + setCopied(copiedString); + }, [stringToCopy]); + + useEffect(() => { + stringToCopy.current = clipboardText; + }, [clipboardText]); + + return [copied, copyAction, setCopied]; +} diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000000..ab9a86a472 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,2 @@ +export useClipboard from '@plone/volto/hooks/clipboard/useClipboard'; +export useToken from '@plone/volto/hooks/userSession/useToken'; diff --git a/yarn.lock b/yarn.lock index c4a6999d35..3188760ebf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2690,6 +2690,7 @@ __metadata: express: 4.17.3 filesize: 6 full-icu: 1.4.0 + github-slugger: 1.4.0 glob: 7.1.6 history: 4.10.1 hoist-non-react-statics: 3.3.2 @@ -12198,6 +12199,13 @@ __metadata: languageName: node linkType: hard +"github-slugger@npm:1.4.0": + version: 1.4.0 + resolution: "github-slugger@npm:1.4.0" + checksum: 4f52e7a21f5c6a4c5328f01fe4fe13ae8881fea78bfe31f9e72c4038f97e3e70d52fb85aa7633a52c501dc2486874474d9abd22aa61cbe9b113099a495551c6b + languageName: node + linkType: hard + "github-slugger@npm:^1.0.0": version: 1.5.0 resolution: "github-slugger@npm:1.5.0"