Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34dd39791b | |||
| fdccc0deb5 |
+22
-3
@@ -21,6 +21,7 @@ import {
|
|||||||
import type { Project } from '../shared/types';
|
import type { Project } from '../shared/types';
|
||||||
import { asNpcId } from '../shared/types/ids';
|
import { asNpcId } from '../shared/types/ids';
|
||||||
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
|
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
|
||||||
|
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
|
||||||
import {
|
import {
|
||||||
asPreviewRotationDeg,
|
asPreviewRotationDeg,
|
||||||
previewRotationStepsCw,
|
previewRotationStepsCw,
|
||||||
@@ -390,6 +391,18 @@ function emitTokensState(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */
|
||||||
|
async function importAppTokensFromOpenProjectCache(): Promise<Project | null> {
|
||||||
|
const cacheDir = projectStore.getOpenCacheDir();
|
||||||
|
if (!tokensStore || !cacheDir) return null;
|
||||||
|
const remap = await tokensStore.importFromExportDir(cacheDir);
|
||||||
|
if (remap.size === 0) return null;
|
||||||
|
emitTokensState();
|
||||||
|
const needsRemap = [...remap.entries()].some(([from, to]) => from !== to);
|
||||||
|
if (!needsRemap) return null;
|
||||||
|
return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap));
|
||||||
|
}
|
||||||
|
|
||||||
function emitSceneTokensSessionState(): void {
|
function emitSceneTokensSessionState(): void {
|
||||||
const state = sceneTokensSessionStore.getState();
|
const state = sceneTokensSessionStore.getState();
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
@@ -882,7 +895,9 @@ async function main() {
|
|||||||
return { project };
|
return { project };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||||
const project = await projectStore.openProjectById(projectId);
|
let project = await projectStore.openProjectById(projectId);
|
||||||
|
const remapped = await importAppTokensFromOpenProjectCache();
|
||||||
|
if (remapped) project = remapped;
|
||||||
sceneViewStore.reset();
|
sceneViewStore.reset();
|
||||||
sceneTokensSessionStore.reset();
|
sceneTokensSessionStore.reset();
|
||||||
sceneNpcTokensSessionStore.reset();
|
sceneNpcTokensSessionStore.reset();
|
||||||
@@ -1445,7 +1460,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const srcPath = filePaths[0];
|
const srcPath = filePaths[0];
|
||||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
||||||
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
|
let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
|
||||||
emitZipProgress({
|
emitZipProgress({
|
||||||
kind: 'import',
|
kind: 'import',
|
||||||
stage: p.stage,
|
stage: p.stage,
|
||||||
@@ -1454,6 +1469,8 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const cacheDir = projectStore.getOpenCacheDir();
|
const cacheDir = projectStore.getOpenCacheDir();
|
||||||
|
const remapped = await importAppTokensFromOpenProjectCache();
|
||||||
|
if (remapped) project = remapped;
|
||||||
if (playlistsStore && cacheDir) {
|
if (playlistsStore && cacheDir) {
|
||||||
await playlistsStore.importFromExportDir(cacheDir);
|
await playlistsStore.importFromExportDir(cacheDir);
|
||||||
emitPlaylistsState();
|
emitPlaylistsState();
|
||||||
@@ -1564,7 +1581,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
|
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
|
||||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
||||||
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
|
let project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
|
||||||
emitZipProgress({
|
emitZipProgress({
|
||||||
kind: 'import',
|
kind: 'import',
|
||||||
stage: p.stage,
|
stage: p.stage,
|
||||||
@@ -1573,6 +1590,8 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const cacheDir = projectStore.getOpenCacheDir();
|
const cacheDir = projectStore.getOpenCacheDir();
|
||||||
|
const remapped = await importAppTokensFromOpenProjectCache();
|
||||||
|
if (remapped) project = remapped;
|
||||||
if (playlistsStore && cacheDir) {
|
if (playlistsStore && cacheDir) {
|
||||||
await playlistsStore.importFromExportDir(cacheDir);
|
await playlistsStore.importFromExportDir(cacheDir);
|
||||||
emitPlaylistsState();
|
emitPlaylistsState();
|
||||||
|
|||||||
@@ -122,6 +122,23 @@ void test('main index: setCurrentGraphNode uses setSessionPointer not updateProj
|
|||||||
assert.doesNotMatch(m[0], /updateProject/);
|
assert.doesNotMatch(m[0], /updateProject/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('main index: full zip import loads app-tokens like playlists', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||||
|
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
|
||||||
|
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
|
||||||
|
assert.match(src, /remapProjectSceneTokenIds/);
|
||||||
|
const importZip = src.match(
|
||||||
|
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
|
||||||
|
);
|
||||||
|
assert.ok(importZip);
|
||||||
|
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
|
||||||
|
const importFromPath = src.match(
|
||||||
|
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
|
||||||
|
);
|
||||||
|
assert.ok(importFromPath);
|
||||||
|
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||||
const i = src.indexOf('export async function replaceFileAtomic');
|
const i = src.indexOf('export async function replaceFileAtomic');
|
||||||
|
|||||||
@@ -113,3 +113,10 @@ void test('createWindows: логи окон не валят main через EPIP
|
|||||||
assert.ok(src.includes('errorCode === -3'));
|
assert.ok(src.includes('errorCode === -3'));
|
||||||
assert.ok(src.includes('isMainFrame'));
|
assert.ok(src.includes('isMainFrame'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
|
||||||
|
assert.match(src, /backgroundThrottling:\s*false/);
|
||||||
|
assert.match(src, /setBackgroundThrottling\(false\)/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -398,6 +398,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
autoplayPolicy: 'no-user-gesture-required',
|
autoplayPolicy: 'no-user-gesture-required',
|
||||||
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
||||||
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
||||||
|
// macOS throttles RAF in unfocused windows — token paths and video stutter on presentation.
|
||||||
|
...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor'
|
||||||
|
? { backgroundThrottling: false }
|
||||||
|
: {}),
|
||||||
...(kind === 'npcs'
|
...(kind === 'npcs'
|
||||||
? {
|
? {
|
||||||
webviewTag: true,
|
webviewTag: true,
|
||||||
@@ -416,6 +420,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindWindowChromeTitle(win, kind);
|
bindWindowChromeTitle(win, kind);
|
||||||
|
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
|
||||||
|
win.webContents.setBackgroundThrottling(false);
|
||||||
|
}
|
||||||
if (kind === 'npcs') {
|
if (kind === 'npcs') {
|
||||||
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
|
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
|
||||||
delete guestPrefs.preload;
|
delete guestPrefs.preload;
|
||||||
|
|||||||
@@ -1073,7 +1073,7 @@ export function ControlApp() {
|
|||||||
/** Действия с токенами/ловушками только без активной кисти эффектов. */
|
/** Действия с токенами/ловушками только без активной кисти эффектов. */
|
||||||
const markersInteractive = tool.tool === 'none';
|
const markersInteractive = tool.tool === 'none';
|
||||||
|
|
||||||
const pathLivePoses = useTokenPathLivePoses({
|
useTokenPathLivePoses({
|
||||||
tokens: currentScene?.tokens ?? [],
|
tokens: currentScene?.tokens ?? [],
|
||||||
npcTokens: visibleNpcTokens,
|
npcTokens: visibleNpcTokens,
|
||||||
pathSession: tokenPathSession,
|
pathSession: tokenPathSession,
|
||||||
@@ -2727,7 +2727,6 @@ export function ControlApp() {
|
|||||||
viewport={previewContentRect}
|
viewport={previewContentRect}
|
||||||
editable={markersInteractive}
|
editable={markersInteractive}
|
||||||
snapNorm={snapNormActive}
|
snapNorm={snapNormActive}
|
||||||
poseOverrides={pathLivePoses.tokenPoses}
|
|
||||||
dragEnabledById={tokenPathDragEnabled}
|
dragEnabledById={tokenPathDragEnabled}
|
||||||
onMove={(placementId, nx, ny) => {
|
onMove={(placementId, nx, ny) => {
|
||||||
const snapped = snapNormActive(nx, ny);
|
const snapped = snapNormActive(nx, ny);
|
||||||
@@ -2767,7 +2766,6 @@ export function ControlApp() {
|
|||||||
grid={currentScene?.grid ?? null}
|
grid={currentScene?.grid ?? null}
|
||||||
editable={markersInteractive}
|
editable={markersInteractive}
|
||||||
snapNorm={snapNormActive}
|
snapNorm={snapNormActive}
|
||||||
poseOverrides={pathLivePoses.npcPoses}
|
|
||||||
dragEnabledById={npcPathDragEnabled}
|
dragEnabledById={npcPathDragEnabled}
|
||||||
onMove={(placementId, nx, ny) => {
|
onMove={(placementId, nx, ny) => {
|
||||||
const snapped = snapNormActive(nx, ny);
|
const snapped = snapNormActive(nx, ny);
|
||||||
|
|||||||
@@ -33,3 +33,15 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
|
|||||||
assert.ok(pixi.includes('mergeDraftState'));
|
assert.ok(pixi.includes('mergeDraftState'));
|
||||||
assert.ok(pixi.includes('forwardRef'));
|
assert.ok(pixi.includes('forwardRef'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: path playback не гоняет setState корня', () => {
|
||||||
|
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||||
|
const hook = fs.readFileSync(
|
||||||
|
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(app, /\bpathLivePoses\b/);
|
||||||
|
assert.doesNotMatch(hook, /\bsetPoses\b/);
|
||||||
|
assert.ok(hook.includes('applyLivePathPose'));
|
||||||
|
assert.ok(hook.includes('requestAnimationFrame'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function PresentationView({
|
|||||||
sceneNpcTokensSession.spawned,
|
sceneNpcTokensSession.spawned,
|
||||||
session?.currentSceneId,
|
session?.currentSceneId,
|
||||||
);
|
);
|
||||||
const pathLivePoses = useTokenPathLivePoses({
|
useTokenPathLivePoses({
|
||||||
tokens: scene?.tokens ?? [],
|
tokens: scene?.tokens ?? [],
|
||||||
npcTokens: visibleNpcTokens,
|
npcTokens: visibleNpcTokens,
|
||||||
pathSession: tokenPathSession,
|
pathSession: tokenPathSession,
|
||||||
@@ -257,7 +257,6 @@ export function PresentationView({
|
|||||||
library={appTokens}
|
library={appTokens}
|
||||||
session={sceneTokensSession}
|
session={sceneTokensSession}
|
||||||
viewport={contentRect}
|
viewport={contentRect}
|
||||||
poseOverrides={pathLivePoses.tokenPoses}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||||
@@ -269,7 +268,6 @@ export function PresentationView({
|
|||||||
session={sceneNpcTokensSession}
|
session={sceneNpcTokensSession}
|
||||||
viewport={contentRect}
|
viewport={contentRect}
|
||||||
grid={scene.grid}
|
grid={scene.grid}
|
||||||
poseOverrides={pathLivePoses.npcPoses}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
|
|
||||||
.token {
|
.token {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
NpcDisposition,
|
NpcDisposition,
|
||||||
@@ -16,6 +16,8 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
|||||||
import { useAssetUrl } from '../useAssetImageUrl';
|
import { useAssetUrl } from '../useAssetImageUrl';
|
||||||
|
|
||||||
import { PlayerTokenView } from './PlayerTokenView';
|
import { PlayerTokenView } from './PlayerTokenView';
|
||||||
|
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
|
||||||
|
import { registerPathTokenNode, writeTokenPathTransform } from '../tokens/tokenPathLiveDom';
|
||||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||||
import {
|
import {
|
||||||
TokenInitiativeDecor,
|
TokenInitiativeDecor,
|
||||||
@@ -100,6 +102,8 @@ function NpcSprite({
|
|||||||
onResize,
|
onResize,
|
||||||
String(placement.id),
|
String(placement.id),
|
||||||
);
|
);
|
||||||
|
const elRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pathKey = tokenPathKey('npcToken', String(placement.id));
|
||||||
const pos = localPos ?? { nx, ny };
|
const pos = localPos ?? { nx, ny };
|
||||||
const shownSizeN = localSize ?? sizeN;
|
const shownSizeN = localSize ?? sizeN;
|
||||||
const minDim = Math.min(viewport.w, viewport.h);
|
const minDim = Math.min(viewport.w, viewport.h);
|
||||||
@@ -107,6 +111,17 @@ function NpcSprite({
|
|||||||
const canDrag = editable && dragEnabled && onMove !== undefined;
|
const canDrag = editable && dragEnabled && onMove !== undefined;
|
||||||
const canResize = editable && dragEnabled && onResize !== undefined;
|
const canResize = editable && dragEnabled && onResize !== undefined;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerPathTokenNode(pathKey, elRef.current, viewport, false);
|
||||||
|
return () => registerPathTokenNode(pathKey, null, viewport, false);
|
||||||
|
}, [pathKey, viewport]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = elRef.current;
|
||||||
|
if (!el || el.dataset.pathLive === '1') return;
|
||||||
|
writeTokenPathTransform(el, pos.nx, pos.ny, 0, viewport, false);
|
||||||
|
}, [pos.nx, pos.ny, viewport]);
|
||||||
|
|
||||||
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
|
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
|
||||||
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
|
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
@@ -133,6 +148,7 @@ function NpcSprite({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={elRef}
|
||||||
className={[
|
className={[
|
||||||
styles.token,
|
styles.token,
|
||||||
canDrag ? styles.editable : '',
|
canDrag ? styles.editable : '',
|
||||||
@@ -143,8 +159,6 @@ function NpcSprite({
|
|||||||
data-testid={`session-npc-token-${placement.id}`}
|
data-testid={`session-npc-token-${placement.id}`}
|
||||||
data-initiative-active={initiativeActive ? '1' : '0'}
|
data-initiative-active={initiativeActive ? '1' : '0'}
|
||||||
style={{
|
style={{
|
||||||
left: viewport.x + pos.nx * viewport.w,
|
|
||||||
top: viewport.y + pos.ny * viewport.h,
|
|
||||||
width: sizePx,
|
width: sizePx,
|
||||||
height: sizePx,
|
height: sizePx,
|
||||||
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
|
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
.token {
|
.token {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
|
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
|
||||||
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
|
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
|
||||||
|
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
|
||||||
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
|
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
|
||||||
|
|
||||||
|
import { registerPathTokenNode, writeTokenPathTransform } from './tokenPathLiveDom';
|
||||||
import { useTokenImageUrl } from './useTokenImageUrl';
|
import { useTokenImageUrl } from './useTokenImageUrl';
|
||||||
import styles from './SceneTokensOverlay.module.css';
|
import styles from './SceneTokensOverlay.module.css';
|
||||||
|
|
||||||
@@ -106,18 +108,29 @@ function TokenSprite({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const elRef = useRef<HTMLDivElement>(null);
|
||||||
const posNx = localPos?.nx ?? nx;
|
const posNx = localPos?.nx ?? nx;
|
||||||
const posNy = localPos?.ny ?? ny;
|
const posNy = localPos?.ny ?? ny;
|
||||||
const shownSizeN = localSize ?? sizeN;
|
const shownSizeN = localSize ?? sizeN;
|
||||||
|
const pathKey = tokenPathKey('token', String(placement.id));
|
||||||
|
|
||||||
const minDim = Math.min(viewport.w, viewport.h);
|
const minDim = Math.min(viewport.w, viewport.h);
|
||||||
const sizePx = Math.max(16, shownSizeN * minDim);
|
const sizePx = Math.max(16, shownSizeN * minDim);
|
||||||
const left = viewport.x + posNx * viewport.w;
|
|
||||||
const top = viewport.y + posNy * viewport.h;
|
|
||||||
const canDrag = editable && dragEnabled && Boolean(onMove);
|
const canDrag = editable && dragEnabled && Boolean(onMove);
|
||||||
const canResize = editable && dragEnabled && Boolean(onResize);
|
const canResize = editable && dragEnabled && Boolean(onResize);
|
||||||
const interactive = canDrag || canResize || Boolean(onContextMenu);
|
const interactive = canDrag || canResize || Boolean(onContextMenu);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerPathTokenNode(pathKey, elRef.current, viewport, true);
|
||||||
|
return () => registerPathTokenNode(pathKey, null, viewport, true);
|
||||||
|
}, [pathKey, viewport]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = elRef.current;
|
||||||
|
if (!el || el.dataset.pathLive === '1') return;
|
||||||
|
writeTokenPathTransform(el, posNx, posNy, rotationDeg, viewport, true);
|
||||||
|
}, [posNx, posNy, rotationDeg, viewport]);
|
||||||
|
|
||||||
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||||
const r = host.getBoundingClientRect();
|
const r = host.getBoundingClientRect();
|
||||||
const w = Math.max(1e-6, viewport.w);
|
const w = Math.max(1e-6, viewport.w);
|
||||||
@@ -160,6 +173,7 @@ function TokenSprite({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={elRef}
|
||||||
className={[
|
className={[
|
||||||
styles.token,
|
styles.token,
|
||||||
canDrag ? styles.tokenEditable : '',
|
canDrag ? styles.tokenEditable : '',
|
||||||
@@ -168,11 +182,8 @@ function TokenSprite({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
style={{
|
style={{
|
||||||
left,
|
|
||||||
top,
|
|
||||||
width: sizePx,
|
width: sizePx,
|
||||||
height: sizePx,
|
height: sizePx,
|
||||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
|
||||||
}}
|
}}
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
onContextMenu
|
onContextMenu
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { writeTokenPathTransform } from './tokenPathLiveDom';
|
||||||
|
|
||||||
|
void test('writeTokenPathTransform: GPU-friendly translate3d, not left/top', () => {
|
||||||
|
const el = { style: { transform: '' } } as unknown as HTMLElement;
|
||||||
|
writeTokenPathTransform(el, 0.25, 0.5, 45, { x: 10, y: 20, w: 200, h: 100 }, true);
|
||||||
|
assert.equal(el.style.transform, 'translate3d(60px, 70px, 0) translate(-50%, -50%) rotate(45deg)');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('writeTokenPathTransform: NPC without rotate keeps centering only', () => {
|
||||||
|
const el = { style: { transform: '' } } as unknown as HTMLElement;
|
||||||
|
writeTokenPathTransform(el, 0, 0, 90, { x: 0, y: 0, w: 100, h: 100 }, false);
|
||||||
|
assert.equal(el.style.transform, 'translate3d(0px, 0px, 0) translate(-50%, -50%)');
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/** Direct DOM transforms for path playback — avoids React re-renders on every RAF tick. */
|
||||||
|
|
||||||
|
export type TokenPathViewport = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
type NodeReg = {
|
||||||
|
el: HTMLElement;
|
||||||
|
viewport: TokenPathViewport;
|
||||||
|
rotate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = new Map<string, NodeReg>();
|
||||||
|
|
||||||
|
export function writeTokenPathTransform(
|
||||||
|
el: HTMLElement,
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
rotationDeg: number,
|
||||||
|
viewport: TokenPathViewport,
|
||||||
|
rotate: boolean,
|
||||||
|
): void {
|
||||||
|
const x = viewport.x + nx * viewport.w;
|
||||||
|
const y = viewport.y + ny * viewport.h;
|
||||||
|
const rot = rotate ? ` rotate(${String(rotationDeg)}deg)` : '';
|
||||||
|
el.style.transform = `translate3d(${String(x)}px, ${String(y)}px, 0) translate(-50%, -50%)${rot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPathTokenNode(
|
||||||
|
key: string,
|
||||||
|
el: HTMLElement | null,
|
||||||
|
viewport: TokenPathViewport,
|
||||||
|
rotate: boolean,
|
||||||
|
): void {
|
||||||
|
if (!el) {
|
||||||
|
registry.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registry.set(key, { el, viewport, rotate });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLivePathPose(
|
||||||
|
key: string,
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
rotationDeg: number,
|
||||||
|
animating: boolean,
|
||||||
|
): void {
|
||||||
|
const n = registry.get(key);
|
||||||
|
if (!n) return;
|
||||||
|
n.el.dataset.pathLive = '1';
|
||||||
|
n.el.style.willChange = animating ? 'transform' : '';
|
||||||
|
writeTokenPathTransform(n.el, nx, ny, rotationDeg, n.viewport, n.rotate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endLivePathPose(key: string): void {
|
||||||
|
const n = registry.get(key);
|
||||||
|
if (!n) return;
|
||||||
|
delete n.el.dataset.pathLive;
|
||||||
|
n.el.style.willChange = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endAllLivePathPoses(): void {
|
||||||
|
for (const key of [...registry.keys()]) {
|
||||||
|
endLivePathPose(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
|
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
|
||||||
import {
|
import {
|
||||||
tokenPathKey,
|
tokenPathKey,
|
||||||
type TokenPathPlaybackPhase,
|
|
||||||
type TokenPathSessionState,
|
type TokenPathSessionState,
|
||||||
} from '../../../shared/types/tokenPathSession';
|
} from '../../../shared/types/tokenPathSession';
|
||||||
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
||||||
|
|
||||||
export type TokenPathLivePose = {
|
import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom';
|
||||||
nx: number;
|
|
||||||
ny: number;
|
|
||||||
rotationDeg: number;
|
|
||||||
phase: TokenPathPlaybackPhase;
|
|
||||||
dist: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PoseMaps = {
|
|
||||||
tokenPoses: Record<string, TokenPathLivePose>;
|
|
||||||
npcPoses: Record<string, TokenPathLivePose>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
|
|
||||||
|
|
||||||
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
|
|
||||||
const aT = a.tokenPoses;
|
|
||||||
const bT = b.tokenPoses;
|
|
||||||
const aN = a.npcPoses;
|
|
||||||
const bN = b.npcPoses;
|
|
||||||
const aTk = Object.keys(aT);
|
|
||||||
const bTk = Object.keys(bT);
|
|
||||||
const aNk = Object.keys(aN);
|
|
||||||
const bNk = Object.keys(bN);
|
|
||||||
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
|
|
||||||
for (const k of aTk) {
|
|
||||||
const x = aT[k];
|
|
||||||
const y = bT[k];
|
|
||||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const k of aNk) {
|
|
||||||
const x = aN[k];
|
|
||||||
const y = bN[k];
|
|
||||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RAF poses for control/presentation while path playback is active.
|
* RAF poses for control/presentation while path playback is active.
|
||||||
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
|
* Writes transforms on registered token DOM nodes — no React setState per frame.
|
||||||
*
|
*
|
||||||
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
|
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
|
||||||
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
|
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
|
||||||
@@ -63,10 +22,10 @@ export function useTokenPathLivePoses(args: {
|
|||||||
pathSession: TokenPathSessionState | null;
|
pathSession: TokenPathSessionState | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
|
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
|
||||||
}): PoseMaps {
|
}): void {
|
||||||
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
|
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
|
||||||
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
|
|
||||||
const markedDoneRef = useRef<Set<string>>(new Set());
|
const markedDoneRef = useRef<Set<string>>(new Set());
|
||||||
|
const liveKeysRef = useRef<Set<string>>(new Set());
|
||||||
const onMarkDoneRef = useRef(onMarkDone);
|
const onMarkDoneRef = useRef(onMarkDone);
|
||||||
onMarkDoneRef.current = onMarkDone;
|
onMarkDoneRef.current = onMarkDone;
|
||||||
|
|
||||||
@@ -83,7 +42,8 @@ export function useTokenPathLivePoses(args: {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setPoses(EMPTY);
|
endAllLivePathPoses();
|
||||||
|
liveKeysRef.current.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
@@ -92,22 +52,14 @@ export function useTokenPathLivePoses(args: {
|
|||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
const session = pathSessionRef.current;
|
const session = pathSessionRef.current;
|
||||||
if (!session) {
|
const nextKeys = new Set<string>();
|
||||||
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY));
|
if (session) {
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
|
|
||||||
const sampleNow = Date.now();
|
const sampleNow = Date.now();
|
||||||
const tokenPoses: Record<string, TokenPathLivePose> = {};
|
|
||||||
const npcPoses: Record<string, TokenPathLivePose> = {};
|
|
||||||
|
|
||||||
const sampleOne = (
|
const sampleOne = (
|
||||||
kind: 'token' | 'npcToken',
|
kind: 'token' | 'npcToken',
|
||||||
placementId: string,
|
placementId: string,
|
||||||
path: NonNullable<SceneToken['path']>,
|
path: NonNullable<SceneToken['path']>,
|
||||||
out: Record<string, TokenPathLivePose>,
|
|
||||||
) => {
|
) => {
|
||||||
const key = tokenPathKey(kind, placementId);
|
const key = tokenPathKey(kind, placementId);
|
||||||
const entry = session.playback[key];
|
const entry = session.playback[key];
|
||||||
@@ -115,13 +67,14 @@ export function useTokenPathLivePoses(args: {
|
|||||||
if (entry.phase === 'stopped') return;
|
if (entry.phase === 'stopped') return;
|
||||||
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
|
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
out[placementId] = {
|
nextKeys.add(key);
|
||||||
nx: result.sample.nx,
|
applyLivePathPose(
|
||||||
ny: result.sample.ny,
|
key,
|
||||||
rotationDeg: result.sample.rotationDeg,
|
result.sample.nx,
|
||||||
phase: result.phase,
|
result.sample.ny,
|
||||||
dist: result.sample.dist,
|
result.sample.rotationDeg,
|
||||||
};
|
entry.phase === 'moving' || entry.phase === 'delay',
|
||||||
|
);
|
||||||
if (result.markDone && !markedDoneRef.current.has(key)) {
|
if (result.markDone && !markedDoneRef.current.has(key)) {
|
||||||
markedDoneRef.current.add(key);
|
markedDoneRef.current.add(key);
|
||||||
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
||||||
@@ -130,17 +83,20 @@ export function useTokenPathLivePoses(args: {
|
|||||||
|
|
||||||
for (const t of tokensRef.current) {
|
for (const t of tokensRef.current) {
|
||||||
if (t.path && t.path.points.length >= 2) {
|
if (t.path && t.path.points.length >= 2) {
|
||||||
sampleOne('token', String(t.id), t.path, tokenPoses);
|
sampleOne('token', String(t.id), t.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const t of npcTokensRef.current) {
|
for (const t of npcTokensRef.current) {
|
||||||
if (t.path && t.path.points.length >= 2) {
|
if (t.path && t.path.points.length >= 2) {
|
||||||
sampleOne('npcToken', String(t.id), t.path, npcPoses);
|
sampleOne('npcToken', String(t.id), t.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const next: PoseMaps = { tokenPoses, npcPoses };
|
for (const key of liveKeysRef.current) {
|
||||||
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
|
if (!nextKeys.has(key)) endLivePathPose(key);
|
||||||
|
}
|
||||||
|
liveKeysRef.current = nextKeys;
|
||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,8 +104,10 @@ export function useTokenPathLivePoses(args: {
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
|
for (const key of liveKeysRef.current) {
|
||||||
|
endLivePathPose(key);
|
||||||
|
}
|
||||||
|
liveKeysRef.current.clear();
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
return poses;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
asProjectId,
|
asProjectId,
|
||||||
asSceneId,
|
asSceneId,
|
||||||
asSceneNpcTokenId,
|
asSceneNpcTokenId,
|
||||||
|
asSceneTokenId,
|
||||||
|
asTokenId,
|
||||||
} from '../types/ids';
|
} from '../types/ids';
|
||||||
import { PROJECT_SCHEMA_VERSION } from '../types';
|
import { PROJECT_SCHEMA_VERSION } from '../types';
|
||||||
|
|
||||||
@@ -21,8 +23,10 @@ import {
|
|||||||
findSceneTitleConflicts,
|
findSceneTitleConflicts,
|
||||||
listExportedNpcsFromBundle,
|
listExportedNpcsFromBundle,
|
||||||
listImportableStorylines,
|
listImportableStorylines,
|
||||||
|
collectTokenIdsFromProject,
|
||||||
mergeStorylinesIntoProject,
|
mergeStorylinesIntoProject,
|
||||||
newExportBundleProjectId,
|
newExportBundleProjectId,
|
||||||
|
remapProjectSceneTokenIds,
|
||||||
selectNpcsByIds,
|
selectNpcsByIds,
|
||||||
} from './storylineExportImport';
|
} from './storylineExportImport';
|
||||||
|
|
||||||
@@ -550,3 +554,48 @@ void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () =
|
|||||||
assert.equal(merged.sceneGraphNodes.length, 1);
|
assert.equal(merged.sceneGraphNodes.length, 1);
|
||||||
assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId);
|
assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('collectTokenIdsFromProject: собирает id неигровых токенов со сцен', () => {
|
||||||
|
const s1 = scene('s1', 'Start');
|
||||||
|
s1.tokens = [
|
||||||
|
{
|
||||||
|
id: asSceneTokenId('st1'),
|
||||||
|
tokenId: asTokenId('tok_a'),
|
||||||
|
nx: 0.2,
|
||||||
|
ny: 0.3,
|
||||||
|
sizeN: 0.08,
|
||||||
|
rotationDeg: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: asSceneTokenId('st2'),
|
||||||
|
tokenId: asTokenId('tok_b'),
|
||||||
|
nx: 0.5,
|
||||||
|
ny: 0.5,
|
||||||
|
sizeN: 0.08,
|
||||||
|
rotationDeg: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const project = minimalProject({
|
||||||
|
scenes: { [asSceneId('s1')]: s1 },
|
||||||
|
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
|
||||||
|
});
|
||||||
|
const ids = collectTokenIdsFromProject(project).map(String).sort();
|
||||||
|
assert.deepEqual(ids, ['tok_a', 'tok_b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('remapProjectSceneTokenIds: переписывает tokenId на сценах', () => {
|
||||||
|
const s1 = scene('s1', 'Start');
|
||||||
|
s1.tokens = [
|
||||||
|
{
|
||||||
|
id: asSceneTokenId('st1'),
|
||||||
|
tokenId: asTokenId('old'),
|
||||||
|
nx: 0.2,
|
||||||
|
ny: 0.3,
|
||||||
|
sizeN: 0.08,
|
||||||
|
rotationDeg: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const project = minimalProject({ scenes: { [asSceneId('s1')]: s1 } });
|
||||||
|
const remapped = remapProjectSceneTokenIds(project, new Map([['old', 'new']]));
|
||||||
|
assert.equal(String(remapped.scenes[asSceneId('s1')]!.tokens[0]!.tokenId), 'new');
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,32 @@
|
|||||||
|
# ВК: успешный краудфандинг НРИ Плеер
|
||||||
|
|
||||||
|
Обложка: `docs/vk-covers/vk-cover-crowdfunding-success.png` (16:9, без текста).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Черновик поста
|
||||||
|
|
||||||
|
Мастера, коротко и по делу.
|
||||||
|
|
||||||
|
Кампания НРИ Плеера на CrowdRepublic закрыта — успешно. Цель набрана. Спасибо каждому, кто поддержал сбор: взносом, репостом, советом за столом или просто тем, что оставались рядом, пока мы собирали редактор, пульт и экран для игроков в один инструмент.
|
||||||
|
|
||||||
|
Для нас это не «галочка в отчёте». Это знак, что приложение нужно живому сообществу — тем, кто ведёт партии по выходным, готовит сцены ночами и устал жонглировать десятком вкладок вместо одного стола.
|
||||||
|
|
||||||
|
Дальше — спокойная работа: доводим обещанное, слушаем вас и выпускаем обновления без лишнего шума. О следующих шагах расскажем здесь отдельно.
|
||||||
|
|
||||||
|
Ещё раз спасибо. Без вас этого бы не было.
|
||||||
|
|
||||||
|
Скачать бесплатно: https://ttrpgplayer.ru
|
||||||
|
Windows · macOS · Linux
|
||||||
|
|
||||||
|
#НРИплеер #TTRPG #DnD #настолки #мастер #краудфандинг #CrowdRepublic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Короткая версия
|
||||||
|
|
||||||
|
Мастера, крауд НРИ Плеера на CrowdRepublic закрыт успешно — цель набрана. Спасибо всем, кто поддержал. Дальше работаем и выпускаем обновления.
|
||||||
|
|
||||||
|
https://ttrpgplayer.ru
|
||||||
|
|
||||||
|
#НРИплеер #TTRPG #мастер #краудфандинг
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user