Bettern cmap handling. Lots of changes.

This commit is contained in:
2026-08-17 23:06:23 +02:00
parent 8997f7f94a
commit 12f1ed2764
27 changed files with 10351 additions and 217 deletions
+50
View File
@@ -0,0 +1,50 @@
# Racket Wiki CMap component
This directory contains the complete browser-side CMap component.
`cmap.js` is the drawing and hit-testing engine. It is based on the
MIT-licensed ionstage/cmap 0.1.3 source and is now maintained as part of
racket-wiki. Its public additions include `onSelection` on a map and
`onActivation` and `onRendered` callbacks. Activation is recognized inside the
same hit-test and drag lifecycle as selection, so it does not depend on a DOM
`dblclick` event that may be suppressed by dragging.
`cmap-racket-wiki.js` contains the wiki-specific editor model, selection state,
content-based initial sizing, resize and relation controls, page concepts and
submap concepts. Automatic sizing remains active while text is edited and is
disabled when the user resizes a concept manually.
Selected concepts expose an edit handle. The host opens a modal editor for
their independent display label, synopsis, background colour, typography and
optional image. Page concepts retain their linked `pageSlug`; double-click is
reserved for opening that wiki page. Concepts may alternatively retain a
`cmapSlug` for another stored CMap or a `parentCmapLink` for navigation back
from a child map. Images are data URLs inside the persisted CMap JSON document.
Font families are selected from a practical list and font sizes are stored in
typographic points. The host can change the linked page or CMap independently
of the visible label and provides background and text-colour pickers.
Linking phrases are compact borderless nodes at the intentional bend between
two connector segments. Their position is stored with the rest of the map.
When an endpoint is deleted, a genuinely branching phrase is retained while it
still has at least one incoming and one outgoing concept. Losing the final
endpoint on either side removes the phrase and all its remaining segments.
Multiple selected items can be moved or deleted together. Grouping is a
structural operation: the editor creates an expanded sub-CMap with a named main
concept and adopts the selection as its contents. Ungrouping dissolves a
selected inline sub-CMap or moves selected child items one level outward.
`toDocument` and `loadDocument` round-trip the complete editor model: items,
formatting, positions, connectors, recursive submap membership and promoted
map references. The wiki host persists this JSON document through its CMap API.
The editor keeps up to one hundred complete document states for Undo and Redo.
One drag or resize gesture forms one history step. The public `undo`, `redo`,
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
for keyboard shortcuts and context-menu state.
`cmap.css` contains all CMap presentation and interaction styles.
The source files deliberately remain separate but form one component and are
loaded directly from `/cmap/`; there is no runtime download or vendor patch.
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
/* Racket Wiki CMap view, concepts and CMapTools-like interactions. */
body.cmap-mode {
overflow: hidden;
}
body.cmap-mode #app {
height: 100vh;
min-height: 0;
}
body.cmap-mode #main {
height: 100vh;
min-height: 0;
overflow: auto;
}
#cmap-view {
max-width: none;
}
.cmap-page-header {
position: sticky;
z-index: 160;
top: 0;
margin: 0 0 10px;
padding: 6px 0;
background: rgb(255 255 255 / 96%);
box-shadow: 0 1px 0 rgb(128 139 150 / 22%);
backdrop-filter: blur(4px);
}
.cmap-storage-controls {
align-items: center;
display: flex;
flex-wrap: nowrap;
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.cmap-toolbar-separator {
flex: 0 0 1px;
width: 1px;
height: 24px;
background: #d5d9de;
}
.cmap-toolbar-spacer {
flex: 1 1 auto;
min-width: 16px;
}
.cmap-storage-controls input[type="search"] {
box-sizing: border-box;
min-width: 220px;
padding: 7px 9px;
border: 1px solid #aab2bd;
border-radius: 4px;
font: inherit;
}
.wiki-combobox {
position: relative;
min-width: 220px;
font-weight: 400;
}
.wiki-combobox input[type="search"] {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding-right: 38px;
}
.wiki-combobox-toggle {
position: absolute;
top: 1px;
right: 1px;
width: 34px;
height: calc(100% - 2px);
padding: 0;
border: 0;
border-left: 1px solid #c4cad1;
border-radius: 0 4px 4px 0;
background: #f3f5f7;
color: #485563;
font-size: 18px;
cursor: pointer;
}
.wiki-combobox:focus-within input,
.wiki-combobox:focus-within .wiki-combobox-toggle {
border-color: #527da8;
}
.wiki-combobox-list {
position: absolute;
top: calc(100% + 3px);
right: 0;
left: 0;
z-index: 180;
max-height: 280px;
overflow: auto;
padding: 4px;
border: 1px solid #8794a2;
border-radius: 5px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 20%);
}
.wiki-combobox-option {
display: grid;
gap: 2px;
padding: 7px 9px;
border-radius: 4px;
cursor: default;
}
.wiki-combobox-option:hover,
.wiki-combobox-option.active {
background: #e8f1fa;
}
.wiki-combobox-option[aria-selected="true"] {
box-shadow: inset 3px 0 #527da8;
}
.wiki-combobox-option-label {
overflow: hidden;
color: #1e2a35;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.wiki-combobox-option-description {
overflow: hidden;
color: #697784;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
#cmap-save-status {
max-width: 240px;
opacity: 0;
transition: opacity .2s ease;
}
#cmap-save-status.cmap-status-visible {
opacity: 1;
}
.cmap-help-menu {
position: relative;
}
.cmap-help-menu summary {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid #aab2bd;
border-radius: 50%;
background: #fff;
color: #536273;
cursor: pointer;
font-weight: 700;
list-style: none;
}
.cmap-help-menu summary::-webkit-details-marker {
display: none;
}
.cmap-help-popover {
position: absolute;
z-index: 220;
top: calc(100% + 6px);
right: 0;
width: min(520px, calc(100vw - 72px));
padding: 10px 12px;
border: 1px solid #aab2bd;
border-radius: 6px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
line-height: 1.4;
}
.cmap-help-popover > p {
margin: 0 0 10px;
}
.cmap-shortcut-list {
display: grid;
gap: 5px;
margin: 0;
}
.cmap-shortcut-list > div {
display: grid;
grid-template-columns: minmax(155px, auto) 1fr;
gap: 12px;
align-items: baseline;
}
.cmap-shortcut-list dt,
.cmap-shortcut-list dd {
margin: 0;
}
.cmap-shortcut-list kbd {
display: inline-block;
min-width: 22px;
padding: 1px 4px;
border: 1px solid #bbc1c8;
border-bottom-width: 2px;
border-radius: 3px;
background: #f7f8f9;
color: #28313a;
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: center;
}
.cmap-workspace {
position: relative;
width: 100%;
}
.cmap-canvas {
--cmap-a4-width: 1123px;
--cmap-a4-height: 794px;
position: relative;
width: max-content;
min-width: max(760px, 100%);
min-height: 794px;
overflow: visible;
border: 0;
background-color: #fff;
background-image:
linear-gradient(to right, transparent calc(100% - 1px), #d9dde2 calc(100% - 1px)),
linear-gradient(to bottom, transparent calc(100% - 1px), #d9dde2 calc(100% - 1px));
background-size: var(--cmap-a4-width) var(--cmap-a4-height);
box-shadow: inset 1px 1px #d9dde2;
}
.cmap-canvas.cmap-page-guides-hidden {
background-image: none;
box-shadow: none;
}
.cmap-canvas > .rw-cmap-surface {
overflow: visible !important;
min-width: 100%;
min-height: 100%;
transform-origin: 0 0;
}
.cmap-zoom-controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
width: auto;
margin: 0;
}
.cmap-zoom-controls button,
.cmap-zoom-controls input {
box-sizing: border-box;
height: 30px;
font: inherit;
}
.cmap-map-navigation {
display: flex;
align-items: center;
gap: 5px;
max-width: 260px;
}
.cmap-map-navigation strong {
overflow: hidden;
max-width: 190px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmap-context-menu {
position: fixed;
z-index: 500;
display: grid;
min-width: 210px;
padding: 5px;
border: 1px solid #8c95a3;
border-radius: 6px;
background: #fff;
box-shadow: 0 8px 28px rgb(0 0 0 / 24%);
}
.cmap-context-menu button {
padding: 7px 10px;
border: 0;
border-radius: 4px;
background: transparent;
text-align: left;
}
.cmap-context-menu button:hover:not(:disabled),
.cmap-context-menu button:focus-visible {
background: #e9eef5;
}
.cmap-context-menu button:disabled {
color: #999;
}
.cmap-context-menu .danger:not(:disabled) {
color: #a32626;
}
.cmap-context-separator {
height: 1px;
margin: 4px 5px;
background: #d8dde3;
}
#cmap-toggle-page-guides[aria-checked="true"]::before {
content: "✓ ";
}
.cmap-zoom-controls button {
min-width: 30px;
padding: 2px 7px;
}
.cmap-zoom-controls label {
display: flex;
align-items: center;
gap: 2px;
}
.cmap-zoom-controls input {
width: 62px;
padding: 2px 4px;
text-align: right;
}
.cmap-prototype-node {
box-sizing: border-box;
cursor: move;
border-radius: 6px;
box-shadow: 0 1px 2px rgb(0 0 0 / 10%);
font-family: Arial, Helvetica, sans-serif;
font-size: 11pt;
line-height: 1.25;
}
.cmap-card {
height: 100%;
box-sizing: border-box;
padding: 9px 11px;
overflow: hidden;
}
.cmap-card-title {
margin-bottom: .3rem;
font-size: 1rem;
font-weight: 700;
}
.cmap-card-synopsis {
color: inherit;
opacity: .78;
font-size: .84rem;
line-height: 1.25;
}
.cmap-card-image {
display: block;
width: auto;
max-width: min(220px, 100%);
max-height: 150px;
margin: 0 auto 8px;
object-fit: contain;
}
.cmap-card-page .cmap-card-title::after {
content: " ↗";
color: #4479a1;
font-weight: 400;
}
.cmap-card-linked-cmap .cmap-card-title::after {
content: " ↗";
color: #477b3e;
font-weight: 400;
}
.rw-cmap-item {
overflow: visible !important;
}
.rw-cmap-submap-toggle {
position: absolute;
top: 6px;
right: 7px;
z-index: 45;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid #57834a;
border-radius: 50%;
background: #fff;
color: #35672c;
cursor: pointer;
font: 700 17px/18px Arial, Helvetica, sans-serif;
}
.cmap-card-submap {
padding-right: 38px;
}
.rw-cmap-submap-frame {
position: absolute;
z-index: 0;
box-sizing: border-box;
border: 2px solid var(--rw-cmap-submap-border, #7a9d70);
border-radius: 12px;
background: color-mix(
in srgb,
var(--rw-cmap-submap-background, #edf7e8) 82%,
transparent
);
box-shadow: 0 1px 3px rgb(35 55 30 / 18%);
cursor: move;
pointer-events: auto;
user-select: none;
}
.rw-cmap-submap-frame-selected {
outline: 3px solid #9994ff;
outline-offset: 2px;
box-shadow: 0 0 0 5px rgb(132 126 255 / 28%);
}
.rw-cmap-submap-frame-toggle {
position: absolute;
top: 50%;
right: -11px;
width: 22px;
height: 22px;
padding: 0;
transform: translateY(-50%);
border: 1px solid var(--rw-cmap-submap-border, #7a9d70);
border-radius: 4px;
background: #fff;
color: #35672c;
cursor: pointer;
font: 700 15px/18px Arial, Helvetica, sans-serif;
pointer-events: auto;
}
.rw-cmap-selected {
z-index: 20 !important;
outline: 3px solid #9994ff !important;
outline-offset: 2px;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(132 126 255 / 38%) !important;
}
.rw-cmap-marquee {
position: absolute;
z-index: 10000;
box-sizing: border-box;
border: 1px solid #5368d9;
background: rgb(83 104 217 / 14%);
pointer-events: none;
}
.rw-cmap-handle {
position: absolute;
z-index: 60;
display: grid;
place-items: center;
width: 12px;
height: 12px;
padding: 0;
border: 1px solid #111;
border-radius: 0;
background: #fff;
box-shadow: none;
color: #111;
cursor: crosshair;
font: 10px/1 Arial, Helvetica, sans-serif;
}
.rw-cmap-relation-handle {
top: -21px;
left: 50%;
width: 34px;
height: 12px;
transform: translateX(-50%);
}
.rw-cmap-relation-handle::before,
.rw-cmap-relation-handle::after {
content: "";
position: absolute;
top: 5px;
width: 15px;
border-top: 1px solid #111;
}
.rw-cmap-relation-handle::before {
left: 2px;
transform: rotate(18deg);
transform-origin: right center;
}
.rw-cmap-relation-handle::after {
right: 2px;
transform: rotate(-18deg);
transform-origin: left center;
}
.rw-cmap-resize-handle {
right: -8px;
bottom: -8px;
background:
linear-gradient(135deg,
transparent 0 42%,
#111 43% 51%,
transparent 52% 63%,
#111 64% 72%,
transparent 73%),
#fff;
cursor: nwse-resize;
}
.rw-cmap-edit-handle {
left: -8px;
bottom: -8px;
cursor: pointer;
}
.rw-cmap-edit-handle::before {
content: "✎";
position: relative;
top: -1px;
font-size: 12px;
line-height: 1;
}
.rw-cmap-item-phrase {
border: 0 !important;
border-radius: 0 !important;
background: #fbfbf8 !important;
box-shadow: none !important;
cursor: move;
}
.rw-cmap-phrase-label {
display: grid;
place-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 2px 6px;
font-size: 14px;
line-height: 1.1;
text-align: center;
}
.rw-cmap-phrase-input {
width: calc(100% - 8px);
height: calc(100% - 8px);
box-sizing: border-box;
margin: 4px;
padding: 2px 5px;
border: 1px solid #5966d6;
background: #fff;
font: inherit;
text-align: center;
}
.rw-cmap-draft-layer {
position: absolute;
top: 0;
left: 0;
z-index: 50;
overflow: visible;
pointer-events: none;
}
.rw-cmap-draft-line {
stroke: #4f5ee8;
stroke-width: 2.5;
stroke-dasharray: 6 4;
}
.rw-cmap-connector {
cursor: pointer;
}
.cmap-concept-dialog {
width: min(520px, calc(100vw - 32px));
padding: 0;
border: 1px solid #8c95a3;
border-radius: 8px;
box-shadow: 0 18px 60px rgb(0 0 0 / 24%);
}
.cmap-concept-dialog::backdrop {
background: rgb(22 30 44 / 38%);
}
.cmap-unsaved-dialog {
width: min(480px, calc(100vw - 32px));
padding: 0;
border: 1px solid #8c95a3;
border-radius: 8px;
box-shadow: 0 18px 60px rgb(0 0 0 / 24%);
}
.cmap-unsaved-dialog::backdrop {
background: rgb(22 30 44 / 38%);
}
.cmap-unsaved-dialog form {
display: grid;
gap: 14px;
padding: 22px;
}
.cmap-unsaved-dialog h2,
.cmap-unsaved-dialog p {
margin: 0;
}
.cmap-concept-dialog form {
display: grid;
gap: 14px;
padding: 22px;
}
.cmap-concept-dialog h2 {
margin: 0 0 2px;
}
.cmap-concept-dialog label {
display: grid;
gap: 5px;
font-weight: 600;
}
.cmap-concept-dialog input[type="text"],
.cmap-concept-dialog input[type="search"],
.cmap-concept-dialog input[type="number"],
.cmap-concept-dialog select,
.cmap-concept-dialog textarea {
box-sizing: border-box;
width: 100%;
padding: 7px 9px;
border: 1px solid #aab2bd;
border-radius: 4px;
font: inherit;
font-weight: 400;
}
.cmap-concept-dialog input[type="color"] {
width: 72px;
height: 36px;
padding: 2px;
border: 1px solid #aab2bd;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
.cmap-concept-image-fields,
.cmap-submap-style-fields {
display: grid;
gap: 9px;
margin: 0;
padding: 10px 12px 12px;
border: 1px solid #c4cad2;
border-radius: 4px;
}
.cmap-concept-image-fields legend,
.cmap-submap-style-fields legend {
padding: 0 4px;
font-weight: 600;
}
.cmap-submap-style-fields {
grid-template-columns: 1fr 1fr;
}
.cmap-submap-style-fields legend {
grid-column: 1 / -1;
}
.cmap-concept-image-preview-row {
display: flex;
align-items: flex-end;
gap: 12px;
}
.cmap-concept-image-preview {
display: block;
}
#cmap-concept-image-preview {
max-width: 180px;
max-height: 120px;
border: 1px solid #d1d5db;
background: #fff;
object-fit: contain;
}
.cmap-concept-dialog textarea {
resize: vertical;
}
.cmap-concept-page-row {
display: grid;
gap: 4px;
padding: 9px 11px;
border: 1px solid #c8d8e6;
border-radius: 4px;
background: #eef6fc;
}
.cmap-concept-page-row select {
width: 100%;
}
.cmap-concept-page-row input[type="search"] {
width: 100%;
}
.cmap-font-size-input {
display: grid;
grid-template-columns: 90px auto;
align-items: center;
gap: 7px;
font-weight: 400;
}
.cmap-concept-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
@media (max-width: 1100px) {
.cmap-storage-controls {
flex-wrap: wrap;
}
.cmap-toolbar-spacer {
display: none;
}
.cmap-canvas {
min-width: 0;
height: 620px;
}
}
+2235
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
/* CMap view, concept presentation and CMapTools-like interactions. */
#cmap-view {
max-width: none;
}
.cmap-page-header {
align-items: flex-start;
}
.cmap-toolbar {
display: flex;
gap: .45rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.cmap-toolbar button {
font: inherit;
padding: .35rem .65rem;
}
.cmap-help {
margin: .25rem 0 .75rem;
}
.cmap-canvas {
position: relative;
width: min(1380px, 100%);
min-width: 760px;
height: 720px;
overflow: auto;
border: 1px solid var(--wiki-border);
background-color: #fbfbf8;
background-image:
linear-gradient(#ecece7 1px, transparent 1px),
linear-gradient(90deg, #ecece7 1px, transparent 1px);
background-size: 24px 24px;
}
.cmap-prototype-node {
box-sizing: border-box;
cursor: move;
border-radius: 6px;
box-shadow: 0 1px 2px rgb(0 0 0 / 10%);
font-family: Arial, Helvetica, sans-serif;
font-size: 15px;
line-height: 1.25;
}
.cmap-card {
height: 100%;
box-sizing: border-box;
padding: 9px 11px;
overflow: hidden;
}
.cmap-card-title {
margin-bottom: .3rem;
font-size: 1rem;
font-weight: 700;
}
.cmap-card-synopsis {
color: #4b4b4b;
font-size: .84rem;
line-height: 1.25;
}
.cmap-card-page .cmap-card-title::after {
content: " ↗";
color: #4479a1;
font-weight: 400;
}
.cmap-card-submap .cmap-card-title::after {
content: " [+]";
color: #57834a;
font-weight: 400;
}
.rw-cmap-item {
overflow: visible !important;
}
.rw-cmap-selected {
z-index: 20 !important;
outline: 3px solid #9994ff !important;
outline-offset: 2px;
box-shadow: 0 0 0 1px rgb(255 255 255 / 95%),
0 0 0 5px rgb(132 126 255 / 38%) !important;
}
.rw-cmap-handle {
position: absolute;
z-index: 60;
display: grid;
place-items: center;
width: 12px;
height: 12px;
padding: 0;
border: 1px solid #111;
border-radius: 0;
background: #fff;
box-shadow: none;
color: #111;
cursor: crosshair;
font: 10px/1 Arial, Helvetica, sans-serif;
}
.rw-cmap-relation-handle {
top: -21px;
left: 50%;
width: 34px;
height: 12px;
transform: translateX(-50%);
}
.rw-cmap-relation-handle::before,
.rw-cmap-relation-handle::after {
content: "";
position: absolute;
top: 5px;
width: 15px;
border-top: 1px solid #111;
}
.rw-cmap-relation-handle::before {
left: 2px;
transform: rotate(18deg);
transform-origin: right center;
}
.rw-cmap-relation-handle::after {
right: 2px;
transform: rotate(-18deg);
transform-origin: left center;
}
.rw-cmap-resize-handle {
right: -8px;
bottom: -8px;
background:
linear-gradient(135deg,
transparent 0 42%,
#111 43% 51%,
transparent 52% 63%,
#111 64% 72%,
transparent 73%),
#fff;
cursor: nwse-resize;
}
.rw-cmap-item-phrase {
border-radius: 4px !important;
cursor: move;
}
.rw-cmap-phrase-label {
display: grid;
place-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 4px 8px;
font-size: 14px;
line-height: 1.1;
text-align: center;
}
.rw-cmap-phrase-input {
width: calc(100% - 8px);
height: calc(100% - 8px);
box-sizing: border-box;
margin: 4px;
padding: 2px 5px;
border: 1px solid #5966d6;
background: #fff;
font: inherit;
text-align: center;
}
.rw-cmap-draft-layer {
position: absolute;
top: 0;
left: 0;
z-index: 50;
overflow: visible;
pointer-events: none;
}
.rw-cmap-draft-line {
stroke: #4f5ee8;
stroke-width: 2.5;
stroke-dasharray: 6 4;
}
.rw-cmap-connector {
cursor: pointer;
}
@media (max-width: 1100px) {
.cmap-canvas {
min-width: 0;
height: 620px;
}
}
+283 -4
View File
@@ -9,6 +9,17 @@ body { margin: 0; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.hidden { display: none !important; }
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.muted { color: #6b7280; font-size: 0.9rem; }
.error { color: #b42318; margin-top: .75rem; }
.danger { color: #b42318; }
@@ -91,6 +102,14 @@ button { cursor: pointer; }
background: #e5e7eb;
}
.EasyMDEContainer .editor-toolbar button.rw-mde-button svg {
width: 17px;
height: 17px;
vertical-align: middle;
stroke-width: 1.9;
pointer-events: none;
}
.EasyMDEContainer .editor-toolbar button.rw-mde-bold {
font-weight: 750;
}
@@ -207,6 +226,40 @@ body {
text-decoration: underline;
}
.sidebar-icon-nav {
display: flex;
align-items: center;
justify-content: center;
margin: -2px 0 14px;
}
.sidebar-icon-link {
position: relative;
display: grid;
width: 38px;
height: 32px;
place-items: center;
color: var(--wiki-link);
text-decoration: none;
}
.sidebar-icon-link + .sidebar-icon-link {
border-left: 1px solid #c8cbd0;
}
.sidebar-icon-link:hover,
.sidebar-icon-link:focus-visible {
background: #fff;
color: #24356f;
outline: 0;
}
.sidebar-icon-link svg {
width: 19px;
height: 19px;
stroke-width: 1.8;
}
#user-box,
#sidebar .muted {
color: var(--wiki-muted);
@@ -452,10 +505,6 @@ body {
color: #333;
}
.EasyMDEContainer .editor-toolbar button.fa::before {
font-family: FontAwesome;
}
@media (max-width: 900px) {
#app { grid-template-columns: 1fr; }
#sidebar {
@@ -565,10 +614,45 @@ body {
}
.search-result h2 {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin: 0 0 5px;
font-size: 1.08rem;
}
.search-result-type {
display: inline-block;
padding: 2px 7px;
border: 1px solid #b7c0ca;
border-radius: 999px;
background: #f3f5f7;
color: #536273;
font-size: .72rem;
font-weight: 650;
line-height: 1.35;
}
.search-result[data-result-type="cmap"] .search-result-type {
border-color: #86a77c;
background: #edf7e8;
color: #35672c;
}
.special-page-heading {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.special-page-row[data-result-type="cmap"] .search-result-type {
border-color: #86a77c;
background: #edf7e8;
color: #35672c;
}
.search-result h2 a {
color: var(--wiki-link);
text-decoration: none;
@@ -1057,6 +1141,16 @@ body.editor-mode .editor-metadata-row {
stroke: #1e2f6d;
}
.wiki-graph-node-cmap circle {
fill: #57834a;
}
.wiki-graph-node-cmap:hover circle,
.wiki-graph-node-cmap:focus circle {
fill: #315f2b;
stroke: #315f2b;
}
/* 0.2.15 context navigation and sticky-anchor correction ---------------- */
.context-link {
@@ -1218,3 +1312,188 @@ body.editor-mode .editor-metadata-row {
font-size: 1rem;
font-weight: 600;
}
/* Rename and alias administration */
.rename-form { max-width: 48rem; }
.rename-form label { display: block; margin: 0 0 1rem; }
.rename-form input { display: block; width: 100%; margin-top: .3rem; }
.admin-version { margin: 0 0 1rem; }
.alias-row { padding: .75rem 0; border-bottom: 1px solid #d9d9d9; }
.alias-row .alias-address { font-family: monospace; }
.alias-references { margin: .35rem 0 0 1.25rem; }
.alias-summary { margin-top: .35rem; }
.alias-history { color: #666; }
.alias-actions { display: flex; gap: .5rem; margin-top: .6rem; }
.alias-actions button { font: inherit; }
/* 0.2.36 context graph overlay and docking ------------------------------ */
.page-reading-layout {
min-width: 0;
}
.page-reading-layout.context-dock-right {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 42%);
gap: 1rem;
align-items: start;
}
.page-reading-layout.context-dock-top {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-reading-layout.context-dock-top .context-dock {
order: -1;
}
.context-dock {
border: 1px solid var(--wiki-border);
background: #fafafa;
padding: .75rem;
position: sticky;
top: 84px;
max-height: calc(100vh - 100px);
overflow: auto;
}
.context-dock-header,
.context-overlay-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.context-graph-actions {
display: flex;
gap: .7rem;
flex-wrap: wrap;
font-size: .82rem;
}
.context-graph-actions a {
color: var(--wiki-link);
text-decoration: none;
}
.context-graph-actions a:hover {
text-decoration: underline;
}
.context-graph-small {
min-width: 0;
min-height: 320px;
max-height: 520px;
}
.context-overlay {
position: fixed;
inset: 0;
z-index: 500;
background: rgb(0 0 0 / 35%);
display: grid;
place-items: center;
padding: 2rem;
}
.context-overlay-panel {
width: min(1180px, 96vw);
max-height: 92vh;
overflow: auto;
background: white;
border: 1px solid var(--wiki-border);
box-shadow: 0 14px 44px rgb(0 0 0 / 24%);
padding: 1rem;
}
.context-overlay-header h2 {
margin: 0 0 .2rem;
}
.context-overlay-graph-shell {
margin-top: .8rem;
}
.wiki-graph-edges marker path {
fill: #9a9a9a;
}
.wiki-graph-edge-incoming {
stroke: #7a5d9e !important;
}
.wiki-graph-edge-outgoing {
stroke: #46639b !important;
}
@media (max-width: 1100px) {
.page-reading-layout.context-dock-right {
display: flex;
flex-direction: column;
}
.page-reading-layout.context-dock-right .context-dock {
order: -1;
width: 100%;
position: static;
max-height: none;
}
}
@media (max-width: 700px) {
.context-overlay {
padding: .5rem;
}
.context-overlay-panel {
width: 100%;
max-height: 96vh;
}
.context-overlay-header {
display: block;
}
.context-graph-actions {
margin-top: .5rem;
}
}
/* Keep the established document width and use otherwise empty desktop
space for the docked context view. */
#page-view:has(.context-dock-right) {
max-width: none;
}
.page-reading-layout.context-dock-right {
grid-template-columns: minmax(0, 900px) minmax(420px, 520px);
justify-content: start;
gap: 1.5rem;
}
.page-reading-layout.context-dock-right .markdown-body {
width: 100%;
}
.page-reading-layout.context-dock-right .context-dock {
width: 100%;
}
.context-dock .wiki-graph-node text {
font-size: 20px;
font-weight: 600;
}
.context-dock .wiki-graph-node-focus text {
font-size: 22px;
}
@media (max-width: 1450px) {
.page-reading-layout.context-dock-right {
grid-template-columns: minmax(0, 760px) minmax(360px, 440px);
}
}
+266 -15
View File
@@ -3,25 +3,27 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'">
<title>Racket Wiki</title>
<link rel="stylesheet" href="/vendor/font-awesome/css/font-awesome.min.css">
<link rel="stylesheet" href="/vendor/easymde.min.css">
<link rel="stylesheet" href="/vendor/highlight-github.min.css">
<link rel="stylesheet" href="/vendor/diff2html.min.css">
<link rel="stylesheet" href="/css/wiki.css">
<link rel="stylesheet" href="/css/wiki.css?id=__CMAP_CACHE_ID__">
<link rel="stylesheet" href="/cmap/cmap.css?id=__CMAP_CACHE_ID__">
</head>
<body>
<div id="connection-status" class="connection-status hidden" role="status"></div>
<div id="app">
<aside id="sidebar">
<a id="wiki-brand" class="brand" href="#">Racket Wiki</a>
<a id="recent-link" class="sidebar-primary-link" href="#">* <span data-tr="recent">Recent</span></a>
<a id="bookmarks-link" class="sidebar-primary-link" href="#">* <span data-tr="bookmarks">Bookmarks</span></a>
<a id="todo-link" class="sidebar-primary-link" href="#">* <span data-tr="todo-list">Todo list</span></a>
<a id="graph-link" class="sidebar-primary-link" href="#">* <span data-tr="graph">Graph</span></a>
<nav class="sidebar-icon-nav" aria-label="Wiki navigation">
<a id="bookmarks-link" class="sidebar-icon-link" href="#bookmarks" title="Bookmarks" data-tr-title="bookmarks" aria-label="Bookmarks" data-tr-aria-label="bookmarks"><i data-lucide="star"></i><span class="visually-hidden" data-tr="bookmarks">Bookmarks</span></a>
<a id="todo-link" class="sidebar-icon-link" href="#todos" title="Todo list" data-tr-title="todo-list" aria-label="Todo list" data-tr-aria-label="todo-list"><i data-lucide="list-checks"></i><span class="visually-hidden" data-tr="todo-list">Todo list</span></a>
<a id="recent-link" class="sidebar-icon-link" href="#recent" title="Recent" data-tr-title="recent" aria-label="Recent" data-tr-aria-label="recent"><i data-lucide="square-stack"></i><span class="visually-hidden" data-tr="recent">Recent</span></a>
<a id="graph-link" class="sidebar-icon-link" href="#graph" title="Navigation graph" data-tr-title="navigation-graph" aria-label="Navigation graph" data-tr-aria-label="navigation-graph"><i data-lucide="ship-wheel"></i><span class="visually-hidden" data-tr="navigation-graph">Navigation graph</span></a>
<a id="cmaps-link" class="sidebar-icon-link" href="#" title="CMaps" data-tr-title="cmaps" aria-label="CMaps" data-tr-aria-label="cmaps"><i data-lucide="share-2"></i><span class="visually-hidden" data-tr="cmaps">CMaps</span></a>
</nav>
<form id="search-form" class="wiki-search" role="search">
<input id="search-input" type="search" placeholder="Search wiki" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search wiki">
<input id="search-input" type="search" placeholder="Search pages and concept maps" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search pages and concept maps" data-tr-aria-label="search-wiki">
</form>
<section class="sidebar-section toc-section">
<div class="sidebar-heading"><span data-tr="contents">Contents</span> <a id="context-link" class="context-link hidden" href="#">(<span data-tr="context">context</span>)</a></div>
@@ -42,13 +44,14 @@
<span class="account-separator" aria-hidden="true">·</span>
<a id="logout-link" href="#" data-tr="sign-out">Sign out</a>
<span class="account-separator admin-only hidden" aria-hidden="true">·</span>
<a id="admin-link" class="admin-only hidden" href="#" data-tr="admin">Admin</a>
<a id="admin-link" class="admin-only hidden" href="#admin" data-tr="admin">Admin</a>
</div>
<div class="navigation-row">
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav>
<div id="page-action-links" class="page-action-links hidden">
<a id="bookmark-page" href="#" data-tr="bookmark">Bookmark</a>
<a id="edit-page" class="editor-only hidden" href="#" data-tr="edit">Edit</a>
<a id="rename-page" class="editor-only hidden" href="#" data-tr="rename">Rename</a>
<a id="history-page" href="#" data-tr="history">History</a>
<a id="delete-page" class="editor-only danger hidden" href="#" data-tr="delete">Delete</a>
</div>
@@ -62,7 +65,20 @@
<div id="page-meta" class="muted"></div>
</div>
</header>
<article id="markdown-preview" class="markdown-body"></article>
<div id="page-reading-layout" class="page-reading-layout">
<article id="markdown-preview" class="markdown-body"></article>
<aside id="context-dock" class="context-dock hidden" aria-label="Context graph">
<div class="context-dock-header">
<strong data-tr="context-graph">Context graph</strong>
<span class="context-graph-actions">
<a id="context-dock-popup" href="#" data-tr="popup">Popup</a>
<a id="context-dock-close" href="#" data-tr="close">Close</a>
</span>
</div>
<div id="context-dock-summary" class="muted"></div>
<svg id="context-dock-graph" class="wiki-graph context-graph-small" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
</aside>
</div>
<footer id="page-details" class="page-details"></footer>
</section>
@@ -146,6 +162,93 @@
</div>
</section>
<section id="cmap-view" class="hidden">
<header class="cmap-page-header">
<div class="cmap-storage-controls">
<span id="cmap-map-navigation" class="cmap-map-navigation hidden"><button id="cmap-map-back" type="button" aria-label="Back to parent map"></button><strong id="cmap-active-map-title"></strong></span>
<div id="cmap-map-combobox" class="wiki-combobox">
<input id="cmap-map-select" type="search" role="combobox"
aria-label="Concept map" data-tr-placeholder="select-concept-map"
placeholder="Select a CMap">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="cmap-map-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
<span class="cmap-toolbar-separator" aria-hidden="true"></span>
<button id="cmap-tools-menu" class="editor-only" type="button" aria-label="Concept map tools" aria-haspopup="menu"></button>
<span id="cmap-save-status" class="muted" role="status"></span>
<span class="cmap-toolbar-spacer" aria-hidden="true"></span>
<div class="cmap-zoom-controls" aria-label="Zoom controls">
<button id="cmap-zoom-out" type="button" aria-label="Zoom out"></button>
<label><input id="cmap-zoom-percent" type="number" min="25" max="300" step="10" value="100" aria-label="Zoom percentage"><span>%</span></label>
<button id="cmap-zoom-in" type="button" aria-label="Zoom in">+</button>
<button id="cmap-zoom-reset" type="button">100%</button>
</div>
<span class="cmap-toolbar-separator" aria-hidden="true"></span>
<details class="cmap-help-menu">
<summary aria-label="Concept map help">?</summary>
<div class="cmap-help-popover muted">
<p data-tr="concept-map-help-context">Select with Ctrl/Cmd/Shift-click or by dragging over empty space. Drag a selected element to move the complete selection. Grouping creates a new sub-CMap around the selection. Double-click a page or sub-CMap to open it; right-click for editing tools.</p>
<dl class="cmap-shortcut-list">
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>/<kbd>Shift</kbd> + <span data-tr="click">click</span></dt><dd data-tr="cmap-help-add-selection">Add to or remove from the selection</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>A</kbd></dt><dd data-tr="select-all">Select all</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>G</kbd></dt><dd data-tr="group-selected">Group selection as sub-CMap</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></dt><dd data-tr="ungroup-selected">Detach from sub-CMap</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></dt><dd data-tr="undo">Undo</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Y</kbd> / <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></dt><dd data-tr="redo-action">Redo</dd></div>
<div><dt><kbd>F2</kbd></dt><dd data-tr="cmap-help-rename">Edit the selected concept</dd></div>
<div><dt><kbd>Delete</kbd>/<kbd>Backspace</kbd></dt><dd data-tr="delete-selected">Delete selection</dd></div>
<div><dt><kbd>Esc</kbd></dt><dd data-tr="cmap-help-clear-selection">Clear selection</dd></div>
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>S</kbd></dt><dd data-tr="save-now">Save now</dd></div>
</dl>
</div>
</details>
</div>
</header>
<div class="cmap-workspace">
<div id="cmap-canvas" class="cmap-canvas" aria-label="Concept map"></div>
<nav id="cmap-context-menu" class="cmap-context-menu hidden" role="menu" aria-label="Concept map tools">
<button id="cmap-new-map" class="editor-only" type="button" role="menuitem" data-tr="new-concept-map">New CMap</button>
<button id="cmap-save-map" class="editor-only" type="button" role="menuitem" data-tr="save-now">Save now</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-rename-map" type="button" role="menuitem" data-tr="rename-concept-map" disabled>Rename CMap</button>
<button id="cmap-delete-map" class="danger" type="button" role="menuitem" data-tr="delete-concept-map" disabled>Delete CMap</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-add-concept" type="button" role="menuitem" data-tr="add-concept">Add concept</button>
<button id="cmap-add-page" type="button" role="menuitem" data-tr="add-page-node">Add current page</button>
<button id="cmap-add-submap" type="button" role="menuitem" data-tr="add-submap">Add sub-CMap</button>
<button id="cmap-promote-submap" type="button" role="menuitem" data-tr="promote-submap" disabled>Make separate CMap</button>
<button id="cmap-edit-selected" type="button" role="menuitem" data-tr="edit-selected-concept">Edit selected</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-undo" type="button" role="menuitem" data-tr="undo" disabled>Undo</button>
<button id="cmap-redo" type="button" role="menuitem" data-tr="redo-action" disabled>Redo</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-select-all" type="button" role="menuitem" data-tr="select-all">Select all</button>
<button id="cmap-group-selected" type="button" role="menuitem" data-tr="group-selected" disabled>Group selection as sub-CMap</button>
<button id="cmap-ungroup-selected" type="button" role="menuitem" data-tr="ungroup-selected" disabled>Detach from sub-CMap</button>
<button id="cmap-delete-selected" class="danger" type="button" role="menuitem" data-tr="delete-selected" disabled>Delete selection</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-toggle-page-guides" type="button" role="menuitemcheckbox" aria-checked="true" data-tr="a4-page-boundaries">A4 page boundaries</button>
<button id="cmap-reset" type="button" role="menuitem" data-tr="reload-concept-map">Reload CMap</button>
</nav>
</div>
</section>
<section id="rename-view" class="hidden">
<div class="section-header">
<h1 data-tr="rename-page">Rename page</h1>
</div>
<div class="rename-form">
<label><span data-tr="page-title">Page title</span><input id="rename-title" type="text"></label>
<label><span data-tr="namespace">Namespace</span><input id="rename-namespace" type="text"></label>
<label><span data-tr="slug">Slug</span><input id="rename-slug" type="text"></label>
<div class="editor-buttons">
<button id="save-rename" type="button" data-tr="save">Save</button>
<button id="cancel-rename" type="button" data-tr="cancel">Cancel</button>
</div>
<p id="rename-status" class="muted"></p>
</div>
</section>
<section id="history-view" class="hidden">
<header class="page-header">
<h1 data-tr="page-history">Page history</h1>
@@ -159,25 +262,36 @@
<header class="page-header">
<h1 data-tr="admin">Admin</h1>
</header>
<p class="admin-version"><span data-tr="software-version">Software version</span>: <strong id="admin-software-version"></strong></p>
<nav class="admin-menu">
<a id="admin-users-link" href="#" data-tr="users">Users</a>
<a id="admin-users-link" href="#admin/users" data-tr="users">Users</a>
<a id="admin-aliases-link" href="#admin/aliases" data-tr="page-aliases">Page aliases</a>
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
<a id="admin-orphaned-uploads-link" href="#" data-tr="orphaned-uploads">Orphaned uploads</a>
<a id="admin-orphaned-uploads-link" href="#admin/orphaned-uploads" data-tr="orphaned-uploads">Orphaned uploads</a>
</nav>
</section>
<section id="orphaned-uploads-view" class="hidden">
<header class="page-header">
<h1 data-tr="orphaned-uploads">Orphaned uploads</h1>
<a id="close-orphaned-uploads" href="#" data-tr="back">Back</a>
<a id="close-orphaned-uploads" href="#admin" data-tr="back">Back</a>
</header>
<div id="orphaned-uploads-list" class="orphaned-uploads-list"></div>
</section>
<section id="alias-admin-view" class="hidden">
<div class="section-header">
<h1 data-tr="page-aliases">Page aliases</h1>
<a id="close-alias-admin" href="#admin" data-tr="back">Back</a>
</div>
<p id="alias-admin-status" class="muted" role="status" aria-live="polite"></p>
<div id="alias-list"></div>
</section>
<section id="user-admin-view" class="hidden">
<header class="page-header">
<h1 data-tr="user-administration">User administration</h1>
<a id="close-user-admin" href="#" data-tr="back">Back</a>
<a id="close-user-admin" href="#admin" data-tr="back">Back</a>
</header>
<form id="new-user-form" class="user-form">
<input id="new-username" placeholder="Username" data-tr-placeholder="username" required>
@@ -195,11 +309,148 @@
</main>
</div>
<div id="context-overlay" class="context-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="context-overlay-title">
<div class="context-overlay-panel">
<div class="context-overlay-header">
<div>
<h2 id="context-overlay-title" data-tr="context-graph">Context graph</h2>
<div id="context-overlay-summary" class="muted"></div>
</div>
<nav class="context-graph-actions">
<a id="context-dock-right" href="#" data-tr="dock-right">Dock right</a>
<a id="context-dock-top" href="#" data-tr="dock-above">Dock above</a>
<a id="context-open-full" href="#" data-tr="open-full-graph">Open full graph</a>
<a id="context-overlay-close" href="#" data-tr="close">Close</a>
</nav>
</div>
<div class="wiki-graph-shell context-overlay-graph-shell">
<svg id="context-overlay-graph" class="wiki-graph" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
</div>
</div>
</div>
<dialog id="cmap-concept-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-concept-dialog-title">
<form id="cmap-concept-form">
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
<label id="cmap-concept-page-row" class="cmap-concept-page-row">
<span data-tr="linked-page">Linked wiki page</span>
<div id="cmap-concept-page-combobox" class="wiki-combobox">
<input id="cmap-concept-page" type="search" role="combobox"
data-tr-placeholder="filter-pages" placeholder="Filter pages">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show wiki pages"></button>
<div id="cmap-concept-page-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label id="cmap-concept-cmap-row" class="cmap-concept-page-row">
<span data-tr="linked-concept-map">Linked concept map</span>
<div id="cmap-concept-cmap-combobox" class="wiki-combobox">
<input id="cmap-concept-cmap" type="search" role="combobox"
data-tr-placeholder="filter-concept-maps" placeholder="Filter concept maps">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="cmap-concept-cmap-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<label>
<span data-tr="label">Label</span>
<input id="cmap-concept-label" type="text" required>
</label>
<label>
<span data-tr="synopsis">Synopsis</span>
<textarea id="cmap-concept-synopsis" rows="4"></textarea>
</label>
<label>
<span id="cmap-concept-background-label" data-tr="background-color">Background color</span>
<input id="cmap-concept-background" type="color" value="#f3f6f8">
</label>
<fieldset id="cmap-submap-style-fields" class="cmap-submap-style-fields hidden">
<legend data-tr="submap-appearance">Sub-CMap appearance</legend>
<label>
<span data-tr="submap-background-color">Sub-CMap background color</span>
<input id="cmap-submap-background" type="color" value="#edf7e8">
</label>
<label>
<span data-tr="submap-border-color">Sub-CMap border color</span>
<input id="cmap-submap-border" type="color" value="#57834a">
</label>
</fieldset>
<label>
<span data-tr="text-color">Text color</span>
<input id="cmap-concept-text-color" type="color" value="#222222">
</label>
<fieldset class="cmap-concept-image-fields">
<legend data-tr="concept-image">Concept image</legend>
<input id="cmap-concept-image" type="file" accept="image/*">
<div id="cmap-concept-image-preview-row" class="cmap-concept-image-preview-row hidden">
<img id="cmap-concept-image-preview" alt="">
<button id="cmap-concept-image-remove" type="button" data-tr="remove-image">Remove image</button>
</div>
</fieldset>
<label>
<span data-tr="font-family">Font family</span>
<select id="cmap-concept-font-family">
<option value="Arial, Helvetica, sans-serif">Arial</option>
<option value="Verdana, Geneva, sans-serif">Verdana</option>
<option value="Tahoma, Geneva, sans-serif">Tahoma</option>
<option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option>
<option value="Georgia, Times New Roman, serif">Georgia</option>
<option value="Times New Roman, Times, serif">Times New Roman</option>
<option value="Courier New, Courier, monospace">Courier New</option>
<option value="system-ui, sans-serif">System UI</option>
</select>
</label>
<label>
<span data-tr="font-size">Font size</span>
<span class="cmap-font-size-input"><input id="cmap-concept-font-size" type="number" min="6" max="54" step="1"><span>pt</span></span>
</label>
<div class="cmap-concept-dialog-actions">
<button id="cmap-concept-cancel" type="button" data-tr="cancel">Cancel</button>
<button type="submit" class="primary" data-tr="save">Save</button>
</div>
</form>
</dialog>
<dialog id="wiki-cmap-link-dialog" class="cmap-concept-dialog" aria-labelledby="wiki-cmap-link-title">
<form id="wiki-cmap-link-form">
<h2 id="wiki-cmap-link-title" data-tr="insert-concept-map-link">Insert CMap link</h2>
<label>
<span data-tr="linked-concept-map">Linked concept map</span>
<div id="wiki-cmap-link-combobox" class="wiki-combobox">
<input id="wiki-cmap-link" type="search" role="combobox"
data-tr-placeholder="filter-concept-maps" placeholder="Filter concept maps">
<button type="button" class="wiki-combobox-toggle" tabindex="-1" aria-label="Show concept maps"></button>
<div id="wiki-cmap-link-options" class="wiki-combobox-list hidden" role="listbox"></div>
</div>
</label>
<p class="muted" data-tr="concept-map-link-help">The selected CMap is inserted as a normal Markdown link.</p>
<div class="cmap-concept-dialog-actions">
<button id="wiki-cmap-link-cancel" type="button" data-tr="cancel">Cancel</button>
<button id="wiki-cmap-link-submit" type="submit" class="primary" data-tr="insert">Insert</button>
</div>
</form>
</dialog>
<dialog id="cmap-unsaved-dialog" class="cmap-unsaved-dialog" aria-labelledby="cmap-unsaved-title">
<form method="dialog">
<h2 id="cmap-unsaved-title" data-tr="unsaved-concept-map">Unsaved concept map</h2>
<p data-tr="save-concept-map-before-leaving">Save the changes to this concept map before leaving?</p>
<div class="cmap-concept-dialog-actions">
<button id="cmap-unsaved-cancel" type="button" data-tr="cancel">Cancel</button>
<button id="cmap-unsaved-discard" type="button" data-tr="dont-save">Don't save</button>
<button id="cmap-unsaved-save" type="button" class="primary editor-only" data-tr="save">Save</button>
</div>
</form>
</dialog>
<script src="/vendor/purify.min.js"></script>
<script src="/vendor/highlight.min.js"></script>
<script src="/vendor/highlight-scheme.min.js"></script>
<script src="/vendor/easymde.min.js"></script>
<script src="/vendor/lucide.min.js"></script>
<script src="/vendor/diff2html-ui-base.min.js"></script>
<script src="/js/wiki.js"></script>
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
<script src="/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/combobox.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
/* Accessible, dependency-free combobox used by racket-wiki. */
(() => {
"use strict";
class RacketWikiComboBox {
constructor(root) {
this.root = root;
this.input = root.querySelector("input");
this.button = root.querySelector("button");
this.list = root.querySelector('[role="listbox"]');
this.options = [];
this.filteredOptions = [];
this.selectedValue = "";
this.activeIndex = -1;
this.selectionHandlers = [];
this.input.setAttribute("aria-controls", this.list.id);
this.input.setAttribute("aria-expanded", "false");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("autocomplete", "off");
this.input.addEventListener("input", () => {
this.selectedValue = "";
this.input.setCustomValidity("");
this.open();
this.renderOptions(false);
});
this.input.addEventListener("focus", () => {
this.open();
this.renderOptions(true);
});
this.input.addEventListener("keydown", (event) => this.handleKeydown(event));
this.input.addEventListener("blur", () => {
window.setTimeout(() => this.close(), 100);
});
this.button.addEventListener("pointerdown", (event) => event.preventDefault());
this.button.addEventListener("click", () => {
if (this.isOpen()) {
this.close();
} else {
this.open();
this.renderOptions(true);
this.input.focus({ preventScroll: true });
}
});
}
setOptions(options, selectedValue = "") {
this.options = options.map((option) => ({
value: String(option.value),
label: String(option.label),
description: String(option.description || "")
}));
const selected = this.options.find((option) => option.value === selectedValue) || null;
this.selectedValue = selected ? selected.value : "";
this.input.value = selected ? selected.label : "";
this.activeIndex = -1;
this.renderOptions();
}
value() {
const text = this.input.value.trim().toLocaleLowerCase();
if (!text) {
this.selectedValue = "";
return "";
}
if (this.selectedValue) {
const selected = this.options.find((option) => option.value === this.selectedValue);
if (selected && selected.label.toLocaleLowerCase() === text) return selected.value;
}
const selected = this.options.find((option) =>
option.label.toLocaleLowerCase() === text ||
option.value.toLocaleLowerCase() === text) || null;
this.selectedValue = selected ? selected.value : "";
return selected ? selected.value : null;
}
clear() {
this.selectedValue = "";
this.input.value = "";
this.input.setCustomValidity("");
this.activeIndex = -1;
this.renderOptions();
}
onSelect(handler) {
this.selectionHandlers.push(handler);
return this;
}
isOpen() {
return !this.list.classList.contains("hidden");
}
open() {
this.list.classList.remove("hidden");
this.input.setAttribute("aria-expanded", "true");
this.button.setAttribute("aria-expanded", "true");
}
close() {
this.list.classList.add("hidden");
this.input.setAttribute("aria-expanded", "false");
this.button.setAttribute("aria-expanded", "false");
this.activeIndex = -1;
this.input.removeAttribute("aria-activedescendant");
}
renderOptions(showAll = false) {
const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase();
this.filteredOptions = this.options.filter((option) =>
!query || option.label.toLocaleLowerCase().includes(query) ||
option.description.toLocaleLowerCase().includes(query) ||
option.value.toLocaleLowerCase().includes(query));
this.list.replaceChildren();
for (const [index, option] of this.filteredOptions.entries()) {
const item = document.createElement("div");
item.id = `${this.list.id}-option-${index}`;
item.className = "wiki-combobox-option";
item.setAttribute("role", "option");
item.setAttribute("aria-selected", String(option.value === this.selectedValue));
item.dataset.index = String(index);
const label = document.createElement("span");
label.className = "wiki-combobox-option-label";
label.textContent = option.label;
item.append(label);
if (option.description && option.description !== option.label) {
const description = document.createElement("span");
description.className = "wiki-combobox-option-description";
description.textContent = option.description;
item.append(description);
}
item.addEventListener("pointerdown", (event) => {
event.preventDefault();
this.selectOption(option);
});
this.list.append(item);
}
this.updateActiveOption();
}
selectOption(option) {
this.selectedValue = option.value;
this.input.value = option.label;
this.input.setCustomValidity("");
this.close();
for (const handler of this.selectionHandlers) handler(option.value, option);
this.input.dispatchEvent(new Event("change", { bubbles: true }));
}
handleKeydown(event) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!this.isOpen()) {
this.open();
this.renderOptions();
}
const direction = event.key === "ArrowDown" ? 1 : -1;
const maximum = this.filteredOptions.length - 1;
if (maximum < 0) return;
this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction));
this.updateActiveOption();
return;
}
if (event.key === "Enter" && this.isOpen()) {
const exactText = this.input.value.trim().toLocaleLowerCase();
const exact = this.filteredOptions.find((option) =>
option.label.toLocaleLowerCase() === exactText ||
option.value.toLocaleLowerCase() === exactText) || null;
const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] :
(exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null));
if (selected) {
event.preventDefault();
this.selectOption(selected);
return;
}
}
if (event.key === "Escape") {
event.preventDefault();
this.close();
}
}
updateActiveOption() {
const elements = Array.from(this.list.querySelectorAll('[role="option"]'));
for (const [index, element] of elements.entries()) {
element.classList.toggle("active", index === this.activeIndex);
}
const active = elements[this.activeIndex];
if (active) {
this.input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
} else {
this.input.removeAttribute("aria-activedescendant");
}
}
}
window.RacketWikiComboBox = RacketWikiComboBox;
})();
+2005 -103
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
# Vendor extensions
`cmap-racket-wiki.js` is the racket-wiki interaction layer for upstream
`ionstage/cmap` 0.1.3. The upstream library is installed as `/vendor/cmap.js`
by the normal frontend setup. Racket-wiki adjusts its `Node.prototype.style`
so rendered concept nodes accept pointer events; upstream's link hit testing is
left unchanged.
The extension remains separate because selection handles, resize controls,
drag-to-connect relations, separate linking-phrase nodes and wiki page/submap
behaviour belong to the racket-wiki editor rather than the generic drawing
library.
@@ -0,0 +1,672 @@
/*
* Racket Wiki interaction layer for ionstage/cmap 0.1.3.
*
* This file contains the racket-wiki-specific editor model and interactions.
* The installed upstream cmap.js is adjusted separately so its rendered nodes
* accept pointer events; concepts can then be selected as normal DOM targets.
*/
(() => {
"use strict";
const debugPrefix = "[racket-wiki:cmap 0.2.44]";
function debug(message, details) {
if (details === undefined) {
console.info(debugPrefix, message);
return;
}
console.info(debugPrefix, message, details);
}
function elementDescription(element) {
if (!(element instanceof Element)) return String(element);
return {
tag: element.tagName,
id: element.id || null,
classes: Array.from(element.classList),
itemId: element.dataset.rwCmapItemId || null
};
}
function selectionStyle(element) {
if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
const style = window.getComputedStyle(element);
return {
pointerEvents: style.pointerEvents,
outline: style.outline,
outlineOffset: style.outlineOffset,
boxShadow: style.boxShadow,
overflow: style.overflow,
zIndex: style.zIndex
};
}
debug("cmap-racket-wiki.js loaded", {
script: document.currentScript ? document.currentScript.src : null,
cmapAvailable: typeof window.Cmap === "function",
stylesheets: Array.from(document.styleSheets || [])
.map((sheet) => sheet.href)
.filter((href) => href && href.includes("cmap.css"))
});
//////////////////////////////////////////////////////////////////////////////
// Small helpers
//////////////////////////////////////////////////////////////////////////////
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function numberOr(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
//////////////////////////////////////////////////////////////////////////////
// Editor
//////////////////////////////////////////////////////////////////////////////
/**
* goal : Add CmapTools-like selection, resize and relation drawing on top
* of ionstage/cmap without changing the upstream library.
* pre : canvas is a DOM element and window.Cmap is available.
* post : Concepts and linking phrases can be selected and connected by
* direct manipulation.
* result : A CmapEditor instance.
*/
class CmapEditor {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.CmapFactory = options.Cmap || window.Cmap;
this.renderItem = options.renderItem || null;
this.onOpenPage = options.onOpenPage || null;
this.onOpenSubMap = options.onOpenSubMap || null;
this.onSelectionChange = options.onSelectionChange || null;
this.labels = {
createRelation: options.createRelationLabel || "Create relation",
resizeConcept: options.resizeConceptLabel || "Resize concept",
relation: options.relationLabel || "Relation"
};
this.map = this.CmapFactory(canvas);
this.items = [];
this.connectors = [];
this.selectedItem = null;
this.selectedConnector = null;
this.nextId = 1;
this.nextConnectorId = 1;
this.dragRelation = null;
this.installCanvasHandlers();
debug("editor created", {
canvas: elementDescription(canvas),
cmapFactoryAvailable: typeof this.CmapFactory === "function"
});
}
/**
* goal : Add a draggable concept or linking-phrase node.
* pre : options may contain the normal ionstage/cmap node attributes.
* post : The item is drawn and receives selection/relation/resize UI.
* result : The item record used by the editor.
*/
addItem(options = {}) {
const id = this.nextId++;
const kind = options.kind || "concept";
const record = {
id,
kind,
label: options.label || "Concept",
synopsis: options.synopsis || "",
pageSlug: options.pageSlug || null,
childMap: options.childMap || null,
backgroundColor: options.backgroundColor || "#f3f6f8",
borderColor: options.borderColor || "#5d6d7e",
fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif",
fontSize: options.fontSize || "15px",
width: options.width || (kind === "phrase" ? 145 : 220),
height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)),
node: null
};
const node = this.map.node({
content: this.itemHtml(record),
contentType: "html",
x: numberOr(options.x, 80 + ((id * 37) % 420)),
y: numberOr(options.y, 80 + ((id * 83) % 360)),
width: record.width,
height: record.height,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
borderWidth: kind === "phrase" ? 1 : 2,
textColor: "#222"
});
record.node = node;
this.items.push(record);
node.redraw();
this.decorateItem(record);
debug("item added", {
id: record.id,
kind: record.kind,
label: record.label,
element: elementDescription(record.node.element()),
style: selectionStyle(record.node.element())
});
return record;
}
/**
* goal : Change presentation/content of an existing item.
* pre : record belongs to this editor.
* post : The ionstage node and interaction handles are redrawn.
*/
updateItem(record, changes = {}) {
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) record[key] = value;
}
record.width = numberOr(Number(record.width), record.node.attr("width"));
record.height = numberOr(Number(record.height), record.node.attr("height"));
record.node.attr({
content: this.itemHtml(record),
width: record.width,
height: record.height,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor
});
record.node.redraw();
this.decorateItem(record);
this.redrawConnectorsFor(record);
}
/**
* goal : Connect source to target with a separate linking phrase.
* pre : source and target are items in this editor.
* post : source -> phrase -> target is visible; the phrase can branch.
* result : The newly created linking-phrase item.
*/
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
const a = this.itemCenter(source);
const b = this.itemCenter(target);
const phrase = this.addItem({
kind: "phrase",
label,
x: ((a.x + b.x) / 2) - 72,
y: ((a.y + b.y) / 2) - 18,
width: 145,
height: 36,
backgroundColor: "#fffdf7",
borderColor: "#8c7a4f"
});
this.addConnector(source, phrase, false);
this.addConnector(phrase, target, true);
this.selectItem(phrase);
if (editImmediately) this.editPhraseInline(phrase);
return phrase;
}
/**
* goal : Add one directed connector between two existing map items.
* pre : source and target are items in this editor.
* post : A selectable ionstage/cmap link joins them.
* result : Connector record.
*/
addConnector(source, target, hasArrow = true) {
const link = this.map.link({
content: "",
width: 1,
height: 1,
backgroundColor: "transparent",
borderColor: "transparent",
borderWidth: 0,
lineColor: "#333",
lineWidth: 2,
hasArrow
});
link.sourceNode(source.node).targetNode(target.node);
link.draggable(true);
link.redraw();
const record = {
id: this.nextConnectorId++,
link,
source,
target,
hasArrow,
lineColor: "#333",
lineWidth: 2
};
this.connectors.push(record);
this.decorateConnector(record);
return record;
}
/**
* goal : Select a concept/linking phrase and expose its handles.
* pre : record belongs to this editor.
* post : Previous selection is cleared and record is visually selected.
*/
selectItem(record) {
debug("selectItem called", {
requestedId: record ? record.id : null,
requestedKind: record ? record.kind : null,
previousId: this.selectedItem ? this.selectedItem.id : null
});
this.clearSelection();
this.selectedItem = record;
record.node.toFront();
const element = record.node.element();
if (element) {
element.classList.add("rw-cmap-selected");
element.setAttribute("aria-selected", "true");
this.ensureHandles(record, element);
}
debug("selection applied", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
elementFound: Boolean(element),
element: elementDescription(element),
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
computedStyle: selectionStyle(element)
});
if (element && typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(() => {
debug("selection after browser redraw", {
selectedId: this.selectedItem ? this.selectedItem.id : null,
selectedClassPresent: element.classList.contains("rw-cmap-selected"),
connected: element.isConnected,
handleCount: element.querySelectorAll(":scope > .rw-cmap-handle").length,
computedStyle: selectionStyle(element)
});
});
}
this.notifySelection();
}
/**
* goal : Select a connector so its line becomes clearly visible.
* pre : record belongs to this editor.
* post : Previous selection is cleared and the connector is highlighted.
*/
selectConnector(record) {
this.clearSelection();
this.selectedConnector = record;
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
record.link.redraw();
this.decorateConnector(record);
this.notifySelection();
}
/**
* goal : Remove the current item/connector selection.
* post : No item handles or connector highlight remain.
*/
clearSelection() {
const clearedItemId = this.selectedItem ? this.selectedItem.id : null;
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
if (this.selectedItem) {
const element = this.selectedItem.node.element();
if (element) {
element.classList.remove("rw-cmap-selected");
element.removeAttribute("aria-selected");
this.removeHandles(element);
}
}
if (this.selectedConnector) {
const connector = this.selectedConnector;
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
connector.link.redraw();
this.decorateConnector(connector);
}
this.selectedItem = null;
this.selectedConnector = null;
if (clearedItemId || clearedConnectorId) {
debug("selection cleared", { itemId: clearedItemId, connectorId: clearedConnectorId });
}
this.notifySelection();
}
selected() {
return this.selectedItem;
}
/**
* goal : Start direct editing of a linking phrase.
* pre : record.kind is "phrase".
* post : An input appears in the relation-name node and receives focus.
*/
editPhraseInline(record) {
if (!record || record.kind !== "phrase") return;
const value = record.label || "?????";
record.node.attr("content",
`<input class="rw-cmap-phrase-input" type="text" value="${escapeHtml(value)}" aria-label="${escapeHtml(this.labels.relation)}">`);
record.node.redraw();
this.decorateItem(record);
const element = record.node.element();
const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
if (!input) return;
const commit = () => {
const text = input.value.trim() || "?????";
record.label = text;
record.node.attr("content", this.itemHtml(record));
record.node.redraw();
this.decorateItem(record);
this.selectItem(record);
};
input.addEventListener("pointerdown", (event) => event.stopPropagation());
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
input.blur();
}
if (event.key === "Escape") {
event.preventDefault();
input.value = value;
input.blur();
}
});
input.addEventListener("blur", commit, { once: true });
input.focus();
input.select();
}
/**
* goal : Redraw selection controls after ionstage/cmap updates a node.
* pre : record.node.redraw() has made a DOM element available.
* post : Selection, drag-to-link and resize interactions are attached.
*/
decorateItem(record) {
const element = record.node.element();
if (!element) return;
element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
element.dataset.rwCmapItemId = String(record.id);
element.style.fontFamily = record.fontFamily;
element.style.fontSize = record.fontSize;
element.style.overflow = "visible";
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
debug("item pointer handlers attached", {
id: record.id,
kind: record.kind,
element: elementDescription(element),
style: selectionStyle(element)
});
element.addEventListener("dblclick", (event) => {
if (event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) return;
event.preventDefault();
event.stopPropagation();
if (record.kind === "phrase") {
this.editPhraseInline(record);
return;
}
if (record.pageSlug && this.onOpenPage) {
this.onOpenPage(record);
return;
}
if (record.childMap && this.onOpenSubMap) this.onOpenSubMap(record);
});
}
if (this.selectedItem === record) {
element.classList.add("rw-cmap-selected");
this.ensureHandles(record, element);
}
}
decorateConnector(record) {
const element = record.link.element();
if (!element) return;
element.classList.add("rw-cmap-connector");
element.dataset.rwCmapConnectorId = String(record.id);
if (element.dataset.rwCmapBound !== "1") {
element.dataset.rwCmapBound = "1";
element.addEventListener("pointerdown", (event) => {
event.stopPropagation();
this.selectConnector(record);
}, true);
}
}
redrawConnectorsFor(record) {
for (const connector of this.connectors) {
if (connector.source === record || connector.target === record) {
connector.link.redraw();
this.decorateConnector(connector);
}
}
}
/**
* goal : Select concepts before ionstage/cmap starts a possible drag.
* pre : Item DOM elements contain data-rw-cmap-item-id attributes.
* post : Pointer-down on an item selects it immediately; pointer-down on
* empty canvas space clears the selection.
*/
installCanvasHandlers() {
const itemFromEvent = (event) => {
if (!(event.target instanceof Element)) return null;
const element = event.target.closest("[data-rw-cmap-item-id]");
if (!element || !this.canvas.contains(element)) return null;
const id = Number(element.dataset.rwCmapItemId);
return this.items.find((item) => item.id === id) || null;
};
this.canvas.addEventListener("pointerdown", (event) => {
debug("canvas pointerdown", {
pointerId: event.pointerId,
pointerType: event.pointerType,
button: event.button,
target: elementDescription(event.target)
});
if (event.target instanceof Element &&
event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
debug("pointerdown belongs to a selection control", elementDescription(event.target));
return;
}
const item = itemFromEvent(event);
if (item) {
debug("pointerdown matched item", { id: item.id, kind: item.kind, label: item.label });
this.selectItem(item);
return;
}
debug("pointerdown did not match an item", {
targetIsCanvas: event.target === this.canvas,
target: elementDescription(event.target)
});
if (event.target === this.canvas) this.clearSelection();
}, true);
}
ensureHandles(record, element) {
this.removeHandles(element);
const relation = document.createElement("button");
relation.type = "button";
relation.className = "rw-cmap-handle rw-cmap-relation-handle";
relation.title = this.labels.createRelation;
relation.setAttribute("aria-label", this.labels.createRelation);
relation.setAttribute("aria-hidden", "false");
relation.addEventListener("pointerdown", (event) => this.startRelationDrag(event, record));
element.append(relation);
if (record.kind !== "phrase") {
const resize = document.createElement("button");
resize.type = "button";
resize.className = "rw-cmap-handle rw-cmap-resize-handle";
resize.title = this.labels.resizeConcept;
resize.setAttribute("aria-label", this.labels.resizeConcept);
resize.setAttribute("aria-hidden", "false");
resize.addEventListener("pointerdown", (event) => this.startResize(event, record));
element.append(resize);
}
}
removeHandles(element) {
for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
}
startResize(event, record) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = Number(record.node.attr("width"));
const startHeight = Number(record.node.attr("height"));
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
record.width = Math.max(100, startWidth + (moveEvent.clientX - startX));
record.height = Math.max(42, startHeight + (moveEvent.clientY - startY));
record.node.attr({ width: record.width, height: record.height });
record.node.redraw();
this.decorateItem(record);
this.redrawConnectorsFor(record);
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
this.decorateItem(record);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
startRelationDrag(event, source) {
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const start = this.itemCenter(source);
const draft = this.createDraftLine(start);
this.dragRelation = { source, draft };
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const point = this.canvasPoint(moveEvent);
draft.line.setAttribute("x2", String(point.x));
draft.line.setAttribute("y2", String(point.y));
};
const up = (upEvent) => {
if (upEvent.pointerId !== pointerId) return;
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
draft.svg.remove();
this.dragRelation = null;
const target = this.itemAt(upEvent.clientX, upEvent.clientY);
if (!target || target === source) return;
this.finishRelation(source, target);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
finishRelation(source, target) {
if (source.kind === "phrase" && target.kind !== "phrase") {
this.addConnector(source, target, true);
this.selectItem(source);
return;
}
if (source.kind !== "phrase" && target.kind === "phrase") {
this.addConnector(source, target, false);
this.selectItem(target);
return;
}
if (source.kind === "phrase" && target.kind === "phrase") return;
this.connectWithPhrase(source, target, "?????", true);
}
createDraftLine(start) {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.classList.add("rw-cmap-draft-layer");
svg.setAttribute("width", String(Math.max(this.canvas.scrollWidth, this.canvas.clientWidth)));
svg.setAttribute("height", String(Math.max(this.canvas.scrollHeight, this.canvas.clientHeight)));
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", String(start.x));
line.setAttribute("y1", String(start.y));
line.setAttribute("x2", String(start.x));
line.setAttribute("y2", String(start.y));
line.setAttribute("class", "rw-cmap-draft-line");
svg.append(line);
this.canvas.append(svg);
return { svg, line };
}
canvasPoint(event) {
const rect = this.canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left + this.canvas.scrollLeft,
y: event.clientY - rect.top + this.canvas.scrollTop
};
}
itemAt(clientX, clientY) {
const element = document.elementFromPoint(clientX, clientY);
const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null;
if (!itemElement) return null;
const id = Number(itemElement.dataset.rwCmapItemId);
return this.items.find((item) => item.id === id) || null;
}
itemCenter(record) {
return {
x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2),
y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2)
};
}
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.renderItem) return this.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
notifySelection() {
if (this.onSelectionChange) this.onSelectionChange(this.selectedItem, this.selectedConnector);
}
}
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.44",
createEditor(canvas, options) {
lastEditor = new CmapEditor(canvas, options);
return lastEditor;
},
debugSelection() {
if (!lastEditor) {
debug("debugSelection: no editor has been created");
return null;
}
const record = lastEditor.selected();
const element = record ? record.node.element() : null;
const result = {
selectedId: record ? record.id : null,
selectedKind: record ? record.kind : null,
selectedLabel: record ? record.label : null,
element: elementDescription(element),
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
computedStyle: selectionStyle(element)
};
debug("manual selection inspection", result);
return result;
}
};
})();
+4
View File
@@ -4,6 +4,7 @@ racket-wiki 0.2.3 no longer writes downloaded browser libraries into this packag
Pinned sources:
- Lucide 1.31.0: `https://cdn.jsdelivr.net/npm/lucide@1.31.0/dist/umd/lucide.min.js`
- EasyMDE 2.21.0: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.js`
- EasyMDE 2.21.0 CSS: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.css`
- DOMPurify 3.4.13: `https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js`
@@ -14,3 +15,6 @@ Pinned sources:
- diff2html 3.4.56 CSS: `https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/css/diff2html.min.css`
EasyMDE bundles CodeMirror and Marked, so no separate CodeMirror or Markdown renderer is downloaded.
The CMap implementation is maintained with racket-wiki itself below
`static/cmap/`; it is no longer installed as a runtime vendor dependency.