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