@lincyaw/addon-classroom 0.0.0-stage → 1.0.0-rc.1
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.
- package/LICENSE +202 -0
- package/README.md +30 -2
- package/components/PmExercise.css +58 -0
- package/components/PmExercise.tsx +165 -0
- package/components/PmPredict.css +31 -0
- package/components/PmPredict.tsx +182 -0
- package/components/PmPulse.css +24 -0
- package/components/PmPulse.tsx +109 -0
- package/components/PmQuestionBox.css +16 -0
- package/components/PmQuestionBox.tsx +155 -0
- package/components/PmQuiz.css +43 -0
- package/components/PmQuiz.tsx +221 -0
- package/package.json +44 -3
- package/src/client/ClassroomOverlays.tsx +15 -0
- package/src/client/ClassroomPanel.css +32 -0
- package/src/client/ClassroomPanel.tsx +213 -0
- package/src/client/ClassroomToolbar.css +26 -0
- package/src/client/ClassroomToolbar.tsx +47 -0
- package/src/client/NoteSpot.css +50 -0
- package/src/client/NoteSpot.tsx +119 -0
- package/src/client/NotesLayer.css +9 -0
- package/src/client/NotesLayer.tsx +113 -0
- package/src/client/QuestionItem.css +17 -0
- package/src/client/QuestionItem.tsx +106 -0
- package/src/client/ai-answers.ts +29 -0
- package/src/client/browser-store.ts +86 -0
- package/src/client/composables.ts +43 -0
- package/src/client/env.d.ts +7 -0
- package/src/client/ids.ts +3 -0
- package/src/client/index.ts +4 -0
- package/src/client/plugin.ts +57 -0
- package/src/client/style.css +44 -0
- package/src/domain/config.ts +25 -0
- package/src/domain/exercise.ts +143 -0
- package/src/domain/index.ts +40 -0
- package/src/domain/messages.ts +298 -0
- package/src/domain/notes.ts +12 -0
- package/src/domain/place.ts +25 -0
- package/src/domain/predict.ts +34 -0
- package/src/domain/pulse.ts +19 -0
- package/src/domain/questions.ts +42 -0
- package/src/domain/quiz.ts +63 -0
- package/src/domain/records.ts +97 -0
- package/src/domain/source.ts +55 -0
- package/src/domain/summary.ts +96 -0
- package/src/domain/tally.ts +8 -0
- package/src/node/classroom-pass.ts +84 -0
- package/src/node/components.ts +10 -0
- package/src/node/directives.ts +77 -0
- package/src/node/fallbacks.ts +86 -0
- package/src/node/notebook.ts +32 -0
- package/src/node/presmium.ts +21 -0
- 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
|
+
}
|