diff --git a/packages/tdev/page-index/README.mdx b/packages/tdev/page-index/README.mdx index 7f6c93007..69d309837 100644 --- a/packages/tdev/page-index/README.mdx +++ b/packages/tdev/page-index/README.mdx @@ -62,6 +62,11 @@ export const Comp = observer(() => { ## Für Plugin-Autoren +:::tip[`@tdev/page-read-check` als Beispiel] +Das Plugin `@tdev/page-read-check` implementiert das `iTaskableDocument`-Interface und wird daher automatisch im Seitenindex erfasst. +Es dient als Beispiel und Referenz, wie ein *Statusdokument* implementiert werden kann, damit es im Seitenindex erfasst wird. +::: + Wenn ein Plugin ein weiteres *Statusdokument* implementiert, das im Seitenindex erfasst werden soll, müssen folgende Schritte durchgeführt werden: 1. In der `siteConfig` die neue Komponente für das `remark`-Plugin registrieren: ```ts title="siteConfig.ts" @@ -84,7 +89,7 @@ Wenn ein Plugin ein weiteres *Statusdokument* implementiert, das im Seitenindex export interface TaskableDocumentMapping { ['my_new_taskable_document']: MyNewTaskableData; } - export interface TypeModelMapping { + export interface TaskableTypeModelMapping { ['my_new_taskable_document']: MyNewTaskableDocument; } } @@ -104,6 +109,7 @@ Wenn ein Plugin ein weiteres *Statusdokument* implementiert, das im Seitenindex und den neuen Dokumenttyp im `ComponentStore` registrieren: ```ts title="packages///register.ts" const register = () => { + rootStore.documentStore.registerFactory('my_new_taskable_document', createModel); rootStore.componentStore.registerTaskableDocumentType('my_new_taskable_document'); }; ``` diff --git a/packages/tdev/page-read-check/PageReadCheck/index.tsx b/packages/tdev/page-read-check/PageReadCheck/index.tsx new file mode 100644 index 000000000..0316a20a8 --- /dev/null +++ b/packages/tdev/page-read-check/PageReadCheck/index.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import { MetaInit, ModelMeta } from '../model/ModelMeta'; +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import SlideButton from '@tdev-components/shared/SlideButton'; +import Badge from '@tdev-components/shared/Badge'; +import styles from './styles.module.scss'; +import clsx from 'clsx'; +import { mdiFlashTriangle } from '@mdi/js'; +import Icon from '@mdi/react'; +import PageReadChecker from '../model'; + +interface Props extends MetaInit { + id: string; + text?: (unlocked: boolean, doc: PageReadChecker) => string; + disabledReason?: (doc: PageReadChecker) => string; + hideTime?: boolean; + hideWarning?: boolean; + continueAfterUnlock?: boolean; +} + +const defaultText = (unlocked: boolean, doc: PageReadChecker) => + unlocked ? `Gelesen ${doc.fReadTime}` : doc.fReadTime; +const defaultDisabledReason = (doc: PageReadChecker) => + `Mindestens ${doc.meta.fMinReadTime} lesen, um zu entsperren`; + +const PageReadCheck = observer((props: Props) => { + const { text = defaultText, disabledReason = defaultDisabledReason } = props; + const [meta] = React.useState(new ModelMeta(props)); + const ref = React.useRef(null); + + const viewStore = useStore('viewStore'); + const doc = useFirstMainDocument(props.id, meta); + const [animate, setAnimate] = React.useState(false); + + React.useEffect(() => { + if (!viewStore.isPageVisible || !doc) { + return; + } + if (doc.read && !props.continueAfterUnlock) { + return; + } + const id = setInterval(() => { + doc.incrementReadTime(1); + }, 1000); + return () => { + clearInterval(id); + }; + }, [doc, doc?.read, viewStore.isPageVisible, props.continueAfterUnlock]); + + React.useEffect(() => { + if (ref.current && doc?.scrollTo) { + ref.current.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' }); + doc.setScrollTo(false); + setAnimate(true); + } + }, [ref, doc?.scrollTo]); + + React.useEffect(() => { + if (animate) { + const timeout = setTimeout(() => { + setAnimate(false); + }, 2000); + return () => { + clearTimeout(timeout); + }; + } + }, [animate]); + + if (!doc) { + return null; + } + + return ( +
+ text(unlocked, doc)} + onUnlock={() => doc.setReadState(true)} + onReset={() => doc.setReadState(false)} + isUnlocked={doc.read} + disabled={!doc.canUnlock} + sliderWidth={320} + disabledReason={disabledReason(doc)} + /> +
+ {!doc.canUnlock && ( + + {doc.meta.fMinReadTime} + + )} + {doc.isDummy && !props.hideWarning && ( + + )} +
+
+ ); +}); + +export default PageReadCheck; diff --git a/packages/tdev/page-read-check/PageReadCheck/styles.module.scss b/packages/tdev/page-read-check/PageReadCheck/styles.module.scss new file mode 100644 index 000000000..604d5bb6f --- /dev/null +++ b/packages/tdev/page-read-check/PageReadCheck/styles.module.scss @@ -0,0 +1,53 @@ +.pageReadCheck { + display: flex; + flex-direction: column; + align-items: center; + border-radius: var(--ifm-global-radius); + .status { + margin-left: 240px; + min-width: 4em; + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + .minReadTime { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + @keyframes flash { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } + } + + @keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 10%, + 30%, + 50%, + 70%, + 90% { + transform: translateX(-5px); + } + 20%, + 40%, + 60%, + 80% { + transform: translateX(5px); + } + } + &.animate { + animation: + flash 1s normal, + shake 1.5s normal; + transform-origin: center; + } +} diff --git a/packages/tdev/page-read-check/README.mdx b/packages/tdev/page-read-check/README.mdx new file mode 100644 index 000000000..5e0c27979 --- /dev/null +++ b/packages/tdev/page-read-check/README.mdx @@ -0,0 +1,125 @@ +--- +page_id: 34714568-d5e3-4965-8947-fdcf46da810a +sidebar_custom_props: + taskable_state: 'show' +--- + +import PageReadCheck from '@tdev/page-read-check/PageReadCheck'; +import BrowserWindow from '@tdev-components/BrowserWindow'; +import { action } from 'mobx'; + +# page-read-check + +Mit der Komponente `PageReadCheck` kann der Bearbeitungs- und Lesezustand einer Seite als "gelesen" oder "ungelesen" markiert werden. + +```tsx +import PageReadCheck from '@tdev/page-read-check/PageReadCheck'; + + +``` + + + + + + +## Mit Mindestlesezeit + +Über die `minReadTime` kann eine Mindestlesezeit vorgegeben werden, bevor die Seite als "gelesen" markiert werden kann. Der Timer startet und stoppt automatisch, wenn die Seite sichtbar bzw. unsichtbar wird. Der Standardwert beträgt 10 Sekunden. + +```tsx + +``` + + + + + +## Lesezeit nach Freischaltung fortsetzen + +Standardmässig wird die Lesezeit nicht weiter gezählt, wenn die Seite freigeschaltet wurde. Mit der Option `continueAfterUnlock` kann dieses Verhalten geändert werden, sodass die Lesezeit auch nach Freischaltung weiter gezählt wird. + +```tsx + +``` + + + + + +## Anpassung der Texte + +Die angezeigten Texte und Tooltipps können angepasst werden: + +```tsx + { + if (unlocked || doc.canUnlock) { + return `Gelesen ${doc.fReadTime}`; + } + return `Noch ${doc.meta.minReadTime - doc.readTime}s übrig`; + }} + disabledReason={(doc) => `Erst ${doc.fReadTime} bearbeitet, das ist zu wenig!`} +/> +``` + + + { + if (unlocked || doc.canUnlock) { + return `Gelesen ${doc.fReadTime}`; + } + return `Noch ${doc.meta.minReadTime - doc.readTime}s übrig`; + }} + disabledReason={(doc) => `Erst ${doc.fReadTime} bearbeitet, das ist zu wenig!`} + /> + + +## Installation + +:::info[`packages/tdev/page-read-check`] +Kopiere des `packages/tdev/page-read-check`-Verzeichnis in das `tdev-website/website/packages`-Verzeichnis oder über `updateTdev.config.yaml` hinzufügen. +::: + +Hinzufügen des `page-read-check`-Package zu den `apiDocumentProviders` im `siteConfig.ts`: + +```ts title="siteConfig.ts" +const getSiteConfig: SiteConfigProvider = () => { + return { + apiDocumentProviders: [ + require.resolve('@tdev/page-read-check/register'), + ] + }; +}; +``` + +Falls **nicht** die standardoptionen des `PageIndexPluginDefaultOptions` verwendet werden, muss zusätzlich die `remark`-Plugin-Konfiguration angepasst werden, damit die neuen Dokumente im Seitenindex erfasst werden: + +```ts title="siteConfig.ts" +import pageIndexPlugin from './packages/tdev/page-index/plugin'; + +const getSiteConfig: SiteConfigProvider = () => ({ + remarkPlugins: [ + [ + pageIndexPlugin, + { + // ... + components: [ + // highlight-start + { + name: 'PageReadCheck', + docTypeExtractor: () => 'page_read_check' + } + // highlight-end + ] + } + ] + ] +}); +``` + +Danach muss erneut installiert werden: + +```bash +yarn install +``` \ No newline at end of file diff --git a/packages/tdev/page-read-check/helpers/time.ts b/packages/tdev/page-read-check/helpers/time.ts new file mode 100644 index 000000000..bc708930f --- /dev/null +++ b/packages/tdev/page-read-check/helpers/time.ts @@ -0,0 +1,19 @@ +export const fSeconds = (time: number) => { + const hours = Math.floor(time / 3600); + const minutes = Math.floor((time % 3600) / 60); + const seconds = time % 60; + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; + +export const fSecondsLong = (time: number) => { + const hours = Math.floor(time / 3600); + const minutes = Math.floor((time % 3600) / 60); + const seconds = time % 60; + if (hours > 0) { + return `${hours} Stunden ${minutes.toString().padStart(2, '0')} Minuten ${seconds.toString().padStart(2, '0')} Sekunden`; + } + return `${minutes} Minuten ${seconds.toString().padStart(2, '0')} Sekunden`; +}; diff --git a/packages/tdev/page-read-check/index.ts b/packages/tdev/page-read-check/index.ts new file mode 100644 index 000000000..49cebf435 --- /dev/null +++ b/packages/tdev/page-read-check/index.ts @@ -0,0 +1,14 @@ +import PageReadCheck from './model'; +export interface PageReadCheckData { + readTime: number; + read: boolean; +} + +declare module '@tdev-api/document' { + export interface TaskableDocumentMapping { + ['page_read_check']: PageReadCheckData; + } + export interface TaskableTypeModelMapping { + ['page_read_check']: PageReadCheck; + } +} diff --git a/packages/tdev/page-read-check/model/ModelMeta.ts b/packages/tdev/page-read-check/model/ModelMeta.ts new file mode 100644 index 000000000..27421bbef --- /dev/null +++ b/packages/tdev/page-read-check/model/ModelMeta.ts @@ -0,0 +1,29 @@ +import { TypeDataMapping, Access } from '@tdev-api/document'; +import { TypeMeta } from '@tdev-models/DocumentRoot'; +import { fSeconds } from '../helpers/time'; + +export interface MetaInit { + readonly?: boolean; + minReadTime?: number; +} + +export class ModelMeta extends TypeMeta<'page_read_check'> { + readonly type = 'page_read_check'; + readonly minReadTime: number; + + constructor(props: Partial) { + super('page_read_check', props.readonly ? Access.RO_User : undefined); + this.minReadTime = props.minReadTime || 10; + } + + get defaultData(): TypeDataMapping['page_read_check'] { + return { + readTime: 0, + read: false + }; + } + + get fMinReadTime() { + return fSeconds(this.minReadTime); + } +} diff --git a/packages/tdev/page-read-check/model/index.ts b/packages/tdev/page-read-check/model/index.ts new file mode 100644 index 000000000..a3cb156c6 --- /dev/null +++ b/packages/tdev/page-read-check/model/index.ts @@ -0,0 +1,121 @@ +import { action, computed, observable } from 'mobx'; +import iDocument, { Source } from '@tdev-models/iDocument'; +import { iTaskableDocument } from '@tdev-models/iTaskableDocument'; +import { Document as DocumentProps, TypeDataMapping, Factory } from '@tdev-api/document'; +import DocumentStore from '@tdev-stores/DocumentStore'; +import { ModelMeta } from './ModelMeta'; +import { mdiBookCheck, mdiBookCheckOutline, mdiBookEducation, mdiBookOpenVariantOutline } from '@mdi/js'; +import { fSeconds, fSecondsLong } from '../helpers/time'; + +export const createModel: Factory = (data, store) => { + return new PageReadChecker(data as DocumentProps<'page_read_check'>, store); +}; + +class PageReadChecker extends iDocument<'page_read_check'> implements iTaskableDocument<'page_read_check'> { + @observable accessor readTime: number = 0; + @observable accessor read: boolean = false; + @observable accessor scrollTo: boolean = false; + constructor(props: DocumentProps<'page_read_check'>, store: DocumentStore) { + super(props, store); + this.readTime = props.data.readTime || 0; + this.read = props.data.read || false; + } + + @action + setData(data: Partial, from: Source, updatedAt?: Date): void { + if (data.readTime !== undefined) { + this.readTime = data.readTime; + } + if (data.read !== undefined) { + this.read = data.read; + } + if (from === Source.LOCAL) { + this.save(); + } + if (updatedAt) { + this.updatedAt = new Date(updatedAt); + } + } + + get isDone() { + return this.read; + } + + @computed + get canUnlock() { + return this.readTime >= this.meta.minReadTime; + } + + @computed + get progress() { + return this.read ? 2 : this.canUnlock ? 1 : 0; + } + + get totalSteps() { + return 2; + } + + @computed + get editingIconState() { + if (this.read) { + return { + path: mdiBookCheck, + color: 'var(--ifm-color-success)' + }; + } + if (this.canUnlock) { + return { + path: mdiBookEducation, + color: 'var(--ifm-color-warning)' + }; + } + return { + path: mdiBookOpenVariantOutline, + color: 'var(--ifm-color-grey-500)' + }; + } + + @action + setScrollTo(scrollTo: boolean) { + this.scrollTo = scrollTo; + } + + @computed + get fReadTime(): string { + return fSeconds(this.readTime); + } + + @computed + get fReadTimeLong(): string { + return fSecondsLong(this.readTime); + } + + @action + setReadState(read: boolean) { + this.read = read; + this.saveNow(); + } + + @action + incrementReadTime(by: number = 1) { + this.readTime += by; + this.saveNow(); + } + + get data(): TypeDataMapping['page_read_check'] { + return { + readTime: this.readTime, + read: this.read + }; + } + + @computed + get meta(): ModelMeta { + if (this.root?.type === 'page_read_check') { + return this.root.meta as ModelMeta; + } + return new ModelMeta({}); + } +} + +export default PageReadChecker; diff --git a/packages/tdev/page-read-check/package.json b/packages/tdev/page-read-check/package.json new file mode 100644 index 000000000..19a6f8e58 --- /dev/null +++ b/packages/tdev/page-read-check/package.json @@ -0,0 +1,17 @@ +{ + "name": "@tdev/page-read-check", + "version": "1.0.0", + "main": "index.ts", + "types": "index.ts", + "dependencies": { + }, + "devDependencies": { + "vitest": "*", + "@docusaurus/module-type-aliases": "*", + "@docusaurus/core": "*", + "@types/node": "*" + }, + "peerDependencies": { + "@tdev/core": "1.0.0" + } +} diff --git a/packages/tdev/page-read-check/register.ts b/packages/tdev/page-read-check/register.ts new file mode 100644 index 000000000..2c88385f9 --- /dev/null +++ b/packages/tdev/page-read-check/register.ts @@ -0,0 +1,9 @@ +import { rootStore } from '@tdev-stores/rootStore'; +import { createModel } from './model'; + +const register = () => { + rootStore.documentStore.registerFactory('page_read_check', createModel); + rootStore.componentStore.registerTaskableDocumentType('page_read_check'); +}; + +register(); diff --git a/packages/tdev/page-read-check/tsconfig.json b/packages/tdev/page-read-check/tsconfig.json new file mode 100644 index 000000000..ab1408716 --- /dev/null +++ b/packages/tdev/page-read-check/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.package.json" +} diff --git a/src/components/shared/SlideButton/index.tsx b/src/components/shared/SlideButton/index.tsx new file mode 100644 index 000000000..31f11bcf1 --- /dev/null +++ b/src/components/shared/SlideButton/index.tsx @@ -0,0 +1,174 @@ +import React, { useRef, useState } from 'react'; +import styles from './styles.module.scss'; +import Icon from '@mdi/react'; +import { mdiArrowRightBoldBox, mdiCheckCircle, mdiCloseCircle, mdiTimerSand } from '@mdi/js'; +import Button from '@tdev-components/shared/Button'; +import { SIZE_S } from '@tdev-components/shared/iconSizes'; +import { observer } from 'mobx-react-lite'; +import clsx from 'clsx'; + +const SLIDER_WIDTH = 320; // px +const HANDLE_SIZE = 32; // px +const THRESHOLD = 0.85; // % + +interface Props { + onUnlock: () => void; + onReset?: () => void; + isUnlocked?: boolean; + text?: (unlocked: boolean) => string; + sliderWidth?: number; + title?: string; + disabled?: boolean; + disabledReason?: string; +} + +const SlideButton = observer((props: Props) => { + const { onUnlock, text, sliderWidth = SLIDER_WIDTH } = props; + const trackRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [offset, setOffset] = useState(props.isUnlocked ? sliderWidth - HANDLE_SIZE : 0); + const [unlocked, setUnlocked] = useState(props.isUnlocked ?? false); + + const getRelativeX = (clientX: number) => { + const rect = trackRef.current!.getBoundingClientRect(); + const x = Math.max(0, Math.min(clientX - rect.left, sliderWidth - HANDLE_SIZE)); + return x; + }; + + React.useEffect(() => { + if (props.isUnlocked === undefined) { + return; + } + setUnlocked(props.isUnlocked); + if (!props.isUnlocked) { + setOffset(0); + } else { + setOffset(sliderWidth - HANDLE_SIZE); + } + }, [props.isUnlocked]); + + const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => { + if (unlocked) { + return; + } + setDragging(true); + e.preventDefault(); + }; + + const handleDragMove = (e: MouseEvent | TouchEvent) => { + if (!dragging || unlocked) { + return; + } + let clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX; + setOffset(getRelativeX(clientX)); + }; + + const handleDragEnd = () => { + if (!dragging || unlocked) { + return; + } + const progress = offset / (sliderWidth - HANDLE_SIZE); + if (progress >= THRESHOLD) { + setOffset(sliderWidth - HANDLE_SIZE); + setUnlocked(true); + onUnlock(); + } else { + setOffset(0); + } + setDragging(false); + }; + + React.useEffect(() => { + const moveListener = (e: MouseEvent | TouchEvent) => handleDragMove(e); + const upListener = () => handleDragEnd(); + if (dragging) { + window.addEventListener('mousemove', moveListener); + window.addEventListener('touchmove', moveListener, { passive: false }); + window.addEventListener('mouseup', upListener); + window.addEventListener('touchend', upListener); + } + return () => { + window.removeEventListener('mousemove', moveListener); + window.removeEventListener('touchmove', moveListener); + window.removeEventListener('mouseup', upListener); + window.removeEventListener('touchend', upListener); + }; + }, [dragging, offset, unlocked]); + + return ( +
+
+ {props.onReset && unlocked && ( +
+
+ )} +
+ ); +}); + +const Label = observer((props: Pick) => { + return ( + {props.text ? props.text(props.isUnlocked ?? false) : 'Ziehen'} + ); +}); + +export default SlideButton; diff --git a/src/components/shared/SlideButton/styles.module.scss b/src/components/shared/SlideButton/styles.module.scss new file mode 100644 index 000000000..490b5c3e4 --- /dev/null +++ b/src/components/shared/SlideButton/styles.module.scss @@ -0,0 +1,130 @@ +.container { + display: flex; + justify-content: center; + align-items: center; + user-select: none; + --tdev-slide-button-size: 2em; + .track { + position: relative; + height: var(--tdev-slide-button-size); + background: linear-gradient(90deg, var(--ifm-color-gray-400) 0%, var(--ifm-color-gray-100) 100%); + border-radius: var(--ifm-global-radius); + overflow: hidden; + box-shadow: var(--ifm-global-shadow-tl); + .text { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + color: var(--ifm-color-secondary-darkest); + font-weight: var(--ifm-font-weight-base); + z-index: 2; + pointer-events: none; + letter-spacing: 0.04em; + } + .handle { + position: absolute; + top: 0; + left: 0; + width: var(--tdev-slide-button-size); + height: var(--tdev-slide-button-size); + border-radius: var(--ifm-global-radius); + + background-color: var(--ifm-background-surface-color); + box-shadow: var(--ifm-global-shadow-md); + z-index: 3; + display: flex; + justify-content: center; + align-items: center; + cursor: grab; + user-select: none; + background-color: var(--ifm-color-white); + &:active, + &.dragging { + background-color: var(--ifm-color-gray-300); + box-shadow: 0 3px 28px 0 var(--ifm-color-success-lightest); + cursor: grabbing; + } + &.handleUnlocked { + background-color: var(--ifm-color-success); + color: var(--ifm-color-white); + pointer-events: none; + } + + .arrow { + user-select: none; + pointer-events: none; + line-height: 0; + } + } + .progress { + position: absolute; + top: 0; + left: 0; + bottom: 0; + background: linear-gradient( + 90deg, + var(--ifm-color-gray-100) 0%, + var(--ifm-color-success-lightest) 100% + ); + border-radius: var(--ifm-global-radius); + z-index: 1; + pointer-events: none; + } + .resetButton { + display: none; + align-items: center; + height: 100%; + } + &.unlocked { + background: linear-gradient( + 90deg, + var(--ifm-color-gray-100) 0%, + var(--ifm-color-success-lightest) 100% + ); + .progress { + display: none; + } + .text { + color: var(--ifm-color-gray-700); + } + .handle { + right: 0; + left: auto; + .arrow { + background-color: unset; + line-height: 0; + } + } + } + } + &:hover { + .track { + .resetButton { + display: flex; + } + } + } + &.disabled { + .track { + &.unlocked { + background: linear-gradient( + 90deg, + var(--ifm-color-gray-400) 0%, + var(--ifm-color-gray-100) 100% + ); + } + .handle { + &:active, + &:hover { + cursor: not-allowed; + } + } + } + } +} diff --git a/src/css/custom.scss b/src/css/custom.scss index 62c0c4840..559af70d1 100644 --- a/src/css/custom.scss +++ b/src/css/custom.scss @@ -19,6 +19,7 @@ --ifm-color-blue: #3578e5; --ifm-color-blue-dark: #306cce; --ifm-color-blue-darker: #2d66c3; + --ifm-color-blue-darkest: #2554a0; --ifm-color-disabled: #6e6e6e; --ifm-color-violet: #8957e5; --ifm-color-boxed: #3348b5; diff --git a/src/models/iDocument.ts b/src/models/iDocument.ts index 90e1441e2..39f8f89bc 100644 --- a/src/models/iDocument.ts +++ b/src/models/iDocument.ts @@ -35,7 +35,7 @@ abstract class iDocument { * Time [s] : 0 1 2 3 4 5 6 7 * Edits : ||| | ||| || | | || |||| ||| || ||| ||||| */ - save: DebouncedFunc<() => Promise>; + save: DebouncedFunc; @observable accessor state: ApiState = ApiState.IDLE; diff --git a/src/siteConfig/markdownPluginConfigs.ts b/src/siteConfig/markdownPluginConfigs.ts index f219e8a39..0699e1f5a 100644 --- a/src/siteConfig/markdownPluginConfigs.ts +++ b/src/siteConfig/markdownPluginConfigs.ts @@ -157,6 +157,10 @@ export const PageIndexPluginDefaultOptions: PageIndexPluginOptions = { name: 'ProgressState', docTypeExtractor: () => 'progress_state' }, + { + name: 'PageReadCheck', + docTypeExtractor: () => 'page_read_check' + }, { name: 'TaskState', docTypeExtractor: () => 'task_state' diff --git a/src/stores/ViewStores/index.ts b/src/stores/ViewStores/index.ts index 124d94221..14abae9eb 100644 --- a/src/stores/ViewStores/index.ts +++ b/src/stores/ViewStores/index.ts @@ -10,6 +10,7 @@ export default class ViewStore { readonly root: RootStore; stores = new Map(); @observable accessor fullscreenTargetId: string | null = null; + @observable accessor isPageVisible: boolean = true; constructor(store: RootStore) { this.root = store; } @@ -25,6 +26,11 @@ export default class ViewStore { this.stores.set(type, store(this)); } + @action + setPageVisibility(visible: boolean) { + this.isPageVisible = visible; + } + @action requestFullscreen(targetId: string) { if (this.fullscreenTargetId === targetId) { diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx index dd1b10f47..ab1df0fd5 100644 --- a/src/theme/Root.tsx +++ b/src/theme/Root.tsx @@ -4,7 +4,7 @@ import { enableStaticRendering, observer } from 'mobx-react-lite'; import Head from '@docusaurus/Head'; import siteConfig from '@generated/docusaurus.config'; import { useStore } from '@tdev-hooks/useStore'; -import { reaction } from 'mobx'; +import { action, reaction } from 'mobx'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { useHistory, useLocation } from '@docusaurus/router'; import LoggedOutOverlay from '@tdev-components/LoggedOutOverlay'; @@ -111,6 +111,21 @@ const Sentry = observer(() => { return null; }); +const TrackPageVisibility = observer(() => { + const viewStore = useStore('viewStore'); + const onVisibilityChange = React.useEffectEvent(() => { + viewStore.setPageVisibility(!document.hidden); + }); + React.useEffect(() => { + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [viewStore]); + return null; +}); + const LivenessChecker = observer(() => { const lastHiddenTimeRef = React.useRef(null); React.useEffect(() => { @@ -179,6 +194,7 @@ const FullscreenHandler = observer(() => { }, []); return null; }); + let devHash: string | null = null; const DevGlobalDataTracker = observer(() => { if (process.env.NODE_ENV === 'production') { @@ -252,6 +268,7 @@ function Root({ children }: { children: React.ReactNode }) { )} + {SENTRY_DSN && } diff --git a/tdev-website/siteConfig.ts b/tdev-website/siteConfig.ts index e83c4f787..634f28ac1 100644 --- a/tdev-website/siteConfig.ts +++ b/tdev-website/siteConfig.ts @@ -78,7 +78,8 @@ const getSiteConfig: SiteConfigProvider = () => { require.resolve('@tdev/netpbm-graphic/register'), require.resolve('@tdev/text-message/register'), require.resolve('@tdev/pyodide-code/register'), - require.resolve('@tdev/brython-code/register') + require.resolve('@tdev/brython-code/register'), + require.resolve('@tdev/page-read-check/register') ] }; };