feat: screenshot whole page, hide certain elements, screenshot fixed size (#180)

* feat: screenshot whole page, hide certain elements, screenshot fixed size

* refactor: use variants instead of groups and custom rules

* feat: scaled header, smaller body, weird padding/margin changes

* feat: consistent sizing & style regardless of zoom

* feat: use downloadBlob instead of hand-rolled image saving

* fix: be type safe is toBlob returns Promise<null>

* fix: revoke object url when it should be

* fix: animation scheduling

---------

Co-authored-by: Razboy20 <razboy20@gmail.com>
This commit is contained in:
Samuel Gunter
2024-03-21 19:20:03 -05:00
committed by GitHub
parent 2dfb10e57b
commit 7d4c5d7be8
11 changed files with 81 additions and 37 deletions

View File

@@ -18,7 +18,6 @@ export function downloadBlob(blobPart: BlobPart, type: MIMETypeKey, fileName: st
link.download = fileName; link.download = fileName;
link.addEventListener('click', () => { link.addEventListener('click', () => {
URL.revokeObjectURL(url);
resolve(); resolve();
}); });
link.addEventListener('error', () => { link.addEventListener('error', () => {
@@ -26,5 +25,6 @@ export function downloadBlob(blobPart: BlobPart, type: MIMETypeKey, fileName: st
reject(new Error('Download failed')); reject(new Error('Download failed'));
}); });
link.click(); link.click();
URL.revokeObjectURL(url);
}); });
} }

View File

@@ -96,7 +96,6 @@ export const Default: Story = {
status: examplePsyCourse.status, status: examplePsyCourse.status,
}, },
], ],
calendarRef: { current: null },
}, },
render: props => ( render: props => (
<div className='outline-red outline w-292.5!'> <div className='outline-red outline w-292.5!'>
@@ -107,7 +106,6 @@ export const Default: Story = {
export const Empty: Story = { export const Empty: Story = {
args: { args: {
courses: [], courses: [],
calendarRef: { current: null },
}, },
render: props => ( render: props => (
<div className='outline-red outline w-292.5!'> <div className='outline-red outline w-292.5!'>

View File

@@ -9,7 +9,7 @@ import Divider from '@views/components/common/Divider';
import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup'; import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup';
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule'; import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
import { MessageListener } from 'chrome-extension-toolkit'; import { MessageListener } from 'chrome-extension-toolkit';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useState } from 'react';
import CalendarFooter from './CalendarFooter'; import CalendarFooter from './CalendarFooter';
import TeamLinks from './TeamLinks'; import TeamLinks from './TeamLinks';
@@ -18,7 +18,6 @@ import TeamLinks from './TeamLinks';
* Calendar page component * Calendar page component
*/ */
export default function Calendar(): JSX.Element { export default function Calendar(): JSX.Element {
const calendarRef = useRef<HTMLDivElement>(null);
const { courseCells, activeSchedule } = useFlattenedCourseSchedule(); const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
const [course, setCourse] = useState<Course | null>((): Course | null => { const [course, setCourse] = useState<Course | null>((): Course | null => {
@@ -73,7 +72,7 @@ export default function Calendar(): JSX.Element {
/> />
<div className='h-full flex overflow-auto pl-3'> <div className='h-full flex overflow-auto pl-3'>
{showSidebar && ( {showSidebar && (
<div className='h-full flex flex-none flex-col justify-between pb-5 pl-4.5'> <div className='h-full flex flex-none flex-col justify-between pb-5 pl-4.5 screenshot:hidden'>
<div className='mb-3 h-full w-fit flex flex-col overflow-auto pb-2 pr-4 pt-5'> <div className='mb-3 h-full w-fit flex flex-col overflow-auto pb-2 pr-4 pt-5'>
<CalendarSchedules /> <CalendarSchedules />
<Divider orientation='horizontal' size='100%' className='my-5' /> <Divider orientation='horizontal' size='100%' className='my-5' />
@@ -84,11 +83,11 @@ export default function Calendar(): JSX.Element {
<CalendarFooter /> <CalendarFooter />
</div> </div>
)} )}
<div className='h-full min-w-4xl flex flex-grow flex-col overflow-y-auto' ref={calendarRef}> <div className='h-full min-w-4xl flex flex-grow flex-col overflow-y-auto'>
<div className='min-h-2xl flex-grow overflow-auto pl-2 pr-4 pt-6'> <div className='min-h-2xl flex-grow overflow-auto pl-2 pr-4 pt-6 screenshot:min-h-xl'>
<CalendarGrid courseCells={courseCells} setCourse={setCourse} /> <CalendarGrid courseCells={courseCells} setCourse={setCourse} />
</div> </div>
<CalendarBottomBar calendarRef={calendarRef} /> <CalendarBottomBar />
</div> </div>
</div> </div>

View File

@@ -13,17 +13,15 @@ import CalendarCourseBlock from './CalendarCourseCell';
type CalendarBottomBarProps = { type CalendarBottomBarProps = {
courses?: CalendarCourseCellProps[]; courses?: CalendarCourseCellProps[];
calendarRef: React.RefObject<HTMLDivElement>;
}; };
/** /**
* Renders the bottom bar of the calendar component. * Renders the bottom bar of the calendar component.
* *
* @param {Object[]} courses - The list of courses to display in the calendar. * @param {Object[]} courses - The list of courses to display in the calendar.
* @param {React.RefObject} calendarRef - The reference to the calendar component.
* @returns {JSX.Element} The rendered bottom bar component. * @returns {JSX.Element} The rendered bottom bar component.
*/ */
export default function CalendarBottomBar({ courses, calendarRef }: CalendarBottomBarProps): JSX.Element { export default function CalendarBottomBar({ courses }: CalendarBottomBarProps): JSX.Element {
const displayCourses = courses && courses.length > 0; const displayCourses = courses && courses.length > 0;
return ( return (
@@ -50,13 +48,18 @@ export default function CalendarBottomBar({ courses, calendarRef }: CalendarBott
</> </>
)} )}
</div> </div>
<div className='flex items-center'> <div className='flex items-center screenshot:hidden'>
{displayCourses && <Divider orientation='vertical' size='1rem' className='mx-1.25' />} {displayCourses && <Divider orientation='vertical' size='1rem' className='mx-1.25' />}
<Button variant='single' color='ut-black' icon={CalendarMonthIcon} onClick={saveAsCal}> <Button variant='single' color='ut-black' icon={CalendarMonthIcon} onClick={saveAsCal}>
Save as .CAL Save as .CAL
</Button> </Button>
<Divider orientation='vertical' size='1rem' className='mx-1.25' /> <Divider orientation='vertical' size='1rem' className='mx-1.25' />
<Button variant='single' color='ut-black' icon={ImageIcon} onClick={() => saveCalAsPng(calendarRef)}> <Button
variant='single'
color='ut-black'
icon={ImageIcon}
onClick={() => requestAnimationFrame(() => saveCalAsPng())}
>
Save as .PNG Save as .PNG
</Button> </Button>
</div> </div>

View File

@@ -93,7 +93,7 @@ export default function CalendarCourseCell({
</div> </div>
{rightIcon && ( {rightIcon && (
<div <div
className='h-fit flex items-center justify-center justify-self-start rounded p-0.5 text-white' className='h-fit flex items-center justify-center justify-self-start rounded p-0.5 text-white screenshot:hidden'
style={{ style={{
backgroundColor: colors.secondaryColor, backgroundColor: colors.secondaryColor,
}} }}

View File

@@ -134,8 +134,8 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
gridRow: `${block.calendarGridPoint.startIndex} / ${block.calendarGridPoint.endIndex}`, gridRow: `${block.calendarGridPoint.startIndex} / ${block.calendarGridPoint.endIndex}`,
width: `calc(100% / ${block.totalColumns ?? 1})`, width: `calc(100% / ${block.totalColumns ?? 1})`,
marginLeft: `calc(100% * ${((block.gridColumnStart ?? 0) - 1) / (block.totalColumns ?? 1)})`, marginLeft: `calc(100% * ${((block.gridColumnStart ?? 0) - 1) / (block.totalColumns ?? 1)})`,
padding: '0px 10px 4px 0px',
}} }}
className='pb-1 pl-0 pr-2.5 pt-0 screenshot:pb-0.5 screenshot:pr-0.5'
> >
<CalendarCourseCell <CalendarCourseCell
courseDeptAndInstr={courseDeptAndInstr} courseDeptAndInstr={courseDeptAndInstr}

View File

@@ -35,16 +35,22 @@ export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps)
return ( return (
<div className='flex items-center gap-5 border-b border-ut-offwhite px-7 py-4'> <div className='flex items-center gap-5 border-b border-ut-offwhite px-7 py-4'>
<Button variant='single' icon={MenuIcon} color='ut-gray' onClick={onSidebarToggle} /> <Button
variant='single'
icon={MenuIcon}
color='ut-gray'
onClick={onSidebarToggle}
className='screenshot:hidden'
/>
<LargeLogo /> <LargeLogo />
<Divider className='mx-2 self-center md:mx-4' size='2.5rem' orientation='vertical' /> <Divider className='mx-2 self-center md:mx-4' size='2.5rem' orientation='vertical' />
<div className='flex-1'> <div className='flex-1 screenshot:transform-origin-left screenshot:scale-120'>
<ScheduleTotalHoursAndCourses <ScheduleTotalHoursAndCourses
scheduleName={activeSchedule.name} scheduleName={activeSchedule.name}
totalHours={activeSchedule.hours} totalHours={activeSchedule.hours}
totalCourses={activeSchedule.courses.length} totalCourses={activeSchedule.courses.length}
/> />
<div className='flex items-center gap-1'> <div className='flex items-center gap-1 screenshot:hidden'>
<Text variant='mini' className='text-ut-gray'> <Text variant='mini' className='text-ut-gray'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)} DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
</Text> </Text>
@@ -53,7 +59,7 @@ export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps)
</button> </button>
</div> </div>
</div> </div>
<div className='hidden flex-row items-center justify-end gap-6 lg:flex'> <div className='hidden flex-row items-center justify-end gap-6 screenshot:hidden lg:flex'>
<CourseStatus size='small' status={Status.WAITLISTED} /> <CourseStatus size='small' status={Status.WAITLISTED} />
<CourseStatus size='small' status={Status.CLOSED} /> <CourseStatus size='small' status={Status.CLOSED} />
<CourseStatus size='small' status={Status.CANCELLED} /> <CourseStatus size='small' status={Status.CANCELLED} />

View File

@@ -2,7 +2,7 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import type { UserSchedule } from '@shared/types/UserSchedule'; import type { UserSchedule } from '@shared/types/UserSchedule';
import { downloadBlob } from '@shared/util/downloadBlob'; import { downloadBlob } from '@shared/util/downloadBlob';
import type { Serialized } from 'chrome-extension-toolkit'; import type { Serialized } from 'chrome-extension-toolkit';
import { toPng } from 'html-to-image'; import { toBlob } from 'html-to-image';
export const CAL_MAP = { export const CAL_MAP = {
Sunday: 'SU', Sunday: 'SU',
@@ -91,17 +91,46 @@ export const saveAsCal = async () => {
* *
* @param calendarRef - The reference to the calendar component. * @param calendarRef - The reference to the calendar component.
*/ */
export const saveCalAsPng = (calendarRef: React.RefObject<HTMLDivElement>) => { export const saveCalAsPng = () => {
if (calendarRef.current) { const rootNode = document.createElement('div');
toPng(calendarRef.current, { cacheBust: true }) rootNode.style.backgroundColor = 'white';
.then(dataUrl => { rootNode.style.position = 'fixed';
const link = document.createElement('a'); rootNode.style.zIndex = '1000';
link.download = 'my-calendar.png'; rootNode.style.top = '-10000px';
link.href = dataUrl; rootNode.style.left = '-10000px';
link.click(); rootNode.style.width = '1165px';
}) rootNode.style.height = '754px';
.catch(err => { document.body.appendChild(rootNode);
console.error(err);
const clonedNode = document.querySelector('#root')!.cloneNode(true) as HTMLDivElement;
clonedNode.style.backgroundColor = 'white';
(clonedNode.firstChild as HTMLDivElement).classList.add('screenshot-in-progress');
return new Promise<void>((resolve, reject) => {
requestAnimationFrame(async () => {
rootNode.appendChild(clonedNode);
try {
const screenshotBlob = await toBlob(clonedNode, {
cacheBust: true,
canvasWidth: 1165 * 2,
canvasHeight: 754 * 2,
skipAutoScale: true,
pixelRatio: 2,
}); });
if (!screenshotBlob) {
throw new Error('Failed to create screenshot');
} }
downloadBlob(screenshotBlob, 'IMAGE', 'my-calendar.png');
} catch (e: unknown) {
console.error(e);
reject(e);
} finally {
document.body.removeChild(rootNode);
resolve();
}
});
});
}; };

View File

@@ -46,7 +46,7 @@ export function LargeLogo({ className }: { className?: string }): JSX.Element {
return ( return (
<div className={clsx('flex items-center gap-2', className)}> <div className={clsx('flex items-center gap-2', className)}>
<LogoIcon className='h-12 w-12' /> <LogoIcon className='h-12 w-12' />
<div className='hidden flex-col text-[1.35rem] font-medium leading-[1em] md:flex'> <div className='hidden flex-col text-[1.35rem] font-medium leading-[1em] md:flex screenshot:flex'>
<p className='text-nowrap text-ut-burntorange'>UT Registration</p> <p className='text-nowrap text-ut-burntorange'>UT Registration</p>
<p className='text-ut-orange'>Plus</p> <p className='text-ut-orange'>Plus</p>
</div> </div>

View File

@@ -27,7 +27,7 @@ export default function ScheduleTotalHoursAndCourses({
</Text> </Text>
<Text variant='h3' as='div' className='flex flex-row items-center gap-2 text-theme-black'> <Text variant='h3' as='div' className='flex flex-row items-center gap-2 text-theme-black'>
{totalHours} {totalHours === 1 ? 'Hour' : 'Hours'} {totalHours} {totalHours === 1 ? 'Hour' : 'Hours'}
<Text variant='h4' as='span' className='hidden text-ut-black capitalize sm:inline'> <Text variant='h4' as='span' className='hidden text-ut-black capitalize screenshot:inline sm:inline'>
{totalCourses} {totalCourses === 1 ? 'Course' : 'Courses'} {totalCourses} {totalCourses === 1 ? 'Course' : 'Courses'}
</Text> </Text>
</Text> </Text>

View File

@@ -33,7 +33,16 @@ export default defineConfig({
}, },
colors, colors,
}, },
variants: [
matcher => {
const search = 'screenshot:';
if (!matcher.startsWith(search)) return matcher;
return {
matcher: matcher.slice(search.length),
selector: s => `.screenshot-in-progress ${s}`,
};
},
],
presets: [ presets: [
presetUno(), presetUno(),
presetWebFonts({ presetWebFonts({