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:
@@ -18,7 +18,6 @@ export function downloadBlob(blobPart: BlobPart, type: MIMETypeKey, fileName: st
|
||||
link.download = fileName;
|
||||
|
||||
link.addEventListener('click', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
});
|
||||
link.addEventListener('error', () => {
|
||||
@@ -26,5 +25,6 @@ export function downloadBlob(blobPart: BlobPart, type: MIMETypeKey, fileName: st
|
||||
reject(new Error('Download failed'));
|
||||
});
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ export const Default: Story = {
|
||||
status: examplePsyCourse.status,
|
||||
},
|
||||
],
|
||||
calendarRef: { current: null },
|
||||
},
|
||||
render: props => (
|
||||
<div className='outline-red outline w-292.5!'>
|
||||
@@ -107,7 +106,6 @@ export const Default: Story = {
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
courses: [],
|
||||
calendarRef: { current: null },
|
||||
},
|
||||
render: props => (
|
||||
<div className='outline-red outline w-292.5!'>
|
||||
|
||||
@@ -9,7 +9,7 @@ import Divider from '@views/components/common/Divider';
|
||||
import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup';
|
||||
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
|
||||
import { MessageListener } from 'chrome-extension-toolkit';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import CalendarFooter from './CalendarFooter';
|
||||
import TeamLinks from './TeamLinks';
|
||||
@@ -18,7 +18,6 @@ import TeamLinks from './TeamLinks';
|
||||
* Calendar page component
|
||||
*/
|
||||
export default function Calendar(): JSX.Element {
|
||||
const calendarRef = useRef<HTMLDivElement>(null);
|
||||
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
||||
|
||||
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'>
|
||||
{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'>
|
||||
<CalendarSchedules />
|
||||
<Divider orientation='horizontal' size='100%' className='my-5' />
|
||||
@@ -84,11 +83,11 @@ export default function Calendar(): JSX.Element {
|
||||
<CalendarFooter />
|
||||
</div>
|
||||
)}
|
||||
<div className='h-full min-w-4xl flex flex-grow flex-col overflow-y-auto' ref={calendarRef}>
|
||||
<div className='min-h-2xl flex-grow overflow-auto pl-2 pr-4 pt-6'>
|
||||
<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 screenshot:min-h-xl'>
|
||||
<CalendarGrid courseCells={courseCells} setCourse={setCourse} />
|
||||
</div>
|
||||
<CalendarBottomBar calendarRef={calendarRef} />
|
||||
<CalendarBottomBar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,17 +13,15 @@ import CalendarCourseBlock from './CalendarCourseCell';
|
||||
|
||||
type CalendarBottomBarProps = {
|
||||
courses?: CalendarCourseCellProps[];
|
||||
calendarRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the bottom bar of the calendar component.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
export default function CalendarBottomBar({ courses, calendarRef }: CalendarBottomBarProps): JSX.Element {
|
||||
export default function CalendarBottomBar({ courses }: CalendarBottomBarProps): JSX.Element {
|
||||
const displayCourses = courses && courses.length > 0;
|
||||
|
||||
return (
|
||||
@@ -50,13 +48,18 @@ export default function CalendarBottomBar({ courses, calendarRef }: CalendarBott
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex items-center screenshot:hidden'>
|
||||
{displayCourses && <Divider orientation='vertical' size='1rem' className='mx-1.25' />}
|
||||
<Button variant='single' color='ut-black' icon={CalendarMonthIcon} onClick={saveAsCal}>
|
||||
Save as .CAL
|
||||
</Button>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function CalendarCourseCell({
|
||||
</div>
|
||||
{rightIcon && (
|
||||
<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={{
|
||||
backgroundColor: colors.secondaryColor,
|
||||
}}
|
||||
|
||||
@@ -134,8 +134,8 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
|
||||
gridRow: `${block.calendarGridPoint.startIndex} / ${block.calendarGridPoint.endIndex}`,
|
||||
width: `calc(100% / ${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
|
||||
courseDeptAndInstr={courseDeptAndInstr}
|
||||
|
||||
@@ -35,16 +35,22 @@ export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps)
|
||||
|
||||
return (
|
||||
<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 />
|
||||
<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
|
||||
scheduleName={activeSchedule.name}
|
||||
totalHours={activeSchedule.hours}
|
||||
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'>
|
||||
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
|
||||
</Text>
|
||||
@@ -53,7 +59,7 @@ export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps)
|
||||
</button>
|
||||
</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.CLOSED} />
|
||||
<CourseStatus size='small' status={Status.CANCELLED} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||
import type { Serialized } from 'chrome-extension-toolkit';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { toBlob } from 'html-to-image';
|
||||
|
||||
export const CAL_MAP = {
|
||||
Sunday: 'SU',
|
||||
@@ -91,17 +91,46 @@ export const saveAsCal = async () => {
|
||||
*
|
||||
* @param calendarRef - The reference to the calendar component.
|
||||
*/
|
||||
export const saveCalAsPng = (calendarRef: React.RefObject<HTMLDivElement>) => {
|
||||
if (calendarRef.current) {
|
||||
toPng(calendarRef.current, { cacheBust: true })
|
||||
.then(dataUrl => {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'my-calendar.png';
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
export const saveCalAsPng = () => {
|
||||
const rootNode = document.createElement('div');
|
||||
rootNode.style.backgroundColor = 'white';
|
||||
rootNode.style.position = 'fixed';
|
||||
rootNode.style.zIndex = '1000';
|
||||
rootNode.style.top = '-10000px';
|
||||
rootNode.style.left = '-10000px';
|
||||
rootNode.style.width = '1165px';
|
||||
rootNode.style.height = '754px';
|
||||
document.body.appendChild(rootNode);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export function LargeLogo({ className }: { className?: string }): JSX.Element {
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-2', className)}>
|
||||
<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-ut-orange'>Plus</p>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function ScheduleTotalHoursAndCourses({
|
||||
</Text>
|
||||
<Text variant='h3' as='div' className='flex flex-row items-center gap-2 text-theme-black'>
|
||||
{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'}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
@@ -33,7 +33,16 @@ export default defineConfig({
|
||||
},
|
||||
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: [
|
||||
presetUno(),
|
||||
presetWebFonts({
|
||||
|
||||
Reference in New Issue
Block a user