merge: sinkronkan upstream v3.2 ke elemes-v3.2
- ambil 4 fix upstream: socket latch saat drag, run-state board, toleransi posisi board di socket, koreksi kartu gesture sensor di picker - pertahankan custom Elemes: EmbedBridge, DeployProgress, backend auth/projects, nginx.conf - kembalikan EditorToolbar.tsx/.css + SimulatorCanvas.css (masih di-import oleh EditorPage/SimulatorCanvas; baseline sebelumnya broken build)
This commit is contained in:
commit
2b1747041b
|
|
@ -0,0 +1,434 @@
|
|||
/* ── Toolbar container ──────────────────────────────────────────────────────
|
||||
* Single-row layout: [left actions] [file tabs flex] [right actions]
|
||||
* The center slot (file tabs) flex-grows and scrolls horizontally so the
|
||||
* action icons on either side stay pinned and visible no matter how narrow
|
||||
* the editor pane gets when the user drags the editor/canvas divider.
|
||||
*
|
||||
* Uses CSS container queries so the Libraries label collapses based on the
|
||||
* toolbar's *own* width (not the viewport) — which is what changes when the
|
||||
* user drags the editor/canvas divider.
|
||||
*/
|
||||
.editor-toolbar-wrapper {
|
||||
container-type: inline-size;
|
||||
container-name: editor-toolbar;
|
||||
}
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 0 6px;
|
||||
height: 38px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Groups ─────────────────────────────────────── */
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-group-right {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Center slot — a flexible spacer between the left and right action groups
|
||||
that keeps the right icons pinned to the far right. Normally empty. */
|
||||
.toolbar-center-slot {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Divider ─────────────────────────────────────── */
|
||||
.tb-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #3a3a3a;
|
||||
margin: 0 3px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* ── Icon buttons ───────────────────────────────── */
|
||||
.tb-btn {
|
||||
/* 28px — the one control height across the whole editor strip (view-mode
|
||||
segments, Libraries, language select, canvas controls all match). */
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: #9d9d9d;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.tb-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Compile — accent blue (token, not neon) */
|
||||
.tb-btn-compile {
|
||||
color: var(--color-action-primary);
|
||||
}
|
||||
.tb-btn-compile:hover:not(:disabled) {
|
||||
background: rgba(0, 113, 227, 0.14);
|
||||
color: var(--color-action-primary-hover);
|
||||
}
|
||||
.tb-btn-compile:active:not(:disabled) {
|
||||
background: rgba(0, 113, 227, 0.22);
|
||||
}
|
||||
|
||||
/* Run — semantic success green (token) */
|
||||
.tb-btn-run {
|
||||
color: var(--color-feedback-success);
|
||||
}
|
||||
.tb-btn-run:hover:not(:disabled) {
|
||||
background: rgba(48, 209, 88, 0.14);
|
||||
color: var(--color-feedback-success);
|
||||
}
|
||||
|
||||
/* Stop — semantic error red (token) */
|
||||
.tb-btn-stop {
|
||||
color: var(--color-feedback-error);
|
||||
}
|
||||
.tb-btn-stop:hover:not(:disabled) {
|
||||
background: rgba(255, 69, 58, 0.14);
|
||||
color: var(--color-feedback-error);
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
.tb-btn-reset:hover:not(:disabled) {
|
||||
background: #2a2d2e;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Libraries button (always visible, with label) */
|
||||
.tb-btn-libraries {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 8px 0 7px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.tb-btn-libraries:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.tb-libraries-label {
|
||||
line-height: 1;
|
||||
}
|
||||
/* Hide the label only when the toolbar is genuinely cramped — keep the
|
||||
"Libraries" text visible in the default 50/50 split (~376px toolbar) so
|
||||
the button reads clearly. Below 360px the icon alone keeps it reachable
|
||||
without stealing horizontal space from the file tabs. The container query
|
||||
reacts to the toolbar's own width, so it triggers when the user drags the
|
||||
editor/canvas divider very narrow, not just on small viewports. */
|
||||
@container editor-toolbar (max-width: 360px) {
|
||||
.tb-libraries-label {
|
||||
display: none;
|
||||
}
|
||||
.tb-btn-libraries {
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
/* When even narrower, also collapse the board pill label. */
|
||||
@container editor-toolbar (max-width: 460px) {
|
||||
.tb-board-pill-label {
|
||||
display: none;
|
||||
}
|
||||
.tb-board-pill {
|
||||
padding: 3px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Import / Export moved to the File menu in the header (EditorMenuBar):
|
||||
their inline buttons and the responsive overflow twins are gone, which
|
||||
is ~70px the row no longer has to find on laptop widths. */
|
||||
|
||||
/* Legacy lib icon-only button (for overflow items) */
|
||||
.tb-btn-lib {
|
||||
color: var(--color-action-primary);
|
||||
}
|
||||
.tb-btn-lib:hover {
|
||||
background: rgba(0, 113, 227, 0.12);
|
||||
color: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
/* Missing library hint banner */
|
||||
.tb-lib-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: rgba(255, 159, 10, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 159, 10, 0.25);
|
||||
color: var(--color-feedback-warning);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tb-lib-hint svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-feedback-warning);
|
||||
}
|
||||
.tb-lib-hint-btn {
|
||||
background: var(--color-action-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.tb-lib-hint-btn:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.tb-lib-hint-close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.tb-lib-hint-close:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Output Console toggle */
|
||||
.tb-btn-output {
|
||||
color: #9d9d9d;
|
||||
}
|
||||
.tb-btn-output:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #ccc;
|
||||
}
|
||||
.tb-btn-output-active {
|
||||
color: var(--color-action-primary);
|
||||
background: rgba(0, 113, 227, 0.12);
|
||||
}
|
||||
.tb-btn-output-active:hover {
|
||||
background: rgba(0, 113, 227, 0.2);
|
||||
color: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
/* ── Spin animation (compiling) ─────────────────── */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Status inline message ──────────────────────── */
|
||||
.tb-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tb-status-success {
|
||||
color: var(--color-feedback-success);
|
||||
background: rgba(48, 209, 88, 0.1);
|
||||
}
|
||||
|
||||
.tb-status-error {
|
||||
color: var(--color-feedback-error);
|
||||
background: rgba(255, 69, 58, 0.1);
|
||||
}
|
||||
|
||||
.tb-status-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Compile All / Run All buttons ─────────────── */
|
||||
.tb-btn-compile-all {
|
||||
color: var(--color-action-primary);
|
||||
}
|
||||
.tb-btn-compile-all:hover:not(:disabled) {
|
||||
background: rgba(0, 113, 227, 0.14);
|
||||
color: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.tb-btn-run-all {
|
||||
color: var(--color-feedback-success);
|
||||
}
|
||||
.tb-btn-run-all:hover:not(:disabled) {
|
||||
background: rgba(48, 209, 88, 0.14);
|
||||
color: var(--color-feedback-success);
|
||||
}
|
||||
|
||||
/* ── Board context pill ─────────────────────────── */
|
||||
.tb-board-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px 3px 7px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tb-board-pill-icon {
|
||||
font-size: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-board-pill-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tb-board-pill-running {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-feedback-success);
|
||||
flex-shrink: 0;
|
||||
animation: pulse-run 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-run {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Overflow (…) menu ──────────────────────────── */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* ── Run split-button (multi-board: primary Run = all boards + menu) ──── */
|
||||
.tb-run-split {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.tb-btn-run-caret {
|
||||
width: 16px;
|
||||
color: var(--color-feedback-success);
|
||||
}
|
||||
.tb-btn-run-caret:hover:not(:disabled) {
|
||||
background: rgba(48, 209, 88, 0.14);
|
||||
color: var(--color-feedback-success);
|
||||
}
|
||||
.tb-run-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
background: #2a2a2c;
|
||||
border: 1px solid #3a3a3c;
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.tb-run-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
.tb-run-menu-item:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
.tb-run-menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
/* Pro pill — signals premium items in the overflow menu BEFORE the user
|
||||
clicks. Used in place of a hidden gate so free users aren't surprised
|
||||
by an upgrade prompt on what looked like a free button. */
|
||||
|
||||
/* ── Error detail bar (for long compiler errors) ── */
|
||||
.toolbar-error-detail {
|
||||
background: #1a0000;
|
||||
border-bottom: 1px solid #5a1a1a;
|
||||
color: #f48fb1;
|
||||
font-size: 11px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
padding: 4px 12px;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,585 @@
|
|||
.simulator-canvas-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.simulator-canvas {
|
||||
flex: 1;
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Canvas header ───────────────────────────────── */
|
||||
.canvas-header {
|
||||
height: 46px;
|
||||
padding: 0 10px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* When portaled into the unified top toolbar, drop the standalone chrome
|
||||
(border + background) and shrink to match the editor toolbar height so
|
||||
the two halves read as a single bar. Also drop space-between so the
|
||||
left/right groups sit adjacent to each other inside the slot. */
|
||||
.canvas-header--portaled {
|
||||
height: 38px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.canvas-header-left,
|
||||
.canvas-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Status LED dot ──────────────────────────────── */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.3s,
|
||||
box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 6px rgba(76, 175, 80, 0.7);
|
||||
animation: pulse-green 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.stopped {
|
||||
background: #555;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-green {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px rgba(76, 175, 80, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Board Selector ──────────────────────────────── */
|
||||
.board-selector {
|
||||
padding: 4px 8px;
|
||||
background: #1e2a38;
|
||||
color: #c9d1d9;
|
||||
border: 1px solid #3a4a5a;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
height: 28px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
.board-selector:hover:not(:disabled) {
|
||||
border-color: var(--color-action-primary);
|
||||
}
|
||||
|
||||
.board-selector:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Serial Monitor button ───────────────────────── */
|
||||
.canvas-serial-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 10px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid #3a4a5a;
|
||||
border-radius: 5px;
|
||||
color: #9d9d9d;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.canvas-serial-btn:hover {
|
||||
border-color: var(--color-action-primary);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.canvas-serial-btn-active {
|
||||
background: #0e3a5a;
|
||||
border-color: var(--color-action-primary);
|
||||
color: var(--color-action-primary);
|
||||
}
|
||||
|
||||
/* ── Canvas icon-only buttons (undo/redo, …) ─────── */
|
||||
.canvas-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid #3a4a5a;
|
||||
border-radius: 5px;
|
||||
color: #9d9d9d;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.canvas-icon-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-action-primary);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.canvas-icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Component count ─────────────────────────────── */
|
||||
.component-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ── Add Component button ────────────────────────── */
|
||||
.add-component-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 10px;
|
||||
height: 28px;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-component-btn:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.add-component-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Canvas content (viewport) ───────────────────── */
|
||||
.canvas-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #1a1a1a;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 19px,
|
||||
rgba(255, 255, 255, 0.025) 19px,
|
||||
rgba(255, 255, 255, 0.025) 20px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 19px,
|
||||
rgba(255, 255, 255, 0.025) 19px,
|
||||
rgba(255, 255, 255, 0.025) 20px
|
||||
);
|
||||
user-select: none;
|
||||
touch-action: none; /* Disable browser scroll/zoom so JS handlers take full control */
|
||||
}
|
||||
|
||||
/* ── Infinite canvas world ───────────────────────── */
|
||||
.canvas-world {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
width: 4000px;
|
||||
height: 3000px;
|
||||
}
|
||||
|
||||
/* ── Components area ─────────────────────────────── */
|
||||
.components-area {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* Hit-test transparent: this full-canvas div sits later in the DOM than
|
||||
the boards, so with pointer events it would swallow clicks meant for
|
||||
board pins / board drag overlays (their z-index is local to each
|
||||
board's stacking context). Component groups re-enable their own. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Zoom controls ───────────────────────────────── */
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 5px;
|
||||
padding: 0 2px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #9d9d9d;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9d9d9d;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.zoom-level:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.component-label {
|
||||
font-size: 11px;
|
||||
background-color: #252526;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* ── Wire creation mode banner ──────────────────── */
|
||||
.wire-mode-banner {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
background: rgba(0, 113, 227, 0.92);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.wire-mode-banner button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
padding: 4px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wire-mode-banner button:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ── Mobile-friendly adjustments ────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.canvas-header {
|
||||
height: 42px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.canvas-header-left,
|
||||
.canvas-header-right {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.canvas-serial-btn {
|
||||
padding: 0 6px;
|
||||
font-size: 0; /* hide text, show icon only */
|
||||
}
|
||||
|
||||
.canvas-serial-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.add-component-btn {
|
||||
padding: 0 8px;
|
||||
font-size: 0; /* hide text, show icon only */
|
||||
}
|
||||
|
||||
.add-component-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.board-selector {
|
||||
font-size: 11px;
|
||||
max-width: 90px;
|
||||
padding: 4px 20px 4px 6px;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: none; /* Use pinch-to-zoom on mobile */
|
||||
}
|
||||
|
||||
.component-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wire-mode-banner {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
gap: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Narrow shared toolbar (desktop, AI chat open) ────────────────────────
|
||||
When the canvas header is portaled into the unified top toolbar, it shares
|
||||
one row with the editor actions. As that *bar* narrows (mainly when the
|
||||
right-docked AI chat opens), collapse the canvas controls the same way the
|
||||
mobile block does — but keyed to the bar's own width via a container query,
|
||||
not the viewport, so it triggers on a wide screen with the chat open too.
|
||||
Priority: labels -> icons, then drop the non-interactive count, then the
|
||||
zoom buttons (mouse-wheel zoom still works). Mirrors the thresholds the
|
||||
editor toolbar and view-mode toggle collapse at, so the whole bar degrades
|
||||
together and nothing overlaps. */
|
||||
@container unified-toolbar (max-width: 1010px) {
|
||||
.canvas-serial-btn {
|
||||
padding: 0 8px;
|
||||
font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */
|
||||
}
|
||||
.canvas-serial-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.add-component-btn {
|
||||
padding: 0 9px;
|
||||
font-size: 0; /* hide "Add" text, keep the icon */
|
||||
}
|
||||
.add-component-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.board-selector {
|
||||
max-width: 120px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@container unified-toolbar (max-width: 905px) {
|
||||
.component-count {
|
||||
display: none;
|
||||
}
|
||||
.board-selector {
|
||||
max-width: 96px;
|
||||
}
|
||||
}
|
||||
@container unified-toolbar (max-width: 835px) {
|
||||
.zoom-controls {
|
||||
display: none; /* mouse-wheel / trackpad zoom still available */
|
||||
}
|
||||
}
|
||||
|
||||
/* ── WiFi / BLE status badges ─────────────────────────────────────────── */
|
||||
.canvas-wifi-badge,
|
||||
.canvas-ble-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.canvas-wifi-badge svg,
|
||||
.canvas-ble-badge svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* WiFi status colours */
|
||||
.canvas-wifi-initializing {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.canvas-wifi-connected {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.canvas-wifi-got_ip {
|
||||
color: #22c55e;
|
||||
}
|
||||
.canvas-wifi-disconnected {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.canvas-wifi-clickable {
|
||||
cursor: pointer !important;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
filter 0.2s;
|
||||
}
|
||||
|
||||
.canvas-wifi-clickable:hover {
|
||||
transform: scale(1.15);
|
||||
filter: drop-shadow(0 0 5px currentColor);
|
||||
}
|
||||
|
||||
.canvas-wifi-clickable:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* BLE status colours */
|
||||
.canvas-ble-initialized {
|
||||
color: var(--color-action-primary);
|
||||
}
|
||||
.canvas-ble-advertising {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
/* ── Selection action bar (floating toolbar for selected wire/component/board) ─── */
|
||||
.selection-action-bar button:hover {
|
||||
background: #2a2d2e !important;
|
||||
border-color: #3c3c3c !important;
|
||||
}
|
||||
.selection-action-bar button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.selection-action-bar button:focus-visible {
|
||||
outline: 2px solid var(--color-action-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Hide button labels on narrow viewports — keep just the icons */
|
||||
@media (max-width: 520px) {
|
||||
.selection-action-bar .selection-action-bar__label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Runtime burnout (P4): a component destroyed mid-simulation renders charred,
|
||||
with a red dashed outline + a smoke badge (the badge is a sibling of the
|
||||
container, so it stays full-colour). Cleared on Reset. */
|
||||
.velxio-burnt .web-component-container {
|
||||
filter: grayscale(0.85) brightness(0.4) sepia(0.5) contrast(1.15);
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
.velxio-burnt {
|
||||
outline: 1px dashed rgba(220, 38, 38, 0.65);
|
||||
outline-offset: 1px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Selected component: marching ants.
|
||||
A newly added part lands in a corner of the viewport and the previous
|
||||
flat dashed border was easy to miss on a busy canvas — a moving outline
|
||||
is what the eye actually catches. Drawn on a pseudo-element just OUTSIDE
|
||||
the body so it never covers the artwork, and pointer-events: none so it
|
||||
never steals a click or a pin.
|
||||
|
||||
Animating a dashed border is not possible (dash offset is not an
|
||||
animatable property), hence the four gradient edges with a moving
|
||||
background-position — the standard recipe. */
|
||||
.velxio-ants::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 5px;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
background-image:
|
||||
linear-gradient(90deg, var(--ant-color) 50%, transparent 0),
|
||||
linear-gradient(90deg, var(--ant-color) 50%, transparent 0),
|
||||
linear-gradient(0deg, var(--ant-color) 50%, transparent 0),
|
||||
linear-gradient(0deg, var(--ant-color) 50%, transparent 0);
|
||||
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
|
||||
background-size: 10px 2px, 10px 2px, 2px 10px, 2px 10px;
|
||||
background-position: 0 0, 0 100%, 0 0, 100% 0;
|
||||
animation: velxio-ants 0.55s linear infinite;
|
||||
}
|
||||
|
||||
.velxio-ants {
|
||||
--ant-color: #2f9bff;
|
||||
}
|
||||
|
||||
@keyframes velxio-ants {
|
||||
to {
|
||||
background-position: 10px 0, -10px 100%, 0 -10px, 100% 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect the OS setting: the outline still marks the selection, it just
|
||||
stops moving. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.velxio-ants::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ import {
|
|||
seatOnDrop,
|
||||
snapPositionToBreadboard,
|
||||
} from '../../utils/breadboardSnap';
|
||||
import { snapBoardToSocket } from '../../utils/socketSnap';
|
||||
import { snapBoardToSocket, isBoardSeated } from '../../utils/socketSnap';
|
||||
import {
|
||||
findWireNearPoint,
|
||||
findSegmentNearPoint,
|
||||
|
|
@ -138,8 +138,7 @@ function carrySeatedBoards(
|
|||
for (const b of st.boards) {
|
||||
// Restricting the candidate list to the dragged component asks the
|
||||
// narrow question: is this board seated on THIS socket?
|
||||
const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [dragged]);
|
||||
if (seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5) {
|
||||
if (isBoardSeated(b.id, b.boardKind, b.x, b.y, [dragged])) {
|
||||
st.setBoardPosition({ x: b.x + dx, y: b.y + dy }, b.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -371,6 +370,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
// Which drag session already raised its item (drag-to-front fires once per
|
||||
// drag, on the first mousemove).
|
||||
const raisedThisDragRef = useRef<string | null>(null);
|
||||
/**
|
||||
* The socket a dragged board was plugged into when the drag STARTED.
|
||||
* Latched once per drag: "is it seated?" is only true at the seat, so
|
||||
* re-asking after the first pixel of movement answers no and the stack
|
||||
* would come apart one frame in.
|
||||
*/
|
||||
const carriedSocketRef = useRef<{ dragId: string; sockId: string } | null>(null);
|
||||
// Captures (x, y) of the dragged component at mousedown so a drag-end
|
||||
// can record the diff as a single undoable Move. Boards are intentionally
|
||||
// skipped — board moves don't go through component history.
|
||||
|
|
@ -932,14 +938,22 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
const st = useSimulatorStore.getState();
|
||||
const b = st.boards.find((bb) => bb.id === boardId);
|
||||
// Same stack rule as the mouse path: while simulating, a seated
|
||||
// board drags its socket along instead of unplugging.
|
||||
const sock =
|
||||
st.running && b
|
||||
? st.components.find((c) => {
|
||||
const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]);
|
||||
return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5;
|
||||
})
|
||||
: undefined;
|
||||
// board drags its socket along instead of unplugging. Latched at
|
||||
// drag start for the same reason (see carriedSocketRef).
|
||||
if (carriedSocketRef.current?.dragId !== touchId) {
|
||||
// Ask the DRAGGED board whether it is running, not the store's
|
||||
// top-level flag — that one mirrors the ACTIVE board only, so a
|
||||
// running non-active board read as stopped and its stack came
|
||||
// apart mid-simulation.
|
||||
const boardRunning = !!(b && (b.running || st.running));
|
||||
const seatedOn =
|
||||
boardRunning && b
|
||||
? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c]))
|
||||
: undefined;
|
||||
carriedSocketRef.current = { dragId: touchId, sockId: seatedOn?.id ?? '' };
|
||||
}
|
||||
const sockId = carriedSocketRef.current.sockId;
|
||||
const sock = sockId ? st.components.find((c) => c.id === sockId) : undefined;
|
||||
if (b && sock) {
|
||||
updateComponent(sock.id, {
|
||||
x: sock.x + (nb.x - b.x),
|
||||
|
|
@ -1602,13 +1616,23 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
// While SIMULATING, a seated stack moves as one piece: dragging the
|
||||
// board drags its socket along instead of unplugging it. Unplugging
|
||||
// is an edit-mode gesture (carrySeatedBoards is the other half).
|
||||
const sock =
|
||||
st.running && b
|
||||
? st.components.find((c) => {
|
||||
const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]);
|
||||
return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5;
|
||||
})
|
||||
: undefined;
|
||||
if (carriedSocketRef.current?.dragId !== draggedComponentId) {
|
||||
// Ask the DRAGGED board whether it is running, not the store's
|
||||
// top-level flag — that one mirrors the ACTIVE board only, so a
|
||||
// running non-active board read as stopped and its stack came
|
||||
// apart mid-simulation.
|
||||
const boardRunning = !!(b && (b.running || st.running));
|
||||
const seatedOn =
|
||||
boardRunning && b
|
||||
? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c]))
|
||||
: undefined;
|
||||
carriedSocketRef.current = {
|
||||
dragId: draggedComponentId,
|
||||
sockId: seatedOn?.id ?? '',
|
||||
};
|
||||
}
|
||||
const sockId = carriedSocketRef.current.sockId;
|
||||
const sock = sockId ? st.components.find((c) => c.id === sockId) : undefined;
|
||||
if (b && sock) {
|
||||
updateComponent(sock.id, {
|
||||
x: sock.x + (nb.x - b.x),
|
||||
|
|
@ -1940,6 +1964,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
recalculateAllWirePositions();
|
||||
setDraggedComponentId(null);
|
||||
raisedThisDragRef.current = null;
|
||||
carriedSocketRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2911,6 +2936,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
setPan({ ...panRef.current });
|
||||
setDraggedComponentId(null);
|
||||
raisedThisDragRef.current = null;
|
||||
carriedSocketRef.current = null;
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -189,10 +189,10 @@ export const ONLINE_ONLY_COMPONENT_ADS: OnlineOnlyComponentAd[] = [
|
|||
},
|
||||
{
|
||||
id: 'grove-gesture-pag7660',
|
||||
label: 'Grove Smart IR Gesture (PAG7660)',
|
||||
description: 'IR gesture recognition (rotate/push/tap/...) - available in the online editor',
|
||||
label: 'Grove Smart IR Gesture (PAG7661QN)',
|
||||
description: 'IR camera gesture sensor on a XIAO carrier: seat a XIAO on its socket and read rotate/tap/grab/pinch/swipe over I2C - available in the online editor',
|
||||
category: 'input',
|
||||
thumbnailSvg: '<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="8" y="12" width="50" height="40" rx="6" fill="#1d3a5f"/><circle cx="36" cy="32" r="12" fill="#0c0d10" stroke="#3a3f49" stroke-width="2"/><circle cx="36" cy="32" r="6" fill="#1b1030"/></svg>',
|
||||
thumbnailSvg: '<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="17" y="4" width="30" height="56" rx="5" fill="#12518f" stroke="#0d3c6b"/><circle cx="34" cy="18" r="8" fill="#0a0b0e" stroke="#3a3f49"/><circle cx="34" cy="18" r="4" fill="#1b1030"/><rect x="21" y="30" width="7" height="24" rx="1.5" fill="#15171c"/><rect x="38" y="30" width="7" height="24" rx="1.5" fill="#15171c"/><rect x="8" y="12" width="8" height="14" rx="2" fill="#e8e8ec"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'respeaker-lite',
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import {
|
|||
collectWireSegments,
|
||||
} from '../utils/wireAutoRoute';
|
||||
import { isBreadboard } from '../utils/breadboardNets';
|
||||
import { snapBoardToSocket } from '../utils/socketSnap';
|
||||
import { isBoardSeated } from '../utils/socketSnap';
|
||||
import { computeSeating } from '../utils/breadboardSnap';
|
||||
import { createSerialBatcher } from './serialBatcher';
|
||||
import {
|
||||
|
|
@ -3169,16 +3169,9 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
// above — raising the socket alone buried its own seated board, and a
|
||||
// buried board cannot be grabbed to unplug it.
|
||||
const seatedOnIt = s.components.some((c) => c.id === id)
|
||||
? s.boards.filter((b) => {
|
||||
const seat = snapBoardToSocket(
|
||||
b.id,
|
||||
b.boardKind,
|
||||
b.x,
|
||||
b.y,
|
||||
s.components.filter((c) => c.id === id),
|
||||
);
|
||||
return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5;
|
||||
})
|
||||
? s.boards.filter((b) =>
|
||||
isBoardSeated(b.id, b.boardKind, b.x, b.y, s.components.filter((c) => c.id === id)),
|
||||
)
|
||||
: [];
|
||||
let top = s.zTop;
|
||||
const zOrders = { ...s.zOrders, [id]: ++top };
|
||||
|
|
|
|||
|
|
@ -95,12 +95,24 @@ export function snapBoardToSocket(
|
|||
}
|
||||
|
||||
/**
|
||||
* True when the board's CURRENT position IS a socket seat (within half a
|
||||
* pixel). This is the z-order question, not the drag question: a seated
|
||||
* board must paint above its socket component, an unseated one must stay
|
||||
* below components like every other board — the blanket zIndex bump that
|
||||
* preceded this check hid a resistor behind an Arduino in every ordinary
|
||||
* example.
|
||||
* How far off the exact seat a board may sit and still count as plugged in.
|
||||
* Not zero, and deliberately far below SOCKET_SNAP_TOLERANCE: a stack the
|
||||
* magnet built lands exact, but one an EXAMPLE declares (or a project saved
|
||||
* before a socket's art was nudged) can be a fraction of a pixel out. At the
|
||||
* old half-pixel bar such a board looked seated on screen while every seat
|
||||
* test said otherwise — so it got no electrical connection and its socket
|
||||
* did not travel with it. A couple of pixels is invisible to the eye and
|
||||
* still nowhere near the next hole.
|
||||
*/
|
||||
const SEATED_EPSILON = 2;
|
||||
|
||||
/**
|
||||
* True when the board's CURRENT position IS a socket seat. This is the
|
||||
* "is it plugged in?" question, asked by z-order (a seated board must paint
|
||||
* above its socket, an unseated one stays below components like every other
|
||||
* board — a blanket zIndex bump once hid a resistor behind an Arduino),
|
||||
* by the electrical hop that makes seating mean connection, and by the drag
|
||||
* rules that keep a plugged stack together.
|
||||
*/
|
||||
export function isBoardSeated(
|
||||
boardId: string,
|
||||
|
|
@ -110,5 +122,5 @@ export function isBoardSeated(
|
|||
components: ComponentLike[],
|
||||
): boolean {
|
||||
const seat = snapBoardToSocket(boardId, boardKind, x, y, components);
|
||||
return !!seat && Math.hypot(seat.x - x, seat.y - y) < 0.5;
|
||||
return !!seat && Math.hypot(seat.x - x, seat.y - y) <= SEATED_EPSILON;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue