@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 +1 -1
- package/src/blocks/BlockRenderer.js +277 -0
- package/src/blocks/BlockSchema.js +39 -38
- package/src/editor/Editor.js +203 -0
- package/src/griot.css +67 -0
- package/src/inline/InlineLexer.js +94 -94
package/package.json
CHANGED
|
@@ -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 '&';
|
|
352
|
+
if (m === '<') return '<';
|
|
353
|
+
if (m === '>') return '>';
|
|
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
|
-
|
|
35
|
-
export function
|
|
36
|
-
export function
|
|
37
|
-
export function
|
|
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;
|
package/src/editor/Editor.js
CHANGED
|
@@ -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
|
-
//  → 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  — 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
|
+
//  → 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  — 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
|
}
|