Bettern cmap handling. Lots of changes.
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
Vendored
+4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user