0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2024-12-30 22:34:01 -05:00

Improved ActivityPub reader view (#21854)

ref https://linear.app/ghost/issue/AP-633/reader-view-customization-options, https://linear.app/ghost/issue/AP-631/estimated-reading-time-in-reader-view

- Added reader view customization options (typefaces, font sizes and line height) which allow users to make the reading experience suit their personal needs and taste. Changing the font size also subtly tweaks the spacing and width of the articles.
- Added an estimated reading time and a simple text-based progress indicator, so users have a better idea of the article length and their progress when reading long-form content.
This commit is contained in:
Djordje Vlaisavljevic 2024-12-11 18:58:46 +00:00 committed by GitHub
parent fbbf34e1d0
commit 038a3e5939
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 397 additions and 117 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.3.36",
"version": "0.3.37",
"license": "MIT",
"repository": {
"type": "git",

View file

@ -23,6 +23,7 @@ const articleBodyStyles = (siteUrl: string|undefined) => {
--container-width: 1320px;
--container-gap: clamp(24px, 1.7032rem + 1.9355vw, 48px);
--grid-gap: 42px;
--font-size: 17px; /* Default font size */
}
:root.has-light-text,
@ -74,6 +75,7 @@ p, h1, h2, h3, h4, h5, h6 {
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
line-height: 1.2;
}
@ -310,14 +312,14 @@ button.gh-form-input {
.gh-article-title {
font-weight: 700;
text-wrap: pretty;
font-size: 3rem;
font-size: calc(3rem * var(--content-spacing-factor, 1));
letter-spacing: -0.021em;
line-height: 1.4;
}
.gh-article-excerpt {
margin-top: 16px;
font-size: 2rem;
font-size: calc(2rem * var(--content-spacing-factor, 1));
line-height: 1.4;
letter-spacing: -0.017em;
text-wrap: pretty;
@ -340,10 +342,11 @@ created within the Ghost editor. The main content handles
headings, text, images and lists. We deal with cards lower down. */
.gh-content {
font-size: 17px;
font-size: var(--font-size);
overflow-x: hidden;
letter-spacing: -0.013em;
line-height: 1.6;
line-height: var(--line-height);
font-family: var(--font-family);
}
/* Default vertical spacing */
@ -399,7 +402,7 @@ unless a heading is the very first element in the post content */
}
.gh-content h2 {
font-size: 2.4rem;
font-size: calc(2.4rem * var(--content-spacing-factor, 1));
letter-spacing: -0.019em;
line-height: 1.4166666667;
}

View file

@ -1,21 +1,21 @@
import FeedItem from './FeedItem';
import FeedItemStats from './FeedItemStats';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from '../articleBodyStyles';
import getUsername from '../../utils/get-username';
import {type Activity} from '../activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, LoadingIndicator, Modal} from '@tryghost/admin-x-design-system';
import {Button, LoadingIndicator, Modal, Popover, Select, SelectOption} from '@tryghost/admin-x-design-system';
import {renderTimestamp} from '../../utils/render-timestamp';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useEffect, useRef, useState} from 'react';
import {useModal} from '@ebay/nice-modal-react';
import {useThreadForUser} from '../../hooks/useActivityPubQueries';
import APAvatar from '../global/APAvatar';
import APReplyBox from '../global/APReplyBox';
import getReadingTime from '../../utils/get-reading-time';
interface ArticleModalProps {
activityId: string;
@ -33,122 +33,191 @@ interface ArticleModalProps {
}[];
}
const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string}> = ({heading, image, excerpt, html}) => {
interface IframeWindow extends Window {
resizeIframe?: () => void;
}
const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string, fontSize: FontSize, lineHeight: string, fontFamily: SelectOption}> = ({
heading,
image,
excerpt,
html,
fontSize,
lineHeight,
fontFamily
}) => {
const site = useBrowseSite();
const siteData = site.data?.site;
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [iframeHeight, setIframeHeight] = useState('0px');
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
<style>
body {
margin: 0;
padding: 0;
overflow-y: hidden;
}
</style>
<script>
let isFullyLoaded = false;
<html>
<head>
${cssContent}
<style>
:root {
--font-size: ${fontSize};
--line-height: ${lineHeight};
--font-family: ${fontFamily.value};
--content-spacing-factor: ${SPACING_FACTORS[FONT_SIZES.indexOf(fontSize)]};
}
body {
margin: 0;
padding: 0;
overflow-y: hidden;
}
</style>
<script>
let isFullyLoaded = false;
function resizeIframe() {
const finalHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight
);
window.parent.postMessage({
type: 'resize',
height: finalHeight,
isLoaded: isFullyLoaded
}, '*');
}
function resizeIframe() {
const bodyHeight = document.body.offsetHeight;
function waitForImages() {
const images = document.getElementsByTagName('img');
const imagePromises = Array.from(images).map(img => {
if (img.complete) {
return Promise.resolve();
}
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
});
return Promise.all(imagePromises);
}
window.parent.postMessage({
type: 'resize',
height: bodyHeight,
isLoaded: isFullyLoaded,
bodyHeight: bodyHeight
}, '*');
}
function initializeResize() {
resizeIframe();
function waitForImages() {
const images = document.getElementsByTagName('img');
const imagePromises = Array.from(images).map(img => {
if (img.complete) {
return Promise.resolve();
}
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
});
return Promise.all(imagePromises);
}
waitForImages().then(() => {
isFullyLoaded = true;
resizeIframe();
});
}
function initializeResize() {
resizeIframe();
window.addEventListener('DOMContentLoaded', initializeResize);
window.addEventListener('load', resizeIframe);
window.addEventListener('resize', resizeIframe);
new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true });
</script>
</head>
<body>
<header class='gh-article-header gh-canvas'>
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${excerpt ? `
<p class='gh-article-excerpt'>${excerpt}</p>
` : ''}
${image ? `
<figure class='gh-article-image'>
<img src='${image}' alt='${heading}' />
</figure>
` : ''}
</header>
<div class='gh-content gh-canvas is-body'>
${html}
</div>
</body>
</html>
`;
waitForImages().then(() => {
isFullyLoaded = true;
resizeIframe();
});
}
window.addEventListener('DOMContentLoaded', initializeResize);
window.addEventListener('load', resizeIframe);
window.addEventListener('resize', resizeIframe);
new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true });
window.addEventListener('message', (event) => {
if (event.data.type === 'triggerResize') {
resizeIframe();
}
});
</script>
</head>
<body>
<header class='gh-article-header gh-canvas'>
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${excerpt ? `
<p class='gh-article-excerpt'>${excerpt}</p>
` : ''}
${image ? `
<figure class='gh-article-image'>
<img src='${image}' alt='${heading}' />
</figure>
` : ''}
</header>
<div class='gh-content gh-canvas is-body'>
${html}
</div>
</body>
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'resize') {
setIframeHeight(`${event.data.height}px`);
iframe.style.height = `${event.data.height}px`;
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
if (!iframe) {
return;
}
if (!iframe.srcdoc) {
iframe.srcdoc = htmlContent;
}
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'resize') {
const newHeight = `${event.data.bodyHeight + 24}px`;
setIframeHeight(newHeight);
iframe.style.height = newHeight;
if (event.data.isLoaded) {
setIsLoading(false);
}
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [htmlContent]);
// Separate effect for style updates
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) {
return;
}
const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
if (!iframeDocument) {
return;
}
const root = iframeDocument.documentElement;
root.style.setProperty('--font-size', fontSize);
root.style.setProperty('--line-height', lineHeight);
root.style.setProperty('--font-family', fontFamily.value);
root.style.setProperty('--content-spacing-factor', SPACING_FACTORS[FONT_SIZES.indexOf(fontSize)]);
const iframeWindow = iframe.contentWindow as IframeWindow;
if (iframeWindow && typeof iframeWindow.resizeIframe === 'function') {
iframeWindow.resizeIframe();
} else {
// Fallback: trigger a resize event
const resizeEvent = new Event('resize');
iframeDocument.dispatchEvent(resizeEvent);
}
}, [fontSize, lineHeight, fontFamily]);
return (
<div className='w-full pb-6'>
<iframe
ref={iframeRef}
id='gh-ap-article-iframe'
style={{
width: '100%',
border: 'none',
height: iframeHeight,
overflow: 'hidden'
}}
title='Embedded Content'
/>
<div className='relative'>
{isLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-white/60'>
<LoadingIndicator />
</div>
)}
<iframe
ref={iframeRef}
id='gh-ap-article-iframe'
style={{
width: '100%',
border: 'none',
height: iframeHeight,
overflow: 'hidden',
opacity: isLoading ? 0 : 1,
transition: 'opacity 0.2s ease-in-out'
}}
title='Embedded Content'
/>
</div>
</div>
);
};
@ -157,6 +226,28 @@ const FeedItemDivider: React.FC = () => (
<div className="h-px bg-grey-200"></div>
);
const FONT_SIZES = ['1.4rem', '1.7rem', '1.9rem', '2.1rem', '2.4rem'] as const;
const LINE_HEIGHTS = ['1.3', '1.4', '1.5', '1.6', '1.7', '1.8'] as const;
const SPACING_FACTORS = ['0.7', '1', '1.1', '1.2', '1.3'] as const;
type FontSize = typeof FONT_SIZES[number];
// Add constants for localStorage keys
const STORAGE_KEYS = {
FONT_SIZE: 'ghost-ap-font-size',
LINE_HEIGHT: 'ghost-ap-line-height',
FONT_FAMILY: 'ghost-ap-font-family'
} as const;
// Add this constant near your other constants
const MAX_WIDTHS = {
'1.4rem': '544px',
'1.7rem': '644px',
'1.9rem': '684px',
'2.1rem': '724px',
'2.4rem': '764px'
} as const;
const ArticleModal: React.FC<ArticleModalProps> = ({
activityId,
object,
@ -266,6 +357,104 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
}, 100);
}, [focusReply, focusReplies]);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Initialize state with values from localStorage, falling back to defaults
const [currentFontSizeIndex, setCurrentFontSizeIndex] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.FONT_SIZE);
return saved ? parseInt(saved) : 1;
});
const [currentLineHeightIndex, setCurrentLineHeightIndex] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.LINE_HEIGHT);
return saved ? parseInt(saved) : 3;
});
const [fontFamily, setFontFamily] = useState<SelectOption>(() => {
const saved = localStorage.getItem(STORAGE_KEYS.FONT_FAMILY);
return saved ? JSON.parse(saved) : {
value: 'sans-serif',
label: 'Clean sans-serif'
};
});
// Update localStorage when values change
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.FONT_SIZE, currentFontSizeIndex.toString());
}, [currentFontSizeIndex]);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LINE_HEIGHT, currentLineHeightIndex.toString());
}, [currentLineHeightIndex]);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.FONT_FAMILY, JSON.stringify(fontFamily));
}, [fontFamily]);
const increaseFontSize = () => {
setCurrentFontSizeIndex(prevIndex => Math.min(prevIndex + 1, FONT_SIZES.length - 1));
};
const decreaseFontSize = () => {
setCurrentFontSizeIndex(prevIndex => Math.max(prevIndex - 1, 0));
};
const increaseLineHeight = () => {
setCurrentLineHeightIndex(prevIndex => Math.min(prevIndex + 1, LINE_HEIGHTS.length - 1));
};
const decreaseLineHeight = () => {
setCurrentLineHeightIndex(prevIndex => Math.max(prevIndex - 1, 0));
};
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDocument) {
iframeDocument.documentElement.style.setProperty('--font-size', FONT_SIZES[currentFontSizeIndex]);
iframeDocument.documentElement.style.setProperty('--line-height', LINE_HEIGHTS[currentLineHeightIndex]);
iframeDocument.documentElement.style.setProperty('--font-family', fontFamily.value);
iframeDocument.documentElement.style.setProperty('--content-spacing-factor', SPACING_FACTORS[FONT_SIZES.indexOf(FONT_SIZES[currentFontSizeIndex])]);
}
}
}, [currentFontSizeIndex, currentLineHeightIndex, fontFamily]);
// Get the current max width based on font size
const currentMaxWidth = MAX_WIDTHS[FONT_SIZES[currentFontSizeIndex]];
// Calculate the grid column width by subtracting 64px from the current max width
const currentGridWidth = `${parseInt(currentMaxWidth) - 64}px`;
const [readingProgress, setReadingProgress] = useState(0);
// Add the scroll handler
useEffect(() => {
const container = document.querySelector('.overflow-y-auto');
const article = document.getElementById('object-content');
const handleScroll = () => {
if (!container || !article) {
return;
}
const articleRect = article.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const totalHeight = article.offsetHeight;
// Calculate how much of the article has been scrolled past the viewport
const scrolledPast = Math.max(0, containerRect.top - articleRect.top);
// Only add the visible portion if we've started scrolling
const visibleHeight = scrolledPast > 0 ? Math.min(containerRect.height, articleRect.height) : 0;
const progress = Math.round(Math.min(Math.max(((scrolledPast + visibleHeight) / totalHeight) * 100, 0), 100));
setReadingProgress(progress);
};
container?.addEventListener('scroll', handleScroll);
return () => container?.removeEventListener('scroll', handleScroll);
}, []);
return (
<Modal
align='right'
@ -276,40 +465,113 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
footer={<></>}
height={'full'}
padding={false}
scrolling={true}
size='bleed'
width={modalSize === MODAL_SIZE_LG ? 'toSidebar' : modalSize}
>
<div className='flex h-full flex-col'>
<div className='sticky top-0 z-50 border-b border-grey-200 bg-white py-8'>
<div className={`flex h-8 ${modalSize === MODAL_SIZE_LG ? 'mx-auto w-full max-w-[644px] px-8' : 'px-8'}`}>
<div
className={`flex h-8 ${modalSize === MODAL_SIZE_LG ? 'grid px-8' : 'justify-between gap-2 px-8'}`}
style={modalSize === MODAL_SIZE_LG ? {
gridTemplateColumns: `1fr minmax(0,${currentGridWidth}) 1fr`
} : undefined}
>
{(canNavigateBack || (activityThreadParents.length > 0)) ? (
<div className='col-[1/2] flex items-center justify-between'>
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='chevron-left' size='sm' unstyled onClick={navigateBack}/>
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='arrow-left' size='sm' unstyled onClick={navigateBack}/>
</div>
) : <div className='flex items-center gap-3'>
) : (<div className='col-[2/3] mx-auto flex w-full items-center gap-3'>
<div className='relative z-10 pt-[3px]'>
<APAvatar author={actor}/>
</div>
<div className='relative z-10 flex w-full min-w-0 flex-col overflow-visible text-[1.5rem]'>
<div className='flex w-full'>
<span className='min-w-0 truncate whitespace-nowrap font-bold after:mx-1 after:font-normal after:text-grey-700 after:content-["·"]'>{actor.name}</span>
<div>{renderTimestamp(object)}</div>
<span className='min-w-0 truncate whitespace-nowrap font-bold'>{actor.name}</span>
</div>
<div className='flex w-full'>
<span className='min-w-0 truncate text-grey-700'>{getUsername(actor)}</span>
<span className='text-grey-700 after:mx-1 after:font-normal after:text-grey-700 after:content-["·"]'>{getUsername(actor)}</span>
<span className='text-grey-700'>{renderTimestamp(object)}</span>
</div>
</div>
</div>}
<div className='col-[2/3] flex grow items-center justify-center px-8 text-center'>
</div>
</div>)}
<div className='col-[3/4] flex items-center justify-end space-x-6'>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && <Popover position='end' trigger={ <Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='typography' size='sm' unstyled onClick={() => {}}/>
}>
<div className='flex min-w-[240px] flex-col p-5'>
<Select
className='mb-3'
options={[
{value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif', label: 'Clean sans-serif'},
{value: 'Georgia, Times, serif', label: 'Elegant serif'}
]}
title='Typeface'
value={fontFamily}
onSelect={option => setFontFamily(option || {
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
label: 'Clean sans-serif'
})}
/>
<div className='mb-2 flex items-center justify-between'>
<span className='text-sm font-medium'>Font size</span>
<div className='flex items-center gap-2'>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white ${currentFontSizeIndex === 0 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentFontSizeIndex === 0}
hideLabel={true}
icon='substract'
iconSize='xs'
label='Decrease font size'
unstyled={true}
onClick={decreaseFontSize}
/>
{/* <span className='text-grey-700'>{FONT_SIZES[currentFontSizeIndex]}</span> */}
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentFontSizeIndex === FONT_SIZES.length - 1 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentFontSizeIndex === FONT_SIZES.length - 1}
hideLabel={true}
icon='add'
iconSize='xs'
label='Increase font size'
unstyled={true}
onClick={increaseFontSize}
/>
</div>
</div>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>Line spacing</span>
<div className='flex items-center gap-2'>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentLineHeightIndex === 0 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentLineHeightIndex === 0}
hideLabel={true}
icon='substract'
iconSize='xs'
label='Decrease line spacing'
unstyled={true}
onClick={decreaseLineHeight}
/>
{/* <span className='text-grey-700'>{LINE_HEIGHTS[currentLineHeightIndex]}</span> */}
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentLineHeightIndex === LINE_HEIGHTS.length - 1 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentLineHeightIndex === LINE_HEIGHTS.length - 1}
hideLabel={true}
icon='add'
iconSize='xs'
label='Increase line spacing'
unstyled={true}
onClick={increaseLineHeight}
/>
</div>
</div>
</div>
</Popover>}
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
</div>
</div>
</div>
<div className='grow overflow-y-auto'>
<div className='mx-auto max-w-[644px] px-8 pb-10 pt-5'>
<div className={`mx-auto px-8 pb-10 pt-5`} style={{maxWidth: currentMaxWidth}}>
{activityThreadParents.map((item) => {
return (
<>
@ -349,12 +611,15 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
/>
)}
{object.type === 'Article' && (
<div className='border-b border-grey-200 pb-8'>
<div className='border-b border-grey-200 pb-8' id='object-content'>
<ArticleBody
excerpt={object?.preview?.content}
fontFamily={fontFamily}
fontSize={FONT_SIZES[currentFontSizeIndex]}
heading={object.name}
html={object.content}
image={typeof object.image === 'string' ? object.image : object.image?.url}
lineHeight={LINE_HEIGHTS[currentLineHeightIndex]}
/>
<div className='ml-[-7px]'>
<FeedItemStats
@ -413,6 +678,16 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
</div>
</div>
</div>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && (
<div className='pointer-events-none sticky bottom-0 flex items-end justify-between p-8'>
<div className='pointer-events-auto text-grey-600'>
{getReadingTime(object.content)}
</div>
<div className='pointer-events-auto text-grey-600'>
{readingProgress}%
</div>
</div>
)}
</Modal>
);
};

View file

@ -1,6 +1,6 @@
export default function getReadingTime(content: string): string {
// Average reading speed (words per minute)
const wordsPerMinute = 238;
const wordsPerMinute = 275;
const wordCount = content.replace(/<[^>]*>/g, '')
.split(/\s+/)

View file

@ -0,0 +1 @@
<svg viewBox="-0.75 -0.75 20 20" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><desc></desc><path d="m0.578125 9.279291666666667 17.34375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1 @@
<svg viewBox="-0.75 -0.75 20 20" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><desc>Typography</desc><path d="m0.578125 9.827354166666668 8.09375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m12.140625 13.296104166666666 5.78125 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M12.140625 16.764854166666666V10.40625a2.890625 2.890625 0 0 1 5.78125 0v6.359375" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M0.578125 16.764854166666666V5.78125a4.046875 4.046875 0 0 1 8.09375 0v10.984375" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 830 B