@lincyaw/addon-classroom 0.0.0-stage → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +30 -2
  3. package/components/PmExercise.css +58 -0
  4. package/components/PmExercise.tsx +165 -0
  5. package/components/PmPredict.css +31 -0
  6. package/components/PmPredict.tsx +182 -0
  7. package/components/PmPulse.css +24 -0
  8. package/components/PmPulse.tsx +109 -0
  9. package/components/PmQuestionBox.css +16 -0
  10. package/components/PmQuestionBox.tsx +155 -0
  11. package/components/PmQuiz.css +43 -0
  12. package/components/PmQuiz.tsx +221 -0
  13. package/package.json +44 -3
  14. package/src/client/ClassroomOverlays.tsx +15 -0
  15. package/src/client/ClassroomPanel.css +32 -0
  16. package/src/client/ClassroomPanel.tsx +213 -0
  17. package/src/client/ClassroomToolbar.css +26 -0
  18. package/src/client/ClassroomToolbar.tsx +47 -0
  19. package/src/client/NoteSpot.css +50 -0
  20. package/src/client/NoteSpot.tsx +119 -0
  21. package/src/client/NotesLayer.css +9 -0
  22. package/src/client/NotesLayer.tsx +113 -0
  23. package/src/client/QuestionItem.css +17 -0
  24. package/src/client/QuestionItem.tsx +106 -0
  25. package/src/client/ai-answers.ts +29 -0
  26. package/src/client/browser-store.ts +86 -0
  27. package/src/client/composables.ts +43 -0
  28. package/src/client/env.d.ts +7 -0
  29. package/src/client/ids.ts +3 -0
  30. package/src/client/index.ts +4 -0
  31. package/src/client/plugin.ts +57 -0
  32. package/src/client/style.css +44 -0
  33. package/src/domain/config.ts +25 -0
  34. package/src/domain/exercise.ts +143 -0
  35. package/src/domain/index.ts +40 -0
  36. package/src/domain/messages.ts +298 -0
  37. package/src/domain/notes.ts +12 -0
  38. package/src/domain/place.ts +25 -0
  39. package/src/domain/predict.ts +34 -0
  40. package/src/domain/pulse.ts +19 -0
  41. package/src/domain/questions.ts +42 -0
  42. package/src/domain/quiz.ts +63 -0
  43. package/src/domain/records.ts +97 -0
  44. package/src/domain/source.ts +55 -0
  45. package/src/domain/summary.ts +96 -0
  46. package/src/domain/tally.ts +8 -0
  47. package/src/node/classroom-pass.ts +84 -0
  48. package/src/node/components.ts +10 -0
  49. package/src/node/directives.ts +77 -0
  50. package/src/node/fallbacks.ts +86 -0
  51. package/src/node/notebook.ts +32 -0
  52. package/src/node/presmium.ts +21 -0
  53. package/src/node/reveals.ts +29 -0
@@ -0,0 +1,213 @@
1
+ import { Fragment, useState, useRef, useEffect, useMemo, useId } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { useMessages, useSectionConfig } from '@lincyaw/host'
4
+ import {
5
+ CLASSROOM_ID,
6
+ classroomRows,
7
+ COLLECTIONS,
8
+ exerciseTallyText,
9
+ MESSAGES,
10
+ placeText,
11
+ questionsByPlace,
12
+ toAnswer,
13
+ toPrediction,
14
+ toPulseVote,
15
+ toQuestion,
16
+ toQuizState,
17
+ toSectionNumbers,
18
+ toSubmission,
19
+ unansweredCount,
20
+ } from '../domain/index.ts'
21
+ import type { Question } from '../domain/index.ts'
22
+ import { useAiAnswers } from './ai-answers.ts'
23
+ import { useClassroomStore, useCollection, useGuardedWrite, useParticipation } from './composables.ts'
24
+ import QuestionItem from './QuestionItem.tsx'
25
+ import './style.css'
26
+ import './ClassroomPanel.css'
27
+ export default function ClassroomPanel(props: {
28
+ children?: ReactNode
29
+ className?: string
30
+ onDone?(): void
31
+ onCited?(): void
32
+ }) {
33
+ const { status, presenter } = useParticipation()
34
+ const store = useClassroomStore()
35
+ const from = () => store
36
+ const questions = useCollection(from, COLLECTIONS.questions, toQuestion)
37
+ const votes = useCollection(from, COLLECTIONS.pulse, toPulseVote)
38
+ const quizzes = useCollection(from, COLLECTIONS.quizzes, toQuizState)
39
+ const answers = useCollection(from, COLLECTIONS.answers, toAnswer)
40
+ const submissions = useCollection(from, COLLECTIONS.submissions, toSubmission)
41
+ const predictions = useCollection(from, COLLECTIONS.predictions, toPrediction)
42
+ const { failure, write } = useGuardedWrite()
43
+ const t = useMessages(MESSAGES)
44
+ const ai = useAiAnswers()
45
+
46
+ const config = useSectionConfig(CLASSROOM_ID)
47
+ const numbers = toSectionNumbers(config)
48
+
49
+ const questionGroups = questionsByPlace(
50
+ questions.map(({ id, value }) => ({ ...value, id })),
51
+ t,
52
+ numbers,
53
+ )
54
+ const unanswered = unansweredCount(questions.map((item) => item.value))
55
+
56
+ const rows = classroomRows({
57
+ quizzes: quizzes.map(({ id, value }) => ({ ...value, id })),
58
+ answers: answers.map((item) => item.value),
59
+ predictions: predictions.map((item) => item.value),
60
+ votes: votes.map((item) => item.value),
61
+ submissions: submissions.map((item) => item.value),
62
+ numbers: numbers,
63
+ })
64
+
65
+ const pulses = rows.pulse.map((row) => ({
66
+ id: row.id,
67
+ place: placeText(row, t),
68
+ levels: row.levels.map((level, i) => `${level} ${t.people(row.counts[i]!)}`).join(t.panel.levels),
69
+ }))
70
+
71
+ const quizRows = rows.quizzes.map((row) => ({
72
+ id: row.id,
73
+ text: t.panel.quiz(placeText(row, t), row.open, row.tally.respondents, row.tally.correct),
74
+ }))
75
+
76
+ const predicted = rows.predictions.map((row) => ({
77
+ id: row.id,
78
+ place: t.panel.predicted(placeText(row, t), row.tally.respondents),
79
+ question: row.question,
80
+ choices: (row.choices ?? []).map((choice, i) => ({ choice, count: row.tally.counts[i]! })),
81
+ texts: row.tally.texts,
82
+ }))
83
+
84
+ const exercises = rows.exercises.map((row) => ({
85
+ id: row.id,
86
+ text: t.panel.exercise(placeText(row, t), exerciseTallyText(row.tally, t)),
87
+ }))
88
+
89
+ function toggleAnswered(question: Question & { id: string }): void {
90
+ const { id, ...value } = question
91
+ write(() => store?.put<Question>(COLLECTIONS.questions, id, { ...value, answered: !value.answered }))
92
+ }
93
+
94
+ function sendReply(question: Question & { id: string }): void {
95
+ const draft = ai.drafts.get(question.id)
96
+ if (draft?.state !== 'ready') return
97
+ const { id, ...value } = question
98
+ const sent = write(() =>
99
+ store?.put<Question>(COLLECTIONS.questions, id, {
100
+ ...value,
101
+ answered: true,
102
+ reply: { text: draft.text, ...(draft.provider ? { provider: draft.provider } : {}), at: Date.now() },
103
+ }),
104
+ )
105
+ if (sent) ai.discard(id)
106
+ }
107
+
108
+ return (
109
+ <>
110
+ <div className="pm-classroom-panel">
111
+ {!presenter ? (
112
+ <p className="pm-classroom-status">{t.panel.waiting}</p>
113
+ ) : (
114
+ <>
115
+ <section aria-label={t.panel.questions}>
116
+ <h4 className="pm-ui-heading">{t.panel.unanswered(unanswered)}</h4>
117
+ {!questionGroups.length ? (
118
+ <p className="pm-classroom-status">{t.panel.none}</p>
119
+ ) : status === 'active' && ai.offered && !ai.provider ? (
120
+ <p className="pm-classroom-status pm-classroom-panel__ai-setup">{t.questions.aiSetup}</p>
121
+ ) : null}
122
+ {questionGroups.map((group) => (
123
+ <div
124
+ key={group.label}
125
+ className="pm-classroom-panel__place"
126
+ data-section={group.section}
127
+ data-block={group.block}
128
+ >
129
+ <p className="pm-classroom-panel__where">{group.label}</p>
130
+ <ul>
131
+ {group.questions.map((q) => (
132
+ <QuestionItem
133
+ key={q.id}
134
+ question={q}
135
+ active={status === 'active'}
136
+ ai={ai}
137
+ onToggleAnswered={() => {
138
+ toggleAnswered(q)
139
+ }}
140
+ onSend={() => {
141
+ sendReply(q)
142
+ }}
143
+ ></QuestionItem>
144
+ ))}
145
+ </ul>
146
+ </div>
147
+ ))}
148
+ </section>
149
+ {pulses.length ? (
150
+ <section aria-label={t.panel.pulse}>
151
+ <h4 className="pm-ui-heading">{t.panel.pulse}</h4>
152
+ {pulses.map((pulse) => (
153
+ <div key={pulse.id} className="pm-classroom-panel__place" data-pulse={pulse.id}>
154
+ <p className="pm-classroom-panel__where">{pulse.place}</p>
155
+ <p className="pm-classroom-panel__levels">{pulse.levels}</p>
156
+ </div>
157
+ ))}
158
+ </section>
159
+ ) : null}
160
+ {quizRows.length ? (
161
+ <section aria-label={t.panel.quizzes}>
162
+ <h4 className="pm-ui-heading">{t.panel.quizzes}</h4>
163
+ {quizRows.map((quiz) => (
164
+ <p key={quiz.id} className="pm-classroom-panel__place" data-quiz={quiz.id}>
165
+ {quiz.text}
166
+ </p>
167
+ ))}
168
+ </section>
169
+ ) : null}
170
+ {predicted.length ? (
171
+ <section aria-label={t.panel.predictions}>
172
+ <h4 className="pm-ui-heading">{t.panel.predictions}</h4>
173
+ {predicted.map((item) => (
174
+ <div key={item.id} className="pm-classroom-panel__place" data-predict={item.id}>
175
+ <p className="pm-classroom-panel__where">{item.place}</p>
176
+ <p>{item.question}</p>
177
+ <ul>
178
+ {item.choices.map(({ choice, count }, i) => (
179
+ <li key={i} data-choice={choice} data-count={count}>
180
+ {choice}
181
+ {t.colon}
182
+ {t.people(count)}
183
+ </li>
184
+ ))}
185
+ {item.texts.map((text, i) => (
186
+ <li key={i}>{text}</li>
187
+ ))}
188
+ </ul>
189
+ </div>
190
+ ))}
191
+ </section>
192
+ ) : null}
193
+ {exercises.length ? (
194
+ <section aria-label={t.panel.exercises}>
195
+ <h4 className="pm-ui-heading">{t.panel.exercises}</h4>
196
+ {exercises.map((exercise) => (
197
+ <p key={exercise.id} className="pm-classroom-panel__place" data-exercise={exercise.id}>
198
+ {exercise.text}
199
+ </p>
200
+ ))}
201
+ </section>
202
+ ) : null}
203
+ </>
204
+ )}
205
+ {failure ? (
206
+ <p className="pm-classroom-error" role="alert">
207
+ {failure}
208
+ </p>
209
+ ) : null}
210
+ </div>
211
+ </>
212
+ )
213
+ }
@@ -0,0 +1,26 @@
1
+ .pm-classroom-toolbar {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ }
5
+ .pm-classroom-toolbar__badge {
6
+ margin-left: 0.125rem;
7
+ }
8
+ .pm-classroom-popover {
9
+ box-sizing: border-box;
10
+ width: min(26rem, calc(100vw - 2rem));
11
+ max-height: 75vh;
12
+ margin: 0;
13
+ overflow: auto;
14
+ padding: var(--pm-space-3) var(--pm-space-4);
15
+ border: var(--pm-size-hairline) solid var(--pm-color-line);
16
+ border-radius: var(--pm-radius-card);
17
+ background: var(--pm-color-surface);
18
+ color: var(--pm-color-ink);
19
+ box-shadow: var(--pm-shadow-float);
20
+ font-family: var(--pm-font-sans);
21
+ white-space: normal;
22
+ overflow-wrap: anywhere;
23
+ }
24
+ .pm-classroom-popover__close {
25
+ margin-top: 0.75rem;
26
+ }
@@ -0,0 +1,47 @@
1
+ import { useState } from 'react'
2
+ import { PmButton, PmPopover, useMessages } from '@lincyaw/host'
3
+ import { COLLECTIONS, MESSAGES, toQuestion, unansweredCount } from '../domain/index.ts'
4
+ import { useClassroomStore, useCollection, useParticipation } from './composables.ts'
5
+ import ClassroomOverlays from './ClassroomOverlays.tsx'
6
+ import './style.css'
7
+ import ClassroomPanel from './ClassroomPanel.tsx'
8
+ import './ClassroomToolbar.css'
9
+ export default function ClassroomToolbar() {
10
+ const t = useMessages(MESSAGES)
11
+ const [open, setOpen] = useState(false)
12
+ const { presenter } = useParticipation()
13
+ const store = useClassroomStore()
14
+ const questions = useCollection(() => store, COLLECTIONS.questions, toQuestion)
15
+ const unanswered = unansweredCount(questions.map((item) => item.value))
16
+ return (
17
+ <span className="pm-classroom-toolbar">
18
+ {presenter && (
19
+ <PmPopover
20
+ open={open}
21
+ onOpenChange={setOpen}
22
+ label={t.interaction}
23
+ name="pm-classroom-popover"
24
+ trigger={
25
+ <PmButton variant="ghost" small title={t.toolbar.title} aria-expanded={open}>
26
+ {t.toolbar.button}
27
+ {unanswered > 0 && (
28
+ <span
29
+ className="pm-classroom-toolbar__badge pm-ui-badge pm-ui-badge--danger pm-ui-badge--pill"
30
+ aria-label={t.toolbar.unanswered(unanswered)}
31
+ >
32
+ {unanswered}
33
+ </span>
34
+ )}
35
+ </PmButton>
36
+ }
37
+ >
38
+ <ClassroomPanel />
39
+ <PmButton small className="pm-classroom-popover__close" onClick={() => setOpen(false)}>
40
+ {t.close}
41
+ </PmButton>
42
+ </PmPopover>
43
+ )}
44
+ <ClassroomOverlays />
45
+ </span>
46
+ )
47
+ }
@@ -0,0 +1,50 @@
1
+ .pm-note-spot {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: flex-end;
5
+ color: var(--pm-color-ink);
6
+ font-family: var(--pm-font-sans);
7
+ font-size: 0.8rem;
8
+ line-height: 1.5;
9
+ }
10
+ .pm-note-spot__toggle {
11
+ color: var(--pm-color-muted);
12
+ }
13
+ .pm-note-spot__toggle[aria-expanded='true'],
14
+ .pm-note-spot:not([data-notes='0']) .pm-note-spot__toggle {
15
+ color: var(--pm-color-ink);
16
+ }
17
+ .pm-note-spot__count {
18
+ margin-left: 0.25rem;
19
+ }
20
+ .pm-note-spot__panel {
21
+ box-sizing: border-box;
22
+ width: 100%;
23
+ margin-top: 0.25rem;
24
+ padding: 0.5rem 0.75rem;
25
+ border: var(--pm-size-hairline) solid var(--pm-color-line);
26
+ border-radius: var(--pm-radius-control);
27
+ background: var(--pm-color-warn-soft);
28
+ }
29
+ .pm-note-spot__list {
30
+ margin: 0 0 0.3rem;
31
+ padding-left: 1.2em;
32
+ }
33
+ .pm-note-spot__list li {
34
+ display: flex;
35
+ justify-content: space-between;
36
+ gap: 0.5rem;
37
+ }
38
+ .pm-note-spot__text {
39
+ white-space: pre-wrap;
40
+ }
41
+ .pm-note-spot__actions {
42
+ display: flex;
43
+ flex-wrap: wrap;
44
+ align-items: center;
45
+ gap: 0.4rem;
46
+ margin: 0.25rem 0 0;
47
+ }
48
+ .pm-note-spot__where {
49
+ color: var(--pm-color-muted);
50
+ }
@@ -0,0 +1,119 @@
1
+ import { Fragment, useState, useRef, useEffect, useMemo, useId } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { useMessages } from '@lincyaw/host'
4
+ import type { ClassroomStore, StoredItem } from '@lincyaw/kernel'
5
+ import { COLLECTIONS, MESSAGES } from '../domain/index.ts'
6
+ import type { Note } from '../domain/index.ts'
7
+ import { newId } from './ids.ts'
8
+ import './style.css'
9
+ import './NoteSpot.css'
10
+ export default function NoteSpot(
11
+ props: {
12
+ section: string
13
+ block?: string
14
+ label: string
15
+ notes: StoredItem<Note>[]
16
+ store: ClassroomStore
17
+ inClassroom: boolean
18
+ } & {
19
+ children?: ReactNode
20
+ className?: string
21
+ onDone?(): void
22
+ onCited?(): void
23
+ onToggleAnswered?(): void
24
+ onSend?(): void
25
+ },
26
+ ) {
27
+ const { section, block, label, notes, store, inClassroom } = props
28
+
29
+ const t = useMessages(MESSAGES)
30
+ const [open, setOpen] = useState(false)
31
+ const [text, setText] = useState('')
32
+
33
+ function add(): void {
34
+ const note = text.trim()
35
+ if (!note) return
36
+ props.store.put<Note>(COLLECTIONS.notes, newId(), {
37
+ section: props.section,
38
+ ...(props.block ? { block: props.block } : {}),
39
+ text: note,
40
+ at: Date.now(),
41
+ })
42
+ setText('')
43
+ }
44
+
45
+ return (
46
+ <>
47
+ <div className="pm-note-spot" data-section={section} data-block={block} data-notes={notes.length}>
48
+ <button
49
+ type="button"
50
+ className="pm-note-spot__toggle pm-ui-button pm-ui-button--ghost pm-ui-button--small"
51
+ aria-expanded={open}
52
+ aria-label={t.notes.label(label)}
53
+ title={t.notes.label(label)}
54
+ onClick={(event) => {
55
+ setOpen(!open)
56
+ }}
57
+ >
58
+ {t.notes.button}
59
+ {notes.length ? (
60
+ <span className="pm-note-spot__count pm-ui-badge pm-ui-badge--accent">{notes.length}</span>
61
+ ) : null}
62
+ </button>
63
+ {open ? (
64
+ <div className="pm-note-spot__panel">
65
+ {notes.length ? (
66
+ <ul className="pm-note-spot__list" aria-label={t.notes.mine}>
67
+ {notes.map((note) => (
68
+ <li key={note.id}>
69
+ <span className="pm-note-spot__text">{note.value.text}</span>
70
+ <button
71
+ type="button"
72
+ className="pm-ui-button pm-ui-button--ghost pm-ui-button--small"
73
+ aria-label={t.notes.removeLabel(note.value.text)}
74
+ onClick={(event) => {
75
+ store.remove(COLLECTIONS.notes, note.id)
76
+ }}
77
+ >
78
+ {t.notes.remove}
79
+ </button>
80
+ </li>
81
+ ))}
82
+ </ul>
83
+ ) : null}
84
+ <form
85
+ onSubmit={(event) => {
86
+ event.preventDefault()
87
+ add()
88
+ }}
89
+ >
90
+ <textarea
91
+ value={text}
92
+ onChange={(event) => setText(event.target.value)}
93
+ className="pm-ui-textarea"
94
+ aria-label={t.notes.new}
95
+ rows={2}
96
+ maxLength={2000}
97
+ onKeyDown={(event) => {
98
+ event.stopPropagation()
99
+ }}
100
+ ></textarea>
101
+ <p className="pm-note-spot__actions">
102
+ <button
103
+ type="submit"
104
+ className="pm-ui-button pm-ui-button--primary pm-ui-button--small"
105
+ disabled={!text.trim()}
106
+ >
107
+ {t.notes.save}
108
+ </button>
109
+ <span className="pm-note-spot__where">
110
+ {inClassroom ? t.notes.inClassroom : t.notes.inBrowser}
111
+ </span>
112
+ </p>
113
+ </form>
114
+ </div>
115
+ ) : null}
116
+ </div>
117
+ </>
118
+ )
119
+ }
@@ -0,0 +1,9 @@
1
+ .pm-note-slot--section {
2
+ margin: -0.5rem 0 0.5rem;
3
+ }
4
+ .pm-note-slot--block {
5
+ margin: 0.15rem 0 0.4rem;
6
+ }
7
+ .pm-block:not(:hover, :focus-within, :has(> .pm-note-slot :is([aria-expanded='true'], [data-notes]:not([data-notes='0'])))) > .pm-note-slot--block {
8
+ opacity: 0;
9
+ }
@@ -0,0 +1,113 @@
1
+ import { useMessages } from '@lincyaw/host'
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { COLLECTIONS, isAuthorLabel, MESSAGES, notesAt, toNote } from '../domain/index.ts'
5
+ import { useCollection, useNoteStore } from './composables.ts'
6
+ import NoteSpot from './NoteSpot.tsx'
7
+ import './NotesLayer.css'
8
+ interface Spot {
9
+ key: string
10
+ slot: HTMLElement
11
+ section: string
12
+ block?: string
13
+ label: string
14
+ }
15
+ export default function NotesLayer() {
16
+ const t = useMessages(MESSAGES)
17
+ const { store, inClassroom } = useNoteStore()
18
+ const notes = useCollection(() => store, COLLECTIONS.notes, toNote)
19
+ const [spots, setSpots] = useState<Spot[]>([])
20
+ const current = useRef(spots)
21
+ current.current = spots
22
+ const notesBySpot = new Map<string, typeof notes>()
23
+ for (const note of notes) {
24
+ const key = note.value.block ? `${note.value.section}/${note.value.block}` : note.value.section
25
+ notesBySpot.set(key, [...(notesBySpot.get(key) ?? []), note])
26
+ }
27
+ useEffect(() => {
28
+ let frame = 0
29
+ const slots = new Set<HTMLElement>()
30
+ const slotIn = (parent: HTMLElement, kind: string, after?: Element | null) => {
31
+ const existing = [...parent.children].find((c) => c.classList.contains('pm-note-slot'))
32
+ if (existing) return existing as HTMLElement
33
+ const slot = document.createElement('div')
34
+ slot.className = `pm-note-slot pm-note-slot--${kind}`
35
+ slots.add(slot)
36
+ if (after) after.after(slot)
37
+ else parent.append(slot)
38
+ return slot
39
+ }
40
+ const scan = () => {
41
+ frame = 0
42
+ const found: Spot[] = []
43
+ for (const section of document.querySelectorAll<HTMLElement>('.pm-view .pm-section[data-section]')) {
44
+ const id = section.dataset.section!
45
+ const title = section.querySelector(':scope > .pm-section__title')
46
+ const label = title?.textContent?.replace(/​/g, '').trim() || id
47
+ found.push({ key: id, slot: slotIn(section, 'section', title), section: id, label })
48
+ for (const block of section.querySelectorAll<HTMLElement>('.pm-block[data-block]')) {
49
+ const blockId = block.dataset.block!
50
+ if (!isAuthorLabel(blockId) || block.parentElement?.closest('.pm-block')) continue
51
+ const copy = block.cloneNode(true) as HTMLElement
52
+ copy.querySelectorAll('table, pre, .pm-note-slot, .pm-content-marks').forEach((e) => e.remove())
53
+ const text = (copy.textContent ?? '').replace(/\s+/g, ' ').trim()
54
+ const kind = block.querySelector('table')
55
+ ? 'table'
56
+ : block.querySelector('pre')
57
+ ? 'code'
58
+ : undefined
59
+ const description = text
60
+ ? [...text].slice(0, 20).join('') + (text.length > 20 ? '…' : '')
61
+ : kind
62
+ ? t.notes.blockOf(label, t.notes.kinds[kind])
63
+ : label
64
+ found.push({
65
+ key: `${id}/${blockId}`,
66
+ slot: slotIn(block, 'block'),
67
+ section: id,
68
+ block: blockId,
69
+ label: description,
70
+ })
71
+ }
72
+ }
73
+ const before = current.current
74
+ if (
75
+ found.length !== before.length ||
76
+ found.some(
77
+ (s, i) => s.key !== before[i]!.key || s.slot !== before[i]!.slot || s.label !== before[i]!.label,
78
+ )
79
+ ) {
80
+ current.current = found
81
+ setSpots(found)
82
+ }
83
+ }
84
+ const observer = new MutationObserver(() => {
85
+ frame ||= requestAnimationFrame(scan)
86
+ })
87
+ observer.observe(document.body, { childList: true, subtree: true })
88
+ scan()
89
+ return () => {
90
+ observer.disconnect()
91
+ cancelAnimationFrame(frame)
92
+ slots.forEach((slot) => slot.remove())
93
+ }
94
+ }, [t])
95
+ return (
96
+ <>
97
+ {spots.map((spot) =>
98
+ createPortal(
99
+ <NoteSpot
100
+ section={spot.section}
101
+ block={spot.block}
102
+ label={spot.label}
103
+ notes={notesAt(notesBySpot.get(spot.key) ?? [], spot.section, spot.block)}
104
+ store={store}
105
+ inClassroom={inClassroom}
106
+ />,
107
+ spot.slot,
108
+ spot.key,
109
+ ),
110
+ )}
111
+ </>
112
+ )
113
+ }
@@ -0,0 +1,17 @@
1
+ .pm-classroom-question p {
2
+ margin: 0;
3
+ }
4
+ .pm-classroom-question > .pm-ui-button {
5
+ margin-left: 0.35rem;
6
+ }
7
+ .pm-classroom-question[data-answered] {
8
+ color: var(--pm-color-muted);
9
+ }
10
+ .pm-classroom-panel__draft {
11
+ margin: 0.35rem 0;
12
+ padding: 0.35rem 0.75rem;
13
+ border-left: var(--pm-size-rule) solid var(--pm-color-line-strong);
14
+ }
15
+ .pm-classroom-panel__answer {
16
+ white-space: pre-wrap;
17
+ }