@0m0g1/griot 0.1.15 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0m0g1/griot",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "A self-contained block-based rich text editor and renderer built for historical document authoring.",
5
5
  "type": "module",
6
6
  "main": "./src/Griot.js",
@@ -298,6 +298,9 @@ function _render(block, opts) {
298
298
  return _renderCitation(block, opts);
299
299
  }
300
300
 
301
+ case 'quiz':
302
+ return _renderQuiz(block, opts);
303
+
301
304
  default: {
302
305
  const el = document.createElement('p');
303
306
  el.className = 'griot-block griot-paragraph';
@@ -341,6 +344,280 @@ function _renderCitation(block, opts) {
341
344
  return wrap;
342
345
  }
343
346
 
347
+ // ─── Quiz renderer ────────────────────────────────────────────────────────
348
+ function escapeHtml(str) {
349
+ if (!str) return '';
350
+ return str.replace(/[&<>]/g, function(m) {
351
+ if (m === '&') return '&amp;';
352
+ if (m === '<') return '&lt;';
353
+ if (m === '>') return '&gt;';
354
+ return m;
355
+ });
356
+ }
357
+
358
+ function _renderQuiz(block, opts) {
359
+ const { meta = {} } = block;
360
+ const title = meta.title || '';
361
+ const questions = Array.isArray(meta.questions) ? meta.questions : [];
362
+
363
+ const container = document.createElement('div');
364
+ container.className = 'griot-block griot-quiz';
365
+
366
+ if (title) {
367
+ const titleEl = document.createElement('h4');
368
+ titleEl.className = 'griot-quiz__title';
369
+ titleEl.textContent = title;
370
+ container.appendChild(titleEl);
371
+ }
372
+
373
+ if (questions.length === 0) {
374
+ const empty = document.createElement('p');
375
+ empty.className = 'griot-quiz__empty';
376
+ empty.textContent = 'No questions yet.';
377
+ container.appendChild(empty);
378
+ return container;
379
+ }
380
+
381
+ const form = document.createElement('form');
382
+ form.className = 'griot-quiz__form';
383
+
384
+ questions.forEach((q, idx) => {
385
+ const qid = q.id || `q${idx}`;
386
+ const text = q.text || `Question ${idx + 1}`;
387
+ const options = Array.isArray(q.options) ? q.options : [];
388
+ const correctIdx = q.correctOption;
389
+ const explanation = q.explanation || '';
390
+
391
+ const fieldset = document.createElement('fieldset');
392
+ fieldset.className = 'griot-quiz__question';
393
+ fieldset.dataset.index = idx;
394
+
395
+ const legend = document.createElement('legend');
396
+ legend.className = 'griot-quiz__question-text';
397
+ legend.innerHTML = `<span class="griot-quiz__q-num">${idx + 1}.</span> ${escapeHtml(text)}`;
398
+ fieldset.appendChild(legend);
399
+
400
+ const optionsContainer = document.createElement('div');
401
+ optionsContainer.className = 'griot-quiz__options';
402
+
403
+ options.forEach((opt, optIdx) => {
404
+ const label = document.createElement('label');
405
+ label.className = 'griot-quiz__option';
406
+
407
+ const radio = document.createElement('input');
408
+ radio.type = 'radio';
409
+ radio.name = `quiz_${block.id}_${qid}`;
410
+ radio.value = optIdx;
411
+
412
+ const span = document.createElement('span');
413
+ span.textContent = `${String.fromCharCode(65 + optIdx)}. ${escapeHtml(opt)}`;
414
+
415
+ label.appendChild(radio);
416
+ label.appendChild(span);
417
+ optionsContainer.appendChild(label);
418
+ });
419
+
420
+ fieldset.appendChild(optionsContainer);
421
+
422
+ const feedback = document.createElement('div');
423
+ feedback.className = 'griot-quiz__feedback';
424
+ fieldset.appendChild(feedback);
425
+
426
+ form.appendChild(fieldset);
427
+ });
428
+
429
+ const submitBtn = document.createElement('button');
430
+ submitBtn.type = 'button';
431
+ submitBtn.className = 'griot-quiz__submit';
432
+ submitBtn.textContent = 'Check answers';
433
+ submitBtn.addEventListener('click', () => {
434
+ let score = 0;
435
+ const answers = {};
436
+ questions.forEach((q, idx) => {
437
+ const qid = q.id || `q${idx}`;
438
+ const radios = form.querySelectorAll(`input[name="quiz_${block.id}_${qid}"]`);
439
+ let selected = null;
440
+ radios.forEach((r, i) => { if (r.checked) selected = i; });
441
+ answers[qid] = selected;
442
+ const isCorrect = (selected !== null && selected === q.correctOption);
443
+ if (isCorrect) score++;
444
+
445
+ const feedbackDiv = form.querySelector(`fieldset[data-index="${idx}"] .griot-quiz__feedback`);
446
+ if (feedbackDiv) {
447
+ if (selected === null) {
448
+ feedbackDiv.textContent = '❓ No answer selected.';
449
+ feedbackDiv.className = 'griot-quiz__feedback griot-quiz__feedback--missing';
450
+ } else if (isCorrect) {
451
+ feedbackDiv.textContent = '✓ Correct!';
452
+ feedbackDiv.className = 'griot-quiz__feedback griot-quiz__feedback--correct';
453
+ } else {
454
+ const correctAnswerText = q.options[q.correctOption];
455
+ feedbackDiv.innerHTML = `✗ Incorrect. Correct answer: ${escapeHtml(correctAnswerText)}. ${escapeHtml(q.explanation || '')}`;
456
+ feedbackDiv.className = 'griot-quiz__feedback griot-quiz__feedback--wrong';
457
+ }
458
+ }
459
+ });
460
+ const totalScore = questions.length;
461
+
462
+ const existingScore = container.querySelector('.griot-quiz__score');
463
+ if (existingScore) existingScore.remove();
464
+ const scoreDiv = document.createElement('div');
465
+ scoreDiv.className = 'griot-quiz__score';
466
+ scoreDiv.textContent = `You scored ${score} out of ${totalScore}.`;
467
+ container.appendChild(scoreDiv);
468
+
469
+ if (typeof opts.onQuizSubmit === 'function') {
470
+ opts.onQuizSubmit(block.id, score, totalScore, answers);
471
+ }
472
+ });
473
+
474
+ form.appendChild(submitBtn);
475
+ container.appendChild(form);
476
+ return container;
477
+ }
478
+
479
+ function _renderQuiz(block, opts) {
480
+ const { meta = {} } = block;
481
+ const title = meta.title || '';
482
+ const questions = Array.isArray(meta.questions) ? meta.questions : [];
483
+ const answers = meta._submittedAnswers || {}; // optional: store last answers
484
+
485
+ const container = document.createElement('div');
486
+ container.className = 'griot-block griot-quiz';
487
+
488
+ if (title) {
489
+ const titleEl = document.createElement('h4');
490
+ titleEl.className = 'griot-quiz__title';
491
+ titleEl.textContent = title;
492
+ container.appendChild(titleEl);
493
+ }
494
+
495
+ if (questions.length === 0) {
496
+ const empty = document.createElement('p');
497
+ empty.className = 'griot-quiz__empty';
498
+ empty.textContent = 'No questions yet.';
499
+ container.appendChild(empty);
500
+ return container;
501
+ }
502
+
503
+ const form = document.createElement('form');
504
+ form.className = 'griot-quiz__form';
505
+
506
+ let totalScore = 0;
507
+ let userScore = 0;
508
+
509
+ questions.forEach((q, idx) => {
510
+ const qid = q.id || `q${idx}`;
511
+ const text = q.text || `Question ${idx + 1}`;
512
+ const options = Array.isArray(q.options) ? q.options : [];
513
+ const correctIdx = q.correctOption; // 0‑based index
514
+ const explanation = q.explanation || '';
515
+ const userChoice = answers[qid] !== undefined ? answers[qid] : null;
516
+
517
+ const fieldset = document.createElement('fieldset');
518
+ fieldset.className = 'griot-quiz__question';
519
+ fieldset.dataset.index = idx;
520
+
521
+ const legend = document.createElement('legend');
522
+ legend.className = 'griot-quiz__question-text';
523
+ legend.innerHTML = `<span class="griot-quiz__q-num">${idx + 1}.</span> ${escapeHtml(text)}`;
524
+ fieldset.appendChild(legend);
525
+
526
+ const optionsContainer = document.createElement('div');
527
+ optionsContainer.className = 'griot-quiz__options';
528
+
529
+ options.forEach((opt, optIdx) => {
530
+ const label = document.createElement('label');
531
+ label.className = 'griot-quiz__option';
532
+
533
+ const radio = document.createElement('input');
534
+ radio.type = 'radio';
535
+ radio.name = `quiz_${block.id}_${qid}`;
536
+ radio.value = optIdx;
537
+ if (userChoice === optIdx) radio.checked = true;
538
+ radio.addEventListener('change', () => {
539
+ // Update stored answers in meta (optional – allows preserving after submit)
540
+ const newAnswers = { ...(block.meta._submittedAnswers || {}), [qid]: optIdx };
541
+ // We'll trigger an external callback later – for now just store in meta
542
+ // But meta updates must go through the editor, not viewer. So we only calculate on‑the‑fly.
543
+ // Instead, we call a user callback when the quiz is submitted.
544
+ });
545
+
546
+ const span = document.createElement('span');
547
+ span.textContent = `${String.fromCharCode(65 + optIdx)}. ${escapeHtml(opt)}`;
548
+
549
+ label.appendChild(radio);
550
+ label.appendChild(span);
551
+ optionsContainer.appendChild(label);
552
+ });
553
+
554
+ fieldset.appendChild(optionsContainer);
555
+
556
+ // Show correct / wrong feedback after evaluation
557
+ const feedback = document.createElement('div');
558
+ feedback.className = 'griot-quiz__feedback';
559
+ fieldset.appendChild(feedback);
560
+
561
+ form.appendChild(fieldset);
562
+ });
563
+
564
+ const submitBtn = document.createElement('button');
565
+ submitBtn.type = 'button';
566
+ submitBtn.className = 'griot-quiz__submit';
567
+ submitBtn.textContent = 'Check answers';
568
+ submitBtn.addEventListener('click', () => {
569
+ let score = 0;
570
+ const newAnswers = {};
571
+ questions.forEach((q, idx) => {
572
+ const qid = q.id || `q${idx}`;
573
+ const radios = form.querySelectorAll(`input[name="quiz_${block.id}_${qid}"]`);
574
+ let selected = null;
575
+ radios.forEach((r, i) => { if (r.checked) selected = i; });
576
+ newAnswers[qid] = selected;
577
+ const isCorrect = (selected !== null && selected === q.correctOption);
578
+ if (isCorrect) score++;
579
+
580
+ // Show feedback per question
581
+ const feedbackDiv = form.querySelector(`fieldset[data-index="${idx}"] .griot-quiz__feedback`);
582
+ if (feedbackDiv) {
583
+ if (selected === null) {
584
+ feedbackDiv.textContent = '❓ No answer selected.';
585
+ feedbackDiv.className = 'griot-quiz__feedback griot-quiz__feedback--missing';
586
+ } else if (isCorrect) {
587
+ feedbackDiv.textContent = '✓ Correct!';
588
+ feedbackDiv.className = 'griot-quiz__feedback griot-quiz__feedback--correct';
589
+ } else {
590
+ const correctAnswerText = q.options[q.correctOption];
591
+ feedbackDiv.innerHTML = `✗ Incorrect. Correct answer: ${escapeHtml(correctAnswerText)}. ${escapeHtml(q.explanation || '')}`;
592
+ feedbackDiv.className = 'griot-quiz__feedback griot-quiz__feedback--wrong';
593
+ }
594
+ if (q.explanation && selected !== null && !isCorrect) {
595
+ // Already added above
596
+ }
597
+ }
598
+ });
599
+ totalScore = questions.length;
600
+ userScore = score;
601
+
602
+ // Display overall score
603
+ const existingScore = container.querySelector('.griot-quiz__score');
604
+ if (existingScore) existingScore.remove();
605
+ const scoreDiv = document.createElement('div');
606
+ scoreDiv.className = 'griot-quiz__score';
607
+ scoreDiv.textContent = `You scored ${score} out of ${totalScore}.`;
608
+ container.appendChild(scoreDiv);
609
+
610
+ // Fire user callback if provided
611
+ if (typeof opts.onQuizSubmit === 'function') {
612
+ opts.onQuizSubmit(block.id, score, totalScore, newAnswers);
613
+ }
614
+ });
615
+
616
+ form.appendChild(submitBtn);
617
+ container.appendChild(form);
618
+ return container;
619
+ }
620
+
344
621
  // ─── Embed URL helpers ────────────────────────────────────────────────────────
345
622
 
346
623
  function _ytEmbed(src) {
@@ -1,39 +1,40 @@
1
- // ─── BlockSchema.js ───────────────────────────────────────────────────────────
2
- // Single source of truth for all block types
3
- // Fields: category, label, icon, slashLabel, hasText, hasInline, defaultMeta, placeholder
4
- // ─────────────────────────────────────────────────────────────────────────────
5
-
6
- const SCHEMA = {
7
- paragraph: { category:'text', label:'Paragraph', icon:'¶', slashLabel:'Text', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Write something… **bold** *italic* `code` ==highlight==' },
8
- heading: { category:'text', label:'Heading', icon:'H', slashLabel:'Heading', hasText:true, hasInline:false, defaultMeta:{ level:2 }, placeholder:'Heading…' },
9
- blockquote: { category:'text', label:'Quote', icon:'❝', slashLabel:'Quote', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Quote…' },
10
- callout: { category:'text', label:'Callout', icon:'💡', slashLabel:'Callout', hasText:true, hasInline:true, defaultMeta:{ icon:'💡' }, placeholder:'Callout text…' },
11
- callout_warning: { category:'text', label:'Warning', icon:'⚠️', slashLabel:'Warning', hasText:true, hasInline:true, defaultMeta:{ icon:'⚠️' }, placeholder:'Warning message…' },
12
- callout_tip: { category:'text', label:'Tip', icon:'✅', slashLabel:'Tip', hasText:true, hasInline:true, defaultMeta:{ icon:'✅' }, placeholder:'Tip or note…' },
13
- callout_danger: { category:'text', label:'Danger', icon:'🚨', slashLabel:'Danger', hasText:true, hasInline:true, defaultMeta:{ icon:'🚨' }, placeholder:'Critical warning…' },
14
- code: { category:'text', label:'Code', icon:'</>', slashLabel:'Code block', hasText:true, hasInline:false, defaultMeta:{ language:'' }, placeholder:'// code…' },
15
- list_ul: { category:'text', label:'Bullet List', icon:'•', slashLabel:'Bullet list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'Item 1\nItem 2\nItem 3' },
16
- list_ol: { category:'text', label:'Numbered List', icon:'1.', slashLabel:'Numbered list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'First item\nSecond item' },
17
- checklist: { category:'text', label:'Checklist', icon:'☑', slashLabel:'Checklist', hasText:false, hasInline:false, defaultMeta:{ items:[{ text:'', checked:false }] }, placeholder:null },
18
-
19
- image: { category:'media', label:'Image', icon:'🖼', slashLabel:'Image', hasText:false, hasInline:false, defaultMeta:{ src:'', alt:'', caption:'', width:'full' }, placeholder:null },
20
- video: { category:'media', label:'Video', icon:'▶', slashLabel:'Video', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
21
- audio: { category:'media', label:'Audio', icon:'🎵', slashLabel:'Audio', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
22
- gallery: { category:'media', label:'Gallery', icon:'▦', slashLabel:'Gallery', hasText:false, hasInline:false, defaultMeta:{ items:[], layout:'grid' }, placeholder:null },
23
-
24
- embed: { category:'embed', label:'Embed', icon:'⬡', slashLabel:'Embed / iframe', hasText:false, hasInline:false, defaultMeta:{ src:'', height:400, caption:'' }, placeholder:null },
25
-
26
- table: { category:'structure', label:'Table', icon:'⊞', slashLabel:'Table', hasText:false, hasInline:false, defaultMeta:{ headers:['Column 1','Column 2'], rows:[['','']] }, placeholder:null },
27
- columns: { category:'structure', label:'Columns', icon:'⊟', slashLabel:'Columns', hasText:false, hasInline:false, defaultMeta:{ columns:[{ text:'' },{ text:'' }] }, placeholder:null },
28
- divider: { category:'structure', label:'Divider', icon:'—', slashLabel:'Divider', hasText:false, hasInline:false, defaultMeta:{}, placeholder:null },
29
-
30
- timeline_ref: { category:'structure', label:'Timeline Event', icon:'⏱', slashLabel:'Timeline event', hasText:false, hasInline:false, defaultMeta:{ eventId:'', eventTitle:'', note:'' }, placeholder:null },
31
- book_citation: { category:'structure', label:'Book Citation', icon:'📖', slashLabel:'Book citation', hasText:false, hasInline:false, defaultMeta:{ bookId:'', unitId:'', quote:'', note:'' }, placeholder:null },
32
- };
33
-
34
- export function getBlockDef(type) { return SCHEMA[type] ?? SCHEMA.paragraph; }
35
- export function getAllTypes() { return Object.keys(SCHEMA); }
36
- export function getTypesByCategory(cat) { return Object.entries(SCHEMA).filter(([,d]) => d.category === cat).map(([t]) => t); }
37
- export function defaultMeta(type) { return { ...(SCHEMA[type]?.defaultMeta ?? {}) }; }
38
-
1
+ // ─── BlockSchema.js ───────────────────────────────────────────────────────────
2
+ // Single source of truth for all block types
3
+ // Fields: category, label, icon, slashLabel, hasText, hasInline, defaultMeta, placeholder
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+
6
+ const SCHEMA = {
7
+ paragraph: { category:'text', label:'Paragraph', icon:'¶', slashLabel:'Text', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Write something… **bold** *italic* `code` ==highlight==' },
8
+ heading: { category:'text', label:'Heading', icon:'H', slashLabel:'Heading', hasText:true, hasInline:false, defaultMeta:{ level:2 }, placeholder:'Heading…' },
9
+ blockquote: { category:'text', label:'Quote', icon:'❝', slashLabel:'Quote', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Quote…' },
10
+ callout: { category:'text', label:'Callout', icon:'💡', slashLabel:'Callout', hasText:true, hasInline:true, defaultMeta:{ icon:'💡' }, placeholder:'Callout text…' },
11
+ callout_warning: { category:'text', label:'Warning', icon:'⚠️', slashLabel:'Warning', hasText:true, hasInline:true, defaultMeta:{ icon:'⚠️' }, placeholder:'Warning message…' },
12
+ callout_tip: { category:'text', label:'Tip', icon:'✅', slashLabel:'Tip', hasText:true, hasInline:true, defaultMeta:{ icon:'✅' }, placeholder:'Tip or note…' },
13
+ callout_danger: { category:'text', label:'Danger', icon:'🚨', slashLabel:'Danger', hasText:true, hasInline:true, defaultMeta:{ icon:'🚨' }, placeholder:'Critical warning…' },
14
+ code: { category:'text', label:'Code', icon:'</>', slashLabel:'Code block', hasText:true, hasInline:false, defaultMeta:{ language:'' }, placeholder:'// code…' },
15
+ list_ul: { category:'text', label:'Bullet List', icon:'•', slashLabel:'Bullet list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'Item 1\nItem 2\nItem 3' },
16
+ list_ol: { category:'text', label:'Numbered List', icon:'1.', slashLabel:'Numbered list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'First item\nSecond item' },
17
+ checklist: { category:'text', label:'Checklist', icon:'☑', slashLabel:'Checklist', hasText:false, hasInline:false, defaultMeta:{ items:[{ text:'', checked:false }] }, placeholder:null },
18
+
19
+ image: { category:'media', label:'Image', icon:'🖼', slashLabel:'Image', hasText:false, hasInline:false, defaultMeta:{ src:'', alt:'', caption:'', width:'full' }, placeholder:null },
20
+ video: { category:'media', label:'Video', icon:'▶', slashLabel:'Video', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
21
+ audio: { category:'media', label:'Audio', icon:'🎵', slashLabel:'Audio', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
22
+ gallery: { category:'media', label:'Gallery', icon:'▦', slashLabel:'Gallery', hasText:false, hasInline:false, defaultMeta:{ items:[], layout:'grid' }, placeholder:null },
23
+
24
+ embed: { category:'embed', label:'Embed', icon:'⬡', slashLabel:'Embed / iframe', hasText:false, hasInline:false, defaultMeta:{ src:'', height:400, caption:'' }, placeholder:null },
25
+
26
+ table: { category:'structure', label:'Table', icon:'⊞', slashLabel:'Table', hasText:false, hasInline:false, defaultMeta:{ headers:['Column 1','Column 2'], rows:[['','']] }, placeholder:null },
27
+ columns: { category:'structure', label:'Columns', icon:'⊟', slashLabel:'Columns', hasText:false, hasInline:false, defaultMeta:{ columns:[{ text:'' },{ text:'' }] }, placeholder:null },
28
+ divider: { category:'structure', label:'Divider', icon:'—', slashLabel:'Divider', hasText:false, hasInline:false, defaultMeta:{}, placeholder:null },
29
+
30
+ timeline_ref: { category:'structure', label:'Timeline Event', icon:'⏱', slashLabel:'Timeline event', hasText:false, hasInline:false, defaultMeta:{ eventId:'', eventTitle:'', note:'' }, placeholder:null },
31
+ book_citation: { category:'structure', label:'Book Citation', icon:'📖', slashLabel:'Book citation', hasText:false, hasInline:false, defaultMeta:{ bookId:'', unitId:'', quote:'', note:'' }, placeholder:null },
32
+ quiz: { category:'interactive', label:'Quiz', icon:'✓', slashLabel:'Quiz', hasText:false, hasInline:false, defaultMeta:{ title:'', questions:[] }, placeholder:null },
33
+ };
34
+
35
+ export function getBlockDef(type) { return SCHEMA[type] ?? SCHEMA.paragraph; }
36
+ export function getAllTypes() { return Object.keys(SCHEMA); }
37
+ export function getTypesByCategory(cat) { return Object.entries(SCHEMA).filter(([,d]) => d.category === cat).map(([t]) => t); }
38
+ export function defaultMeta(type) { return { ...(SCHEMA[type]?.defaultMeta ?? {}) }; }
39
+
39
40
  export default SCHEMA;
@@ -649,6 +649,10 @@ export class Editor {
649
649
  wrap.appendChild(this._metaTextarea(block, 'note', 'Commentary (inline syntax ok)…', { rows: 2 }));
650
650
  break;
651
651
  }
652
+
653
+ case 'quiz':
654
+ wrap.appendChild(this._buildQuizEditor(block));
655
+ break;
652
656
  }
653
657
 
654
658
  return wrap;
@@ -759,6 +763,151 @@ export class Editor {
759
763
  return container;
760
764
  }
761
765
 
766
+ _buildQuizEditor(block) {
767
+ const container = document.createElement('div');
768
+ container.className = 'griot-editor-quiz';
769
+
770
+ // Title
771
+ const titleRow = document.createElement('div');
772
+ titleRow.className = 'griot-editor-quiz__title-row';
773
+ const titleLabel = document.createElement('label');
774
+ titleLabel.textContent = 'Quiz title:';
775
+ const titleInput = this._metaInput(block, 'title', 'Optional quiz title', { style: 'flex:1' });
776
+ titleRow.append(titleLabel, titleInput);
777
+ container.appendChild(titleRow);
778
+
779
+ const questionsContainer = document.createElement('div');
780
+ questionsContainer.className = 'griot-editor-quiz__questions';
781
+ container.appendChild(questionsContainer);
782
+
783
+ const refreshQuestions = () => {
784
+ questionsContainer.innerHTML = '';
785
+ const questions = Array.isArray(block.meta?.questions) ? block.meta.questions : [];
786
+
787
+ questions.forEach((q, qIdx) => {
788
+ const qCard = document.createElement('div');
789
+ qCard.className = 'griot-editor-quiz__question-card';
790
+
791
+ const header = document.createElement('div');
792
+ header.className = 'griot-editor-quiz__q-header';
793
+ const qNum = document.createElement('span');
794
+ qNum.className = 'griot-editor-quiz__q-num';
795
+ qNum.textContent = `Question ${qIdx + 1}`;
796
+ const removeBtn = this._mkSmallBtn('×', 'Remove question', () => {
797
+ const newQuestions = [...questions];
798
+ newQuestions.splice(qIdx, 1);
799
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: newQuestions } }));
800
+ }, 'is-del');
801
+ header.append(qNum, removeBtn);
802
+
803
+ const qTextInput = document.createElement('input');
804
+ qTextInput.type = 'text';
805
+ qTextInput.className = 'griot-editor-block__meta-input';
806
+ qTextInput.placeholder = 'Question text...';
807
+ qTextInput.value = q.text || '';
808
+ qTextInput.addEventListener('input', () => {
809
+ const updated = [...questions];
810
+ updated[qIdx] = { ...updated[qIdx], text: qTextInput.value };
811
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: updated } }));
812
+ });
813
+
814
+ const optionsContainer = document.createElement('div');
815
+ optionsContainer.className = 'griot-editor-quiz__options';
816
+
817
+ const refreshOptions = () => {
818
+ optionsContainer.innerHTML = '';
819
+ const opts = q.options || [];
820
+
821
+ opts.forEach((opt, optIdx) => {
822
+ const optRow = document.createElement('div');
823
+ optRow.className = 'griot-editor-quiz__opt-row';
824
+
825
+ const radio = document.createElement('input');
826
+ radio.type = 'radio';
827
+ radio.name = `quiz_correct_${block.id}_${qIdx}`;
828
+ radio.checked = (q.correctOption === optIdx);
829
+ radio.addEventListener('change', () => {
830
+ const updated = [...questions];
831
+ updated[qIdx] = { ...updated[qIdx], correctOption: optIdx };
832
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: updated } }));
833
+ });
834
+
835
+ const optInput = document.createElement('input');
836
+ optInput.type = 'text';
837
+ optInput.className = 'griot-editor-block__meta-input';
838
+ optInput.placeholder = `Option ${String.fromCharCode(65 + optIdx)}`;
839
+ optInput.value = opt;
840
+ optInput.addEventListener('input', () => {
841
+ const updated = [...questions];
842
+ const newOpts = [...(updated[qIdx].options || [])];
843
+ newOpts[optIdx] = optInput.value;
844
+ updated[qIdx] = { ...updated[qIdx], options: newOpts };
845
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: updated } }));
846
+ });
847
+
848
+ const delOptBtn = this._mkSmallBtn('×', 'Remove option', () => {
849
+ if (opts.length <= 1) return;
850
+ const updated = [...questions];
851
+ const newOpts = opts.filter((_, i) => i !== optIdx);
852
+ let newCorrect = q.correctOption;
853
+ if (newCorrect === optIdx) newCorrect = 0;
854
+ else if (newCorrect > optIdx) newCorrect--;
855
+ updated[qIdx] = { ...updated[qIdx], options: newOpts, correctOption: newCorrect };
856
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: updated } }));
857
+ }, 'is-del');
858
+
859
+ optRow.append(radio, optInput, delOptBtn);
860
+ optionsContainer.appendChild(optRow);
861
+ });
862
+
863
+ const addOptBtn = this._mkSmallBtn('+ Add option', 'Add option', () => {
864
+ const updated = [...questions];
865
+ const newOpts = [...(updated[qIdx].options || []), `Option ${(updated[qIdx].options?.length || 0) + 1}`];
866
+ updated[qIdx] = { ...updated[qIdx], options: newOpts };
867
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: updated } }));
868
+ });
869
+ optionsContainer.appendChild(addOptBtn);
870
+ };
871
+
872
+ refreshOptions();
873
+
874
+ const explanationInput = document.createElement('textarea');
875
+ explanationInput.className = 'griot-editor-block__meta-input griot-editor-block__meta-textarea';
876
+ explanationInput.rows = 2;
877
+ explanationInput.placeholder = 'Explanation (shown after answering)';
878
+ explanationInput.value = q.explanation || '';
879
+ explanationInput.addEventListener('input', () => {
880
+ const updated = [...questions];
881
+ updated[qIdx] = { ...updated[qIdx], explanation: explanationInput.value };
882
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: updated } }));
883
+ });
884
+
885
+ qCard.append(header, qTextInput, optionsContainer, explanationInput);
886
+ questionsContainer.appendChild(qCard);
887
+ });
888
+
889
+ const addQBtn = document.createElement('button');
890
+ addQBtn.type = 'button';
891
+ addQBtn.className = 'griot-editor-block__pick-btn';
892
+ addQBtn.textContent = '+ Add question';
893
+ addQBtn.addEventListener('click', () => {
894
+ const newQuestion = {
895
+ id: Date.now() + Math.random(),
896
+ text: 'New question',
897
+ options: ['Option A', 'Option B'],
898
+ correctOption: 0,
899
+ explanation: '',
900
+ };
901
+ const updated = [...(block.meta?.questions || []), newQuestion];
902
+ this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, questions: updated } }));
903
+ });
904
+ questionsContainer.appendChild(addQBtn);
905
+ };
906
+
907
+ refreshQuestions();
908
+ return container;
909
+ }
910
+
762
911
  _mkSmallBtn(label, title, onClick, extraClass = '') {
763
912
  const b = document.createElement('button');
764
913
  b.type = 'button'; b.title = title;
@@ -1175,6 +1324,60 @@ function _injectEditorStyles() {
1175
1324
  .griot-editor-checklist__row { display:flex; align-items:center; gap:6px; }
1176
1325
  .griot-editor-checklist__cb { flex-shrink:0; width:15px; height:15px; accent-color:#6366f1; cursor:pointer; }
1177
1326
  .griot-editor-checklist__text { flex:1; }
1327
+
1328
+ /* ── Quiz editor ────────────────────────────────────────────────────────── */
1329
+ .griot-editor-quiz {
1330
+ background: rgba(0,0,0,0.2);
1331
+ border-radius: var(--griot-radius);
1332
+ padding: 10px;
1333
+ }
1334
+ .griot-editor-quiz__title-row {
1335
+ display: flex;
1336
+ align-items: center;
1337
+ gap: 10px;
1338
+ margin-bottom: 15px;
1339
+ }
1340
+ .griot-editor-quiz__title-row label {
1341
+ font-size: 12px;
1342
+ color: var(--griot-text-muted);
1343
+ }
1344
+ .griot-editor-quiz__question-card {
1345
+ background: rgba(255,255,255,0.02);
1346
+ border: 1px solid var(--griot-border);
1347
+ border-radius: 8px;
1348
+ padding: 12px;
1349
+ margin-bottom: 16px;
1350
+ }
1351
+ .griot-editor-quiz__q-header {
1352
+ display: flex;
1353
+ justify-content: space-between;
1354
+ align-items: center;
1355
+ margin-bottom: 8px;
1356
+ }
1357
+ .griot-editor-quiz__q-num {
1358
+ font-weight: 600;
1359
+ font-size: 13px;
1360
+ color: var(--griot-accent);
1361
+ }
1362
+ .griot-editor-quiz__options {
1363
+ margin-top: 10px;
1364
+ margin-bottom: 10px;
1365
+ }
1366
+ .griot-editor-quiz__opt-row {
1367
+ display: flex;
1368
+ align-items: center;
1369
+ gap: 8px;
1370
+ margin-bottom: 6px;
1371
+ }
1372
+ .griot-editor-quiz__opt-row input[type="radio"] {
1373
+ accent-color: var(--griot-accent);
1374
+ width: 14px;
1375
+ height: 14px;
1376
+ flex-shrink: 0;
1377
+ }
1378
+ .griot-editor-quiz__opt-row .griot-editor-block__meta-input {
1379
+ flex: 1;
1380
+ }
1178
1381
  `;
1179
1382
  document.head.appendChild(s);
1180
1383
  }
package/src/griot.css CHANGED
@@ -351,3 +351,70 @@
351
351
  margin-bottom: 7px;
352
352
  font-weight: 600;
353
353
  }
354
+
355
+ /* ── Quiz viewer ─────────────────────────────────────────────────────────── */
356
+ .griot-quiz {
357
+ background: rgba(99,102,241,0.03);
358
+ border: 1px solid var(--griot-border);
359
+ border-radius: var(--griot-radius);
360
+ padding: 16px;
361
+ margin: 20px 0;
362
+ }
363
+ .griot-quiz__title {
364
+ margin: 0 0 12px 0;
365
+ font-size: 1.25rem;
366
+ font-weight: 600;
367
+ color: var(--griot-accent-text);
368
+ }
369
+ .griot-quiz__question {
370
+ border: none;
371
+ margin: 0 0 20px 0;
372
+ padding: 0;
373
+ }
374
+ .griot-quiz__question-text {
375
+ font-weight: 600;
376
+ margin-bottom: 10px;
377
+ color: var(--griot-text);
378
+ }
379
+ .griot-quiz__options {
380
+ display: flex;
381
+ flex-direction: column;
382
+ gap: 8px;
383
+ margin-bottom: 10px;
384
+ }
385
+ .griot-quiz__option {
386
+ display: flex;
387
+ align-items: center;
388
+ gap: 8px;
389
+ cursor: pointer;
390
+ font-size: 0.95rem;
391
+ }
392
+ .griot-quiz__option input {
393
+ margin: 0;
394
+ accent-color: var(--griot-accent);
395
+ }
396
+ .griot-quiz__feedback {
397
+ font-size: 0.85rem;
398
+ padding: 5px 0;
399
+ }
400
+ .griot-quiz__feedback--correct { color: #86efac; }
401
+ .griot-quiz__feedback--wrong { color: #f87171; }
402
+ .griot-quiz__feedback--missing { color: var(--griot-text-muted); }
403
+ .griot-quiz__submit {
404
+ background: var(--griot-accent);
405
+ border: none;
406
+ border-radius: var(--griot-radius-sm);
407
+ color: white;
408
+ padding: 6px 16px;
409
+ font-size: 0.85rem;
410
+ cursor: pointer;
411
+ transition: background 0.2s;
412
+ }
413
+ .griot-quiz__submit:hover { background: #7c3aed; }
414
+ .griot-quiz__score {
415
+ margin-top: 16px;
416
+ font-weight: 500;
417
+ text-align: right;
418
+ border-top: 1px solid var(--griot-border);
419
+ padding-top: 12px;
420
+ }
@@ -1,95 +1,95 @@
1
- // ─── InlineLexer.js ───────────────────────────────────────────────────────────
2
- // Tokenises a plain-text string that uses lightweight inline markup.
3
- //
4
- // Supported syntax
5
- // ─────────────────────────────────────────────────────────────────────────────
6
- // **bold** → TOKEN.BOLD { text }
7
- // *italic* → TOKEN.ITALIC { text }
8
- // __underline__ → TOKEN.UNDERLINE { text }
9
- // ~~strikethrough~~ → TOKEN.STRIKE { text }
10
- // ^superscript^ → TOKEN.SUPER { text }
11
- // ~subscript~ → TOKEN.SUB { text }
12
- // `inline code` → TOKEN.CODE { code }
13
- // ==highlight== → TOKEN.HIGHLIGHT { text }
14
- // {#f00:red text} {blue:text} → TOKEN.COLOR_MARK { color, text }
15
- // [label](url) → TOKEN.LINK { label, href }
16
- // ![alt](url) → TOKEN.IMAGE { alt, src }
17
- // [[event:id|label]] → TOKEN.EVENT_REF { eventId, label }
18
- // [[cite:blockId|label]] → TOKEN.CITE_REF { blockId, label }
19
- // plain text → TOKEN.TEXT { text }
20
- //
21
- // Stateless and re-entrant. Rules are anchored regexes in priority order.
22
- // ─────────────────────────────────────────────────────────────────────────────
23
-
24
- export const TOKEN = Object.freeze({
25
- TEXT: 'text',
26
- BOLD: 'bold',
27
- ITALIC: 'italic',
28
- UNDERLINE: 'underline',
29
- STRIKE: 'strike',
30
- SUPER: 'super',
31
- SUB: 'sub',
32
- CODE: 'code',
33
- LINK: 'link',
34
- IMAGE: 'image',
35
- HIGHLIGHT: 'highlight',
36
- COLOR_MARK: 'color_mark',
37
- EVENT_REF: 'event_ref',
38
- CITE_REF: 'cite_ref',
39
- });
40
-
41
- const RULES = [
42
- // Inline image ![alt](url) — must precede link rule
43
- { type: TOKEN.IMAGE, re: /^!\[([^\]]*)\]\(([^)\s]+)\)/, build: m => ({ alt: m[1], src: m[2] }) },
44
- // Link [label](url)
45
- { type: TOKEN.LINK, re: /^\[([^\]]+)\]\(([^)\s]+)\)/, build: m => ({ label: m[1], href: m[2] }) },
46
- // Bold **text** — before italic
47
- { type: TOKEN.BOLD, re: /^\*\*((?:[^*]|\*(?!\*))+)\*\*/, build: m => ({ text: m[1] }) },
48
- // Italic *text*
49
- { type: TOKEN.ITALIC, re: /^\*((?:[^*])+)\*/, build: m => ({ text: m[1] }) },
50
- // Underline __text__
51
- { type: TOKEN.UNDERLINE, re: /^__((?:[^_])+)__/, build: m => ({ text: m[1] }) },
52
- // Strikethrough ~~text~~ (must come before single ~ subscript)
53
- { type: TOKEN.STRIKE, re: /^~~((?:[^~])+)~~/, build: m => ({ text: m[1] }) },
54
- // Subscript ~text~
55
- { type: TOKEN.SUB, re: /^~((?:[^~])+)~/, build: m => ({ text: m[1] }) },
56
- // Superscript ^text^
57
- { type: TOKEN.SUPER, re: /^\^((?:[^^])+)\^/, build: m => ({ text: m[1] }) },
58
- // Highlight ==text==
59
- { type: TOKEN.HIGHLIGHT, re: /^==((?:[^=])+)==/, build: m => ({ text: m[1] }) },
60
- // Colour mark {#hex:text} or {colorname:text}
61
- { type: TOKEN.COLOR_MARK, re: /^\{(#[0-9a-fA-F]{3,8}|[a-zA-Z][a-zA-Z0-9_-]*):([^}]+)\}/, build: m => ({ color: m[1], text: m[2] }) },
62
- // Inline code `code`
63
- { type: TOKEN.CODE, re: /^`([^`]+)`/, build: m => ({ code: m[1] }) },
64
- // Event ref [[event:id|label]]
65
- { type: TOKEN.EVENT_REF, re: /^\[\[event:([^\]|]+)(?:\|([^\]]*))?\]\]/, build: m => ({ eventId: m[1], label: m[2] || m[1] }) },
66
- // Cite ref [[cite:id|label]]
67
- { type: TOKEN.CITE_REF, re: /^\[\[cite:([^\]|]+)(?:\|([^\]]*))?\]\]/, build: m => ({ blockId: m[1], label: m[2] || m[1] }) },
68
- ];
69
-
70
- export function tokenizeInline(text = '') {
71
- if (!text) return [];
72
- const tokens = [];
73
- let pos = 0, textStart = 0;
74
-
75
- while (pos < text.length) {
76
- const remaining = text.slice(pos);
77
- let matched = false;
78
-
79
- for (const rule of RULES) {
80
- const m = remaining.match(rule.re);
81
- if (!m) continue;
82
- if (pos > textStart) tokens.push({ type: TOKEN.TEXT, text: text.slice(textStart, pos) });
83
- tokens.push({ type: rule.type, ...rule.build(m) });
84
- pos += m[0].length;
85
- textStart = pos;
86
- matched = true;
87
- break;
88
- }
89
-
90
- if (!matched) pos++;
91
- }
92
-
93
- if (textStart < text.length) tokens.push({ type: TOKEN.TEXT, text: text.slice(textStart) });
94
- return tokens.length ? tokens : [{ type: TOKEN.TEXT, text }];
1
+ // ─── InlineLexer.js ───────────────────────────────────────────────────────────
2
+ // Tokenises a plain-text string that uses lightweight inline markup.
3
+ //
4
+ // Supported syntax
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // **bold** → TOKEN.BOLD { text }
7
+ // *italic* → TOKEN.ITALIC { text }
8
+ // __underline__ → TOKEN.UNDERLINE { text }
9
+ // ~~strikethrough~~ → TOKEN.STRIKE { text }
10
+ // ^superscript^ → TOKEN.SUPER { text }
11
+ // ~subscript~ → TOKEN.SUB { text }
12
+ // `inline code` → TOKEN.CODE { code }
13
+ // ==highlight== → TOKEN.HIGHLIGHT { text }
14
+ // {#f00:red text} {blue:text} → TOKEN.COLOR_MARK { color, text }
15
+ // [label](url) → TOKEN.LINK { label, href }
16
+ // ![alt](url) → TOKEN.IMAGE { alt, src }
17
+ // [[event:id|label]] → TOKEN.EVENT_REF { eventId, label }
18
+ // [[cite:blockId|label]] → TOKEN.CITE_REF { blockId, label }
19
+ // plain text → TOKEN.TEXT { text }
20
+ //
21
+ // Stateless and re-entrant. Rules are anchored regexes in priority order.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ export const TOKEN = Object.freeze({
25
+ TEXT: 'text',
26
+ BOLD: 'bold',
27
+ ITALIC: 'italic',
28
+ UNDERLINE: 'underline',
29
+ STRIKE: 'strike',
30
+ SUPER: 'super',
31
+ SUB: 'sub',
32
+ CODE: 'code',
33
+ LINK: 'link',
34
+ IMAGE: 'image',
35
+ HIGHLIGHT: 'highlight',
36
+ COLOR_MARK: 'color_mark',
37
+ EVENT_REF: 'event_ref',
38
+ CITE_REF: 'cite_ref',
39
+ });
40
+
41
+ const RULES = [
42
+ // Inline image ![alt](url) — must precede link rule
43
+ { type: TOKEN.IMAGE, re: /^!\[([^\]]*)\]\(([^)\s]+)\)/, build: m => ({ alt: m[1], src: m[2] }) },
44
+ // Link [label](url)
45
+ { type: TOKEN.LINK, re: /^\[([^\]]+)\]\(([^)\s]+)\)/, build: m => ({ label: m[1], href: m[2] }) },
46
+ // Bold **text** — before italic
47
+ { type: TOKEN.BOLD, re: /^\*\*((?:[^*]|\*(?!\*))+)\*\*/, build: m => ({ text: m[1] }) },
48
+ // Italic *text*
49
+ { type: TOKEN.ITALIC, re: /^\*((?:[^*])+)\*/, build: m => ({ text: m[1] }) },
50
+ // Underline __text__
51
+ { type: TOKEN.UNDERLINE, re: /^__((?:[^_])+)__/, build: m => ({ text: m[1] }) },
52
+ // Strikethrough ~~text~~ (must come before single ~ subscript)
53
+ { type: TOKEN.STRIKE, re: /^~~((?:[^~])+)~~/, build: m => ({ text: m[1] }) },
54
+ // Subscript ~text~
55
+ { type: TOKEN.SUB, re: /^~((?:[^~])+)~/, build: m => ({ text: m[1] }) },
56
+ // Superscript ^text^
57
+ { type: TOKEN.SUPER, re: /^\^((?:[^^])+)\^/, build: m => ({ text: m[1] }) },
58
+ // Highlight ==text==
59
+ { type: TOKEN.HIGHLIGHT, re: /^==((?:[^=])+)==/, build: m => ({ text: m[1] }) },
60
+ // Colour mark {#hex:text} or {colorname:text}
61
+ { type: TOKEN.COLOR_MARK, re: /^\{(#[0-9a-fA-F]{3,8}|[a-zA-Z][a-zA-Z0-9_-]*):([^}]+)\}/, build: m => ({ color: m[1], text: m[2] }) },
62
+ // Inline code `code`
63
+ { type: TOKEN.CODE, re: /^`([^`]+)`/, build: m => ({ code: m[1] }) },
64
+ // Event ref [[event:id|label]]
65
+ { type: TOKEN.EVENT_REF, re: /^\[\[event:([^\]|]+)(?:\|([^\]]*))?\]\]/, build: m => ({ eventId: m[1], label: m[2] || m[1] }) },
66
+ // Cite ref [[cite:id|label]]
67
+ { type: TOKEN.CITE_REF, re: /^\[\[cite:([^\]|]+)(?:\|([^\]]*))?\]\]/, build: m => ({ blockId: m[1], label: m[2] || m[1] }) },
68
+ ];
69
+
70
+ export function tokenizeInline(text = '') {
71
+ if (!text) return [];
72
+ const tokens = [];
73
+ let pos = 0, textStart = 0;
74
+
75
+ while (pos < text.length) {
76
+ const remaining = text.slice(pos);
77
+ let matched = false;
78
+
79
+ for (const rule of RULES) {
80
+ const m = remaining.match(rule.re);
81
+ if (!m) continue;
82
+ if (pos > textStart) tokens.push({ type: TOKEN.TEXT, text: text.slice(textStart, pos) });
83
+ tokens.push({ type: rule.type, ...rule.build(m) });
84
+ pos += m[0].length;
85
+ textStart = pos;
86
+ matched = true;
87
+ break;
88
+ }
89
+
90
+ if (!matched) pos++;
91
+ }
92
+
93
+ if (textStart < text.length) tokens.push({ type: TOKEN.TEXT, text: text.slice(textStart) });
94
+ return tokens.length ? tokens : [{ type: TOKEN.TEXT, text }];
95
95
  }