Commit 24fabc54 authored by PurkkaKoodari's avatar PurkkaKoodari

Add sidebar resizing

parent 470207c1
......@@ -38,17 +38,32 @@ class RenderInstance {
}
}
const HOURS_WIDTH = 30
const WEEKDAYS_HEIGHT = 20
const HOUR_HEIGHT = 60
function columnStart(days: number, daysToRender: number) {
// 30px + (100% - 30px) * (days / daysToRender)
return `calc(${HOURS_WIDTH * (1 - days / daysToRender)}px + ${100 * days / daysToRender}%)`
}
function columnWidth(days: number, daysToRender: number) {
// (100% - 30px) * (days / daysToRender)
return `calc(${100 * days / daysToRender}% - ${HOURS_WIDTH * days / daysToRender}px)`
}
type InstanceViewProps = {
renderInstance: RenderInstance
columns: number[]
columnCounts: number[]
firstHour: number
daysToRender: number
onInstanceClick: (instance: Instance) => void
selectedAction: ScheduleAction
selectedColor: HSV
selectedColorMode: ScheduleColorMode
}
function InstanceView({renderInstance, columns, firstHour, onInstanceClick, selectedAction, selectedColor, selectedColorMode}: InstanceViewProps) {
function InstanceView({renderInstance, columnCounts, firstHour, daysToRender, onInstanceClick, selectedAction, selectedColor, selectedColorMode}: InstanceViewProps) {
const {instance} = renderInstance
const {activity} = instance
......@@ -94,6 +109,9 @@ ${activity.teachers.length ? `\n${loc`schedule.tooltip.teacher`} ${activity.teac
// override activity color when coloring
const color = selectedAction === "color" && selectedColorMode === "none" && hovered === activity ? hsvToRgb(selectedColor) : activity.color
const startColumn = renderInstance.columns!.start
const widthColumns = renderInstance.columns!.end - renderInstance.columns!.start + 1
return (
<Tooltip text={tooltip}>
{({onMouseEnter, onMouseLeave}) =>
......@@ -101,10 +119,10 @@ ${activity.teachers.length ? `\n${loc`schedule.tooltip.teacher`} ${activity.teac
className={`opp-activity ${hovered === activity ? "opp-hovered" : ""}`}
lang={activity.language || language}
style={{
left: `${20 + 100 * renderInstance.weekday + 100 / columns[renderInstance.weekday] * renderInstance.columns!.start}px`,
top: `${20 + 60 * (renderInstance.start / ONE_HOUR - firstHour)}px`,
width: `${100 / columns[renderInstance.weekday] * (renderInstance.columns!.end - renderInstance.columns!.start + 1)}px`,
height: `${60 * (renderInstance.end - renderInstance.start) / ONE_HOUR}px`,
left: columnStart(renderInstance.weekday + 1 / columnCounts[renderInstance.weekday] * startColumn, daysToRender),
top: `${WEEKDAYS_HEIGHT + HOUR_HEIGHT * (renderInstance.start / ONE_HOUR - firstHour)}px`,
width: columnWidth(1 / columnCounts[renderInstance.weekday] * widthColumns, daysToRender),
height: `${HOUR_HEIGHT * (renderInstance.end - renderInstance.start) / ONE_HOUR}px`,
"background-color": color ? rgbToCss(color) : "",
color: color ? rgbToCss(textColor(color)) : "",
}}
......@@ -154,7 +172,7 @@ function WeekView({renderWeek, onInstanceClick, selectedAction, selectedColor, s
// compute horizontal slots for overlapping instances and first/last hours
let firstHour = 24
let lastHour = 0
const columns = [1, 1, 1, 1, 1, 0, 0]
const columnCounts = [1, 1, 1, 1, 1, 0, 0]
const renderInstances = []
for (const instance of renderWeek.instances) {
// convert to Finnish weekdays here
......@@ -175,7 +193,7 @@ function WeekView({renderWeek, onInstanceClick, selectedAction, selectedColor, s
while (overlappingColumns.includes(column)) column++
renderInstance.columns = {start: column, end: column}
// keep track of how many columns are used per weekday
columns[weekday] = Math.max(columns[weekday], column + 1)
columnCounts[weekday] = Math.max(columnCounts[weekday], column + 1)
renderInstances.push(renderInstance)
}
// do a second pass to widen any instances necessary
......@@ -189,11 +207,11 @@ function WeekView({renderWeek, onInstanceClick, selectedAction, selectedColor, s
}
// expand left & right
while (renderInstance.columns!.start > 0 && !overlappingColumns.includes(renderInstance.columns!.start - 1)) renderInstance.columns!.start--
while (renderInstance.columns!.end < columns[renderInstance.weekday] - 1 && !overlappingColumns.includes(renderInstance.columns!.end + 1)) renderInstance.columns!.end++
while (renderInstance.columns!.end < columnCounts[renderInstance.weekday] - 1 && !overlappingColumns.includes(renderInstance.columns!.end + 1)) renderInstance.columns!.end++
}
// see if there is anything in the weekends
const daysToRender = columns[5] > 0 || columns[6] > 0 ? 7 : 5
const daysToRender = columnCounts[5] > 0 || columnCounts[6] > 0 ? 7 : 5
return (
<>
......@@ -203,10 +221,10 @@ function WeekView({renderWeek, onInstanceClick, selectedAction, selectedColor, s
<div
className="opp-day"
style={{
left: `${20 + 100 * day}px`,
left: columnStart(day, daysToRender),
top: "0",
width: `${500 / daysToRender}px`,
height: "20px",
width: columnWidth(1, daysToRender),
height: `${WEEKDAYS_HEIGHT}px`,
}}
key={`day${day}`}>
{locale.weekdays[day]}
......@@ -217,9 +235,9 @@ function WeekView({renderWeek, onInstanceClick, selectedAction, selectedColor, s
className="opp-hour"
style={{
left: "0",
top: `${20 + 60 * (hour - firstHour)}px`,
width: "20px",
height: "60px",
top: `${WEEKDAYS_HEIGHT + HOUR_HEIGHT * (hour - firstHour)}px`,
width: `${HOURS_WIDTH}px`,
height: `${HOUR_HEIGHT}px`,
}}
key={`hour${hour}`}>
{hour.toString().padStart(2, "0")}
......@@ -228,8 +246,9 @@ function WeekView({renderWeek, onInstanceClick, selectedAction, selectedColor, s
{renderInstances.map(renderInstance =>
<InstanceView
renderInstance={renderInstance}
columns={columns}
columnCounts={columnCounts}
firstHour={firstHour}
daysToRender={daysToRender}
onInstanceClick={onInstanceClick}
selectedAction={selectedAction}
selectedColor={selectedColor}
......
// sidebar.tsx: sidebar layout and functionality
import {h, render} from "preact"
import {useEffect, useState} from "preact/hooks"
import {useCallback, useEffect, useState} from "preact/hooks"
import $ from "jquery"
import {SidebarHeader, unseenReleaseNotes} from "./settings"
import {ScheduleView, needDataFormatUpdate, activitiesInPast} from "./schedule"
import {updateableOnThisPage} from "./opettaptied"
import {Observable, useObservable} from "./utils"
import "./utils"
import {Observable, useEventHandler, useObservable} from "./utils"
const sidebarInitiallyOpen = typeof GM_getValue === "function" && !!GM_getValue("sidebarOpen")
function sidebarWidthLimits(): [number, number] {
const max = window.innerWidth - 50
const min = Math.min(500, max)
return [min, max]
}
function Sidebar() {
const [open, setOpen] = useState(sidebarInitiallyOpen)
function sidebarResizable(): boolean {
const [minWidth, maxWidth] = sidebarWidthLimits()
return minWidth !== maxWidth
}
useEffect(() => void $("body").toggleClass("opp-sidebar-open", open), [open])
const initiallyOpen = typeof GM_getValue === "function" && !!GM_getValue("sidebarOpen")
const initialWidth = (() => {
const loadedWidth = typeof GM_getValue === "function" ? +GM_getValue("sidebarWidth") : NaN
const [minWidth, maxWidth] = sidebarWidthLimits()
return isNaN(loadedWidth) ? minWidth : Math.max(minWidth, Math.min(maxWidth, loadedWidth))
})()
function Sidebar() {
const [open, setOpen] = useState(initiallyOpen)
const [width, setWidth] = useState(initialWidth)
const [resizing, setResizing] = useState<[number, number] | null>(null)
const [resizable, setResizable] = useState(sidebarResizable())
const focusRequested = useObservable(sidebarFocusRequested)
// update width and classes into body, and save openness and width into storage
useEffect(() => {
$("body")
.toggleClass("opp-sidebar-open", open)
.toggleClass("opp-sidebar-closed", !open)
.toggleClass("opp-sidebar-resizing", !!resizing)
if (typeof GM_setValue === "function") GM_setValue("sidebarOpen", open)
}, [open, resizing])
useEffect(() => {
$bodyWrapper.css({marginRight: `${width}px`})
if (typeof GM_setValue === "function") GM_setValue("sidebarWidth", width)
}, [width])
// handler for open button
function toggleSidebar() {
const willBeOpen = !open
setOpen(willBeOpen)
sidebarFocusRequested.value = false
if (typeof GM_setValue === "function") GM_setValue("sidebarOpen", willBeOpen)
}
// handler for resize button
function startResize(e: MouseEvent) {
setResizing([width, e.clientX])
}
// handle actual resizing
useEventHandler(window, "mousemove", (e: MouseEvent) => {
console.log(resizing, e.clientX)
if (resizing) {
const [startWidth, startPos] = resizing
const rawWidth = startPos - e.clientX + startWidth
const [minWidth, maxWidth] = sidebarWidthLimits()
setWidth(Math.max(minWidth, Math.min(maxWidth, rawWidth)))
}
}, [resizing])
// stop resizing when mouse button is unpressed or window focus is lost
useEventHandler(window, "mouseup", () => {
setResizing(null)
}, [])
useEventHandler(window, "mouseleave", () => {
setResizing(null)
}, [])
useEventHandler(window, "blur", () => {
setResizing(null)
}, [])
// re-clamp width and update resizability on window resize
useEventHandler(window, "resize", () => {
const [minWidth, maxWidth] = sidebarWidthLimits()
setResizable(minWidth !== maxWidth)
// re-clamp only when sidebar open
if (!open) return
setWidth(Math.max(minWidth, Math.min(maxWidth, width)))
}, [open, width])
const resizer = open && resizable ? (
<button
className="opp-sidebar-resizer"
onMouseDown={startResize}>
&#x21D4;
</button>
) : null
return (
<div className="opp-sidebar-wrapper">
<button
className={`opp-sidebar-opener ${!open && focusRequested ? "opp-alert" : ""}`}
onClick={toggleSidebar}>
OODI++
</button>
<div className="opp-sidebar-content">
<div className="opp-sidebar-wrapper" style={{width: `${width}px`}}>
<div className={`opp-sidebar-buttons`}>
<button
className={`opp-sidebar-opener ${!open && focusRequested ? "opp-alert" : ""}`}
onClick={toggleSidebar}>
OODI++
</button>
{resizer}
</div>
<div className="opp-sidebar-content" style={{width: `${width}px`}}>
<SidebarHeader sidebarOpen={open} />
<ScheduleView sidebarOpen={open} />
</div>
......@@ -42,14 +121,17 @@ function Sidebar() {
}
// wrap the body contents such that the main scrollbar is to the left of our sidebar
$.make("div")
const $bodyWrapper = $.make("div")
.addClass("opp-body-wrapper")
.appendTo("body")
.append($("body").children().not("script, noscript, .opp-body-wrapper, .opp-tooltip"))
.css({marginRight: `${initialWidth}px`})
const $sidebarWrapper = $.make("div").appendTo("body")
$("body").toggleClass("opp-sidebar-open", sidebarInitiallyOpen)
$("body")
.toggleClass("opp-sidebar-open", initiallyOpen)
.toggleClass("opp-sidebar-closed", !initiallyOpen)
export function renderSidebar() {
render(<Sidebar />, document.body, $sidebarWrapper[0])
......
......@@ -142,16 +142,12 @@ export function setTheme(themeName: string) {
overflow: auto;
transition: margin-right 0.3s ease;
}
body.opp-sidebar-open .opp-body-wrapper {
margin-right: 540px;
}
.opp-sidebar-wrapper {
position: fixed;
right: 0;
top: 0;
z-index: 1000;
height: 100vh;
width: 0;
transition: width 0.3s ease;
margin: 0;
padding: 0;
......@@ -159,20 +155,31 @@ body.opp-sidebar-open .opp-body-wrapper {
background: ${theme.background};
font-size: 14px;
}
body.opp-sidebar-open .opp-sidebar-wrapper {
width: 540px;
body.opp-sidebar-closed .opp-body-wrapper {
margin-right: 0 !important; /* override value set by JS */
}
body.opp-sidebar-closed .opp-sidebar-wrapper {
width: 0 !important; /* override value set by JS */
}
body.opp-sidebar-resizing .opp-body-wrapper, body.opp-sidebar-resizing .opp-sidebar-wrapper {
transition: none;
}
.opp-sidebar-content {
width: 540px;
height: 100vh;
padding: 0 10px;
box-sizing: border-box;
overflow: hidden auto;
}
.opp-sidebar-opener {
.opp-sidebar-buttons {
position: absolute;
right: 100%;
top: 10px;
top: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.opp-sidebar-buttons button {
margin-top: 10px;
padding: 10px;
border: 1px solid #000;
border-right: none;
......@@ -180,9 +187,16 @@ body.opp-sidebar-open .opp-sidebar-wrapper {
background: #fff; /* intentionally not styled to match main Oodi side of page */
color: #000;
font-size: 14px;
width: 18px;
box-sizing: content-box;
}
.opp-sidebar-opener {
writing-mode: vertical-rl;
cursor: pointer;
}
.opp-sidebar-resizer {
cursor: ew-resize;
}
@keyframes opp-sidebar-opener-alert {
0% {
background: #fff;
......
......@@ -60,7 +60,7 @@ const $tooltip = $.make("div")
.appendTo("body")
// track mouse position
$("body").mousemove(e => {
$(window).mousemove(e => {
mousePos = {x: e.clientX, y: e.clientY}
updateTooltip()
})
// utils.ts: general utility functions
import {useState, useEffect} from "preact/hooks"
import {useState, useEffect, useCallback, Inputs} from "preact/hooks"
import $ from "jquery"
......@@ -102,6 +102,17 @@ export function useObservable<T>(observable: Observable<T>): T {
return stored
}
/** Adds and tears down an event listener. */
export function useEventHandler<E extends Event>(target: EventTarget, event: string, handler: (e: E) => void, inputs: Inputs) {
// useCallback to ensure the callback gets updated only when necessary...
const handlerCallback = useCallback(handler, inputs)
// ...and use that as a dependency to useEffect to avoid unnecessary cycles
useEffect(() => {
target.addEventListener(event, handlerCallback as (e: Event) => void)
return () => target.removeEventListener(event, handlerCallback as (e: Event) => void)
}, [handlerCallback])
}
/** Template literal tag that allows a compact format for zeropadding numbers. */
......
TODO:
- add backend sync
- resizable schedule view
- gracefully handle deserializing partially invalid activities
- location database
- add image gallery to README when the ui is nicer
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment