mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-03-24 12:43:57 +00:00
feat: add concat-css-classes helper method
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
d7663e3090
commit
4eb341308a
19 changed files with 129 additions and 41 deletions
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../utils/concat-css-classes'
|
||||
import { UiIcon } from '../../common/icons/ui-icon'
|
||||
import { createNumberRangeArray } from '../../common/number-range/number-range'
|
||||
import styles from './animations.module.scss'
|
||||
|
@ -24,12 +25,12 @@ export const LoadingAnimation: React.FC<HedgeDocLogoProps> = ({ error }) => {
|
|||
const iconRows = useMemo(() => createNumberRangeArray(12).map((index) => <IconRow key={index} />), [])
|
||||
|
||||
return (
|
||||
<div className={`position-relative ${error ? styles.error : ''}`}>
|
||||
<div className={concatCssClasses('position-relative', { [styles.error]: error })}>
|
||||
<div className={styles.logo}>
|
||||
<div>
|
||||
<UiIcon icon={IconPencilFill} className={styles.background} size={5} />
|
||||
</div>
|
||||
<div className={`${styles.overlay}`}>
|
||||
<div className={styles.overlay}>
|
||||
<UiIcon icon={IconPencil} size={5} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -21,19 +21,16 @@ export interface BrandingProps {
|
|||
export const CustomBranding: React.FC<BrandingProps> = ({ inline = false }) => {
|
||||
const branding = useBrandingDetails()
|
||||
|
||||
const className = inline ? styles['inline-size'] : styles['regular-size']
|
||||
|
||||
if (!branding) {
|
||||
return null
|
||||
} else if (branding.logo) {
|
||||
return (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.name}
|
||||
title={branding.name}
|
||||
className={inline ? styles['inline-size'] : styles['regular-size']}
|
||||
/>
|
||||
<img src={branding.logo} alt={branding.name} title={branding.name} className={className} />
|
||||
)
|
||||
} else {
|
||||
return <span className={inline ? styles['inline-size'] : styles['regular-size']}>{branding.name}</span>
|
||||
return <span className={className}>{branding.name}</span>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,13 +5,14 @@
|
|||
*/
|
||||
import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/async-loading-boundary'
|
||||
import { CopyToClipboardButton } from '../../../components/common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
|
||||
import { concatCssClasses } from '../../../utils/concat-css-classes'
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
import styles from './highlighted-code.module.scss'
|
||||
import { useAsyncHighlightJsImport } from './hooks/use-async-highlight-js-import'
|
||||
import { useAttachLineNumbers } from './hooks/use-attach-line-numbers'
|
||||
import { useCodeDom } from './hooks/use-code-dom'
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
export interface HighlightedCodeProps {
|
||||
code: string
|
||||
|
@ -35,6 +36,13 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
|
|||
const codeDom = useCodeDom(code, hljsApi, language)
|
||||
const wrappedDomLines = useAttachLineNumbers(codeDom, startLineNumber)
|
||||
|
||||
const className = useMemo(() => {
|
||||
return concatCssClasses('hljs', {
|
||||
[styles['showGutter']]: showGutter,
|
||||
[styles['wrapLines']]: wrapLines
|
||||
})
|
||||
}, [showGutter, wrapLines])
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary loading={loading || !hljsApi} error={!!error} componentName={'highlight.js'}>
|
||||
<div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
|
||||
|
@ -43,7 +51,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
|
|||
{...cypressId('code-highlighter')}
|
||||
{...cypressAttribute('showgutter', showGutter ? 'true' : 'false')}
|
||||
{...cypressAttribute('wraplines', wrapLines ? 'true' : 'false')}
|
||||
className={`hljs ${showGutter ? styles['showGutter'] : ''} ${wrapLines ? styles['wrapLines'] : ''}`}>
|
||||
className={className}>
|
||||
{wrappedDomLines}
|
||||
</code>
|
||||
<div className={'text-right button-inside'}>
|
||||
|
|
|
@ -3,12 +3,13 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../utils/concat-css-classes'
|
||||
import type { PropsWithDataTestId } from '../../../utils/test-id'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
import { UiIcon } from '../icons/ui-icon'
|
||||
import { ShowIf } from '../show-if/show-if'
|
||||
import styles from './icon-button.module.scss'
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import type { ButtonProps } from 'react-bootstrap'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import type { Icon } from 'react-bootstrap-icons'
|
||||
|
@ -38,13 +39,16 @@ export const IconButton: React.FC<IconButtonProps> = ({
|
|||
iconSize,
|
||||
...props
|
||||
}) => {
|
||||
const finalClassName = useMemo(
|
||||
() =>
|
||||
concatCssClasses(styles['btn-icon'], 'd-inline-flex align-items-stretch', className, {
|
||||
[styles['with-border']]: border
|
||||
}),
|
||||
[border, className]
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
className={`${styles['btn-icon']} d-inline-flex align-items-stretch ${border ? styles['with-border'] : ''} ${
|
||||
className ?? ''
|
||||
}`}
|
||||
{...testId('icon-button')}>
|
||||
<Button {...props} className={finalClassName} {...testId('icon-button')}>
|
||||
<span className={`${styles['icon-part']}`}>
|
||||
<UiIcon size={iconSize} icon={icon} className={'icon'} />
|
||||
</span>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../utils/concat-css-classes'
|
||||
import styles from './ui-icons.module.scss'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import type { Icon } from 'react-bootstrap-icons'
|
||||
|
@ -26,9 +27,7 @@ export const UiIcon: React.FC<UiIconProps> = ({ icon, nbsp, className, size, spi
|
|||
}
|
||||
}, [size])
|
||||
|
||||
const finalClassName = useMemo(() => {
|
||||
return `${spin ? styles.spin : ''} ${className ?? ''}`
|
||||
}, [className, spin])
|
||||
const finalClassName = useMemo(() => concatCssClasses(className, { [styles.spin]: spin }), [className, spin])
|
||||
|
||||
if (icon) {
|
||||
return (
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../utils/concat-css-classes'
|
||||
import type { PropsWithDataCypressId } from '../../../utils/cypress-attribute'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
|
@ -73,7 +74,7 @@ export const CommonModal: React.FC<PropsWithChildren<CommonModalProps>> = ({
|
|||
onHide={onHide}
|
||||
animation={true}
|
||||
{...testId('commonModal')}
|
||||
dialogClassName={`text-dark ${additionalClasses ?? ''}`}
|
||||
dialogClassName={concatCssClasses('text-dark', additionalClasses)}
|
||||
size={modalSize}>
|
||||
<Modal.Header closeButton={!!showCloseButton}>
|
||||
<Modal.Title>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { DarkModePreference } from '../../../redux/dark-mode/types'
|
||||
import { concatCssClasses } from '../../../utils/concat-css-classes'
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { isTestMode } from '../../../utils/test-modes'
|
||||
|
@ -168,7 +169,7 @@ export const RendererIframe: React.FC<RendererIframeProps> = ({
|
|||
allowFullScreen={true}
|
||||
ref={frameReference}
|
||||
referrerPolicy={'no-referrer'}
|
||||
className={`border-0 ${frameClasses ?? ''}`}
|
||||
className={concatCssClasses('border-0', frameClasses)}
|
||||
allow={'clipboard-write'}
|
||||
{...cypressAttribute('renderer-ready', rendererReady ? 'true' : 'false')}
|
||||
{...cypressAttribute('renderer-type', rendererType)}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../../../utils/concat-css-classes'
|
||||
import { createCursorCssClass } from './create-cursor-css-class'
|
||||
import styles from './style.module.scss'
|
||||
import type { SelectionRange } from '@codemirror/state'
|
||||
|
@ -40,9 +41,10 @@ export class RemoteCursorMarker implements LayerMarker {
|
|||
element.style.setProperty('--name', `"${this.name}"`)
|
||||
const cursorOnRightSide = this.left > this.viewWidth / 2
|
||||
const cursorOnDownSide = this.top < 20
|
||||
element.className = `${styles.cursor} ${createCursorCssClass(this.styleIndex)} ${
|
||||
cursorOnRightSide ? styles.right : ''
|
||||
} ${cursorOnDownSide ? styles.down : ''}`
|
||||
element.className = concatCssClasses(styles.cursor, createCursorCssClass(this.styleIndex), {
|
||||
[styles.right]: cursorOnRightSide,
|
||||
[styles.down]: cursorOnDownSide
|
||||
})
|
||||
}
|
||||
|
||||
eq(other: RemoteCursorMarker): boolean {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.sidebar-button {
|
||||
.button {
|
||||
height: var(--sidebar-entry-height);
|
||||
flex: 0 0 var(--sidebar-entry-height);
|
||||
width: 100%;
|
||||
|
@ -25,16 +25,16 @@
|
|||
height: 0;
|
||||
border-width: 0;
|
||||
|
||||
.sidebar-icon {
|
||||
.icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-text {
|
||||
.text {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-icon {
|
||||
.icon {
|
||||
transition: opacity 0.2s;
|
||||
opacity: 1;
|
||||
height: var(--sidebar-entry-height);
|
||||
|
@ -46,7 +46,7 @@
|
|||
flex: 0 0 var(--sidebar-entry-height);
|
||||
}
|
||||
|
||||
.sidebar-text {
|
||||
.text {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../../utils/concat-css-classes'
|
||||
import { UiIcon } from '../../../common/icons/ui-icon'
|
||||
import { ShowIf } from '../../../common/show-if/show-if'
|
||||
import type { SidebarEntryProps } from '../types'
|
||||
|
@ -34,15 +35,15 @@ export const SidebarButton: React.FC<PropsWithChildren<SidebarEntryProps>> = ({
|
|||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`${styles['sidebar-button']} ${hide ? styles['hide'] : ''} ${className ?? ''}`}
|
||||
className={concatCssClasses(styles.button, className, { [styles.hide]: hide })}
|
||||
disabled={disabled}
|
||||
{...props}>
|
||||
<ShowIf condition={!!icon}>
|
||||
<span className={`sidebar-button-icon ${styles['sidebar-icon']}`}>
|
||||
<span className={`sidebar-button-icon ${styles.icon}`}>
|
||||
<UiIcon icon={icon} />
|
||||
</span>
|
||||
</ShowIf>
|
||||
<span className={styles['sidebar-text']}>{children}</span>
|
||||
<span className={styles.text}>{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.sidebar-menu {
|
||||
.menu {
|
||||
transition: height 0.2s, flex-basis 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../../utils/concat-css-classes'
|
||||
import type { SidebarMenuProps } from '../types'
|
||||
import styles from './sidebar-menu.module.scss'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
@ -16,7 +17,7 @@ import React from 'react'
|
|||
*/
|
||||
export const SidebarMenu: React.FC<PropsWithChildren<SidebarMenuProps>> = ({ children, expand }) => {
|
||||
return (
|
||||
<div className={`${styles['sidebar-menu']} ${expand ? styles['show'] : ''}`}>
|
||||
<div className={concatCssClasses(styles.menu, { [styles['show']]: expand })}>
|
||||
<div className={`d-flex flex-column`}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { toggleHistoryEntryPinning } from '../../../../../redux/history/methods'
|
||||
import { concatCssClasses } from '../../../../../utils/concat-css-classes'
|
||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||
import type { SpecificSidebarEntryProps } from '../../types'
|
||||
|
@ -42,7 +43,7 @@ export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ class
|
|||
icon={IconPin}
|
||||
hide={hide}
|
||||
onClick={onPinClicked}
|
||||
className={`${className ?? ''} ${isPinned ? styles['highlighted'] : ''}`}>
|
||||
className={concatCssClasses(className, { [styles['highlighted']]: isPinned })}>
|
||||
<Trans i18nKey={isPinned ? 'editor.documentBar.pinnedToHistory' : 'editor.documentBar.pinNoteToHistory'} />
|
||||
</SidebarButton>
|
||||
)
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
.online-entry {
|
||||
.entry {
|
||||
&:hover {
|
||||
:global(.sidebar-button-icon):after {
|
||||
color: inherit;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { concatCssClasses } from '../../../../../utils/concat-css-classes'
|
||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||
import { SidebarMenu } from '../../sidebar-menu/sidebar-menu'
|
||||
import type { SpecificSidebarMenuProps } from '../../types'
|
||||
|
@ -69,7 +70,7 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
|||
buttonRef={buttonRef}
|
||||
onClick={onClickHandler}
|
||||
icon={expand ? IconArrowLeft : IconPeople}
|
||||
className={`${styles['online-entry']} ${className ?? ''}`}>
|
||||
className={concatCssClasses(styles.entry, className)}>
|
||||
<Trans i18nKey={'editor.onlineStatus.online'} />
|
||||
</SidebarButton>
|
||||
<SidebarMenu expand={expand}>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.markdown-toc {
|
||||
.toc {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
max-height: 100vh;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from '../../../utils/concat-css-classes'
|
||||
import { ShowIf } from '../../common/show-if/show-if'
|
||||
import styles from './table-of-contents.module.scss'
|
||||
import { useBuildReactDomFromTocAst } from './use-build-react-dom-from-toc-ast'
|
||||
|
@ -30,7 +31,7 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({ ast, maxDepth
|
|||
const tocTree = useBuildReactDomFromTocAst(ast, maxDepth, baseUrl)
|
||||
|
||||
return (
|
||||
<div className={`${styles['markdown-toc']} ${className ?? ''}`}>
|
||||
<div className={concatCssClasses(styles.toc, className)}>
|
||||
<ShowIf condition={ast.children.length === 0}>
|
||||
<Trans i18nKey={'editor.infoToc'} />
|
||||
</ShowIf>
|
||||
|
|
32
frontend/src/utils/concat-css-classes.spec.ts
Normal file
32
frontend/src/utils/concat-css-classes.spec.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { concatCssClasses } from './concat-css-classes'
|
||||
|
||||
describe('concat css classes', () => {
|
||||
it('works with a map', () => {
|
||||
expect(concatCssClasses({ a: true, b: false, c: true })).toBe('a c')
|
||||
})
|
||||
|
||||
it('works with a string array ', () => {
|
||||
expect(concatCssClasses('a', 'b', 'c')).toBe('a b c')
|
||||
})
|
||||
|
||||
it('works with a string array and map', () => {
|
||||
expect(concatCssClasses('a', 'b', 'c', { d: true, e: false, f: true })).toBe('a b c d f')
|
||||
})
|
||||
|
||||
it("doesn't include undefined and null", () => {
|
||||
expect(concatCssClasses(undefined, null)).toBe('')
|
||||
})
|
||||
|
||||
it("doesn't include duplicates", () => {
|
||||
expect(concatCssClasses('a', 'a', { a: true })).toBe('a')
|
||||
})
|
||||
|
||||
it("doesn't include empty class names", () => {
|
||||
expect(concatCssClasses('a', '', { '': true })).toBe('a')
|
||||
})
|
||||
})
|
38
frontend/src/utils/concat-css-classes.ts
Normal file
38
frontend/src/utils/concat-css-classes.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
type ClassMap = Record<string, boolean | null | undefined>
|
||||
|
||||
/**
|
||||
* Generates a css class string from the given arguments. It filters out empty values, as well as null and undefined.
|
||||
* If one of the arguments is a string to boolean map then only the keys with a true-ish value will be included.
|
||||
*
|
||||
* @param values The values that should be included in the class name string
|
||||
* @return {string} the generates class name string
|
||||
*/
|
||||
export const concatCssClasses = (...values: (string | null | undefined | ClassMap)[]): string => {
|
||||
const strings = generateCssClassStrings(values).filter((value) => !!value)
|
||||
return Array.from(new Set(strings)).join(' ')
|
||||
}
|
||||
const generateCssClassStrings = (values: (string | null | undefined | ClassMap)[]): string[] => {
|
||||
if (Array.isArray(values)) {
|
||||
return values.flatMap((value) => {
|
||||
if (!value) {
|
||||
return []
|
||||
} else if (typeof value === 'string') {
|
||||
return [value]
|
||||
} else {
|
||||
return generateCssClassStringsFromMap(value)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return generateCssClassStringsFromMap(values)
|
||||
}
|
||||
}
|
||||
|
||||
const generateCssClassStringsFromMap = (values: ClassMap): string[] => {
|
||||
return Object.keys(values).filter((value) => values[value])
|
||||
}
|
Loading…
Reference in a new issue