Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/stopwatch/stopwatch.tsx
1691 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
The stopwatch and timer component
8
*/
9
10
import {
11
DeleteTwoTone,
12
EditTwoTone,
13
PauseCircleTwoTone,
14
PlayCircleTwoTone,
15
StopTwoTone,
16
} from "@ant-design/icons";
17
import { Button, Col, Modal, Row, TimePicker, Tooltip } from "antd";
18
import type { Dayjs } from "dayjs";
19
import dayjs from "dayjs";
20
import { CSSProperties, useEffect, useState } from "react";
21
22
import { redux, useForceUpdate } from "@cocalc/frontend/app-framework";
23
import { Icon } from "@cocalc/frontend/components/icon";
24
import MarkdownInput from "@cocalc/frontend/editors/markdown-input/multimode";
25
import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown";
26
import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
27
import { webapp_client } from "@cocalc/frontend/webapp-client";
28
import { TimerState } from "./actions";
29
import { TimeAmount } from "./time";
30
31
function assertNever(x: never): never {
32
throw new Error("Unexpected object: " + x);
33
}
34
35
interface StopwatchProps {
36
state: TimerState; // 'paused' or 'running' or 'stopped'
37
time: number; // when entered this state
38
countdown?: number; // if given, this is a countdown timer, counting down from this many seconds.
39
clickButton?: (str: string) => void;
40
setLabel?: (str: string) => void;
41
setCountdown?: (time: number) => void; // time in seconds
42
compact?: boolean;
43
label?: string; // a text label
44
noLabel?: boolean; // show no label at all
45
noDelete?: boolean; // do not show delete button
46
noButtons?: boolean; // hide ALL buttons
47
total?: number; // total time accumulated before entering current state
48
style?: CSSProperties;
49
timeStyle?: CSSProperties;
50
readOnly?: boolean; // can't change, and won't display something when timer goes off!
51
}
52
53
export default function Stopwatch(props: StopwatchProps) {
54
const [editingLabel, setEditingLabel] = useState<boolean>(false);
55
const [editingTime, setEditingTime] = useState<boolean>(false);
56
const update = useForceUpdate();
57
const frame = useFrameContext();
58
59
useEffect(() => {
60
const interval = setInterval(update, 1000);
61
return () => clearInterval(interval);
62
}, []);
63
64
function renderStartButton() {
65
return (
66
<Tooltip
67
title={`Start the ${
68
props.countdown != null ? "countdown timer" : "stopwatch"
69
}`}
70
mouseEnterDelay={1}
71
>
72
<Button
73
icon={<PlayCircleTwoTone />}
74
onClick={() => props.clickButton?.("start")}
75
style={!props.compact ? { width: "8em" } : undefined}
76
>
77
{!props.compact ? "Start" : undefined}
78
</Button>
79
</Tooltip>
80
);
81
}
82
83
function renderResetButton() {
84
return (
85
<Tooltip
86
mouseEnterDelay={1}
87
title={
88
<>
89
Reset the{" "}
90
{props.countdown != null ? "countdown timer" : "stopwatch"} to{" "}
91
{props.countdown != null ? (
92
<TimeAmount compact amount={props.countdown * 1000} />
93
) : (
94
"0"
95
)}
96
</>
97
}
98
>
99
<Button
100
icon={<StopTwoTone />}
101
onClick={() => props.clickButton?.("reset")}
102
>
103
{!props.compact ? "Reset" : undefined}
104
</Button>
105
</Tooltip>
106
);
107
}
108
109
function renderEditTimeButton() {
110
const { setCountdown } = props;
111
if (setCountdown == null) return;
112
return (
113
<div>
114
<Tooltip title="Edit countdown timer" mouseEnterDelay={1}>
115
<Button icon={<EditTwoTone />} onClick={() => setEditingTime(true)}>
116
{!props.compact ? "Edit" : undefined}
117
</Button>
118
</Tooltip>
119
{editingTime ? (
120
<TimePicker
121
open
122
defaultValue={getCountdownMoment(props.countdown)}
123
onChange={(time) => {
124
if (time != null) {
125
setCountdown(
126
time.second() + time.minute() * 60 + time.hour() * 60 * 60,
127
);
128
// timeout so the setcountdown can fully propagate through flux; needed for whiteboard
129
setTimeout(() => props.clickButton?.("reset"), 0);
130
}
131
}}
132
showNow={false}
133
onOpenChange={(open) => {
134
if (!open) {
135
setEditingTime(false);
136
}
137
}}
138
/>
139
) : undefined}
140
</div>
141
);
142
}
143
144
function renderDeleteButton() {
145
if (props.compact || props.noDelete) return;
146
return (
147
<Tooltip
148
mouseEnterDelay={1}
149
title={`Delete this ${
150
props.countdown != null ? "countdown timer" : "stopwatch"
151
}`}
152
>
153
<Button
154
icon={<DeleteTwoTone />}
155
onClick={() => props.clickButton?.("delete")}
156
>
157
{!props.compact ? "Delete" : undefined}
158
</Button>
159
</Tooltip>
160
);
161
}
162
163
function renderPauseButton() {
164
return (
165
<Tooltip mouseEnterDelay={1} title="Pause the stopwatch">
166
<Button
167
icon={<PauseCircleTwoTone />}
168
onClick={() => props.clickButton?.("pause")}
169
style={!props.compact ? { width: "8em" } : undefined}
170
>
171
{!props.compact ? "Pause" : undefined}
172
</Button>
173
</Tooltip>
174
);
175
}
176
177
function getRemainingMs(): number {
178
let amount: number = 0;
179
switch (props.state) {
180
case "stopped":
181
break;
182
case "paused":
183
amount = props.total || 0;
184
break;
185
case "running":
186
amount =
187
(props.total || 0) + (webapp_client.server_time() - props.time);
188
break;
189
default:
190
assertNever(props.state);
191
}
192
193
if (props.countdown != null) {
194
// it's a countdown timer.
195
amount = Math.max(0, 1000 * props.countdown - amount);
196
}
197
return amount;
198
}
199
200
function renderTime() {
201
const amount = getRemainingMs();
202
return (
203
<>
204
<TimeAmount
205
key={"time"}
206
amount={amount}
207
compact={props.compact}
208
showIcon={props.compact}
209
countdown={props.countdown}
210
style={{
211
...props.timeStyle,
212
...(props.countdown && amount == 0
213
? {
214
background: "#b71c1c",
215
borderRadius: "3px",
216
marginRight: "15px",
217
color: "white",
218
}
219
: undefined),
220
}}
221
/>
222
{props.countdown && amount == 0 && !props.readOnly && (
223
<Modal
224
title={
225
<>
226
<Icon name="hourglass-half" /> A Countdown Timer in "
227
{frame.path}" is Finished
228
</>
229
}
230
open
231
onOk={() => {
232
props.clickButton?.("reset");
233
redux
234
.getProjectActions(frame.project_id)
235
?.open_file({ path: frame.path });
236
}}
237
onCancel={() => {
238
props.clickButton?.("reset");
239
}}
240
>
241
{props.label && <StaticMarkdown value={props.label} />}
242
</Modal>
243
)}
244
</>
245
);
246
}
247
248
function renderLabel() {
249
if (editingLabel) {
250
return renderEditingLabel();
251
}
252
return (
253
<div
254
key="show-label"
255
style={{
256
fontSize: "16px",
257
marginTop: "25px",
258
width: "100%",
259
color: props.label ? "#444" : "#999",
260
borderBottom: "1px solid #999",
261
marginBottom: "10px",
262
}}
263
onClick={() => setEditingLabel(true)}
264
>
265
{props.label ? <StaticMarkdown value={props.label} /> : "Label"}
266
</div>
267
);
268
}
269
270
function renderEditingLabel() {
271
return (
272
<div
273
key="edit-label"
274
style={{
275
fontSize: "25px",
276
marginTop: "25px",
277
width: "100%",
278
}}
279
>
280
<MarkdownInput
281
autoFocus
282
height="150px"
283
value={props.label ? props.label : ""}
284
onChange={(value) => {
285
props.setLabel?.(value);
286
}}
287
onShiftEnter={() => setEditingLabel(false)}
288
onBlur={() => setEditingLabel(false)}
289
/>
290
</div>
291
);
292
}
293
294
function renderActionButtons() {
295
switch (props.state) {
296
case "stopped":
297
return (
298
<Button.Group>
299
{renderStartButton()}
300
{renderEditTimeButton()}
301
</Button.Group>
302
);
303
case "paused":
304
return (
305
<Button.Group>
306
{renderStartButton()}
307
{renderResetButton()}
308
{renderEditTimeButton()}
309
</Button.Group>
310
);
311
case "running":
312
return (
313
<Button.Group>
314
{renderPauseButton()}
315
{renderResetButton()}
316
</Button.Group>
317
);
318
default:
319
assertNever(props.state);
320
// TS doesn't have strong enough type inference here??
321
return <div />;
322
}
323
}
324
325
function renderButtons() {
326
return (
327
<div key="buttons">
328
{renderActionButtons()}
329
<div style={{ float: "right" }}>{renderDeleteButton()}</div>
330
</div>
331
);
332
}
333
334
function renderFullSize() {
335
if (props.noLabel) {
336
return (
337
<div
338
style={{
339
borderBottom: "1px solid #666",
340
background: "#efefef",
341
padding: "15px",
342
...props.style,
343
}}
344
>
345
<div>{renderTime()}</div>
346
<div>{renderButtons()}</div>
347
</div>
348
);
349
}
350
return (
351
<div
352
style={{
353
borderBottom: "1px solid #666",
354
background: "#efefef",
355
padding: "15px",
356
}}
357
>
358
<div style={{ float: "right", fontSize: "24px", color: "#666" }}>
359
{props.countdown != null ? (
360
<Tooltip title="Countdown Timer" mouseEnterDelay={1}>
361
<Icon name="hourglass-half" />
362
</Tooltip>
363
) : (
364
<Tooltip title="Stopwatch" mouseEnterDelay={1}>
365
<Icon name="stopwatch" />
366
</Tooltip>
367
)}
368
</div>
369
<Row>
370
<Col sm={12} md={12}>
371
{renderTime()}
372
</Col>
373
<Col sm={12} md={12}>
374
{renderLabel()}
375
</Col>
376
</Row>
377
{!props.noButtons && !props.readOnly && (
378
<Row style={{ marginTop: "5px" }}>
379
<Col md={24}>{renderButtons()}</Col>
380
</Row>
381
)}
382
</div>
383
);
384
}
385
386
if (props.compact) {
387
return (
388
<div style={{ display: "flex" }}>
389
{renderTime()}
390
{!props.noButtons && !props.readOnly && (
391
<div style={{ marginTop: "3px", marginLeft: "5px" }}>
392
{renderButtons()}
393
</div>
394
)}
395
</div>
396
);
397
} else {
398
return renderFullSize();
399
}
400
}
401
402
export function getCountdownMoment(countdown: number | undefined): Dayjs {
403
let amount = Math.round(countdown ?? 0);
404
const m = dayjs();
405
m.second(amount % 60);
406
amount = (amount - (amount % 60)) / 60;
407
m.minute(amount % 60);
408
amount = (amount - (amount % 60)) / 60;
409
m.hour(amount);
410
return m;
411
}
412
413