postext 1.59.2 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/__tests__/exercises/digital.test.js +38 -1
  2. package/dist/__tests__/exercises/digital.test.js.map +1 -1
  3. package/dist/__tests__/exercises/followups.test.d.ts +2 -0
  4. package/dist/__tests__/exercises/followups.test.d.ts.map +1 -0
  5. package/dist/__tests__/exercises/followups.test.js +235 -0
  6. package/dist/__tests__/exercises/followups.test.js.map +1 -0
  7. package/dist/__tests__/exports.test.js +16 -0
  8. package/dist/__tests__/exports.test.js.map +1 -1
  9. package/dist/__tests__/figureStyle.test.d.ts +2 -0
  10. package/dist/__tests__/figureStyle.test.d.ts.map +1 -0
  11. package/dist/__tests__/figureStyle.test.js +218 -0
  12. package/dist/__tests__/figureStyle.test.js.map +1 -0
  13. package/dist/__tests__/headingLetterSpacing.test.js +1 -1
  14. package/dist/__tests__/headingLetterSpacing.test.js.map +1 -1
  15. package/dist/__tests__/pipeline/paragraphStyleFaces.test.js +1 -1
  16. package/dist/__tests__/pipeline/paragraphStyleFaces.test.js.map +1 -1
  17. package/dist/__tests__/pipeline/spanHeadingMidPage.test.js +76 -0
  18. package/dist/__tests__/pipeline/spanHeadingMidPage.test.js.map +1 -1
  19. package/dist/canvas-backend/annotations.d.ts +4 -1
  20. package/dist/canvas-backend/annotations.d.ts.map +1 -1
  21. package/dist/canvas-backend/annotations.js +5 -0
  22. package/dist/canvas-backend/annotations.js.map +1 -1
  23. package/dist/canvas-backend/chip.d.ts.map +1 -1
  24. package/dist/canvas-backend/chip.js +4 -0
  25. package/dist/canvas-backend/chip.js.map +1 -1
  26. package/dist/canvas-backend/verticalText.d.ts.map +1 -1
  27. package/dist/canvas-backend/verticalText.js +5 -2
  28. package/dist/canvas-backend/verticalText.js.map +1 -1
  29. package/dist/configWarnings.d.ts.map +1 -1
  30. package/dist/configWarnings.js +18 -4
  31. package/dist/configWarnings.js.map +1 -1
  32. package/dist/defaults/bodyText.d.ts.map +1 -1
  33. package/dist/defaults/bodyText.js +8 -1
  34. package/dist/defaults/bodyText.js.map +1 -1
  35. package/dist/defaults/captionStyle.d.ts.map +1 -1
  36. package/dist/defaults/captionStyle.js +7 -2
  37. package/dist/defaults/captionStyle.js.map +1 -1
  38. package/dist/defaults/exercises.d.ts.map +1 -1
  39. package/dist/defaults/exercises.js +15 -9
  40. package/dist/defaults/exercises.js.map +1 -1
  41. package/dist/defaults/headerFooter.d.ts.map +1 -1
  42. package/dist/defaults/headerFooter.js +3 -1
  43. package/dist/defaults/headerFooter.js.map +1 -1
  44. package/dist/defaults/headings.d.ts.map +1 -1
  45. package/dist/defaults/headings.js +15 -3
  46. package/dist/defaults/headings.js.map +1 -1
  47. package/dist/defaults/paragraphStyles.d.ts.map +1 -1
  48. package/dist/defaults/paragraphStyles.js +4 -1
  49. package/dist/defaults/paragraphStyles.js.map +1 -1
  50. package/dist/defaults/tableStyle.d.ts.map +1 -1
  51. package/dist/defaults/tableStyle.js +5 -2
  52. package/dist/defaults/tableStyle.js.map +1 -1
  53. package/dist/design/elementKeys.d.ts +1 -0
  54. package/dist/design/elementKeys.d.ts.map +1 -1
  55. package/dist/design/elementKeys.js +1 -1
  56. package/dist/design/elementKeys.js.map +1 -1
  57. package/dist/exercises/build.d.ts.map +1 -1
  58. package/dist/exercises/build.js +1 -0
  59. package/dist/exercises/build.js.map +1 -1
  60. package/dist/exercises/configWarnings.d.ts.map +1 -1
  61. package/dist/exercises/configWarnings.js +6 -2
  62. package/dist/exercises/configWarnings.js.map +1 -1
  63. package/dist/exercises/designs.d.ts +5 -0
  64. package/dist/exercises/designs.d.ts.map +1 -1
  65. package/dist/exercises/designs.js +45 -16
  66. package/dist/exercises/designs.js.map +1 -1
  67. package/dist/exercises/digital.d.ts +7 -0
  68. package/dist/exercises/digital.d.ts.map +1 -1
  69. package/dist/exercises/digital.js +8 -1
  70. package/dist/exercises/digital.js.map +1 -1
  71. package/dist/exercises/expand.d.ts +6 -0
  72. package/dist/exercises/expand.d.ts.map +1 -1
  73. package/dist/exercises/expand.js +108 -20
  74. package/dist/exercises/expand.js.map +1 -1
  75. package/dist/exercises/grids.d.ts.map +1 -1
  76. package/dist/exercises/grids.js +76 -16
  77. package/dist/exercises/grids.js.map +1 -1
  78. package/dist/exercises/model.d.ts +4 -1
  79. package/dist/exercises/model.d.ts.map +1 -1
  80. package/dist/exercises/model.js +56 -15
  81. package/dist/exercises/model.js.map +1 -1
  82. package/dist/exercises/record.d.ts +9 -1
  83. package/dist/exercises/record.d.ts.map +1 -1
  84. package/dist/exercises/runtime.d.ts +6 -0
  85. package/dist/exercises/runtime.d.ts.map +1 -1
  86. package/dist/exercises/runtime.js +485 -69
  87. package/dist/exercises/runtime.js.map +1 -1
  88. package/dist/exercises/styles.d.ts +1 -0
  89. package/dist/exercises/styles.d.ts.map +1 -1
  90. package/dist/exercises/styles.js +3 -0
  91. package/dist/exercises/styles.js.map +1 -1
  92. package/dist/fonts/featureFaces.d.ts +32 -0
  93. package/dist/fonts/featureFaces.d.ts.map +1 -0
  94. package/dist/fonts/featureFaces.js +166 -0
  95. package/dist/fonts/featureFaces.js.map +1 -0
  96. package/dist/fonts/figureStyle.d.ts +69 -0
  97. package/dist/fonts/figureStyle.d.ts.map +1 -0
  98. package/dist/fonts/figureStyle.js +196 -0
  99. package/dist/fonts/figureStyle.js.map +1 -0
  100. package/dist/fonts/freezeFeatures.d.ts +10 -0
  101. package/dist/fonts/freezeFeatures.d.ts.map +1 -0
  102. package/dist/fonts/freezeFeatures.js +372 -0
  103. package/dist/fonts/freezeFeatures.js.map +1 -0
  104. package/dist/fonts/glyphFallback.d.ts.map +1 -1
  105. package/dist/fonts/glyphFallback.js +6 -3
  106. package/dist/fonts/glyphFallback.js.map +1 -1
  107. package/dist/fonts/prepare.d.ts.map +1 -1
  108. package/dist/fonts/prepare.js +22 -4
  109. package/dist/fonts/prepare.js.map +1 -1
  110. package/dist/fonts/usage.d.ts +10 -0
  111. package/dist/fonts/usage.d.ts.map +1 -1
  112. package/dist/fonts/usage.js +97 -0
  113. package/dist/fonts/usage.js.map +1 -1
  114. package/dist/html-backend.d.ts.map +1 -1
  115. package/dist/html-backend.js +27 -16
  116. package/dist/html-backend.js.map +1 -1
  117. package/dist/htmlAnnotations.d.ts +5 -1
  118. package/dist/htmlAnnotations.d.ts.map +1 -1
  119. package/dist/htmlAnnotations.js +6 -0
  120. package/dist/htmlAnnotations.js.map +1 -1
  121. package/dist/index.d.ts +7 -2
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +4 -1
  124. package/dist/index.js.map +1 -1
  125. package/dist/measure/cache.js +3 -3
  126. package/dist/measure/cache.js.map +1 -1
  127. package/dist/measure/rich.d.ts.map +1 -1
  128. package/dist/measure/rich.js +72 -4
  129. package/dist/measure/rich.js.map +1 -1
  130. package/dist/parse/types.d.ts +3 -0
  131. package/dist/parse/types.d.ts.map +1 -1
  132. package/dist/pipeline/build.d.ts.map +1 -1
  133. package/dist/pipeline/build.js +38 -7
  134. package/dist/pipeline/build.js.map +1 -1
  135. package/dist/pipeline/headingStyles.d.ts.map +1 -1
  136. package/dist/pipeline/headingStyles.js +3 -0
  137. package/dist/pipeline/headingStyles.js.map +1 -1
  138. package/dist/types.d.ts +61 -0
  139. package/dist/types.d.ts.map +1 -1
  140. package/dist/vdt.d.ts +5 -0
  141. package/dist/vdt.d.ts.map +1 -1
  142. package/dist/vdt.js.map +1 -1
  143. package/dist/worker/client.d.ts.map +1 -1
  144. package/dist/worker/client.js +13 -2
  145. package/dist/worker/client.js.map +1 -1
  146. package/dist/worker/fonts.d.ts +1 -1
  147. package/dist/worker/fonts.d.ts.map +1 -1
  148. package/dist/worker/fonts.js +1 -1
  149. package/dist/worker/fonts.js.map +1 -1
  150. package/dist/worker/protocol.d.ts +4 -0
  151. package/dist/worker/protocol.d.ts.map +1 -1
  152. package/package.json +1 -1
@@ -165,6 +165,32 @@ function exerciseRuntime() {
165
165
  }
166
166
  }
167
167
  }
168
+ /** The buttons an exercise's bar needs: a check where an answer is known
169
+ * for a slot the reader fills, the solution where there is one to show,
170
+ * a fresh start where there is anything to answer. */
171
+ function actionsOf(record) {
172
+ let anyMarkRight = false;
173
+ for (let i = 0; i < record.slots.length; i++)
174
+ if (record.slots[i].kind === 'mark' && (record.slots[i].correct || []).indexOf(1) >= 0)
175
+ anyMarkRight = true;
176
+ let check = false;
177
+ let solution = !!record.key || (!!record.solution && record.solution.length > 0);
178
+ let reset = false;
179
+ for (let i = 0; i < record.slots.length; i++) {
180
+ const slot = record.slots[i];
181
+ // Room on the printed page only.
182
+ if (slot.bare)
183
+ continue;
184
+ reset = true;
185
+ const known = slot.kind === 'mark' ? anyMarkRight : (!!slot.correct && slot.correct.length > 0) || (!!slot.accept && slot.accept.length > 0);
186
+ if (!known)
187
+ continue;
188
+ solution = true;
189
+ if (isGraded(slot, record))
190
+ check = true;
191
+ }
192
+ return { check, solution, reset };
193
+ }
168
194
  // --- Storage -----------------------------------------------------------------
169
195
  function load(key, persist) {
170
196
  let text = null;
@@ -215,28 +241,34 @@ function exerciseRuntime() {
215
241
  // --- The page ----------------------------------------------------------------
216
242
  const CSS = '.pt-ex-ui{pointer-events:auto;box-sizing:border-box;z-index:1;}' +
217
243
  '.pt-ex-field{position:absolute;display:block;}' +
218
- '.pt-ex-input{position:absolute;left:0;top:0;width:100%;height:100%;margin:0;padding:0 .15em;border:0;border-radius:.15em;background:transparent;color:inherit;font:inherit;line-height:1;text-align:center;outline-offset:1px;box-sizing:border-box;}' +
219
- '.pt-ex-input:focus{background:rgba(255,255,255,.9);outline:2px solid #1a5fb4;}' +
220
- 'textarea.pt-ex-input{text-align:start;resize:none;overflow:hidden;background:transparent;padding:0 .2em;}' +
244
+ '.pt-ex-input{position:absolute;left:0;top:0;width:100%;height:100%;margin:0;padding:0 .15em;border:0;border-radius:.15em;background:transparent;color:var(--pt-ex-text,#1d1d1f);font:inherit;line-height:1;text-align:center;box-sizing:border-box;}' +
245
+ // Focus rings, ticks and rings are drawn inside the slot: a page that
246
+ // clips at its edge (the Sandbox's HTML tab) cuts nothing.
247
+ '.pt-ex-input:focus{background:rgba(255,255,255,.9);outline:2px solid #1a5fb4;outline-offset:-2px;}' +
248
+ 'textarea.pt-ex-input{text-align:start;resize:none;overflow:hidden;background:transparent;padding:0 .2em;color:var(--pt-ex-text,#1d1d1f);}' +
221
249
  'textarea.pt-ex-input:focus{background:rgba(255,255,255,.35);}' +
222
250
  '.pt-ex-cell{text-transform:uppercase;}' +
223
251
  '.pt-ex-toggle{cursor:pointer;pointer-events:auto;}' +
224
- '.pt-ex-toggle:focus-visible{outline:2px solid #1a5fb4;outline-offset:2px;}' +
225
- '.pt-ex-on.pt-ex-ring,.pt-ex-on.pt-ex-mk-circle{outline:.12em solid currentColor;outline-offset:.04em;border-radius:999px;}' +
226
- '.pt-ex-on.pt-ex-mk-box{outline:.1em solid currentColor;}' +
227
- '.pt-ex-on.pt-ex-mk-underline{border-bottom:.12em solid currentColor;}' +
252
+ '.pt-ex-toggle:focus-visible{outline:2px solid #1a5fb4;outline-offset:-2px;}' +
253
+ '.pt-ex-on.pt-ex-ring,.pt-ex-on.pt-ex-mk-circle{box-shadow:inset 0 0 0 .1em var(--pt-ex-ring,currentColor);border-radius:999px;}' +
254
+ '.pt-ex-on.pt-ex-mk-box{box-shadow:inset 0 0 0 .09em var(--pt-ex-ring,currentColor);}' +
255
+ '.pt-ex-on.pt-ex-mk-underline{box-shadow:inset 0 -.12em 0 var(--pt-ex-ring,currentColor);}' +
228
256
  '.pt-ex-on.pt-ex-mk-highlight{background:rgba(255,214,0,.45);}' +
229
257
  '.pt-ex-on.pt-ex-mk-strike{background:linear-gradient(transparent 46%,currentColor 46%,currentColor 56%,transparent 56%);}' +
230
258
  '.pt-ex-on.pt-ex-tick::after{content:"\\2713";position:absolute;left:0;right:0;top:50%;transform:translateY(-55%);text-align:center;line-height:1;font-weight:700;}' +
231
259
  '.pt-ex-revealed,.pt-ex-revealed .pt-ex-input{color:var(--pt-ex-answer,#d6246e);}' +
232
- '.pt-ex-right{outline-color:var(--pt-ex-right,#1e8e3e)!important;}' +
233
- '.pt-ex-wrong{outline-color:var(--pt-ex-wrong,#c5221f)!important;}' +
260
+ '.pt-ex-right{--pt-ex-ring:var(--pt-ex-right,#1e8e3e);outline-color:var(--pt-ex-right,#1e8e3e)!important;}' +
261
+ '.pt-ex-wrong{--pt-ex-ring:var(--pt-ex-wrong,#c5221f);outline-color:var(--pt-ex-wrong,#c5221f)!important;}' +
234
262
  '.pt-ex-field.pt-ex-right .pt-ex-input,textarea.pt-ex-right{box-shadow:inset 0 -.12em 0 var(--pt-ex-right,#1e8e3e);}' +
235
263
  '.pt-ex-field.pt-ex-wrong .pt-ex-input,textarea.pt-ex-wrong{box-shadow:inset 0 -.12em 0 var(--pt-ex-wrong,#c5221f);}' +
236
- '.pt-ex-mark{position:absolute;right:-.55em;top:-.6em;font:700 .62em/1 system-ui,sans-serif;padding:.12em .2em;border-radius:999px;color:#fff;pointer-events:none;z-index:2;}' +
264
+ // A right or wrong mark stands on its slot's top corner, over the line
265
+ // gap rather than the next word, slid onto the page at its edge (see
266
+ // `placeMark`).
267
+ '.pt-ex-mark{position:absolute;left:100%;top:0;transform:translate(-55%,-60%);font:700 .62em/1 system-ui,sans-serif;padding:.12em .2em;border-radius:999px;color:#fff;pointer-events:none;z-index:2;white-space:nowrap;}' +
237
268
  '.pt-ex-mark.pt-ex-ok{background:var(--pt-ex-right,#1e8e3e);}' +
238
269
  '.pt-ex-mark.pt-ex-no{background:var(--pt-ex-wrong,#c5221f);}' +
239
270
  '.pt-ex-bar{position:absolute;right:0;top:100%;margin-top:.2em;display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.35em;font:400 1em/1.2 system-ui,sans-serif;color:#1d1d1f;z-index:3;}' +
271
+ '.pt-ex-bar:not(.pt-ex-flow){padding:.15em;border-radius:999px;background:rgba(255,255,255,.85);}' +
240
272
  '.pt-ex-bar button{font:inherit;margin:0;padding:.18em .6em;border:1px solid rgba(0,0,0,.35);border-radius:999px;background:#fff;color:#1d1d1f;cursor:pointer;}' +
241
273
  '.pt-ex-bar button:hover{background:#f1f1f3;}' +
242
274
  '.pt-ex-bar button:focus-visible{outline:2px solid #1a5fb4;outline-offset:2px;}' +
@@ -248,11 +280,26 @@ function exerciseRuntime() {
248
280
  '.pt-ex-menu{position:absolute;left:0;top:100%;margin-top:.2em;display:flex;flex-direction:column;min-width:8em;padding:.2em;background:#fff;border:1px solid rgba(0,0,0,.25);border-radius:.3em;box-shadow:0 .2em .6em rgba(0,0,0,.18);font:400 .8em/1.2 system-ui,sans-serif;z-index:5;}' +
249
281
  '.pt-ex-menu button{font:inherit;text-align:start;padding:.3em .5em;border:0;background:none;color:#1d1d1f;cursor:pointer;border-radius:.2em;}' +
250
282
  '.pt-ex-menu button:hover,.pt-ex-menu button:focus{background:#eef2fb;}' +
251
- '.pt-ex-tag{position:absolute;left:50%;top:100%;transform:translate(-50%,.1em);font:600 .55em/1 system-ui,sans-serif;white-space:nowrap;padding:.1em .3em;border-radius:.3em;background:#eef2fb;color:#1d1d1f;pointer-events:none;}' +
283
+ // A sort exercise: a word sorted fades in the bank and stands in its
284
+ // column; words are dragged from the bank to a column and back.
285
+ '.pt-ex-drag{touch-action:none;user-select:none;-webkit-user-select:none;}' +
286
+ // The word is painted under its slot: a veil fades it.
287
+ '.pt-ex-sorted{background:rgba(255,255,255,.62);border-radius:.25em;}' +
288
+ '.pt-ex-col{position:absolute;left:0;top:0;width:100%;height:100%;box-sizing:border-box;padding:0 .2em;color:var(--pt-ex-text,#1d1d1f);border-radius:.2em;}' +
289
+ '.pt-ex-placed{display:table;cursor:grab;white-space:nowrap;}' +
290
+ '.pt-ex-placed.pt-ex-right{color:var(--pt-ex-right,#1e8e3e);}' +
291
+ '.pt-ex-placed.pt-ex-wrong{color:var(--pt-ex-wrong,#c5221f);}' +
292
+ '.pt-ex-col.pt-ex-revealed,.pt-ex-col.pt-ex-revealed .pt-ex-placed{color:var(--pt-ex-answer,#d6246e);cursor:default;}' +
293
+ '.pt-ex-drop{background:rgba(26,95,180,.08);box-shadow:inset 0 0 0 2px rgba(26,95,180,.55);}' +
252
294
  '.pt-ex-svg{position:absolute;left:0;top:0;overflow:visible;}' +
253
- '.pt-ex-dot{fill:transparent;stroke:transparent;cursor:pointer;pointer-events:all;}' +
254
- '.pt-ex-dot.pt-ex-pending{stroke:#1a5fb4;stroke-width:2;}' +
255
- '.pt-ex-dot:focus{outline:none;stroke:#1a5fb4;stroke-width:2;}' +
295
+ '.pt-ex-layer{position:absolute;left:0;top:0;transform-origin:0 0;}' +
296
+ // The controls of a mirrored flow (a right-to-left page of the HTML
297
+ // viewer) turn back about their own centres, as its text does (#1244).
298
+ '.pt-flow-mirrored .pt-ex-ui:not(svg){transform:scaleX(-1);}' +
299
+ '.pt-ex-dot{fill:transparent;stroke:transparent;cursor:pointer;pointer-events:all;touch-action:none;user-select:none;-webkit-user-select:none;}' +
300
+ '.pt-ex-dot:hover{fill:rgba(26,95,180,.12);}' +
301
+ '.pt-ex-dot.pt-ex-pending{fill:rgba(26,95,180,.2);stroke:#1a5fb4;stroke-width:2.5;}' +
302
+ '.pt-ex-dot:focus{outline:none;stroke:#1a5fb4;stroke-width:2.5;}' +
256
303
  '.pt-ex-bar.pt-ex-flow{position:static;justify-content:flex-start;margin-top:.6em;font-size:.8em;}' +
257
304
  '.pt-ex-panel.pt-ex-flow{position:static;margin-top:.5em;box-shadow:none;font-size:.9em;}' +
258
305
  '.pt-ex-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;}';
@@ -384,7 +431,7 @@ function exerciseRuntime() {
384
431
  const persist = opts.persist !== undefined ? opts.persist : data.persist !== false;
385
432
  const instant = (opts.check || data.check) === 'instant';
386
433
  const words = data.words || {};
387
- const look = (data.styles && data.styles[rec.style]) || { correct: '#1e8e3e', wrong: '#c5221f', answer: '#d6246e', marker: 'none', trueFalse: words.trueFalse || ['T', 'F'] };
434
+ const look = (data.styles && data.styles[rec.style]) || { correct: '#1e8e3e', wrong: '#c5221f', answer: '#d6246e', ink: 'currentColor', marker: 'none', trueFalse: words.trueFalse || ['T', 'F'] };
388
435
  const tf = look.trueFalse;
389
436
  const exName = (words.exercise || 'Exercise') + ' ' + rec.number;
390
437
  const key = base + ':' + (rec.anchor || '#' + rec.ordinal + '.' + rec.id);
@@ -393,6 +440,8 @@ function exerciseRuntime() {
393
440
  const ppt = data.pxPerPt || 1;
394
441
  const body = data.bodyPx || 16;
395
442
  const flow = data.flow === true;
443
+ // A right-to-left page the HTML viewer paints as a mirrored flow (#1244).
444
+ const mirroredFlow = !!(outers[0] || found[0] || { closest: () => null }).closest('.pt-flow-mirrored');
396
445
  // The answer key written for a reader with no script: the bar's own
397
446
  // takes its place.
398
447
  const statics = scope.querySelectorAll('.pt-ex-static[data-pt-ex="' + id + '"]');
@@ -401,6 +450,37 @@ function exerciseRuntime() {
401
450
  // The font of the exercise's text, for what is written on its areas.
402
451
  const firstLine = scope.querySelector('.pt-line');
403
452
  const textFont = firstLine && firstLine.style ? firstLine.style.fontFamily : '';
453
+ // A drawn box of a reflowable book is set in ems (#1244): its controls
454
+ // go on a layer of the size it prints (`data-pt-w`, `data-pt-h`),
455
+ // scaled to the box as the reader sizes the text.
456
+ if (flow) {
457
+ for (const b in boxEls) {
458
+ const box = boxEls[b];
459
+ const w = parseFloat(box.getAttribute('data-pt-w') || '');
460
+ const h = parseFloat(box.getAttribute('data-pt-h') || '');
461
+ if (!(w > 0) || !(h > 0))
462
+ continue;
463
+ const layer = own(el('div', 'pt-ex-layer', box));
464
+ layer.style.width = px(w);
465
+ layer.style.height = px(h);
466
+ const fit = () => {
467
+ const k = box.clientWidth / w;
468
+ layer.style.transform = k > 0 && Math.abs(k - 1) > 0.001 ? 'scale(' + k + ')' : '';
469
+ };
470
+ fit();
471
+ const view = doc.defaultView;
472
+ if (view && view.ResizeObserver) {
473
+ const ro = new view.ResizeObserver(fit);
474
+ ro.observe(box);
475
+ cleanups.push(() => ro.disconnect());
476
+ }
477
+ else if (view) {
478
+ view.addEventListener('resize', fit);
479
+ cleanups.push(() => view.removeEventListener('resize', fit));
480
+ }
481
+ boxEls[b] = layer;
482
+ }
483
+ }
404
484
  function el(tag, cls, parent) {
405
485
  const e = doc.createElement(tag);
406
486
  e.setAttribute('class', cls);
@@ -418,6 +498,8 @@ function exerciseRuntime() {
418
498
  return e;
419
499
  }
420
500
  function colours(e) {
501
+ if (data.text)
502
+ e.style.setProperty('--pt-ex-text', data.text);
421
503
  e.style.setProperty('--pt-ex-right', look.correct);
422
504
  e.style.setProperty('--pt-ex-wrong', look.wrong);
423
505
  e.style.setProperty('--pt-ex-answer', look.answer);
@@ -456,6 +538,24 @@ function exerciseRuntime() {
456
538
  const m = el('span', 'pt-ex-mark ' + (ok ? 'pt-ex-ok' : 'pt-ex-no'), holder);
457
539
  m.setAttribute('aria-hidden', 'true');
458
540
  m.textContent = ok ? '\u2713' : '\u2717';
541
+ placeMark(m, holder);
542
+ }
543
+ /** A mark slid onto the page where its corner place would cross the
544
+ * page's edge (a page that clips, as the Sandbox's does). */
545
+ function placeMark(m, holder) {
546
+ const page = typeof holder.closest === 'function' ? holder.closest('.pt-page') : null;
547
+ if (!page || typeof m.getBoundingClientRect !== 'function')
548
+ return;
549
+ const pr = page.getBoundingClientRect();
550
+ const r = m.getBoundingClientRect();
551
+ if (r.width === 0)
552
+ return;
553
+ // Screen pixels per CSS pixel of the page (a page shown scaled).
554
+ const k = m.offsetWidth > 0 ? r.width / m.offsetWidth : 1;
555
+ const dx = r.right > pr.right - 1 ? pr.right - 1 - r.right : r.left < pr.left + 1 ? pr.left + 1 - r.left : 0;
556
+ const dy = r.top < pr.top + 1 ? pr.top + 1 - r.top : 0;
557
+ if (dx !== 0 || dy !== 0)
558
+ m.style.transform = 'translate(calc(-55% + ' + (dx / k).toFixed(1) + 'px),calc(-60% + ' + (dy / k).toFixed(1) + 'px))';
459
559
  }
460
560
  const widgets = [];
461
561
  let blankN = 0;
@@ -611,12 +711,136 @@ function exerciseRuntime() {
611
711
  const v = state.revealed ? solutionValue(w.slot, rec) : state.v[w.slot.key];
612
712
  const at = typeof v === 'string' ? categories.indexOf(v) : -1;
613
713
  if (at >= 0 && w.word)
614
- lists[at].push(w.word);
714
+ lists[at].push(w);
615
715
  }
616
716
  for (let i = 0; i < columnWidgets.length; i++)
617
- columnWidgets[i].show((lists[i] || []).join('\n'));
717
+ columnWidgets[i].show(lists[i] || []);
718
+ }
719
+ /** The category of the column under a point of the screen: `undefined`
720
+ * off every column. The words set above a column's area (its heading)
721
+ * count as the column. */
722
+ function columnAt(x, y) {
723
+ for (let i = 0; i < columnWidgets.length; i++) {
724
+ const r = columnWidgets[i].col.getBoundingClientRect();
725
+ const above = Math.min(r.height, body * 3 * (r.height / (columnWidgets[i].col.offsetHeight || r.height || 1)));
726
+ if (x >= r.left && x <= r.right && y >= r.top - above && y <= r.bottom)
727
+ return i;
728
+ }
729
+ return undefined;
618
730
  }
619
- /** A word of a sort exercise's bank: a chooser of the categories. */
731
+ /** Dragging a word of a sort exercise (from the bank or from a column)
732
+ * onto a column sorts it there; a word dragged out of its column goes
733
+ * back to the bank. A press with no drag is left to the click. Returns
734
+ * whether the click that follows a drag is to be ignored. */
735
+ function dragWord(t, slot, word, fromColumn) {
736
+ let start = null;
737
+ let ghost = null;
738
+ let over = -1;
739
+ let dragged = false;
740
+ // Where the ghost's left and top of 0 stand on the screen, and screen
741
+ // pixels per CSS pixel: the document may be zoomed.
742
+ let origin = [0, 0];
743
+ let scale = 1;
744
+ addClass(t, 'pt-ex-drag');
745
+ const highlight = (i) => {
746
+ if (i === over)
747
+ return;
748
+ if (over >= 0 && columnWidgets[over])
749
+ columnWidgets[over].col.classList.remove('pt-ex-drop');
750
+ over = i;
751
+ if (over >= 0 && columnWidgets[over])
752
+ columnWidgets[over].col.classList.add('pt-ex-drop');
753
+ };
754
+ const end = () => {
755
+ start = null;
756
+ if (ghost && ghost.parentNode)
757
+ ghost.parentNode.removeChild(ghost);
758
+ ghost = null;
759
+ highlight(-1);
760
+ };
761
+ const down = (e) => {
762
+ if (state.revealed || e.button > 0)
763
+ return;
764
+ start = [e.clientX, e.clientY];
765
+ dragged = false;
766
+ // The pointer's moves come here wherever it goes.
767
+ if (typeof t.setPointerCapture === 'function') {
768
+ try {
769
+ t.setPointerCapture(e.pointerId);
770
+ }
771
+ catch { /* a pointer gone */ }
772
+ }
773
+ };
774
+ const move = (e) => {
775
+ if (!start)
776
+ return;
777
+ if (!ghost) {
778
+ if (Math.abs(e.clientX - start[0]) + Math.abs(e.clientY - start[1]) < 6)
779
+ return;
780
+ // The word follows the pointer, set at the size the page shows it
781
+ // (the page may be scaled, and the ghost lives outside it).
782
+ const r = t.getBoundingClientRect();
783
+ ghost = doc.createElement('span');
784
+ ghost.textContent = word;
785
+ ghost.setAttribute('aria-hidden', 'true');
786
+ const st = ghost.style;
787
+ st.position = 'fixed';
788
+ st.zIndex = '2147483647';
789
+ st.pointerEvents = 'none';
790
+ st.transform = 'translate(-50%,-50%)';
791
+ st.padding = '.1em .4em';
792
+ st.borderRadius = '.3em';
793
+ st.border = '1px solid rgba(0,0,0,.35)';
794
+ st.background = '#fff';
795
+ st.color = data.text || '#1d1d1f';
796
+ st.boxShadow = '0 .2em .6em rgba(0,0,0,.25)';
797
+ st.whiteSpace = 'nowrap';
798
+ st.fontFamily = textFont || 'system-ui,sans-serif';
799
+ st.left = '0';
800
+ st.top = '0';
801
+ (doc.body || doc.documentElement).appendChild(ghost);
802
+ const g0 = ghost.getBoundingClientRect();
803
+ st.left = '100px';
804
+ const g1 = ghost.getBoundingClientRect();
805
+ scale = (g1.left - g0.left) / 100 || 1;
806
+ origin = [g0.left + g0.width / 2, g0.top + g0.height / 2];
807
+ st.fontSize = px(Math.max(12, r.height * (fromColumn ? 0.75 : 0.62)) / scale);
808
+ dragged = true;
809
+ }
810
+ e.preventDefault();
811
+ ghost.style.left = px((e.clientX - origin[0]) / scale);
812
+ ghost.style.top = px((e.clientY - origin[1]) / scale);
813
+ const at = columnAt(e.clientX, e.clientY);
814
+ highlight(at === undefined ? -1 : at);
815
+ };
816
+ const up = (e) => {
817
+ if (!start)
818
+ return;
819
+ const was = !!ghost;
820
+ const at = was ? columnAt(e.clientX, e.clientY) : undefined;
821
+ end();
822
+ if (!was)
823
+ return;
824
+ if (at !== undefined)
825
+ changed(slot, categories[at]);
826
+ else if (fromColumn)
827
+ changed(slot, null);
828
+ };
829
+ t.addEventListener('pointerdown', down);
830
+ t.addEventListener('pointermove', move);
831
+ t.addEventListener('pointerup', up);
832
+ t.addEventListener('pointercancel', end);
833
+ cleanups.push(() => {
834
+ end();
835
+ t.removeEventListener('pointerdown', down);
836
+ t.removeEventListener('pointermove', move);
837
+ t.removeEventListener('pointerup', up);
838
+ t.removeEventListener('pointercancel', end);
839
+ });
840
+ return () => { const d = dragged; dragged = false; return d; };
841
+ }
842
+ /** A word of a sort exercise's bank: dragged to a column, or a chooser
843
+ * of the categories. */
620
844
  function chooser(slot, t) {
621
845
  const word = chipText(t);
622
846
  setAttr(t, 'role', 'button');
@@ -626,8 +850,7 @@ function exerciseRuntime() {
626
850
  addClass(t, 'pt-ex-toggle');
627
851
  addClass(t, 'pt-ex-ui');
628
852
  colours(t);
629
- const tag = own(el('span', 'pt-ex-tag', t));
630
- tag.hidden = true;
853
+ const wasDrag = dragWord(t, slot, word, false);
631
854
  let menu = null;
632
855
  // A click anywhere else closes the menu.
633
856
  const outside = (e) => { if (menu && !t.contains(e.target))
@@ -675,33 +898,78 @@ function exerciseRuntime() {
675
898
  open();
676
899
  }
677
900
  };
678
- const onClick = (e) => { if (e.target === t || e.target === tag)
679
- open(); };
901
+ const onClick = (e) => {
902
+ if (wasDrag())
903
+ return;
904
+ if (!menu || !menu.contains(e.target))
905
+ open();
906
+ };
680
907
  t.addEventListener('click', onClick);
681
908
  t.addEventListener('keydown', onKey);
682
909
  cleanups.push(() => { close(); t.removeEventListener('click', onClick); t.removeEventListener('keydown', onKey); });
683
- widgets.push({
910
+ const w = {
684
911
  slot,
685
912
  word,
913
+ ok: null,
686
914
  paint(v) {
687
915
  const at = typeof v === 'string' ? categories.indexOf(v) : -1;
688
- tag.hidden = at < 0;
689
- tag.textContent = at >= 0 ? categories[at] : '';
690
- t.classList.toggle('pt-ex-on', at >= 0);
916
+ // Sorted: the word stands in its column, and fades in the bank.
917
+ t.classList.toggle('pt-ex-sorted', at >= 0 && columnWidgets.length > 0);
918
+ t.setAttribute('aria-label', (word || lineText(t)) + (at >= 0 ? ', ' + categories[at] : ''));
691
919
  refreshColumns();
692
920
  },
693
- lock(on) { t.classList.toggle('pt-ex-revealed', on); tag.classList.toggle('pt-ex-revealed', on); },
694
- mark(ok) { badge(t, ok); },
695
- });
921
+ lock(on) { t.classList.toggle('pt-ex-revealed', on); },
922
+ mark(ok) { w.ok = ok; badge(t, ok); refreshColumns(); },
923
+ };
924
+ widgets.push(w);
925
+ }
926
+ /** A sort exercise's column over its writing area: the words sorted in
927
+ * it, one a line, each dragged to another column or back to the bank
928
+ * (or sent back with a click). */
929
+ function sortColumn(slot, box) {
930
+ const name = categories[columnWidgets.length];
931
+ const col = own(el('div', 'pt-ex-col pt-ex-ui', box));
932
+ col.setAttribute('role', 'list');
933
+ col.setAttribute('aria-label', (name !== undefined ? name + ', ' : '') + exName);
934
+ const line = (slot.linePt || 0) * ppt || body * 1.35;
935
+ col.style.fontSize = px(body);
936
+ col.style.lineHeight = px(line);
937
+ col.style.paddingTop = px(Math.max(0, line * 0.12));
938
+ if (textFont)
939
+ col.style.fontFamily = textFont;
940
+ colours(col);
941
+ const w = {
942
+ slot,
943
+ col,
944
+ show(list) {
945
+ while (col.firstChild)
946
+ col.removeChild(col.firstChild);
947
+ for (let i = 0; i < list.length; i++) {
948
+ const src = list[i];
949
+ const p = el('span', 'pt-ex-placed', col);
950
+ p.setAttribute('role', 'listitem');
951
+ p.textContent = src.word;
952
+ if (state.revealed)
953
+ continue;
954
+ if (src.ok !== null && src.ok !== undefined)
955
+ p.classList.add(src.ok ? 'pt-ex-right' : 'pt-ex-wrong');
956
+ const wasDrag = dragWord(p, src.slot, src.word, true);
957
+ p.addEventListener('click', () => { if (!wasDrag())
958
+ changed(src.slot, null); });
959
+ }
960
+ },
961
+ paint() { },
962
+ lock(on) { col.classList.toggle('pt-ex-revealed', on); },
963
+ mark() { },
964
+ };
965
+ columnWidgets.push(w);
966
+ widgets.push(w);
696
967
  }
697
968
  /** A text area on a writing area: an open answer, a sentence put in
698
- * order; a sort exercise's column, filled from the words sorted. */
969
+ * order. */
699
970
  function area(slot, box) {
700
- const sortColumn = rec.kind === 'sort';
701
971
  const ta = own(el('textarea', 'pt-ex-input pt-ex-ui', box));
702
- // A sort exercise's column is named by its category.
703
- const column = sortColumn ? categories[columnWidgets.length] : undefined;
704
- ta.setAttribute('aria-label', column !== undefined ? column + ', ' + exName : (words.blank || 'blank') + ' ' + (++blankN) + ', ' + exName);
972
+ ta.setAttribute('aria-label', (words.blank || 'blank') + ' ' + (++blankN) + ', ' + exName);
705
973
  ta.setAttribute('spellcheck', 'false');
706
974
  const line = (slot.linePt || 0) * ppt || body * 1.35;
707
975
  ta.style.fontSize = px(body);
@@ -710,20 +978,11 @@ function exerciseRuntime() {
710
978
  if (textFont)
711
979
  ta.style.fontFamily = textFont;
712
980
  colours(ta);
713
- if (sortColumn) {
714
- ta.readOnly = true;
715
- ta.setAttribute('tabindex', '-1');
716
- ta.setAttribute('aria-live', 'polite');
717
- }
718
- else
719
- ta.addEventListener('input', () => changed(slot, ta.value));
981
+ ta.addEventListener('input', () => changed(slot, ta.value));
720
982
  const w = {
721
983
  slot,
722
- show(text) { ta.value = text; },
723
- paint(v) { if (!sortColumn)
724
- ta.value = typeof v === 'string' ? v : ''; },
725
- lock(on) { if (!sortColumn)
726
- ta.readOnly = on; ta.classList.toggle('pt-ex-revealed', on); },
984
+ paint(v) { ta.value = typeof v === 'string' ? v : ''; },
985
+ lock(on) { ta.readOnly = on; ta.classList.toggle('pt-ex-revealed', on); },
727
986
  mark(ok) {
728
987
  ta.classList.remove('pt-ex-right', 'pt-ex-wrong');
729
988
  const old = box.querySelector(':scope > .pt-ex-mark');
@@ -737,10 +996,9 @@ function exerciseRuntime() {
737
996
  m.textContent = ok ? '\u2713' : '\u2717';
738
997
  m.style.fontSize = px(body * 0.62);
739
998
  colours(m);
999
+ placeMark(m, box);
740
1000
  },
741
1001
  };
742
- if (sortColumn)
743
- columnWidgets.push(w);
744
1002
  widgets.push(w);
745
1003
  }
746
1004
  /** One-character inputs over the cells of a crossword or an operation. */
@@ -750,7 +1008,10 @@ function exerciseRuntime() {
750
1008
  const c = slot.cell ? slot.cell[1] : 0;
751
1009
  const size = grid.cellPt * ppt;
752
1010
  const f = own(el('span', 'pt-ex-field pt-ex-ui', box));
753
- f.style.left = px((grid.xPt + c * grid.cellPt) * ppt);
1011
+ // A right-to-left page's grid is mirrored: its columns count from the
1012
+ // box's right edge. In a flow the page mirrors as a whole (the HTML
1013
+ // viewer's), the flow's left is that edge already.
1014
+ f.style[grid.rtl && !mirroredFlow ? 'right' : 'left'] = px((grid.xPt + c * grid.cellPt) * ppt);
754
1015
  f.style.top = px((grid.yPt + r * grid.cellPt) * ppt);
755
1016
  f.style.width = px(size);
756
1017
  f.style.height = px(size);
@@ -788,15 +1049,21 @@ function exerciseRuntime() {
788
1049
  });
789
1050
  }
790
1051
  /** The dots of a match: a left dot then a right one (or the other way
791
- * round) join them with a line. */
1052
+ * round) join them with a line, clicked one after the other or dragged
1053
+ * from one to the other. */
792
1054
  function matchDots(box, grid, slots) {
793
1055
  const w = parseFloat(box.style.width) || 0;
794
1056
  const h = parseFloat(box.style.height) || 0;
795
1057
  const s = own(svg('svg', { class: 'pt-ex-svg pt-ex-ui', width: w, height: h, viewBox: '0 0 ' + w + ' ' + h }, box));
796
1058
  const lines = svg('g', {}, s);
797
1059
  const marks = svg('g', {}, s);
798
- const lx = grid.dotsPt[0] * ppt;
799
- const rx = grid.dotsPt[1] * ppt;
1060
+ const ink = look.ink || 'currentColor';
1061
+ const stroke = Math.max(2.5, body * 0.13);
1062
+ // The line being drawn: from the dot picked to the pointer.
1063
+ const rubber = svg('line', { stroke: ink, 'stroke-width': stroke, 'stroke-linecap': 'round', 'stroke-dasharray': stroke * 1.5 + ' ' + stroke * 1.5, 'pointer-events': 'none', visibility: 'hidden' }, s);
1064
+ // Mirrored on a right-to-left page: the left items stand on the right.
1065
+ const lx = grid.rtl ? w - grid.dotsPt[0] * ppt : grid.dotsPt[0] * ppt;
1066
+ const rx = grid.rtl ? w - grid.dotsPt[1] * ppt : grid.dotsPt[1] * ppt;
800
1067
  const rows = grid.rowsPt.map((y) => y * ppt);
801
1068
  const radius = Math.max(body * 0.55, 6);
802
1069
  let pendingLeft = -1;
@@ -808,7 +1075,50 @@ function exerciseRuntime() {
808
1075
  leftDots[i].classList.toggle('pt-ex-pending', i === pendingLeft);
809
1076
  for (let i = 0; i < rightDots.length; i++)
810
1077
  rightDots[i].classList.toggle('pt-ex-pending', i === pendingRight);
1078
+ if (pendingLeft < 0 && pendingRight < 0)
1079
+ rubber.setAttribute('visibility', 'hidden');
1080
+ };
1081
+ /** A point of the screen in the drawing's own units. */
1082
+ const local = (x, y) => {
1083
+ const m = typeof s.getScreenCTM === 'function' ? s.getScreenCTM() : null;
1084
+ if (!m)
1085
+ return [x, y];
1086
+ const p = new DOMPoint(x, y).matrixTransform(m.inverse());
1087
+ return [p.x, p.y];
1088
+ };
1089
+ const follow = (x, y) => {
1090
+ const from = pendingLeft >= 0 ? [lx, rows[pendingLeft]] : pendingRight >= 0 ? [rx, rows[pendingRight]] : null;
1091
+ if (!from)
1092
+ return;
1093
+ const to = local(x, y);
1094
+ rubber.setAttribute('x1', String(from[0]));
1095
+ rubber.setAttribute('y1', String(from[1]));
1096
+ rubber.setAttribute('x2', String(to[0]));
1097
+ rubber.setAttribute('y2', String(to[1]));
1098
+ rubber.setAttribute('visibility', 'visible');
1099
+ };
1100
+ /** The dot of a side nearest a screen point, if the point is on it. */
1101
+ const dotAt = (side, x, y) => {
1102
+ const p = local(x, y);
1103
+ const dx = side === 0 ? lx : rx;
1104
+ let best = -1;
1105
+ let bestD = radius * 1.8;
1106
+ const n = side === 0 ? leftDots.length : rightDots.length;
1107
+ for (let i = 0; i < n; i++) {
1108
+ const d = Math.hypot(p[0] - dx, p[1] - rows[i]);
1109
+ if (d < bestD) {
1110
+ best = i;
1111
+ bestD = d;
1112
+ }
1113
+ }
1114
+ return best;
811
1115
  };
1116
+ const onMove = (e) => { if (!state.revealed)
1117
+ follow(e.clientX, e.clientY); };
1118
+ s.addEventListener('pointermove', onMove);
1119
+ let dragFrom = null;
1120
+ let dragging = false;
1121
+ let skipClick = false;
812
1122
  const join = (li, ri) => {
813
1123
  pendingLeft = -1;
814
1124
  pendingRight = -1;
@@ -834,6 +1144,7 @@ function exerciseRuntime() {
834
1144
  join(i, pendingRight);
835
1145
  else {
836
1146
  pendingLeft = pendingLeft === i ? -1 : i;
1147
+ pendingRight = -1;
837
1148
  refresh();
838
1149
  }
839
1150
  }
@@ -841,10 +1152,61 @@ function exerciseRuntime() {
841
1152
  join(pendingLeft, i);
842
1153
  else {
843
1154
  pendingRight = pendingRight === i ? -1 : i;
1155
+ pendingLeft = -1;
844
1156
  refresh();
845
1157
  }
846
1158
  };
847
- dot.addEventListener('click', pick);
1159
+ // A drag from a dot to one of the other side joins them; a press
1160
+ // with no drag is a click.
1161
+ dot.addEventListener('pointerdown', (e) => {
1162
+ if (state.revealed || e.button > 0)
1163
+ return;
1164
+ dragFrom = [e.clientX, e.clientY];
1165
+ dragging = false;
1166
+ if (typeof dot.setPointerCapture === 'function') {
1167
+ try {
1168
+ dot.setPointerCapture(e.pointerId);
1169
+ }
1170
+ catch { /* a pointer gone */ }
1171
+ }
1172
+ });
1173
+ dot.addEventListener('pointermove', (e) => {
1174
+ if (!dragFrom)
1175
+ return;
1176
+ if (!dragging) {
1177
+ if (Math.abs(e.clientX - dragFrom[0]) + Math.abs(e.clientY - dragFrom[1]) < 6)
1178
+ return;
1179
+ dragging = true;
1180
+ pendingLeft = side === 0 ? i : -1;
1181
+ pendingRight = side === 0 ? -1 : i;
1182
+ refresh();
1183
+ }
1184
+ e.preventDefault();
1185
+ follow(e.clientX, e.clientY);
1186
+ });
1187
+ const drop = (e) => {
1188
+ if (!dragFrom)
1189
+ return;
1190
+ dragFrom = null;
1191
+ if (!dragging)
1192
+ return;
1193
+ dragging = false;
1194
+ skipClick = true;
1195
+ const other = e.type === 'pointerup' ? dotAt(1 - side, e.clientX, e.clientY) : -1;
1196
+ if (other >= 0)
1197
+ join(side === 0 ? i : other, side === 0 ? other : i);
1198
+ else {
1199
+ pendingLeft = -1;
1200
+ pendingRight = -1;
1201
+ refresh();
1202
+ }
1203
+ };
1204
+ dot.addEventListener('pointerup', drop);
1205
+ dot.addEventListener('pointercancel', drop);
1206
+ dot.addEventListener('click', () => { if (skipClick) {
1207
+ skipClick = false;
1208
+ return;
1209
+ } pick(); });
848
1210
  dot.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') {
849
1211
  e.preventDefault();
850
1212
  pick();
@@ -864,7 +1226,7 @@ function exerciseRuntime() {
864
1226
  line = null;
865
1227
  }
866
1228
  if (typeof v === 'number' && rows[v - 1] !== undefined) {
867
- line = svg('line', { x1: lx, y1: rows[li], x2: rx, y2: rows[v - 1], stroke: 'currentColor', 'stroke-width': Math.max(1.5, body * 0.09), 'stroke-linecap': 'round' }, lines);
1229
+ line = svg('line', { x1: lx, y1: rows[li], x2: rx, y2: rows[v - 1], stroke: ink, 'stroke-width': stroke, 'stroke-linecap': 'round' }, lines);
868
1230
  if (state.revealed)
869
1231
  line.setAttribute('stroke', look.answer);
870
1232
  }
@@ -876,10 +1238,10 @@ function exerciseRuntime() {
876
1238
  mark = null;
877
1239
  }
878
1240
  if (line)
879
- line.setAttribute('stroke', ok === null ? (state.revealed ? look.answer : 'currentColor') : ok ? look.correct : look.wrong);
1241
+ line.setAttribute('stroke', ok === null ? (state.revealed ? look.answer : ink) : ok ? look.correct : look.wrong);
880
1242
  if (ok === null)
881
1243
  return;
882
- mark = svg('text', { x: lx + radius * 2.2, y: rows[li] - body * 0.25, 'font-size': body * 0.8, 'font-weight': 700, fill: ok ? look.correct : look.wrong, 'text-anchor': 'middle', 'aria-hidden': 'true' }, marks);
1244
+ mark = svg('text', { x: lx + radius * (grid.rtl ? -2.2 : 2.2), y: rows[li] - body * 0.25, 'font-size': body * 0.8, 'font-weight': 700, fill: ok ? look.correct : look.wrong, 'text-anchor': 'middle', 'aria-hidden': 'true' }, marks);
883
1245
  mark.textContent = ok ? '\u2713' : '\u2717';
884
1246
  },
885
1247
  });
@@ -890,11 +1252,13 @@ function exerciseRuntime() {
890
1252
  const w = parseFloat(box.style.width) || 0;
891
1253
  const h = parseFloat(box.style.height) || 0;
892
1254
  const cellPx = grid.cellPt * ppt;
893
- const x0 = grid.xPt * ppt;
894
1255
  const y0 = grid.yPt * ppt;
1256
+ // Where column `c` (a fraction in it) stands: from the box's right edge
1257
+ // on a right-to-left page, whose grid is mirrored.
1258
+ const colX = (c) => (grid.rtl ? w - grid.xPt * ppt - c * cellPx : grid.xPt * ppt + c * cellPx);
895
1259
  const s = own(svg('svg', { class: 'pt-ex-svg pt-ex-ui', width: w, height: h, viewBox: '0 0 ' + w + ' ' + h, role: 'group', 'aria-label': exName }, box));
896
1260
  const paintG = svg('g', {}, s);
897
- const hit = svg('rect', { x: x0, y: y0, width: grid.cols * cellPx, height: grid.rows * cellPx, fill: 'transparent', style: 'cursor:pointer;pointer-events:all' }, s);
1261
+ const hit = svg('rect', { x: Math.min(colX(0), colX(grid.cols)), y: y0, width: grid.cols * cellPx, height: grid.rows * cellPx, fill: 'transparent', style: 'cursor:pointer;pointer-events:all' }, s);
898
1262
  let start = null;
899
1263
  let startMark = null;
900
1264
  const capsule = (sel, color) => {
@@ -902,7 +1266,7 @@ function exerciseRuntime() {
902
1266
  const a = ends[0].split(',').map(Number);
903
1267
  const b = ends[1].split(',').map(Number);
904
1268
  return svg('line', {
905
- x1: x0 + (a[1] + 0.5) * cellPx, y1: y0 + (a[0] + 0.5) * cellPx, x2: x0 + (b[1] + 0.5) * cellPx, y2: y0 + (b[0] + 0.5) * cellPx,
1269
+ x1: colX(a[1] + 0.5), y1: y0 + (a[0] + 0.5) * cellPx, x2: colX(b[1] + 0.5), y2: y0 + (b[0] + 0.5) * cellPx,
906
1270
  stroke: color, 'stroke-opacity': 0.75, 'stroke-width': cellPx * 0.78, 'stroke-linecap': 'round', fill: 'none', 'pointer-events': 'none',
907
1271
  }, paintG);
908
1272
  };
@@ -912,13 +1276,13 @@ function exerciseRuntime() {
912
1276
  const r = hit.getBoundingClientRect();
913
1277
  const sx = r.width / (grid.cols * cellPx) || 1;
914
1278
  const sy = r.height / (grid.rows * cellPx) || 1;
915
- const c = Math.floor((e.clientX - r.left) / sx / cellPx);
1279
+ const c = Math.floor((grid.rtl ? r.right - e.clientX : e.clientX - r.left) / sx / cellPx);
916
1280
  const rr = Math.floor((e.clientY - r.top) / sy / cellPx);
917
1281
  if (rr < 0 || c < 0 || rr >= grid.rows || c >= grid.cols)
918
1282
  return;
919
1283
  if (!start) {
920
1284
  start = [rr, c];
921
- startMark = svg('circle', { cx: x0 + (c + 0.5) * cellPx, cy: y0 + (rr + 0.5) * cellPx, r: cellPx * 0.39, fill: 'none', stroke: '#1a5fb4', 'stroke-width': 2, 'pointer-events': 'none' }, s);
1285
+ startMark = svg('circle', { cx: colX(c + 0.5), cy: y0 + (rr + 0.5) * cellPx, r: cellPx * 0.39, fill: 'none', stroke: '#1a5fb4', 'stroke-width': 2, 'pointer-events': 'none' }, s);
922
1286
  return;
923
1287
  }
924
1288
  const dr = rr - start[0];
@@ -970,7 +1334,7 @@ function exerciseRuntime() {
970
1334
  painted.push(capsule(list[i], state.revealed ? look.answer : ok === null ? '#9ec5ff' : ok ? look.correct : look.wrong));
971
1335
  if (ok !== null) {
972
1336
  const ends = list[i].split('-')[1].split(',').map(Number);
973
- const t = svg('text', { x: x0 + (ends[1] + 1) * cellPx, y: y0 + ends[0] * cellPx + cellPx * 0.3, 'font-size': cellPx * 0.5, 'font-weight': 700, fill: ok ? look.correct : look.wrong, 'aria-hidden': 'true' }, paintG);
1337
+ const t = svg('text', { x: colX(ends[1] + 1), ...(grid.rtl ? { 'text-anchor': 'end' } : {}), y: y0 + ends[0] * cellPx + cellPx * 0.3, 'font-size': cellPx * 0.5, 'font-weight': 700, fill: ok ? look.correct : look.wrong, 'aria-hidden': 'true' }, paintG);
974
1338
  t.textContent = ok ? '\u2713' : '\u2717';
975
1339
  painted.push(t);
976
1340
  }
@@ -1034,7 +1398,9 @@ function exerciseRuntime() {
1034
1398
  }
1035
1399
  else if (slot.kind === 'text') {
1036
1400
  const box = slot.box !== undefined ? boxEls[String(slot.box)] : null;
1037
- if (box)
1401
+ if (box && rec.kind === 'sort')
1402
+ sortColumn(slot, box);
1403
+ else if (box && !slot.bare)
1038
1404
  area(slot, box);
1039
1405
  }
1040
1406
  else if (slot.kind === 'cell') {
@@ -1058,8 +1424,15 @@ function exerciseRuntime() {
1058
1424
  if (g.kind === 'wordsearch' && wordSlots.length > 0)
1059
1425
  wordSearch(box, g, wordSlots);
1060
1426
  }
1427
+ // An exercise with nothing to check, show or answer (a talk with a
1428
+ // classmate) has no bar.
1429
+ const actions = actionsOf(rec);
1430
+ const checkable = actions.check;
1431
+ const solvable = actions.solution;
1432
+ const resettable = actions.reset && widgets.length > 0;
1433
+ const hasHints = !!rec.hints && rec.hints.length > 0;
1061
1434
  // The bar: on the last part of the exercise's outer box.
1062
- const outer = outers.length > 0 ? outers[outers.length - 1] : null;
1435
+ const outer = outers.length > 0 && (resettable || solvable || hasHints) ? outers[outers.length - 1] : null;
1063
1436
  let scoreEl = null;
1064
1437
  let panel = null;
1065
1438
  let solutionBtn = null;
@@ -1081,18 +1454,47 @@ function exerciseRuntime() {
1081
1454
  scoreEl = el('span', 'pt-ex-score', bar);
1082
1455
  scoreEl.setAttribute('aria-live', 'polite');
1083
1456
  scoreEl.setAttribute('role', 'status');
1084
- if (rec.hints && rec.hints.length > 0)
1457
+ if (hasHints)
1085
1458
  hintBtn = button(words.hint || 'Hint', () => showPanel(panel && panel.getAttribute('data-show') === 'hint' ? null : 'hint'));
1086
- if (!instant)
1459
+ if (!instant && checkable)
1087
1460
  button(words.check || 'Check', () => { state.checked = true; persistNow(); feedback(); });
1088
- solutionBtn = button(words.showSolution || 'Show solution', () => reveal(!state.revealed));
1089
- button(words.reset || 'Try again', reset);
1461
+ if (solvable)
1462
+ solutionBtn = button(words.showSolution || 'Show solution', () => reveal(!state.revealed));
1463
+ if (resettable)
1464
+ button(words.reset || 'Try again', reset);
1090
1465
  panel = el('div', 'pt-ex-panel pt-ex-ui' + (flow ? ' pt-ex-flow' : ''), outer);
1091
1466
  own(panel);
1092
1467
  panel.hidden = true;
1093
1468
  if (!flow)
1094
1469
  panel.style.fontSize = px(Math.max(body * 0.72, 10));
1095
1470
  colours(panel);
1471
+ if (!flow) {
1472
+ liftOntoPage(bar);
1473
+ const view = doc.defaultView;
1474
+ // Again once the fonts have set the bar's size.
1475
+ if (view && view.requestAnimationFrame)
1476
+ view.requestAnimationFrame(() => liftOntoPage(bar));
1477
+ }
1478
+ }
1479
+ /** Lifts a control laid under the exercise back onto its page where it
1480
+ * would cross the page's foot (a page that clips, as the Sandbox's
1481
+ * short screen pages do). */
1482
+ function liftOntoPage(e) {
1483
+ e.style.transform = '';
1484
+ const page = typeof e.closest === 'function' ? e.closest('.pt-page') : null;
1485
+ if (!page || typeof e.getBoundingClientRect !== 'function')
1486
+ return;
1487
+ const pr = page.getBoundingClientRect();
1488
+ const r = e.getBoundingClientRect();
1489
+ if (r.height === 0)
1490
+ return;
1491
+ const k = e.offsetHeight > 0 ? r.height / e.offsetHeight : 1;
1492
+ let up = r.bottom - (pr.bottom - 2);
1493
+ // Never above the page's head.
1494
+ if (up > r.top - pr.top)
1495
+ up = r.top - pr.top;
1496
+ if (up > 0)
1497
+ e.style.transform = 'translateY(' + (-up / k).toFixed(1) + 'px)';
1096
1498
  }
1097
1499
  void hintBtn;
1098
1500
  function showPanel(what) {
@@ -1114,6 +1516,11 @@ function exerciseRuntime() {
1114
1516
  for (let i = 0; i < sol.length; i++)
1115
1517
  el('p', '', panel).textContent = sol[i];
1116
1518
  }
1519
+ // A solution shown in its slots alone needs no panel.
1520
+ if (!panel.firstChild)
1521
+ panel.hidden = true;
1522
+ if (!panel.hidden && !flow)
1523
+ liftOntoPage(panel);
1117
1524
  }
1118
1525
  function valueOf(w) {
1119
1526
  if (state.revealed)
@@ -1139,6 +1546,13 @@ function exerciseRuntime() {
1139
1546
  const s = rec.slots[i];
1140
1547
  if (!isGraded(s, rec))
1141
1548
  continue;
1549
+ // A word of a mark exercise not to be marked counts only when it is
1550
+ // marked: a wrong answer that keeps the score short of the total.
1551
+ if (s.kind === 'mark' && (s.correct || []).indexOf(1) < 0) {
1552
+ if (verdict(s) === false)
1553
+ total++;
1554
+ continue;
1555
+ }
1142
1556
  total++;
1143
1557
  if (verdict(s) === true)
1144
1558
  right++;
@@ -1230,7 +1644,7 @@ function exerciseRuntime() {
1230
1644
  solutionBtn.setAttribute('aria-expanded', 'false');
1231
1645
  return { record: rec, state, reveal, reset, feedback };
1232
1646
  }
1233
- return { normalizeAnswer, isGraded, checkSlot, solutionValue, attach };
1647
+ return { normalizeAnswer, isGraded, checkSlot, solutionValue, actionsOf, attach };
1234
1648
  }
1235
1649
  /* eslint-enable @typescript-eslint/no-explicit-any */
1236
1650
  const runtime = exerciseRuntime();
@@ -1242,6 +1656,8 @@ export const isGradedSlot = runtime.isGraded;
1242
1656
  * is no answer or not graded (`null`). `trueFalse` are the style's true
1243
1657
  * and false labels, for a typed true/false. */
1244
1658
  export const checkExerciseSlot = runtime.checkSlot;
1659
+ /** The buttons an exercise's bar offers: Check, Show solution, Try again. */
1660
+ export const exerciseActions = runtime.actionsOf;
1245
1661
  /** What a slot shows when the solution is shown. */
1246
1662
  export const exerciseSolutionValue = runtime.solutionValue;
1247
1663
  /** Makes the exercises of the HTML output under `root` answerable (see the