This commit is contained in:
Alex 2025-02-02 15:54:35 +08:00
parent 22e2aef4f7
commit 3e7be09dcf
15 changed files with 560 additions and 1 deletions

View file

@ -1,4 +1,4 @@
import { Plugin, WorkspaceLeaf } from "obsidian";
import { Plugin, TFile, TFolder, WorkspaceLeaf } from "obsidian";
import { registerIcons, unregisterIcons } from "./icons";
import {
SoloToolkitSettingTab,
@ -6,6 +6,7 @@ import {
DEFAULT_SETTINGS,
} from "./settings";
import { SoloToolkitView, VIEW_TYPE } from "./view";
import { VttView, VTT_VIEW_TYPE, VTT_EXT } from "./view/vtt";
import { soloToolkitExtension } from "./extension";
import { soloToolkitPostprocessor } from "./postprocessor";
import { exportDeck } from "./utils/deck";
@ -31,6 +32,9 @@ export default class SoloToolkitPlugin extends Plugin {
this.registerMarkdownPostProcessor(soloToolkitPostprocessor(this));
this.registerEditorExtension(soloToolkitExtension(this));
this.registerView(VTT_VIEW_TYPE, (leaf) => new VttView(leaf, this));
this.registerExtensions([VTT_EXT], VTT_VIEW_TYPE);
this.addRibbonIcon("srt-ribbon", "Solo RPG Toolkit", () => this.openView());
this.addCommand({
@ -39,6 +43,12 @@ export default class SoloToolkitPlugin extends Plugin {
callback: () => this.openView(),
});
this.addCommand({
id: "create-new-vtt",
name: "Create new VTT",
callback: () => this.createVttFile(),
});
this.addSettingTab(new SoloToolkitSettingTab(this.app, this));
}
@ -66,6 +76,33 @@ export default class SoloToolkitPlugin extends Plugin {
}
}
async createVttFile(folder?: TFolder) {
const targetFolder = folder
? folder
: this.app.fileManager.getNewFileParent(
this.app.workspace.getActiveFile()?.path || ""
);
try {
const file: TFile = await (this.app.fileManager as any).createNewFile(
targetFolder,
`Untitled.${VTT_EXT}`
);
const leaf = this.app.workspace.getLeaf("tab");
await leaf.openFile(file, { active: true });
await leaf.setViewState({
type: VTT_VIEW_TYPE,
state: leaf.view.getState(),
});
this.app.workspace.revealLeaf(
this.app.workspace.getLeavesOfType(VTT_VIEW_TYPE)[0]
);
} catch (e) {
console.error("Error creating vtt:", e);
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}

View file

@ -6,3 +6,4 @@
@import "./styles/oracle";
@import "./styles/plugin";
@import "./styles/editor";
@import "./styles/vtt";

79
src/styles/vtt.scss Normal file
View file

@ -0,0 +1,79 @@
.srt-vtt-root {
position: absolute;
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
padding: 12px 12px 32px;
background: #111;
overflow: hidden !important;
}
.srt-vtt-board {
position: absolute;
inset: 0;
}
.srt-vtt-deck {
position: absolute;
top: 0;
left: 0;
border-radius: 8px;
&:hover {
.srt-vtt-deck-border {
border-color: #fa0;
}
}
.srt-vtt-deck-fg {
position: relative;
background-color: #fafafa;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
width: 96px;
height: 136px;
}
.srt-vtt-deck-bg {
position: absolute;
top: 8px;
left: 0;
right: 0;
width: 96px;
height: 136px;
background: #444;
}
.srt-vtt-deck-border {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: -8px;
border: 3px solid #444;
border-radius: 8px;
transition: border-color 0.3s;
pointer-events: none;
}
}
.srt-vtt-card {
position: absolute;
top: 0;
left: 0;
background-color: #fafafa;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
border: 3px solid #444;
border-radius: 8px;
width: 96px;
height: 136px;
transition: border-color 0.3s;
&:hover {
border-color: #fa0;
}
}

33
src/view/vtt/app/board.ts Normal file
View file

@ -0,0 +1,33 @@
import { Dnd } from "./dnd";
import { Parent, Vec2 } from "./types";
import { newVec2 } from "./utils";
export class Board {
dnd: Dnd;
position: Vec2 = newVec2();
zoom: number = 1;
el: HTMLElement;
constructor(private parent: Parent) {
this.dnd = this.parent.dnd;
this.el = this.parent.el.createDiv("srt-vtt-board");
parent.dnd.makeDraggable(this, {
startDragOnParent: true,
rightBtn: true,
onMove: () => {
this.parent.el.style.backgroundPosition = `${this.position.x}px ${this.position.y}px`;
},
});
this.el.parentElement?.addEventListener(
"mousewheel",
this.handleScroll.bind(this)
);
}
handleScroll(event: WheelEvent) {
this.position.x -= event.deltaX;
this.position.y -= event.deltaY;
this.parent.el.style.backgroundPosition = `${this.position.x}px ${this.position.y}px`;
this.el.style.transform = `translate(${this.position.x}px, ${this.position.y}px)`;
}
}

37
src/view/vtt/app/card.ts Normal file
View file

@ -0,0 +1,37 @@
import { CardId, Parent, Vec2 } from "./types";
import { newVec2 } from "./utils";
export class Card {
id: CardId;
position: Vec2 = newVec2();
image: string;
el: HTMLElement;
drawn: boolean = false;
constructor(private parent: Parent, params?: Partial<Card>) {
this.el = this.parent.el.createDiv("srt-vtt-card");
this.parent.dnd.makeDraggable(this, {
onClick: this.hide.bind(this),
});
if (params) Object.assign(this, params);
this.el.style.display = "none";
this.el.style.backgroundImage = `url(${this.image})`;
this.el.style.transform = `translate(${this.position.x}px, ${this.position.y}px)`;
}
draw(at: Vec2) {
this.drawn = true;
this.position = at;
this.el.style.display = "";
this.el.style.transform = `translate(${this.position.x}px, ${this.position.y}px)`;
this.parent.dnd.moveToTop(this.el);
}
hide(event: MouseEvent) {
if (event.shiftKey) {
this.drawn = false;
this.el.style.display = "none";
}
}
}

File diff suppressed because one or more lines are too long

35
src/view/vtt/app/deck.ts Normal file
View file

@ -0,0 +1,35 @@
import { DeckId, Parent, Vec2 } from "./types";
import { Card } from "./card";
import { newVec2 } from "./utils";
export class Deck {
id: DeckId;
position: Vec2 = newVec2();
image: string;
cards: Card[] = [];
el: HTMLElement;
constructor(private parent: Parent, params?: Partial<Deck>) {
this.el = this.parent.el.createDiv("srt-vtt-deck");
this.el.createDiv("srt-vtt-deck-bg");
this.el.createDiv("srt-vtt-deck-fg");
this.el.createDiv("srt-vtt-deck-border");
this.parent.dnd.makeDraggable(this, {
onClick: this.draw.bind(this),
});
if (params) Object.assign(this, params);
this.el.style.backgroundImage = `url(${this.image})`;
this.el.style.transform = `translate(${this.position.x}px, ${this.position.y}px)`;
}
draw() {
const card = this.cards.find((card) => !card.drawn);
if (card) {
card.draw({
x: this.position.x + 120,
y: this.position.y,
});
}
}
}

3
src/view/vtt/app/dice.ts Normal file
View file

@ -0,0 +1,3 @@
export class Dice {
}

118
src/view/vtt/app/dnd.ts Normal file
View file

@ -0,0 +1,118 @@
import { Draggable, DragOptions, Vec2 } from "./types";
import { identity, newVec2 } from "./utils";
const DRAG_START_DISTANCE = 6;
const CLICK_TIME_DELTA = 600;
export class Dnd {
private dragging: Draggable | null = null;
private dragOptions: DragOptions | null = null;
private dragStartPosition: Vec2 = newVec2();
private dragStartAt: number = 0;
private isDragging: boolean = false;
private mouseMoveHandler: (event: MouseEvent) => void;
private mouseUpHandler: (event: MouseEvent) => void;
constructor() {
this.mouseMoveHandler = this.handleMouseMove.bind(this);
this.mouseUpHandler = this.handleMouseUp.bind(this);
document.addEventListener("mousemove", this.mouseMoveHandler);
document.addEventListener("mouseup", this.mouseUpHandler);
}
cleanup() {
document.removeEventListener("mousemove", this.mouseMoveHandler);
document.removeEventListener("mouseup", this.mouseUpHandler);
}
makeDraggable(draggable: Draggable, options?: DragOptions) {
const handleMouseDown = (event: MouseEvent) => {
if (
(options?.rightBtn && event.button !== 2) ||
(!options?.rightBtn && event.button !== 0)
) {
return;
}
event.stopPropagation();
this.dragging = draggable;
this.dragOptions = options || null;
this.isDragging = false;
this.dragStartPosition = {
x: event.clientX,
y: event.clientY,
};
this.dragStartAt = Date.now();
if (options?.onDrag) {
options.onDrag();
}
};
const el = options?.startDragOnParent
? draggable.el.parentElement
: draggable.el;
if (el) {
el.addEventListener("mousedown", handleMouseDown);
}
}
handleMouseMove(event: MouseEvent) {
if (this.dragging) {
if (this.isDragging) {
const mov = (this.dragOptions?.modifyMovement || identity)({
x: event.movementX,
y: event.movementY,
});
this.dragging.position = (this.dragOptions?.modifyPosition || identity)(
{
x: this.dragging.position.x + mov.x,
y: this.dragging.position.y + mov.y,
}
);
this.dragging.el.style.transform = (
this.dragOptions?.modifyTransform || identity
)(
`translate(${this.dragging.position.x}px, ${this.dragging.position.y}px)`
);
if (this.dragOptions?.onMove) {
this.dragOptions.onMove();
}
} else {
const distance = Math.sqrt(
(event.clientX - this.dragStartPosition.x) ** 2 +
(event.clientY - this.dragStartPosition.y) ** 2
);
if (distance >= DRAG_START_DISTANCE) {
this.isDragging = true;
this.moveToTop(this.dragging.el);
}
}
}
}
handleMouseUp(event: MouseEvent) {
if (this.isDragging) {
if (this.dragOptions?.onDrop) {
this.dragOptions.onDrop();
}
} else {
if (Date.now() - this.dragStartAt <= CLICK_TIME_DELTA) {
if (this.dragOptions?.onClick) {
this.dragOptions.onClick(event);
}
}
}
this.dragging = null;
this.dragOptions = null;
this.isDragging = false;
}
moveToTop(el: HTMLElement) {
el.parentElement?.appendChild(el);
}
}

View file

@ -0,0 +1 @@
export * from "./main";

64
src/view/vtt/app/main.ts Normal file

File diff suppressed because one or more lines are too long

33
src/view/vtt/app/types.ts Normal file
View file

@ -0,0 +1,33 @@
import { Dnd } from "./dnd";
export type Vec2 = {
x: number;
y: number;
};
export interface Draggable {
position: Vec2;
el: HTMLElement;
}
export interface DragOptions {
modifyMovement?: (nextPosition: Vec2) => Vec2;
modifyPosition?: (nextPosition: Vec2) => Vec2;
modifyTransform?: (nextTransform: string) => string;
onDrag?: () => void;
onMove?: () => void;
onDrop?: () => void;
onClick?: (event?: MouseEvent) => void;
startDragOnParent?: boolean;
rightBtn?: boolean;
}
export interface Parent {
dnd: Dnd;
el: HTMLElement;
}
export type CardId = `c${string}`;
export type DeckId = `d${string}`;
export type DiceId = `r${string}`;
export type IdType = CardId | DeckId | DiceId;

11
src/view/vtt/app/utils.ts Normal file
View file

@ -0,0 +1,11 @@
import { IdType, Vec2 } from "./types";
const generateString = () => Math.random().toString(36).substring(2, 8);
export const generateId = <Id extends IdType>(type: Id): `${Id}${string}` =>
`${type}${generateString()}${generateString()}`;
export const identity = <T>(value: T): T => value;
export const newVec2 = (x: number = 0, y: number = 0): Vec2 => {
return { x, y };
};

1
src/view/vtt/index.ts Normal file
View file

@ -0,0 +1 @@
export { VttView, VTT_VIEW_TYPE, VTT_EXT } from "./view";

73
src/view/vtt/view.ts Normal file
View file

@ -0,0 +1,73 @@
import { TextFileView, TFile, WorkspaceLeaf } from "obsidian";
import VttPlugin from "src/main";
import { VttApp } from "./app";
export const VTT_VIEW_TYPE = "vtt";
export const VTT_EXT = "vtt";
export class VttView extends TextFileView {
plugin: VttPlugin;
root: VttApp;
constructor(leaf: WorkspaceLeaf, plugin: VttPlugin) {
super(leaf);
this.plugin = plugin;
}
async onLoadFile(file: TFile) {
this.file = file;
this.render(file);
}
async onUnloadFile(_file: TFile) {
this.clear();
}
onunload() {
this.clear();
}
getViewData() {
return this.data;
}
setViewData(data: string, clear?: boolean) {
this.data = data;
if (clear) {
this.clear();
}
}
async onClose() {
this.clear();
}
getViewType() {
return VTT_VIEW_TYPE;
}
clear() {
this.setViewData("");
this.root?.unrender();
}
getContainer() {
return this.containerEl.children[1];
}
async render(file: TFile) {
const fileData = await this.app.vault.read(file);
this.setViewData(fileData);
const container = this.getContainer();
container.classList.add("srt-vtt-root");
if (this.root) {
this.root.update(fileData);
} else {
this.root = new VttApp(container, fileData);
}
this.root.render();
}
}