DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
.button {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.buttonPrimary {
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.iconOnly {
|
||||
min-width: 38px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
transform: translate(-50%, calc(-100% - 8px));
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background: var(--color-tooltip-bg);
|
||||
border: 1px solid var(--stroke-2);
|
||||
box-shadow: var(--shadow-tooltip);
|
||||
pointer-events: none;
|
||||
z-index: var(--z-tooltip);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.root {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
backdrop-filter: var(--backdrop-blur-shell);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--inspector-w);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.col {
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './LayoutShell.module.css';
|
||||
|
||||
type Props = {
|
||||
topBar: React.ReactNode;
|
||||
left: React.ReactNode;
|
||||
center: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
};
|
||||
|
||||
export function LayoutShell({ topBar, left, center, right }: Props) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.topBar}>{topBar}</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.col}>{left}</div>
|
||||
<div className={styles.col}>{center}</div>
|
||||
<div className={styles.col}>{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-panel-2);
|
||||
border: 1px solid var(--stroke);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--backdrop-blur-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './Surface.module.css';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
/** Разрешено `undefined` из CSS-модулей при `exactOptionalPropertyTypes`. */
|
||||
className?: string | undefined;
|
||||
style?: React.CSSProperties | undefined;
|
||||
};
|
||||
|
||||
export function Surface({ children, className, style }: Props) {
|
||||
return (
|
||||
<div className={[styles.root, className].filter(Boolean).join(' ')} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
void test('Button: тултип через портал (title), не только нативный атрибут', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'controls.tsx'), 'utf8');
|
||||
assert.ok(src.includes('createPortal'));
|
||||
assert.ok(src.includes('role="tooltip"'));
|
||||
assert.ok(src.includes('onMouseEnter={showTip}'));
|
||||
assert.ok(src.includes('document.body'));
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import styles from './Controls.module.css';
|
||||
|
||||
type ButtonProps = {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: 'primary' | 'ghost';
|
||||
disabled?: boolean;
|
||||
title?: string | undefined;
|
||||
/** Подпись для скринридеров (иконки без текста). */
|
||||
ariaLabel?: string | undefined;
|
||||
/** Компактная кнопка под одну иконку. */
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'ghost',
|
||||
disabled = false,
|
||||
title,
|
||||
ariaLabel,
|
||||
iconOnly = false,
|
||||
}: ButtonProps) {
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const showTip = useCallback(() => {
|
||||
if (disabled || !title) return;
|
||||
const el = btnRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setTipPos({ x: r.left + r.width / 2, y: r.top });
|
||||
}, [disabled, title]);
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
setTipPos(null);
|
||||
}, []);
|
||||
|
||||
const btnClass = [
|
||||
styles.button,
|
||||
variant === 'primary' ? styles.buttonPrimary : '',
|
||||
iconOnly ? styles.iconOnly : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const tip =
|
||||
title && tipPos && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div role="tooltip" className={styles.tooltip} style={{ left: tipPos.x, top: tipPos.y }}>
|
||||
{title}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className={btnClass}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onMouseEnter={showTip}
|
||||
onMouseLeave={hideTip}
|
||||
onFocus={showTip}
|
||||
onBlur={hideTip}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{tip}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type InputProps = {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
|
||||
export function Input({ value, placeholder, onChange }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={styles.input}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@import '../styles/variables.css';
|
||||
@import url('https://fonts.cdnfonts.com/css/nimbus-sans');
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--text0);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 2px solid rgba(0, 0, 0, 0);
|
||||
background-clip: padding-box;
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 2px solid rgba(0, 0, 0, 0);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection-bg);
|
||||
}
|
||||
Reference in New Issue
Block a user