diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ac0940a..054cdc606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0 - When pasting tables (e.g. from LibreOffice Calc or MS Excel) they get reformatted to markdown tables. - The history page supports URL parameters that allow bookmarking of a specific search of tags filter. - Users can change the pinning state of a note directly from the editor. +- Note information dialog containing word count, revision count, last editor and creation time. ### Changed diff --git a/cypress/integration/word-count.spec.ts b/cypress/integration/word-count.spec.ts new file mode 100644 index 000000000..912e527e9 --- /dev/null +++ b/cypress/integration/word-count.spec.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +describe('Test word count with', () => { + beforeEach(() => { + cy.visitTestEditor() + }) + + it('empty note', () => { + cy.codemirrorFill('') + cy.wait(500) + cy.get('[data-cy="sidebar-btn-document-info"]').click() + cy.get('[data-cy="document-info-modal"]').should('be.visible') + cy.get('[data-cy="document-info-word-count"]').should('have.text', '0') + }) + + it('simple words', () => { + cy.codemirrorFill('five words should be enough') + cy.wait(500) + cy.get('[data-cy="sidebar-btn-document-info"]').click() + cy.get('[data-cy="document-info-modal"]').should('be.visible') + cy.get('[data-cy="document-info-word-count"]').should('have.text', '5') + }) + + it('excluded codeblocks', () => { + cy.codemirrorFill('```\nthis is should be ignored\n```\n\ntwo `words`') + cy.wait(500) + cy.get('[data-cy="sidebar-btn-document-info"]').click() + cy.get('[data-cy="document-info-modal"]').should('be.visible') + cy.get('[data-cy="document-info-word-count"]').should('have.text', '2') + }) + + it('excluded images', () => { + cy.codemirrorFill('![ignored alt text](https://dummyimage.com/48) not ignored text') + cy.wait(500) + cy.get('[data-cy="sidebar-btn-document-info"]').click() + cy.get('[data-cy="document-info-modal"]').should('be.visible') + cy.get('[data-cy="document-info-word-count"]').should('have.text', '3') + }) +}) diff --git a/package.json b/package.json index e91a2fbdc..67d8c78c5 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,8 @@ "uuid": "8.3.2", "vega": "5.20.2", "vega-embed": "6.17.0", - "vega-lite": "5.0.0" + "vega-lite": "5.0.0", + "words-count": "1.0.8" }, "scripts": { "start": "cross-env PORT=3001 craco start", diff --git a/public/locales/en.json b/public/locales/en.json index 7100f6b5f..545d3be88 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -347,7 +347,8 @@ "created": "<0> created this note <1>", "edited": "<0> was the last editor <1>", "usersContributed": "<0> users contributed to this document", - "revisions": "<0> revisions are saved" + "revisions": "<0> revisions are saved", + "words": "<0> words in document" }, "gistImport": { "title": "Import from Gist", @@ -458,6 +459,7 @@ "and": "and", "avatarOf": "avatar of '{{name}}'", "why": "Why?", + "loading": "Loading ...", "successfullyCopied": "Copied!", "copyError": "Error while copying!", "errorOccurred": "An error occurred", diff --git a/src/components/editor-page/document-bar/document-info/document-info-line-word-count.tsx b/src/components/editor-page/document-bar/document-info/document-info-line-word-count.tsx new file mode 100644 index 000000000..46735a738 --- /dev/null +++ b/src/components/editor-page/document-bar/document-info/document-info-line-word-count.tsx @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useEffect, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { ShowIf } from '../../../common/show-if/show-if' +import { DocumentInfoLine } from './document-info-line' +import { UnitalicBoldText } from './unitalic-bold-text' +import { useIFrameEditorToRendererCommunicator } from '../../render-context/iframe-editor-to-renderer-communicator-context-provider' + +/** + * Creates a new info line for the document information dialog that holds the + * word count of the note, based on counting in the rendered output. + */ +export const DocumentInfoLineWordCount: React.FC = () => { + useTranslation() + const iframeEditorToRendererCommunicator = useIFrameEditorToRendererCommunicator() + const [wordCount, setWordCount] = useState(null) + + useEffect(() => { + iframeEditorToRendererCommunicator?.onWordCountCalculated((words) => { + setWordCount(words) + }) + iframeEditorToRendererCommunicator?.sendGetWordCount() + return () => { + iframeEditorToRendererCommunicator?.onWordCountCalculated(undefined) + } + }, [iframeEditorToRendererCommunicator, setWordCount]) + + return ( + + + + + + + + + + + ) +} diff --git a/src/components/editor-page/document-bar/document-info/document-info-modal.tsx b/src/components/editor-page/document-bar/document-info/document-info-modal.tsx index a4b53dcb3..7cbd9a08a 100644 --- a/src/components/editor-page/document-bar/document-info/document-info-modal.tsx +++ b/src/components/editor-page/document-bar/document-info/document-info-modal.tsx @@ -7,12 +7,13 @@ import { DateTime } from 'luxon' import React from 'react' import { ListGroup, Modal } from 'react-bootstrap' -import { Trans } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { CommonModal } from '../../../common/modals/common-modal' import { DocumentInfoLine } from './document-info-line' import { DocumentInfoLineWithTimeMode, DocumentInfoTimeLine } from './document-info-time-line' import { UnitalicBoldText } from './unitalic-bold-text' import { useCustomizeAssetsUrl } from '../../../../hooks/common/use-customize-assets-url' +import { DocumentInfoLineWordCount } from './document-info-line-word-count' export interface DocumentInfoModalProps { show: boolean @@ -21,10 +22,16 @@ export interface DocumentInfoModalProps { export const DocumentInfoModal: React.FC = ({ show, onHide }) => { const assetsBaseUrl = useCustomizeAssetsUrl() + useTranslation() // TODO Replace hardcoded mock data with real/mock API requests return ( - + @@ -59,6 +66,9 @@ export const DocumentInfoModal: React.FC = ({ show, onHi + + + diff --git a/src/components/editor-page/document-bar/document-info/unitalic-bold-text.tsx b/src/components/editor-page/document-bar/document-info/unitalic-bold-text.tsx index 8d788d4d7..7f1b6d9fa 100644 --- a/src/components/editor-page/document-bar/document-info/unitalic-bold-text.tsx +++ b/src/components/editor-page/document-bar/document-info/unitalic-bold-text.tsx @@ -7,9 +7,14 @@ import React from 'react' export interface UnitalicBoldTextProps { - text: string + text: string | number + dataCy?: string } -export const UnitalicBoldText: React.FC = ({ text }) => { - return {text} +export const UnitalicBoldText: React.FC = ({ text, dataCy }) => { + return ( + + {text} + + ) } diff --git a/src/components/editor-page/sidebar/document-info-sidebar-entry.tsx b/src/components/editor-page/sidebar/document-info-sidebar-entry.tsx index 2c401dda9..51cf5091c 100644 --- a/src/components/editor-page/sidebar/document-info-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/document-info-sidebar-entry.tsx @@ -16,7 +16,12 @@ export const DocumentInfoSidebarEntry: React.FC = ({ return ( - setShowModal(true)}> + setShowModal(true)} + data-cy={'sidebar-btn-document-info'}> setShowModal(false)} /> diff --git a/src/components/render-page/iframe-editor-to-renderer-communicator.ts b/src/components/render-page/iframe-editor-to-renderer-communicator.ts index 84a524bd3..da0bea983 100644 --- a/src/components/render-page/iframe-editor-to-renderer-communicator.ts +++ b/src/components/render-page/iframe-editor-to-renderer-communicator.ts @@ -27,6 +27,7 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< private onRendererReadyHandler?: () => void private onImageClickedHandler?: (details: ImageDetails) => void private onHeightChangeHandler?: (height: number) => void + private onWordCountCalculatedHandler?: (words: number) => void public onHeightChange(handler?: (height: number) => void): void { this.onHeightChangeHandler = handler @@ -60,6 +61,10 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< this.onSetScrollStateHandler = handler } + public onWordCountCalculated(handler?: (words: number) => void): void { + this.onWordCountCalculatedHandler = handler + } + public sendSetBaseConfiguration(baseConfiguration: BaseConfiguration): void { this.sendMessageToOtherSide({ type: RenderIframeMessageType.SET_BASE_CONFIGURATION, @@ -91,6 +96,12 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< }) } + public sendGetWordCount(): void { + this.sendMessageToOtherSide({ + type: RenderIframeMessageType.GET_WORD_COUNT + }) + } + protected handleEvent(event: MessageEvent): boolean | undefined { const renderMessage = event.data switch (renderMessage.type) { @@ -118,6 +129,9 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< case RenderIframeMessageType.ON_HEIGHT_CHANGE: this.onHeightChangeHandler?.(renderMessage.height) return false + case RenderIframeMessageType.ON_WORD_COUNT_CALCULATED: + this.onWordCountCalculatedHandler?.(renderMessage.words) + return false } } } diff --git a/src/components/render-page/iframe-markdown-renderer.tsx b/src/components/render-page/iframe-markdown-renderer.tsx index bab72e891..4bf7d99ec 100644 --- a/src/components/render-page/iframe-markdown-renderer.tsx +++ b/src/components/render-page/iframe-markdown-renderer.tsx @@ -14,6 +14,7 @@ import { ImageClickHandler } from '../markdown-renderer/replace-components/image import { useImageClickHandler } from './hooks/use-image-click-handler' import { MarkdownDocument } from './markdown-document' import { useIFrameRendererToEditorCommunicator } from '../editor-page/render-context/iframe-renderer-to-editor-communicator-context-provider' +import { countWords } from './word-counter' export const IframeMarkdownRenderer: React.FC = () => { const [markdownContent, setMarkdownContent] = useState('') @@ -22,10 +23,24 @@ export const IframeMarkdownRenderer: React.FC = () => { const iframeCommunicator = useIFrameRendererToEditorCommunicator() + const countWordsInRenderedDocument = useCallback(() => { + const documentContainer = document.querySelector('.markdown-body') + if (!documentContainer) { + iframeCommunicator?.sendWordCountCalculated(0) + return + } + const wordCount = countWords(documentContainer) + iframeCommunicator?.sendWordCountCalculated(wordCount) + }, [iframeCommunicator]) + useEffect(() => iframeCommunicator?.onSetBaseConfiguration(setBaseConfiguration), [iframeCommunicator]) useEffect(() => iframeCommunicator?.onSetMarkdownContent(setMarkdownContent), [iframeCommunicator]) useEffect(() => iframeCommunicator?.onSetDarkMode(setDarkMode), [iframeCommunicator]) useEffect(() => iframeCommunicator?.onSetScrollState(setScrollState), [iframeCommunicator, scrollState]) + useEffect( + () => iframeCommunicator?.onGetWordCount(countWordsInRenderedDocument), + [iframeCommunicator, countWordsInRenderedDocument] + ) const onTaskCheckedChange = useCallback( (lineInMarkdown: number, checked: boolean) => { diff --git a/src/components/render-page/iframe-renderer-to-editor-communicator.ts b/src/components/render-page/iframe-renderer-to-editor-communicator.ts index 7632f7dd9..c513092af 100644 --- a/src/components/render-page/iframe-renderer-to-editor-communicator.ts +++ b/src/components/render-page/iframe-renderer-to-editor-communicator.ts @@ -23,6 +23,7 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< private onSetDarkModeHandler?: (darkModeActivated: boolean) => void private onSetScrollStateHandler?: (scrollState: ScrollState) => void private onSetBaseConfigurationHandler?: (baseConfiguration: BaseConfiguration) => void + private onGetWordCountHandler?: () => void public onSetBaseConfiguration(handler?: (baseConfiguration: BaseConfiguration) => void): void { this.onSetBaseConfigurationHandler = handler @@ -40,6 +41,10 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< this.onSetScrollStateHandler = handler } + public onGetWordCount(handler?: () => void): void { + this.onGetWordCountHandler = handler + } + public sendRendererReady(): void { this.sendMessageToOtherSide({ type: RenderIframeMessageType.RENDERER_READY @@ -95,6 +100,13 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< }) } + public sendWordCountCalculated(words: number): void { + this.sendMessageToOtherSide({ + type: RenderIframeMessageType.ON_WORD_COUNT_CALCULATED, + words + }) + } + protected handleEvent(event: MessageEvent): boolean | undefined { const renderMessage = event.data switch (renderMessage.type) { @@ -110,6 +122,9 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< case RenderIframeMessageType.SET_BASE_CONFIGURATION: this.onSetBaseConfigurationHandler?.(renderMessage.baseConfiguration) return false + case RenderIframeMessageType.GET_WORD_COUNT: + this.onGetWordCountHandler?.() + return false } } } diff --git a/src/components/render-page/rendering-message.ts b/src/components/render-page/rendering-message.ts index c80bb1376..c2fdd17dc 100644 --- a/src/components/render-page/rendering-message.ts +++ b/src/components/render-page/rendering-message.ts @@ -17,7 +17,9 @@ export enum RenderIframeMessageType { ON_SET_FRONTMATTER = 'ON_SET_FRONTMATTER', IMAGE_CLICKED = 'IMAGE_CLICKED', ON_HEIGHT_CHANGE = 'ON_HEIGHT_CHANGE', - SET_BASE_CONFIGURATION = 'SET_BASE_CONFIGURATION' + SET_BASE_CONFIGURATION = 'SET_BASE_CONFIGURATION', + GET_WORD_COUNT = 'GET_WORD_COUNT', + ON_WORD_COUNT_CALCULATED = 'ON_WORD_COUNT_CALCULATED' } export interface RendererToEditorSimpleMessage { @@ -40,6 +42,10 @@ export interface SetBaseUrlMessage { baseConfiguration: BaseConfiguration } +export interface GetWordCountMessage { + type: RenderIframeMessageType.GET_WORD_COUNT +} + export interface ImageClickedMessage { type: RenderIframeMessageType.IMAGE_CLICKED details: ImageDetails @@ -76,11 +82,17 @@ export interface OnHeightChangeMessage { height: number } +export interface OnWordCountCalculatedMessage { + type: RenderIframeMessageType.ON_WORD_COUNT_CALCULATED + words: number +} + export type EditorToRendererIframeMessage = | SetMarkdownContentMessage | SetDarkModeMessage | SetScrollStateMessage | SetBaseUrlMessage + | GetWordCountMessage export type RendererToEditorIframeMessage = | RendererToEditorSimpleMessage @@ -90,6 +102,7 @@ export type RendererToEditorIframeMessage = | SetScrollStateMessage | ImageClickedMessage | OnHeightChangeMessage + | OnWordCountCalculatedMessage export enum RendererType { DOCUMENT, diff --git a/src/components/render-page/word-counter.ts b/src/components/render-page/word-counter.ts new file mode 100644 index 000000000..c2ef1759e --- /dev/null +++ b/src/components/render-page/word-counter.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import wordsCount from 'words-count' + +/** List of HTML tag names that should not be counted. */ +const EXCLUDED_TAGS = ['img', 'pre', 'nav'] +/** List of class names that should not be counted. */ +const EXCLUDED_CLASSES = ['katex-mathml'] + +/** + * Checks whether the given node is an excluded HTML tag and therefore should be + * excluded from counting. + * @param node The node to test. + * @return true if the node should be excluded, false otherwise. + */ +const isExcludedTag = (node: Element | ChildNode): boolean => { + return EXCLUDED_TAGS.includes(node.nodeName.toLowerCase()) +} + +/** + * Checks whether the given node is a HTML element with an excluded class name, + * so that it should be excluded. + * @param node The node to test. + * @return true if the node should be excluded, false otherwise. + */ +const isExcludedClass = (node: Element | ChildNode): boolean => { + return EXCLUDED_CLASSES.some((excludedClass) => (node as HTMLElement).classList?.contains(excludedClass)) +} + +/** + * Counts the words of the given node while ignoring empty nodes and excluded + * nodes. Child nodes will recursively counted as well. + * @param node The node whose content's words should be counted. + * @return The number of words counted in this node and its children. + */ +export const countWords = (node: Element | ChildNode): number => { + if (!node.textContent || isExcludedTag(node) || isExcludedClass(node)) { + return 0 + } + if (!node.hasChildNodes()) { + return wordsCount(node.textContent) + } + return [...node.childNodes].reduce((words, childNode) => { + return words + countWords(childNode) + }, 0) +} diff --git a/src/external-types/words-count/words-count.d.ts b/src/external-types/words-count/words-count.d.ts new file mode 100644 index 000000000..0b0a15cd5 --- /dev/null +++ b/src/external-types/words-count/words-count.d.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module 'words-count' { + export default function wordsCount(text: string): number +} diff --git a/yarn.lock b/yarn.lock index 67d9592d7..bfe9281e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15127,6 +15127,11 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +words-count@1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/words-count/-/words-count-1.0.8.tgz#c048de08d49dd68e42d2aa47bcc43993a479b4f3" + integrity sha512-t/uFU5+JgvAm2MCeA9kqWZdNqkNLuzn1+k0gkY3HJ/l/2unEUU8Wrs8opB+BzTERKdH2QMcRU5UI4TJSnhMngA== + workbox-background-sync@^5.1.4: version "5.1.4" resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-5.1.4.tgz#5ae0bbd455f4e9c319e8d827c055bb86c894fd12"