initial commit

This commit is contained in:
poanse 2025-12-17 04:51:25 +03:00
parent e2a64e0534
commit d90f45b637
28 changed files with 6646 additions and 134 deletions

134
main.ts
View file

@ -1,134 +0,0 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (_evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, _view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

3703
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

46
qodana.yaml Normal file
View file

@ -0,0 +1,46 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-js:2025.2

80
src/LinkSuggest.ts Normal file
View file

@ -0,0 +1,80 @@
import { App, TFile, AbstractInputSuggest, prepareFuzzySearch } from "obsidian";
export class LinkSuggest extends AbstractInputSuggest<TFile> {
textInputEl: HTMLTextAreaElement;
constructor(app: App, textInputEl: HTMLTextAreaElement) {
// Cast the textarea to HTMLInputElement to satisfy TypeScript.
// This is a dirty hack, but runtime behavior is compatible
super(app, textInputEl as unknown as HTMLInputElement);
this.textInputEl = textInputEl;
}
getSuggestions(query: string): TFile[] {
const cursor = this.textInputEl.selectionStart;
const text = this.textInputEl.value;
// Find the start of the current line
const lineStart = text.lastIndexOf("\n", cursor - 1) + 1;
const textBeforeCursor = text.substring(lineStart, cursor);
// Regex to match "[[query" at the very end of the text before cursor
const match = textBeforeCursor.match(/\[\[([^\]]*)$/);
// If we are NOT inside a [[link]], return empty array to close/hide suggestions
if (!match) {
return [];
}
// We are inside a link! Extract the actual query string
const linkQuery = match[1];
// Perform the search
const search = prepareFuzzySearch(linkQuery);
const files = this.app.vault.getFiles();
return files
.filter((file) => search(file.path) || search(file.basename))
.sort((a, b) => {
const resA = search(a.path);
const resB = search(b.path);
return (resB?.score || 0) - (resA?.score || 0);
})
.slice(0, 10);
}
renderSuggestion(file: TFile, el: HTMLElement) {
el.createEl("div", { text: file.basename });
el.createEl("small", { text: file.path, cls: "text-muted" });
}
selectSuggestion(file: TFile) {
const cursor = this.textInputEl.selectionStart;
const text = this.textInputEl.value;
const lineStart = text.lastIndexOf("\n", cursor - 1) + 1;
const textBeforeCursor = text.substring(lineStart, cursor);
const match = textBeforeCursor.match(/\[\[([^\]]*)$/);
if (!match) return;
const linkStart = lineStart + match.index!;
const beforeLink = text.substring(0, linkStart);
const afterCursor = text.substring(cursor);
const newLinkText = `[[${file.basename}]]`;
this.textInputEl.value = beforeLink + newLinkText + afterCursor;
// Trigger Svelte update
this.textInputEl.dispatchEvent(new Event("input"));
// Reset cursor
const newCursor = beforeLink.length + newLinkText.length;
this.textInputEl.setSelectionRange(newCursor, newCursor);
this.textInputEl.focus();
this.textInputEl.blur();
this.close();
}
}

7
src/PluginSettings.ts Normal file
View file

@ -0,0 +1,7 @@
export interface PluginSettings {
mySetting: string;
}
export const DEFAULT_SETTINGS: PluginSettings = {
mySetting: 'default'
}

81
src/ProjectData.svelte.ts Normal file
View file

@ -0,0 +1,81 @@
import { StatusCode, type TaskData } from "./types";
import TaskmapPlugin from "./main";
export class ProjectData {
tasks = $state(new Array<TaskData>());
curTaskId = 0;
public static getDefault(): ProjectData {
return new ProjectData({
tasks: new Array<TaskData>(),
curTaskId: 0,
});
}
public serialize() {
return JSON.stringify(
{ tasks: this.tasks, curTaskId: this.curTaskId },
null,
2,
);
}
constructor(obj: { tasks: TaskData[]; curTaskId: number }) {
this.tasks = obj.tasks;
this.curTaskId = obj.curTaskId;
if (this.tasks.length == 0) {
this.addTask();
}
}
public addTask() {
const id = this.curTaskId;
this.tasks.push({
taskId: id,
status: StatusCode.DRAFT,
name: "default",
deleted: false,
});
this.curTaskId += 1;
return id;
}
public removeTask(id: number) {
const t = this.getTask(id);
t.deleted = true;
}
public getTask(taskId: number) {
const res = this.tasks.find((t) => t.taskId == taskId);
if (res) {
return res!;
} else {
throw new Error(`No task found with id ${taskId}`);
}
}
public isTaskDeleted(taskId: number) {
return this.getTask(taskId).deleted;
}
public getTaskStatus(taskId: number) {
return this.getTask(taskId)!.status;
}
public getTaskName(taskId: number) {
return this.getTask(taskId)!.name;
}
public setTaskStatus(taskId: number, status: StatusCode) {
const task = this.getTask(taskId);
if (task) {
task.status = status;
}
}
public setTaskName(taskId: number, name: string) {
const task = this.getTask(taskId);
if (task) {
task.name = name;
}
}
}
export const DEFAULT_DATA = ProjectData.getDefault().serialize();

28
src/TaskmapSettingTab.ts Normal file
View file

@ -0,0 +1,28 @@
import { PluginSettingTab, App, Setting } from "obsidian";
import type TaskmapPlugin from "./main";
export class TaskmapSettingTab extends PluginSettingTab {
plugin: TaskmapPlugin;
constructor(app: App, plugin: TaskmapPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

109
src/TaskmapView.ts Normal file
View file

@ -0,0 +1,109 @@
import { debounce, TextFileView, TFile, WorkspaceLeaf } from "obsidian";
import { mount } from "svelte";
import { DEFAULT_DATA, ProjectData } from "./ProjectData.svelte";
import pixi from "./components/pixi.svelte";
import { UIState } from "./pixi/GlobalState.svelte";
export const VIEW_TYPE_EXAMPLE = "example";
export class TaskmapView extends TextFileView {
// raw svelte/html
// taskMapViewComponent: ReturnType<typeof TaskmapViewComponent> | undefined;
// pixi
pixiComponent: ReturnType<typeof pixi> | undefined;
// canvas
// canvasComponent: ReturnType<typeof DemoApp> | undefined;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
debouncedSave = debounce(() => this.save(), 1000, true);
data: string = DEFAULT_DATA;
projectData: ProjectData;
getViewType() {
return VIEW_TYPE_EXAMPLE;
}
async onLoadFile(file: TFile): Promise<void> {
this.file = file;
this.setViewData(await this.app.vault.read(file));
let projectData = new ProjectData(JSON.parse(this.data));
this.projectData = projectData;
// this.taskMapViewComponent = mount(TaskmapViewComponent, {
// target: this.contentEl,
// props: {
// taskmapView: this,
// },
// });
// this.canvasComponent = mount(DemoApp, {
// target: this.contentEl,
// props: {
// taskmapView: this,
// },
// });
this.pixiComponent = mount(pixi, {
target: this.contentEl,
props: {
uiState: new UIState(this.projectData, this.app),
projectData: this.projectData,
},
});
}
getFilePath(): string {
if (this.file) {
return this.file.path;
} else {
throw new Error();
}
}
getViewData(): string {
return this.data;
}
setViewData(data: string = DEFAULT_DATA, clear = false): void {
this.data = data;
if (clear) {
this.clear();
}
}
async save() {
try {
await this.app.vault.modify(
this.file!,
this.projectData.serialize(),
);
} catch (err) {
console.error("Save failed:", err);
throw new Error(`Save failed. ${err.message}`);
}
}
clear(): void {
this.debouncedSave.cancel();
this.setViewData(DEFAULT_DATA);
}
async onUnloadFile(file: TFile): Promise<void> {
this.clear();
}
onunload() {
this.clear();
}
async onClose() {
this.debouncedSave.cancel();
}
// onChange(value: string) {
// this.setViewData(value);
// this.debouncedSave();
// }
}

View file

@ -0,0 +1,76 @@
<script lang="ts">
import {TASK_SIZE} from "../pixi/Constants";
import {StatusCode} from "../types";
import {UIState} from "../pixi/GlobalState.svelte";
import type {ProjectData} from "../ProjectData.svelte";
const {
taskId,
uiState,
projectData,
}: {
taskId: number,
uiState: UIState,
projectData: ProjectData,
} = $props();
let taskData = $derived(projectData.getTask(taskId));
let entered = $state(false);
function onEnter() {
entered = true;
console.log('enter');
}
function onLeave() {
entered = false;
console.log('leave');
}
function addButtonPressed (event: MouseEvent) {
console.log('add icon clicked')
uiState.addTask();
event.stopPropagation();
}
</script>
<div class="hover-area"
onmouseenter={onEnter}
onmouseleave={onLeave}
style="
position: absolute;
left: {TASK_SIZE.width - 50/2}px;
top: {TASK_SIZE.height/2 - 50/2}px;
width: 50px;
height: 50px;
border: 2px dashed #888;
background: transparent;
pointer-events: auto;
"
>
</div>
{#if entered}
<svg
class="button-add"
class:draft={taskData.status === StatusCode.DRAFT}
class:ready={taskData.status === StatusCode.READY}
class:in-progress={taskData.status === StatusCode.IN_PROGRESS}
class:done={taskData.status === StatusCode.DONE}
onmouseenter={onEnter}
onmouseleave={onLeave}
onclick={addButtonPressed}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"
style="
position: absolute;
left: {TASK_SIZE.width - 41/2 - 1}px;
top: {TASK_SIZE.height/2 - 41/2}px;
width: 41;
height: 41;
"
>
<circle
cx="12" cy="12" r="10"
/>
<path d="M5 12h14"/><path d="M12 5v14"/>
</svg>
{/if}

View file

@ -0,0 +1,86 @@
<script lang="ts">
import type {UIState} from "../pixi/GlobalState.svelte.js";
import {IconCode, StatusCode} from "../types";
import IconService from "../pixi/IconService";
let {
iconCode,
uiState,
}: {
iconCode: IconCode,
uiState: UIState,
} = $props();
let isPressedDown = $state(false);
let isPressed=$derived(uiState.pressedButtonIndex == iconCode);
const stateful = iconCode != IconCode.FOCUS;
function handleMouseDown(event: MouseEvent) {
isPressedDown = true;
event.stopPropagation();
}
function onBlur() {
isPressedDown = false;
}
function handleMouseUp(event: MouseEvent) {
if (!isPressedDown) {
return;
}
isPressedDown = false;
if (isPressed) {
isPressed = false;
uiState.pressedButtonIndex = -1;
} else if (stateful) {
isPressed = true;
uiState.pressedButtonIndex = iconCode;
} else {
uiState.pressedButtonIndex = -1;
}
let newStatus: StatusCode | null = null;
if (iconCode == IconCode.STATUS_READY) {
newStatus = StatusCode.READY;
} else if (iconCode == IconCode.STATUS_DRAFT) {
newStatus = StatusCode.DRAFT;
} else if (iconCode == IconCode.STATUS_DONE) {
newStatus = StatusCode.DONE;
} else if (iconCode == IconCode.STATUS_IN_PROGRESS) {
newStatus = StatusCode.IN_PROGRESS;
}
if (newStatus != null) {
uiState.changeStatus(newStatus);
}
if (iconCode == IconCode.TRASH_SINGLE || iconCode == IconCode.TRASH_MULTIPLE) {
uiState.removeTask(uiState.selectedTaskId);
}
event.stopPropagation();
}
function _getIcon() {
if (iconCode == IconCode.STATUS) {
return IconService.getStatusIcon(uiState.toolbarStatus);
}
return IconService.getIcon(iconCode);
}
</script>
<div class="button"
class:no-pan={true}
class:is-pressed-up={isPressed}
class:is-pressed-down={isPressedDown}
onmousedown={handleMouseDown}
onmouseup={handleMouseUp}
onmouseleave={onBlur}
onclick={(event: MouseEvent) => {
event.stopPropagation();
if (IconCode.FOCUS === iconCode) {
uiState.setSelectedTaskId(-1);
}
}}
role="presentation"
tabindex="-1"
>
{@html _getIcon()}
</div>

204
src/components/Task.svelte Normal file
View file

@ -0,0 +1,204 @@
<script lang="ts">
import {UIState} from "../pixi/GlobalState.svelte.js";
import type {ProjectData} from "../ProjectData.svelte.js";
import {StatusCode} from "../types";
import TaskText from "./TaskText.svelte";
import AddTaskButton from "./AddTaskButton.svelte";
const {
taskId,
uiState,
projectData,
coords
}: {
taskId: number,
uiState: UIState,
projectData: ProjectData,
coords: {x: number, y: number}
} = $props();
let taskData = $derived(projectData.getTask(taskId));
// let coords = $derived(uiState.getTaskPosition(taskId));
let isHovered = $state(false);
// derived here is a must
let isSelected = $derived(uiState.isSelected(taskData.taskId));
let editing = $state(false);
let inputValue = $state(taskData.name);
const useNewTextElement = true;
function startEditing() {
editing = true;
inputValue = taskData.name;
}
function finishEditing(success: boolean) {
if (!useNewTextElement) {
if (success && inputValue.trim() !== "") {
taskData.name = inputValue.trim();
uiState.save();
}
inputValue = taskData.name;
editing = false;
}
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges()
}
// const input = (document.getElementById('titleInput') as HTMLInputElement);
// input.selectionStart = 0;
// input.selectionEnd = 1;
// input.disabled = true;
}
function textInputHandleKey(e: KeyboardEvent) {
console.log('textInputHandleKey ', e.key);
if (!editing) {
return;
}
console.log('textInputHandleKey handled', e.key);
if (e.key === "Enter") {
finishEditing(true);
e.stopPropagation();
}
if (e.key === "Escape") {
finishEditing(false);
e.stopPropagation();
}
}
let mouseDown = $state(false);
function onTaskClick(event: Event) {
console.log(`Task clicked ${taskId}`);
if (!mouseDown) {
return;
}
mouseDown = false;
if (editing) {
finishEditing(true);
} else {
uiState.setSelectedTaskId(taskData.taskId);
// const input = (document.getElementById('titleInput') as HTMLInputElement);
// input.disabled = false;
}
event.stopPropagation();
}
function onTextClick(event: Event) {
console.log('TextClicked');
if (!isSelected) {
uiState.setSelectedTaskId(taskData.taskId);
// const input = (document.getElementById('titleInput') as HTMLInputElement);
// input.disabled = false;
} else if (editing == false) {
startEditing();
}
// } else if (editing) {
// finishEditing(true);
// }
event.stopPropagation();
}
</script>
{#if !projectData.isTaskDeleted(taskId)}
<div
class="task-container"
style="
top: {coords.y}px;
left: {coords.x}px;
position: absolute;
"
>
<div
class="task"
class:no-pan={true}
class:draft={taskData.status === StatusCode.DRAFT}
class:ready={taskData.status === StatusCode.READY}
class:in-progress={taskData.status === StatusCode.IN_PROGRESS}
class:done={taskData.status === StatusCode.DONE}
onmouseenter={() => isHovered = true}
onmouseleave={() => isHovered = false}
onmousedown={(event: MouseEvent) => {
mouseDown = true;
event.stopPropagation();
}}
class:hovered={isHovered || isSelected}
onclick={onTaskClick}
onblur={() => finishEditing(true)}
role="presentation"
>
{#if useNewTextElement}
<TaskText
{taskId}
{uiState}
app={uiState.app}
content={taskData.name}
onSave={(newContent)=> {
taskData.name = newContent;
uiState.save();
}}
sourcePath={uiState.getActiveView().getFilePath()}
/>
{:else}
<input
class="textInput"
class:no-pan={true}
type="text"
id="titleInput"
bind:value={inputValue}
onclick={onTextClick}
onblur={() => finishEditing(true)}
onmousedown={(e: MouseEvent) => e.stopPropagation()}
onkeydown={textInputHandleKey}
disabled={!isSelected}
style='{ !isSelected? "pointer-events: none;": ""}'
/>
{/if}
</div>
<AddTaskButton {uiState} {projectData} {taskId} />
</div>
{/if}
<!--top: {coords.y}px;-->
<!--left: {coords.x}px;-->
<!--style="-->
<!--position: absolute;-->
<!--top: {coords.y + task.shiftY + task.height /2 - fontSize/2 - 7}px;-->
<!--left: {coords.x + task.shiftX + task.strokeWidthSelected-2}px;-->
<!--"-->
<!--onclick={onTextClick}-->
<!--<div-->
<!-- class="taskText"-->
<!-- onclick={onTextClick}-->
<!-- onblur={() => finishEditing(true)}-->
<!-- role="presentation"-->
<!--&gt;-->
<!-- <input-->
<!-- class="textInput"-->
<!-- type="text"-->
<!-- id="titleInput"-->
<!-- bind:value={inputValue}-->
<!-- onkeydown={textInputHandleKey}-->
<!-- disabled={!isSelected}-->
<!-- style='{ !isSelected? "pointer-events: none;": ""}'-->
<!-- />-->
<!--</div>-->
<!--{#if isSelected}-->
<!-- <input-->
<!-- class="textInput"-->
<!-- type="text"-->
<!-- id="titleInput"-->
<!-- bind:value={inputValue}-->
<!-- onkeydown={handleKey}-->
<!-- />-->
<!--{:else}-->
<!-- {taskData.name}-->
<!--{/if}-->

View file

@ -0,0 +1,155 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import {MarkdownRenderer, Component, App} from "obsidian";
import {LinkSuggest} from "../LinkSuggest";
import type {UIState} from "../pixi/GlobalState.svelte";
// PROPS
let {
taskId,
uiState,
app,
content,
sourcePath,
onSave
}: {
taskId: number,
uiState: UIState,
app: App;
content: string;
sourcePath: string;
onSave: (newContent: string) => void
} = $props();
// STATE
let isEditing = $state(false);
let suggest: LinkSuggest | null = null;
let textPreviewEl: HTMLElement;
let textEditEl: HTMLTextAreaElement;
let component = new Component(); // Required by Obsidian to manage render lifecycle
onMount(() => {
if(!isEditing && textPreviewEl) {
renderMarkdown();
}
return () => {
// Clean up Obsidian component references to prevent memory leaks
component.unload();
};
});
$effect(() => {
if (!isEditing && textPreviewEl) {
renderMarkdown();
}
});
function handlePreviewClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Check if the clicked element (or its parent) is an internal link
const internalLink = target.closest(".internal-link");
if (internalLink) {
// Prevent the default "Swap to Edit" behavior
e.preventDefault(); // TODO: is this line needed?
e.stopPropagation();
const href = internalLink.getAttribute("data-href");
if (href === null) {
throw new Error('href is null');
}
// Open the link using Obsidian's API
// Whether to open in a new tab or in current one
const openInNewLeaf = e.ctrlKey || e.metaKey;
app.workspace.openLinkText(href, sourcePath, openInNewLeaf);
return;
}
const externalLink = target.closest("a.external-link");
if (externalLink){
e.stopPropagation();
return;
}
if (uiState.isSelected(taskId)) {
toggleEdit();
e.stopPropagation();
}
}
function handlePreviewMouseOver(e: MouseEvent) {
const target = e.target as HTMLElement;
const link = target.closest(".internal-link");
if (link) {
// Trigger the native hover preview
app.workspace.trigger("hover-link", {
event: e,
source: "preview",
hoverParent: textPreviewEl,
targetEl: link,
linktext: link.getAttribute("data-href"),
sourcePath: sourcePath
});
}
}
async function renderMarkdown() {
textPreviewEl.empty(); // Clear previous render
await MarkdownRenderer.render(
app,
content,
textPreviewEl,
sourcePath,
component
);
}
async function toggleEdit() {
isEditing = true;
// Wait for DOM update so textarea exists, then focus
await tick();
if (textEditEl) {
textEditEl.focus();
suggest = new LinkSuggest(app, textEditEl);
}
}
export function handleBlur() {
isEditing = false;
onSave(content); // Persist changes
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
textEditEl.blur(); // Triggers handleBlur
} else if (e.key === "Tab" && suggest !== null) {
// another hack to select suggest on tab
e.preventDefault();
textEditEl.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
}
}
</script>
<div class="task-text-container" onclick={handlePreviewClick}>
{#if isEditing}
<textarea
class="text-edit tasktext"
maxlength="28"
bind:this={textEditEl}
bind:value={content}
onblur={handleBlur}
onkeydown={handleKeydown}
></textarea>
{:else}
<div
class="text-preview tasktext"
tabindex="0"
bind:this={textPreviewEl}
onclick={handlePreviewClick}
onmouseover={handlePreviewMouseOver}
>
</div>
{/if}
</div>
<!--onkeydown={(e) => e.key === 'Enter' && toggleEdit()}-->

View file

@ -0,0 +1,80 @@
<script lang="ts">
import Button from "./Button.svelte";
import { fade } from 'svelte/transition';
import {
TOOLBAR_PADDING,
TOOLBAR_GAP,
BUTTON_SIZE,
TOOLBAR_SIZE, TASK_SIZE, TOOLBAR_SHIFT, SUBTOOLBAR_SHIFT
} from "../pixi/Constants";
// 1. Import the transition you want
import {quintOut} from 'svelte/easing';
import {slideCustom} from '../pixi/Custom';
import type {UIState} from "../pixi/GlobalState.svelte.js";
import {IconCode} from "../types";
let {
uiState
}: {uiState: UIState} = $props();
let position = $derived(uiState.getCurrentTaskPosition(uiState.selectedTaskId));
function getTop() {
return position.y - TOOLBAR_SIZE.height - TOOLBAR_SHIFT;
}
function getLeft() {
return position.x + TASK_SIZE.width_hovered / 2 - TOOLBAR_SIZE.width / 2;
}
</script>
<div
class="toolbar"
class:no-pan={true}
in:fade={{ duration: 500 }}
out:fade={{ duration: 300 }}
style="
top: {getTop()}px;
left: {getLeft()}px;
"
>
{#key uiState.updateOnZoomCounter}
<Button iconCode={IconCode.TRASH} {uiState}/>
<Button iconCode={IconCode.KEY} {uiState} />
<Button iconCode={IconCode.LOCK} {uiState} />
<Button iconCode={IconCode.FOCUS} {uiState} />
<Button iconCode={IconCode.STATUS} {uiState} />
{/key}
</div>
{#if uiState.pressedButtonIndex === IconCode.TRASH}
<div
class="subtoolbar"
transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }}
style="
top: {getTop() - 2 * BUTTON_SIZE - TOOLBAR_GAP - 2 * TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT}px;
left: {getLeft()}px;
"
>
{#key uiState.updateOnZoomCounter}
<Button iconCode={IconCode.TRASH_SINGLE} {uiState} />
<Button iconCode={IconCode.TRASH_MULTIPLE} {uiState} />
{/key}
</div>
{/if}
{#if uiState.pressedButtonIndex === IconCode.STATUS}
<div
class="subtoolbar"
transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }}
style="
top: {getTop() - 4 * BUTTON_SIZE - 3*TOOLBAR_GAP - 2*TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT}px;
left: {getLeft() + 4 * (BUTTON_SIZE + TOOLBAR_GAP)}px;
"
>
{#key uiState.updateOnZoomCounter}
<Button iconCode={IconCode.STATUS_DRAFT} {uiState} />
<Button iconCode={IconCode.STATUS_READY} {uiState} />
<Button iconCode={IconCode.STATUS_IN_PROGRESS} {uiState} />
<Button iconCode={IconCode.STATUS_DONE} {uiState} />
{/key}
</div>
{/if}

137
src/components/pixi.svelte Normal file
View file

@ -0,0 +1,137 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte";
import Toolbar from "./Toolbar.svelte";
import Task from "./Task.svelte";
import Panzoom, {type PanzoomObject} from '@panzoom/panzoom'
import type {Context} from "../types";
let {uiState, projectData}: Context = $props();
let viewportEl: HTMLDivElement | null = null;
let sceneEl: HTMLDivElement | null = null;
let panzoom: PanzoomObject | null = null;
let isDragging = false;
let mouseDown = $state(false);
let startX = 0;
let startY = 0;
// Cleanup listeners to prevent memory leaks
onDestroy(() => {
if (viewportEl && panzoom) {
viewportEl.removeEventListener('wheel', panzoom.zoomWithWheel);
}
});
onMount(async () => {
if (!sceneEl) {
throw new Error('No mount element');
}
if (!viewportEl) {
throw new Error('No viewport');
}
// 1. Initialize Panzoom on the child (scene), NOT the wrapper
panzoom = Panzoom(sceneEl, {
maxScale: 3, // not higher than 1 to avoid lowRes svg rendering
minScale: 0.1,
// Important: Use the wrapper as the bounds for event handling
// This fixes the "Infinite" panning issue because the parent catches the events
canvas: true,
excludeClass: 'no-pan',
cursor: 'default',
// animate: true,
});
// 2. Fix Zoom: Manually bind the wheel event to the viewport
viewportEl.addEventListener('wheel',
(ev) => {
getPanzoom().zoomWithWheel(ev);
if (getPanzoom().getScale() > 1) {
uiState.incrementUpdateOnZoomCounter();
}
}
);
// 3. Fix Panning from "empty space": Bind down event to viewport
// This ensures you can drag even if the element is off-center
viewportEl.addEventListener('pointerdown', (e) => {
getPanzoom().handleDown(e);
// Track click vs drag
isDragging = false;
startX = e.clientX;
startY = e.clientY;
});
});
function handleKey(e: KeyboardEvent) {
console.log('handleKey ', e.key);
if (e.key === "Escape") {
uiState.setSelectedTaskId(-1);
e.stopPropagation();
}
}
function handlePointerUp(e: PointerEvent) {
// Calculate distance moved
const dist = Math.pow(e.clientX - startX, 2) + Math.pow(e.clientY - startY, 2);
// If moved more than 5 pixels, it's a drag, not a click
const thrDist = 5;
if (dist > Math.pow(thrDist,2)) {
isDragging = true;
}
}
function handleCanvasClick(e: MouseEvent) {
// 4. Fix Click: Only trigger logic if we haven't dragged
if (isDragging) {
e.stopPropagation(); // Stop propagation if it was a drag
return;
}
console.log('Canvas clicked!', e);
if (!mouseDown) {
return
}
mouseDown = false;
console.log(`Window clicked + ${uiState.serializeForDebugging()}`);
console.log('selectedTaskId ' + uiState.selectedTaskId);
uiState.pressedButtonIndex = -1;
uiState.setSelectedTaskId(-1);
sceneEl!.focus();
}
function getPanzoom() {
if (panzoom) {
return panzoom;
} else {
throw new Error('No panzoom element');
}
}
</script>
<div
class="viewport"
bind:this={viewportEl}
onpointerup={handlePointerUp}
style="width: 100%; height: 100vh; overflow: hidden; position: relative; background: #1C1C1C;"
>
<div
class="pixi-container"
bind:this={sceneEl}
onpointerdown={() => mouseDown = true}
onclick={handleCanvasClick}
tabindex="-1"
onkeydown={handleKey}
style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;"
role="presentation"
>
{#each projectData.tasks.filter(t => !t.deleted) as task}
<Task taskId={task.taskId} {uiState} {projectData} coords={uiState.getCurrentTaskPosition(task.taskId)} />
{/each}
{#if uiState.selectedTaskId !== -1}
<Toolbar
uiState={uiState}
/>
{/if}
</div>
</div>

135
src/legacy/Button.ts Normal file
View file

@ -0,0 +1,135 @@
import {
type Application,
Color,
Container,
Graphics,
SCALE_MODES,
type Size,
Sprite,
Texture,
} from "pixi.js";
import { match, P } from "ts-pattern";
export enum ButtonState {
INACTIVE = "INACTIVE",
INACTIVE_HOVERED = "INACTIVE_HOVERED",
ACTIVE = "ACTIVE",
ACTIVE_HOVERED = "ACTIVE_HOVERED",
// TODO: pressed
}
export type ColorStateMap = {
[key in ButtonState]: Color;
};
class ButtonOptions {
size: Size;
backgroundColors: ColorStateMap;
iconColors: ColorStateMap;
}
export class Button extends Container {
private app: Application;
private state: ButtonState;
private opts: ButtonOptions;
private iconSource: Promise<Graphics | Texture>;
private background: Sprite;
private icon: Sprite;
constructor(
app: Application,
initialState: ButtonState,
iconSource: Promise<Graphics | Texture>,
opts: ButtonOptions,
) {
super();
this.app = app;
this.state = initialState;
this.iconSource = iconSource;
this.opts = opts;
}
setup() {
this.eventMode = "static";
this.cursor = "pointer";
this.on("pointerover", this.hoverOn);
this.on("pointerout", this.hoverOff);
this.on("pointerdown", () => {
console.log("Clicked icon");
});
// background
// tooltip
// ...
this.update();
// callbacks
// ...
}
async update() {
// TODO: я идиот и менял не тот бэкграунд
// this.background.destroy();
const bgTexture = this.app.renderer.generateTexture({
target: new Graphics()
.roundRect(0, 0, this.opts.size.width, this.opts.size.height, 2)
.fill({ color: this.opts.backgroundColors[this.state] }),
// resolution: 4,
// antialias: true,
});
bgTexture.source.scaleMode = "linear";
this.background = new Sprite(bgTexture);
this.addChild(this.background);
const icon = await this.iconSource;
const iconTexture = match(icon)
.returnType<Texture>()
.with(P.instanceOf(Graphics), () =>
this.app.renderer.generateTexture({
target: icon as Graphics,
// .fill({
// color: this.opts.iconColors[this.state],
// })
// antialias: true,
resolution: 4,
}),
)
.with(P.instanceOf(Texture), () => icon as Texture)
.exhaustive();
iconTexture.source.scaleMode = "linear";
this.icon = new Sprite({
texture: iconTexture,
anchor: 0.5,
// roundPixels: true,
});
this.icon.scale = 0.6;
this.icon.position.set(
this.opts.size.width / 2,
this.opts.size.height / 2,
);
this.addChild(this.icon);
}
changeState(state: ButtonState) {
this.state = state;
this.icon.destroy();
this.update();
}
hoverOn() {
if (this.state == ButtonState.ACTIVE) {
this.changeState(ButtonState.ACTIVE_HOVERED);
} else if (this.state == ButtonState.INACTIVE) {
this.changeState(ButtonState.INACTIVE_HOVERED);
}
}
hoverOff() {
if (this.state == ButtonState.ACTIVE_HOVERED) {
this.changeState(ButtonState.ACTIVE);
} else if (this.state == ButtonState.INACTIVE_HOVERED) {
this.changeState(ButtonState.INACTIVE);
}
}
}

219
src/legacy/TaskView.ts Normal file
View file

@ -0,0 +1,219 @@
import { Application, Color, Container, Graphics, TextStyle } from "pixi.js";
import TextInput from "./text-input";
import { Button, ButtonState } from "./Button";
import { alphaBlend } from "@napolab/alpha-blend";
import { IconService } from "../pixi/IconService";
export interface TaskCardOptions {
label: string;
width: number;
height: number;
}
const COLOR_WHITE = "#ffffff"; // Double-click detection
const DOUBLE_CLICK_DELAY = 300; // milliseconds
let lastClickTime = 0;
export const TASK_BACKGROUND_DEFAULT_COLOR: Color = new Color("0x1E1E1E");
const TASK_BORDER_DEFAULT_COLOR = new Color("0x343434");
// const TASK_BACKGROUND_DEFAULT_COLOR: Color = new Color(COLOR_WHITE);
const ICON_BACKGROUND_COLOR = new Color("0x1E1E1E");
const HOVERED_ICON_BACKGROUND_COLOR = new Color("0x343434");
const TOOLBAR_BACKGROUND_DEFAULT_COLOR: string = alphaBlend(
ICON_BACKGROUND_COLOR.toRgbaString(),
new Color("#000000").setAlpha(0.5).toRgbaString(),
);
console.log("TASK BACKGROUND COLOR: ", TOOLBAR_BACKGROUND_DEFAULT_COLOR);
export const TASK_DEFAULT_BORDER = {
color: 0x7e7e7e,
width: 1,
};
export const TASK_FOCUSED_BORDER = {
color: 0x7e7e7e,
width: 2,
};
export const TEXT_COLOR = COLOR_WHITE;
export const TEXT_STYLE = new TextStyle({
fill: TEXT_COLOR,
fontSize: 10,
fontFamily: "Segoe UI",
lineHeight: 1.5,
});
export class TaskView extends Container {
private toolbar!: Container;
private card!: Graphics;
// private text!: Text;
private app!: Application;
private readonly opts: TaskCardOptions;
private inputEl: TextInput | null = null;
private mountEl: HTMLDivElement;
constructor(
app: Application,
mountEl: HTMLDivElement,
opts: TaskCardOptions,
) {
super();
this.app = app;
this.mountEl = mountEl;
this.opts = opts;
}
async draw() {
// this.card = new Graphics()
// .roundRect(0, yShift, CARD_W, CARD_H, 10)
// .fill({ color: TASK_BACKGROUND_DEFAULT_COLOR });
// .stroke(TASK_DEFAULT_BORDER)
// this.addChild(this.card);
// TODO: почему-то поле с редактированием текста рисуется на бэкграунде и его не видно
// TODO: после редктирования показываются оба варианта текста...
let input = new TextInput(this.mountEl, {
text: this.opts.label,
input: {
// multiline: true,
fontFamily: TEXT_STYLE.fontFamily,
fontSize: `${TEXT_STYLE.fontSize}pt`,
padding: `${TEXT_STYLE.padding}px`,
width: `${this.opts.width}px`,
height: `${this.opts.height}px`,
color: TEXT_STYLE.fill,
textAlign: "center",
// dom params
"border-radius": 0,
// "background-color": COLOR_WHITE,
// "font-size": `${TEXT_STYLE.fontSize}pt`,
border: "0px solid #ccc",
"box-shadow": "none",
// outline: "none",
},
box: {
default: {
fill: TOOLBAR_BACKGROUND_DEFAULT_COLOR,
rounded: 10,
stroke: TASK_DEFAULT_BORDER,
},
focused: {
fill: TOOLBAR_BACKGROUND_DEFAULT_COLOR,
rounded: 10,
stroke: TASK_FOCUSED_BORDER,
},
disabled: {
fill: TOOLBAR_BACKGROUND_DEFAULT_COLOR,
rounded: 0,
},
},
});
input.placeholder = this.opts.label;
// Background
// input.x = 100;
// input.y = yShift;
// input.eventMode = "static";
// input.on("blur", () => this.finishEditing());
// input.on("pointerdown", (e) => {
// const now = Date.now();
// if (now - lastClickTime < DOUBLE_CLICK_DELAY) {
// this.startEditing();
// }
// lastClickTime = now;
// });
this.addChild(input);
this.inputEl = input;
this.inputEl.render(this.app.renderer);
// this.text = new Text({
// text: opts.label,
// resolution: 4,
// style: new TextStyle({
// fill: COLOR_WHITE,
// fontSize: 10,
// fontFamily: "Segoe UI",
// lineHeight: 1.5,
// }),
// });
// this.text.anchor.set(0.5);
// this.text.position = {
// x: CARD_W / 2,
// y: 50 + CARD_H / 2,
// };
// this.text.eventMode = "static";
// this.text.on("pointerdown", (e) => {
// const now = Date.now();
// if (now - lastClickTime < DOUBLE_CLICK_DELAY) {
// this.startEditing();
// }
// lastClickTime = now;
// });
// this.addChild(this.text);
// const TB_W = 98;
// const TB_H = 22;
// const TB_PADDING = { x: 2, y: 2 };
// const TB_GAP = 1;
//
// this.toolbar = new Container();
// this.toolbar.position = { x: (CARD_W - TB_W) / 2, y: TB_H };
// this.toolbar.addChild(
// new Graphics()
// .roundRect(0, 0, TB_W, TB_H, 4)
// .fill({ color: TOOLBAR_BACKGROUND_DEFAULT_COLOR }),
// );
// this.addChild(this.toolbar);
// // =========================
// // 4) Create icons as vector shapes
// // =========================
//
// const icons = [
// IconService.makeTrashIcon(),
// IconService.makeKeyIcon(),
// IconService.makeLockIcon(),
// IconService.makeFocusIcon(),
// IconService.makeCircleIcon(),
// ];
//
// icons.forEach((icon, i) => {
// const button = new Button(this.app, ButtonState.ACTIVE, icon, {
// size: { width: 18, height: 18 },
// backgroundColors: {
// [ButtonState.ACTIVE]: new Color(
// TOOLBAR_BACKGROUND_DEFAULT_COLOR,
// ),
// [ButtonState.ACTIVE_HOVERED]: HOVERED_ICON_BACKGROUND_COLOR,
// [ButtonState.INACTIVE]: new Color(ICON_BACKGROUND_COLOR),
// [ButtonState.INACTIVE_HOVERED]:
// HOVERED_ICON_BACKGROUND_COLOR,
// },
// iconColors: {
// [ButtonState.ACTIVE]: TASK_BACKGROUND_DEFAULT_COLOR,
// [ButtonState.ACTIVE_HOVERED]: TASK_BACKGROUND_DEFAULT_COLOR,
// [ButtonState.INACTIVE]: TASK_BACKGROUND_DEFAULT_COLOR,
// [ButtonState.INACTIVE_HOVERED]:
// TASK_BACKGROUND_DEFAULT_COLOR,
// },
// });
// button.x = TB_PADDING.x + i * (18 + TB_GAP);
// button.y = TB_PADDING.y;
//
// button.setup();
// this.toolbar.addChild(button);
// });
}
// ==================================================
// LABEL EDIT MODE (HTML input overlay)
// ==================================================
// private startEditing() {
// if (this.inputEl?.visible) return;
// this.inputEl!.visible = true;
// this.inputEl?.focus();
// }
//
// private finishEditing() {
// if (!this.inputEl?.visible) return;
//
// this.text.text = this.inputEl.text;
// this.inputEl.visible = false;
// }
}

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type {TaskmapView} from "../TaskmapView";
import Task from "../components/Task.svelte";
let {
taskmapView
}: {
taskmapView: TaskmapView;
} = $props();
</script>
<div>
<p>Hello world!</p>
<Task taskData={taskmapView.projectData.taskData} taskmapView={taskmapView} />
</div>

View file

@ -0,0 +1,70 @@
<script>
import { Layer } from 'svelte-canvas';
import { Spring } from 'svelte/motion';
let { x, y, color, onclick } = $props();
let dragging = $state(false);
let _x = $state();
let _y = $state();
const radius = new Spring(80, { stiffness: 0.15, damping: 0.2 });
const setup = ({ width, height }) => {
_x = new Spring(width * x, { stiffness: 0.15, damping: 0.2 });
_y = new Spring(height * y, { stiffness: 0.15, damping: 0.2 });
};
const render = ({ context }) => {
context.globalCompositeOperation = 'screen';
context.fillStyle = color;
context.lineWidth = 10;
context.beginPath();
context.arc($_x, $_y, $radius, 0, Math.PI * 2);
context.fill();
context.stroke();
};
const onEnter = () => {
document.body.style.cursor = 'pointer';
radius.set(90);
};
const onLeave = () => {
document.body.style.cursor = 'auto';
dragging = false;
radius.set(80);
};
const onDown = (e) => {
dragging = true;
radius.set(120);
onclick?.();
};
const onUp = () => {
dragging = false;
radius.set(80);
};
const onMove = ({ x, y }) => {
if (dragging) {
_x.set(x);
_y.set(y);
}
};
</script>
<Layer
{setup}
{render}
onmouseenter={onEnter}
onmouseleave={onLeave}
onmousedown={onDown}
onmousemove={onMove}
onmouseup={onUp}
ontouchstart={onDown}
ontouchmove={onMove}
ontouchend={onUp}
/>

View file

@ -0,0 +1,22 @@
<script>
import { Canvas } from 'svelte-canvas';
import Ball from './Ball.svelte';
let balls = [
{ color: 'tomato', x: 0.5, y: 0.333 },
{ color: 'goldenrod', x: 0.333, y: 0.625 },
{ color: 'mediumturquoise', x: 0.667, y: 0.625 },
];
const reorder = (color) => {
balls = balls
.filter((c) => c.color !== color)
.concat(balls.filter((c) => c.color === color));
};
</script>
<Canvas layerEvents style="touch-action: none">
{#each balls as { color, x, y } (color)}
<Ball {color} {x} {y} onclick={() => reorder(color)} />
{/each}
</Canvas>

View file

@ -0,0 +1,203 @@
<script lang="ts">
import {tick} from "svelte";
import {TEXT_COLOR, TEXT_STYLE} from "../TaskView";
import {match, P} from "ts-pattern";
let coords = $state({ x: 150, y: 150 });
let dragging = $state(false);
let taskClicked = $state(false);
let editing = $state(false);
const maxLength = 20;
const taskNameEditSize = 20;
const fontSize = 20;
const fontFamily = match(TEXT_STYLE.fontFamily)
.returnType<string>()
.with(P.string, (str) => str)
.with(P.array<string>(), (ar)=> ar[0])
.exhaustive()
;
let textInput = $state('Task 1');
let title = $state("Task 1");
let task = $state({
width: 240,
height: 80,
shiftX: 2,
shiftY: 50,
rx: 30,
strokeWidthSelected: 4,
strokeWidthDefault: 2,
});
let toolbar = $state({
width: 100,
height: 30,
shiftX: 0,
shiftY: 0,
});
toolbar.shiftX = (task.width - toolbar.width) / 2;
const onMouseMove = (e: MouseEvent) => {
if (dragging) {
coords = { x: e.layerX, y: e.layerY };
}
};
const onMouseUp = () => {
if (dragging) {
dragging = false;
} else if (editing) {
finishEditing(true)
} else if (taskClicked) {
taskClicked = false;
}
};
const onMousedown = () => {
dragging = true;
};
const onTaskClick = () => {
if (!taskClicked) {
taskClicked = true;
} else if (!editing) {
taskClicked = false;
}
};
const onTextClick = () => {
if (!taskClicked) {
taskClicked = true;
} else if (!editing) {
editing = true;
textInput = title;
tick().then(() => {
const el = document.getElementById("titleInput");
if (el) el.focus();
});
}
};
const taskStrokeWidth = () => {
return taskClicked ? task.strokeWidthSelected : task.strokeWidthDefault;
};
function finishEditing(success: boolean) {
if (success && textInput.trim() !== "") {
title = textInput.trim();
// taskmapView.debouncedSave();
}
textInput = title;
editing = false;
}
function handleKey(e: KeyboardEvent) {
if (e.key === "Enter") finishEditing(true);
if (e.key === "Escape") finishEditing(false);
}
</script>
<svg class="Canvas"
width="100%"
height="100%"
onmousemove={onMouseMove}
onmouseup={onMouseUp}
role="presentation"
>
<svg class="Task"
role="presentation"
width="{task.width + task.shiftX + 2 * task.strokeWidthSelected}px"
height="{task.height + task.shiftY + 2 *task.strokeWidthSelected}px"
x={coords.x}
y={coords.y}
>
<rect class="taskBg"
onmousedown={onMousedown}
onclick={onTaskClick}
width={task.width}
height={task.height}
x="{task.shiftX}px"
y="{task.shiftY}px"
rx={task.rx}
fill=#ff3e00
stroke-width="{taskStrokeWidth()}"
stroke="white"
role="presentation"
>
</rect>
<text
onmousedown={onMousedown}
onclick={onTextClick}
x={task.width / 2 + task.shiftX}
y={task.height / 2 + task.shiftY}
fill={TEXT_COLOR}
stroke={TEXT_COLOR}
font-weight="normal"
stroke-width="1px"
dominant-baseline="middle"
text-anchor="middle"
role="presentation"
font-size="{fontSize}px"
font-family="{fontFamily}"
>
{title}
</text>
{#if taskClicked}
<rect class="toolbarBg"
x={toolbar.shiftX}
y={toolbar.shiftY}
width={toolbar.width}
height={toolbar.height}
>
</rect>
{/if}
</svg>
</svg>
{#if editing}
<div
class="textEditDiv"
xmlns="http://www.w3.org/1999/xhtml"
style="
position: absolute;
top: {coords.y + task.shiftY + task.height /2 - fontSize/2 - 7}px;
left: {coords.x + task.shiftX + task.strokeWidthSelected-2}px;
"
>
<input
id="titleInput"
bind:value={textInput}
onkeydown={handleKey}
size={taskNameEditSize}
maxlength={maxLength}
style="
font-family: {fontFamily};
font-size: {fontSize}px;
color: {TEXT_COLOR};
background-color: #ff3e00;
border: 0px;
outline-width: 0px;
padding-left: 5px;
text-align: center;
/*dominant-baseline: middle;*/
/*text-anchor: middle;*/
"
/>
</div>
{/if}
<!--onblur={() => finishEditing(true)}-->
<style>
svg {
position: absolute;
left: 0;
/*right: 200px;*/
top: 0;
/*bottom: 100px;*/
}
.textEditDiv {
position: absolute;
top: 500px;
left: 500px;
}
</style>

720
src/legacy/text-input.js Normal file
View file

@ -0,0 +1,720 @@
import { Container, Graphics, Text, TextStyle } from "pixi.js";
export default class TextInput extends Container {
constructor(mountEl, styles) {
super();
this.mountEl = mountEl;
this._input_style = Object.assign(
{
position: "absolute",
background: "none",
border: "none",
outline: "none",
transformOrigin: "0 0",
lineHeight: "1",
},
styles.input,
);
if (styles.box)
this._box_generator =
typeof styles.box === "function"
? styles.box
: new DefaultBoxGenerator(styles.box);
else this._box_generator = null;
if (this._input_style.hasOwnProperty("multiline")) {
this._multiline = !!this._input_style.multiline;
delete this._input_style.multiline;
} else this._multiline = false;
this._box_cache = {};
this._previous = {};
this._dom_added = false;
this._dom_visible = true;
// Ставим поейсхолдер, но он не использутся если непуст dominput. А он заполняется парой строчек ниже
this._placeholder = styles["text"];
this._placeholderColor = styles["input"]["color"];
this._selection = [0, 0];
this._restrict_value = "";
this._createDOMInput();
this._dom_input.value = styles["text"];
this.substituteText = true;
this._setState("DEFAULT");
this._addListeners();
//
// this.worldAlpha = 1;
this.worldVisible = true;
}
// GETTERS & SETTERS
get substituteText() {
return this._substituted;
}
set substituteText(substitute) {
if (this._substituted == substitute) return;
this._substituted = substitute;
if (substitute) {
this._createSurrogate();
this._dom_visible = false;
} else {
this._destroySurrogate();
this._dom_visible = true;
}
this.placeholder = this._placeholder;
this._update();
}
get placeholder() {
return this._placeholder;
}
set placeholder(text) {
this._placeholder = text;
if (this._substituted) {
this._updateSurrogate();
this._dom_input.placeholder = "";
} else {
this._dom_input.placeholder = text;
}
}
get disabled() {
return this._disabled;
}
set disabled(disabled) {
this._disabled = disabled;
this._dom_input.disabled = disabled;
this._setState(disabled ? "DISABLED" : "DEFAULT");
}
get maxLength() {
return this._max_length;
}
set maxLength(length) {
this._max_length = length;
this._dom_input.setAttribute("maxlength", length);
}
get restrict() {
return this._restrict_regex;
}
set restrict(regex) {
if (regex instanceof RegExp) {
regex = regex.toString().slice(1, -1);
if (regex.charAt(0) !== "^") regex = "^" + regex;
if (regex.charAt(regex.length - 1) !== "$") regex = regex + "$";
regex = new RegExp(regex);
} else {
regex = new RegExp("^[" + regex + "]*$");
}
this._restrict_regex = regex;
}
get text() {
return this._dom_input.value;
}
set text(text) {
this._dom_input.value = text;
if (this._substituted) this._updateSurrogate();
}
get htmlInput() {
return this._dom_input;
}
focus() {
console.log("focus called");
if (this._substituted && !this.dom_visible) {
console.log("dom input visible: true");
this._setDOMInputVisible(true);
}
this._dom_input.focus();
}
blur() {
this._dom_input.blur();
}
select() {
this.focus();
this._dom_input.select();
}
setInputStyle(key, value) {
this._input_style[key] = value;
this._dom_input.style[key] = value;
if (this._substituted && (key === "fontFamily" || key === "fontSize"))
this._updateFontMetrics();
if (this._last_renderer) this._update();
}
destroy(options) {
this._destroyBoxCache();
super.destroy(options);
}
// SETUP
_createDOMInput() {
if (this._multiline) {
this._dom_input = document.createElement("textarea");
this._dom_input.style.resize = "none";
} else {
this._dom_input = document.createElement("input");
this._dom_input.type = "text";
}
for (let key in this._input_style) {
this._dom_input.style[key] = this._input_style[key];
}
}
_addListeners() {
this.on("added", this._onAdded.bind(this));
this.on("removed", this._onRemoved.bind(this));
this._dom_input.addEventListener(
"keydown",
this._onInputKeyDown.bind(this),
);
this._dom_input.addEventListener(
"input",
this._onInputInput.bind(this),
);
this._dom_input.addEventListener(
"keyup",
this._onInputKeyUp.bind(this),
);
this._dom_input.addEventListener("focus", this._onFocused.bind(this));
this._dom_input.addEventListener("blur", this._onBlurred.bind(this));
}
_onInputKeyDown(e) {
this._selection = [
this._dom_input.selectionStart,
this._dom_input.selectionEnd,
];
this.emit("keydown", e.keyCode);
}
_onInputInput(e) {
if (this._restrict_regex) this._applyRestriction();
if (this._substituted) this._updateSubstitution();
this.emit("input", this.text);
}
_onInputKeyUp(e) {
this.emit("keyup", e.keyCode);
}
_onFocused() {
this._setState("FOCUSED");
this.emit("focus");
}
_onBlurred() {
this._setState("DEFAULT");
this.emit("blur");
}
_onAdded() {
this.mountEl.appendChild(this._dom_input);
// document.body.appendChild(this._dom_input);
this._dom_input.style.display = "none";
this._dom_added = true;
}
_onRemoved() {
this.mountEl.removeChild(this._dom_input);
// document.body.removeChild(this._dom_input);
this._dom_added = false;
}
_setState(state) {
this.state = state;
this._updateBox();
if (this._substituted) this._updateSubstitution();
}
// RENDER & UPDATE
render(renderer) {
// super.render(renderer);
this._resolution = renderer.resolution;
this._last_renderer = renderer;
this._canvas_bounds = this._getCanvasBounds();
if (this._needsUpdate()) this._update();
}
_update() {
this._updateDOMInput();
if (this._substituted) this._updateSurrogate();
this._updateBox();
}
_updateBox() {
if (!this._box_generator) return;
if (this._needsNewBoxCache()) this._buildBoxCache();
if (
this.state == this._previous.state &&
this._box == this._box_cache[this.state]
)
return;
if (this._box) this.removeChild(this._box);
this._box = this._box_cache[this.state];
this.addChildAt(this._box, 0);
this._previous.state = this.state;
}
_updateSubstitution() {
if (this.state === "FOCUSED") {
this._dom_visible = true;
this._surrogate.visible = this.text.length === 0;
} else {
this._dom_visible = false;
this._surrogate.visible = true;
}
this._updateDOMInput();
this._updateSurrogate();
}
_updateDOMInput() {
if (!this._canvas_bounds) {
console.log("No Canvas Bounds");
return;
}
this._dom_input.style.top = (this._canvas_bounds.top || 0) + "px";
this._dom_input.style.left = (this._canvas_bounds.left || 0) + "px";
var transform = this._getDOMRelativeWorldTransform();
// !!!!
// transform.tx += 10;
// transform.ty += 50;
this._dom_input.style.transform = this._pixiMatrixToCSS(transform);
console.log(
JSON.stringify({
top: this._dom_input.style.top,
left: this._dom_input.style.left,
transform: this._dom_input.style.transform,
}),
);
this._dom_input.style.opacity = this.worldAlpha;
console.log("Alpha", this.worldAlpha);
this._setDOMInputVisible(this.worldVisible && this._dom_visible);
console.log("world visible", this.worldVisible);
this._previous.canvas_bounds = this._canvas_bounds;
this._previous.world_transform = this.worldTransform.clone();
this._previous.world_alpha = this.worldAlpha;
this._previous.world_visible = this.worldVisible;
}
_applyRestriction() {
if (this._restrict_regex.test(this.text)) {
this._restrict_value = this.text;
} else {
this.text = this._restrict_value;
this._dom_input.setSelectionRange(
this._selection[0],
this._selection[1],
);
}
}
// STATE COMPAIRSON (FOR PERFORMANCE BENEFITS)
_needsUpdate() {
return (
!this._comparePixiMatrices(
this.worldTransform,
this._previous.world_transform,
) ||
!this._compareClientRects(
this._canvas_bounds,
this._previous.canvas_bounds,
) ||
this.worldAlpha != this._previous.world_alpha ||
this.worldVisible != this._previous.world_visible
);
}
_needsNewBoxCache() {
let input_bounds = this._getDOMInputBounds();
return (
!this._previous.input_bounds ||
input_bounds.width != this._previous.input_bounds.width ||
input_bounds.height != this._previous.input_bounds.height
);
}
// INPUT SUBSTITUTION
_createSurrogate() {
this._surrogate_hitbox = new Graphics();
this._surrogate_hitbox.alpha = 0;
this._surrogate_hitbox.interactive = true;
this._surrogate_hitbox.cursor = "text";
this._surrogate_hitbox.on(
"pointerdown",
this._onSurrogateFocus.bind(this),
);
this.addChild(this._surrogate_hitbox);
this._surrogate_mask = new Graphics();
this.addChild(this._surrogate_mask);
this._surrogate = new Text({
text: "",
resolution: 8,
});
this.addChild(this._surrogate);
this._surrogate.mask = this._surrogate_mask;
this._updateFontMetrics();
this._updateSurrogate();
}
_updateSurrogate() {
let padding = this._deriveSurrogatePadding();
let input_bounds = this._getDOMInputBounds();
this._surrogate.style = this._deriveSurrogateStyle();
this._surrogate.style.padding = Math.max.apply(Math, padding);
this._surrogate.y = this._multiline
? padding[0]
: (input_bounds.height - this._surrogate.height) / 2;
this._surrogate.x = padding[3];
this._surrogate.text = this._deriveSurrogateText();
switch (this._surrogate.style.align) {
case "left":
this._surrogate.x = padding[3];
break;
case "center":
this._surrogate.x =
input_bounds.width * 0.5 - this._surrogate.width * 0.5;
break;
case "right":
this._surrogate.x =
input_bounds.width - padding[1] - this._surrogate.width;
break;
}
this._updateSurrogateHitbox(input_bounds);
this._updateSurrogateMask(input_bounds, padding);
}
_updateSurrogateHitbox(bounds) {
this._surrogate_hitbox.clear();
this._surrogate_hitbox.beginFill(0);
this._surrogate_hitbox.drawRect(0, 0, bounds.width, bounds.height);
this._surrogate_hitbox.endFill();
this._surrogate_hitbox.interactive = !this._disabled;
}
_updateSurrogateMask(bounds, padding) {
this._surrogate_mask.clear();
this._surrogate_mask.beginFill(0);
this._surrogate_mask.drawRect(
padding[3],
0,
bounds.width - padding[3] - padding[1],
bounds.height,
);
this._surrogate_mask.endFill();
}
_destroySurrogate() {
if (!this._surrogate) return;
this.removeChild(this._surrogate);
this.removeChild(this._surrogate_mask);
this.removeChild(this._surrogate_hitbox);
this._surrogate.destroy();
this._surrogate_hitbox.destroy();
this._surrogate = null;
this._surrogate_hitbox = null;
}
_onSurrogateFocus() {
this._setDOMInputVisible(true);
//sometimes the input is not being focused by the mouseclick
setTimeout(this._ensureFocus.bind(this), 10);
}
_ensureFocus() {
if (!this._hasFocus()) this.focus();
}
_deriveSurrogateStyle() {
let style = new TextStyle();
for (var key in this._input_style) {
switch (key) {
case "color":
style.fill = this._input_style.color;
break;
case "fontFamily":
case "fontSize":
case "fontWeight":
case "fontVariant":
case "fontStyle":
style[key] = this._input_style[key];
break;
case "letterSpacing":
style.letterSpacing = parseFloat(
this._input_style.letterSpacing,
);
break;
case "textAlign":
style.align = this._input_style.textAlign;
break;
}
}
if (this._multiline) {
style.lineHeight = parseFloat(style.fontSize);
style.wordWrap = true;
style.wordWrapWidth = this._getDOMInputBounds().width;
}
if (this._dom_input.value.length === 0)
style.fill = this._placeholderColor;
return style;
}
_deriveSurrogatePadding() {
let indent = this._input_style.textIndent
? parseFloat(this._input_style.textIndent)
: 0;
if (this._input_style.padding && this._input_style.padding.length > 0) {
let components = this._input_style.padding.trim().split(" ");
if (components.length == 1) {
let padding = parseFloat(components[0]);
return [padding, padding, padding, padding + indent];
} else if (components.length == 2) {
let paddingV = parseFloat(components[0]);
let paddingH = parseFloat(components[1]);
return [paddingV, paddingH, paddingV, paddingH + indent];
} else if (components.length == 4) {
let padding = components.map((component) => {
return parseFloat(component);
});
padding[3] += indent;
return padding;
}
}
return [0, 0, 0, indent];
}
_deriveSurrogateText() {
if (this._dom_input.value.length === 0) return this._placeholder;
if (this._dom_input.type == "password")
return "•".repeat(this._dom_input.value.length);
return this._dom_input.value;
}
_updateFontMetrics() {
const style = this._deriveSurrogateStyle();
// const font = style.toFontString()
// this._font_metrics = TextMetrics.measureFont(font)
}
// CACHING OF INPUT BOX GRAPHICS
_buildBoxCache() {
this._destroyBoxCache();
let states = ["DEFAULT", "FOCUSED", "DISABLED"];
let input_bounds = this._getDOMInputBounds();
for (let state of states) {
this._box_cache[state] = this._box_generator(
input_bounds.width,
input_bounds.height,
state,
);
}
this._previous.input_bounds = input_bounds;
}
_destroyBoxCache() {
if (this._box) {
this.removeChild(this._box);
this._box = null;
}
for (let i in this._box_cache) {
this._box_cache[i].destroy();
this._box_cache[i] = null;
delete this._box_cache[i];
}
}
// HELPER FUNCTIONS
_hasFocus() {
return document.activeElement === this._dom_input;
}
_setDOMInputVisible(visible) {
this._dom_input.style.display = visible ? "block" : "none";
}
_getCanvasBounds() {
console.log("getting bounds");
// let rect = this._last_renderer.view.getBoundingClientRect();
let rect = this._last_renderer.view.screen;
console.log(`Bounds: ${JSON.stringify(rect)}`);
let bounds = {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
bounds.left += window.scrollX;
bounds.top += window.scrollY;
return bounds;
}
_getDOMInputBounds() {
let remove_after = false;
if (!this._dom_added) {
// document.body.appendChild(this._dom_input);
this.mountEl.appendChild(this._dom_input);
remove_after = true;
}
let org_transform = this._dom_input.style.transform;
let org_display = this._dom_input.style.display;
this._dom_input.style.transform = "";
this._dom_input.style.display = "block";
let bounds = this._dom_input.getBoundingClientRect();
this._dom_input.style.transform = org_transform;
this._dom_input.style.display = org_display;
if (remove_after) {
this.mountEl.removeChild(this._dom_input);
// document.body.removeChild(this._dom_input);
}
return bounds;
}
_getDOMRelativeWorldTransform() {
// let canvas_bounds = this._last_renderer.view.getBoundingClientRect();
let canvas_bounds = this._last_renderer.view.screen;
let matrix = this.worldTransform.clone();
console.log(JSON.stringify(matrix));
matrix.scale(this._resolution, this._resolution);
matrix.scale(
canvas_bounds.width / this._last_renderer.width,
canvas_bounds.height / this._last_renderer.height,
);
return matrix;
}
_pixiMatrixToCSS(m) {
return "matrix(" + [m.a, m.b, m.c, m.d, m.tx, m.ty].join(",") + ")";
}
_comparePixiMatrices(m1, m2) {
if (!m1 || !m2) return false;
return (
m1.a == m2.a &&
m1.b == m2.b &&
m1.c == m2.c &&
m1.d == m2.d &&
m1.tx == m2.tx &&
m1.ty == m2.ty
);
}
_compareClientRects(r1, r2) {
if (!r1 || !r2) return false;
return (
r1.left == r2.left &&
r1.top == r2.top &&
r1.width == r2.width &&
r1.height == r2.height
);
}
}
function DefaultBoxGenerator(styles) {
styles = styles || { fill: 0xcccccc };
if (styles.default) {
styles.focused = styles.focused || styles.default;
styles.disabled = styles.disabled || styles.default;
} else {
let temp_styles = styles;
styles = {};
styles.default = styles.focused = styles.disabled = temp_styles;
}
return function (w, h, state) {
let style = styles[state.toLowerCase()];
let box = new Graphics({
// roundPixels: true,
// resolution: 8,
});
if (style.fill) box.beginFill(style.fill);
if (style.stroke)
box.lineStyle(
style.stroke.width || 1,
style.stroke.color || 0,
style.stroke.alpha || 1,
);
if (style.rounded) box.drawRoundedRect(0, 0, w, h, style.rounded);
else box.drawRect(0, 0, w, h);
box.endFill();
box.closePath();
return box;
};
}

0
src/logo.ts Normal file
View file

81
src/main.ts Normal file
View file

@ -0,0 +1,81 @@
import { addIcon, Plugin } from "obsidian";
import { TaskmapView } from "./TaskmapView";
import { type PluginSettings, DEFAULT_SETTINGS } from "./PluginSettings";
import { TaskmapSettingTab } from "./TaskmapSettingTab";
import { DEFAULT_DATA } from "./ProjectData.svelte";
import { LOGO_CONTENT, LOGO_NAME } from "./pixi/IconService";
export const FILE_EXTENSION = "taskmap";
export const VIEW_TYPE_EXAMPLE = "example";
export default class TaskmapPlugin extends Plugin {
static instance: TaskmapPlugin;
settings: PluginSettings;
async onload() {
TaskmapPlugin.instance = this;
await this.loadSettings();
this.registerView(VIEW_TYPE_EXAMPLE, (leaf) => new TaskmapView(leaf));
this.registerExtensions([FILE_EXTENSION], VIEW_TYPE_EXAMPLE);
// This creates an icon in the left ribbon.
addIcon(LOGO_NAME, LOGO_CONTENT);
this.addRibbonIcon(LOGO_NAME, "Taskmap Plugin", (_evt: MouseEvent) => {
this.createAndOpenDrawing();
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new TaskmapSettingTab(this.app, this));
// // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// // Using this function will automatically remove the event listener when this plugin is disabled.
// this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
// console.log('click', evt);
// });
// // When registering intervals, this function will automatically clear the interval when the plugin is disabled.
// this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
public static getActiveView() {
return TaskmapPlugin.instance.app.workspace.getActiveViewOfType(
TaskmapView,
);
}
public async createAndOpenDrawing(): Promise<string> {
this.app.workspace.detachLeavesOfType(VIEW_TYPE_EXAMPLE);
const file = await this.app.vault.create(
`Example ${window.moment().format("YY-MM-DD hh.mm.ss")}.${FILE_EXTENSION}`,
DEFAULT_DATA,
);
const leaf = this.app.workspace.getLeaf("tab");
await leaf.openFile(file, { active: true });
leaf.setViewState({
type: VIEW_TYPE_EXAMPLE,
state: leaf.view.getState(),
});
this.app.workspace.revealLeaf(
this.app.workspace.getLeavesOfType(VIEW_TYPE_EXAMPLE)[0],
);
return file.path;
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
);
}
async saveSettings() {
await this.saveData(this.settings);
}
}

16
src/pixi/Constants.ts Normal file
View file

@ -0,0 +1,16 @@
export const BUTTON_SIZE = 18 * 2;
export const TOOLBAR_GAP = 2;
export const TOOLBAR_PADDING = { x: 4, y: 4 };
export const TOOLBAR_SHIFT = 4 * 2;
export const SUBTOOLBAR_SHIFT = 2;
export const TOOLBAR_SIZE = {
width: 98 * 2,
height: 22 * 2,
};
export const TASK_SIZE = {
width: 280,
height: 80,
width_hovered: 284,
height_hovered: 84,
};

76
src/pixi/Custom.js Normal file
View file

@ -0,0 +1,76 @@
import { match } from "ts-pattern";
/**
* Slides an element in and out.
*
* @param {Element} node
* @param {SlideParamsCustom} [params]
* @returns {TransitionConfig}
*/
export function slideCustom(
node,
{ delay = 0, duration = 400, easing = "cubic_out", axis = "y" } = {},
) {
const style = getComputedStyle(node);
const opacity = +style.opacity;
const primary_property = axis === "y" || axis === "-y" ? "height" : "width";
const primary_property_value = parseFloat(style[primary_property]);
const secondary_properties = match(axis)
.with("y", () => ["top", "bottom"])
.with("-y", () => ["top", "bottom"])
.with("x", () => ["left", "right"])
.with("-x", () => ["left", "right"])
.otherwise((x) => {
throw new Error();
});
const capitalized_secondary_properties = secondary_properties.map(
(e) =>
/** @type {'Left' | 'Right' | 'Top' | 'Bottom'} */ `${e[0].toUpperCase()}${e.slice(1)}`,
);
const padding_start_value = parseFloat(
style[`padding${capitalized_secondary_properties[0]}`],
);
const padding_end_value = parseFloat(
style[`padding${capitalized_secondary_properties[1]}`],
);
const margin_start_value = parseFloat(
style[`margin${capitalized_secondary_properties[0]}`],
);
const margin_end_value = parseFloat(
style[`margin${capitalized_secondary_properties[1]}`],
);
const border_width_start_value = parseFloat(
style[`border${capitalized_secondary_properties[0]}Width`],
);
const border_width_end_value = parseFloat(
style[`border${capitalized_secondary_properties[1]}Width`],
);
let additionalString = "";
if (axis === "-y") {
}
return {
delay,
duration,
easing,
css: (t) =>
"overflow: hidden;" +
`opacity: ${Math.min(t * 20, 1) * opacity};` +
`${primary_property}: ${t * primary_property_value}px;` +
`padding-${secondary_properties[0]}: ${t * padding_start_value}px;` +
`padding-${secondary_properties[1]}: ${t * padding_end_value}px;` +
`margin-${secondary_properties[0]}: ${t * margin_start_value}px;` +
`margin-${secondary_properties[1]}: ${t * margin_end_value}px;` +
`border-${secondary_properties[0]}-width: ${t * border_width_start_value}px;` +
`border-${secondary_properties[1]}-width: ${t * border_width_end_value}px;` +
`min-${primary_property}: 0` +
(axis === "-y"
? `; top: ${parseFloat(style["top"]) + primary_property_value * (1 - t)}px`
: "") +
(axis === "-x"
? `; left: ${parseFloat(style["left"]) + primary_property_value * (1 - t)}px`
: ""),
};
}

View file

@ -0,0 +1,137 @@
import TaskmapPlugin from "../main";
import type { ProjectData } from "../ProjectData.svelte";
import { StatusCode } from "../types";
import { Spring } from "svelte/motion";
import type { App } from "obsidian";
import type { TaskmapView } from "../TaskmapView";
export class UIState {
app: App;
pressedButtonIndex = $state(-1);
selectedTaskId = $state(-1);
toolbarStatus = $state(StatusCode.DRAFT);
projectData: ProjectData;
taskPositions: Array<{
taskId: number;
tween: Spring<{ x: number; y: number }>;
}>;
// svg elements are pixelated zooming from scale < 1 to scale > 1, so we force a redraw manually
updateOnZoomCounter = $state(0);
// parameters for animating task movement
private tweenOptions = { duration: 300 };
private springOptions = { stiffness: 0.07, damping: 0.7 };
constructor(projectData: ProjectData, app: App) {
this.app = app;
this.projectData = projectData;
const posArray = projectData.tasks
.filter((t) => !t.deleted)
.map((task) => {
const t = new Spring(
this.calcTaskPosition(task.taskId),
this.springOptions,
);
return { taskId: task.taskId, tween: t };
});
this.taskPositions = posArray;
}
public incrementUpdateOnZoomCounter(): void {
this.updateOnZoomCounter += 1;
}
public updateTaskPositions() {
this.taskPositions.forEach((taskPos) => {
taskPos.tween.target = this.calcTaskPosition(taskPos.taskId);
});
}
public isSelected(taskId: number) {
return this.selectedTaskId == taskId;
}
public setSelectedTaskId(taskId: number) {
this.selectedTaskId = taskId;
if (taskId != -1) {
this.toolbarStatus = this.projectData.getTaskStatus(taskId);
}
}
// can be called from any component
public save() {
const x = TaskmapPlugin.getActiveView();
if (x != null) {
x.debouncedSave();
console.log("saved");
}
}
public getActiveView(): TaskmapView {
const x = TaskmapPlugin.getActiveView();
if (x) {
return x;
} else {
throw new Error("Unable to get active view");
}
}
public addTask() {
const id = this.projectData.addTask();
this.taskPositions.push({
taskId: id,
tween: new Spring(this.calcTaskPosition(id), this.springOptions),
});
this.save();
}
public removeTask(id: number) {
this.setSelectedTaskId(-1);
this.projectData.removeTask(id);
this.updateTaskPositions();
this.save();
}
public changeStatus(status: StatusCode) {
console.log(
`changeStatus from ${this.toolbarStatus} to ${status} for task ${this.selectedTaskId}`,
);
this.projectData.setTaskStatus(this.selectedTaskId, status);
this.toolbarStatus = status;
const x = TaskmapPlugin.getActiveView();
if (x) {
x.debouncedSave();
}
// get taskdata
// change its status
}
private calcTaskPosition(taskId: number) {
const base_position = { x: 200, y: 500 };
const idx = this.projectData.tasks
.filter((t) => !t.deleted)
.findIndex((t) => t.taskId == taskId);
return {
x: base_position.x + 200 * idx,
y: base_position.y + 200 * idx,
};
}
public getCurrentTaskPosition(taskId: number) {
const pos = this.taskPositions.find((t) => t.taskId === taskId)!.tween
.current;
if (taskId == 5) {
console.log(JSON.stringify(pos));
}
return pos;
}
public serializeForDebugging() {
return JSON.stringify({
pressedButtonIndex: this.pressedButtonIndex,
selectedTaskId: this.selectedTaskId,
toolbarStatus: this.toolbarStatus,
projectData: this.projectData,
});
}
}

117
src/pixi/IconService.ts Normal file
View file

@ -0,0 +1,117 @@
import { IconCode, StatusCode } from "../types";
export const LOGO_NAME = "TaskmapLogo";
export const LOGO_CONTENT = `
<path d="M88.9366 24.8308V33.8571H55.7464V24.8308H88.9366ZM91.1514 22.5714V11.2857C91.1514 10.0391 90.1586 9.02637 88.9366 9.02637H55.7464C54.5244 9.02637 53.5316 10.0391 53.5316 11.2857V22.5714C53.5316 23.818 54.5244 24.8308 55.7464 24.8308V33.8571L54.612 33.802C49.4075 33.2615 45.267 29.0378 44.7371 23.7287L44.683 22.5714V11.2857C44.683 5.05279 49.6363 9.0886e-08 55.7464 0H88.9366C95.0467 0 100 5.05279 100 11.2857V22.5714C100 28.4138 95.6484 33.2227 90.071 33.802L88.9366 33.8571V24.8308C90.1586 24.8308 91.1514 23.818 91.1514 22.5714Z" fill="currentColor"/>
<path d="M88.9366 69.9736V79H55.7464V69.9736H88.9366ZM91.1514 67.7143V56.4286C91.1514 55.182 90.1586 54.1692 88.9366 54.1692H55.7464C54.5244 54.1692 53.5316 55.182 53.5316 56.4286V67.7143C53.5316 68.9609 54.5244 69.9736 55.7464 69.9736V79L54.612 78.9449C49.4075 78.4043 45.267 74.1806 44.7371 68.8715L44.683 67.7143V56.4286C44.683 50.1956 49.6363 45.1429 55.7464 45.1429H88.9366C95.0467 45.1429 100 50.1956 100 56.4286V67.7143C100 73.5566 95.6484 78.3656 90.071 78.9449L88.9366 79V69.9736C90.1586 69.9736 91.1514 68.9609 91.1514 67.7143Z" fill="currentColor"/>
<path d="M22.8099 52.5212V26.4781C22.8099 17.752 29.7487 10.6737 38.3029 10.6737H47.6701V19.7111H38.3029C34.6368 19.7111 31.6692 22.7384 31.6692 26.4781V52.5212C31.6692 56.261 34.6368 59.2993 38.3029 59.2993H47.6701V68.3256H38.3029C29.7487 68.3256 22.8099 61.2473 22.8099 52.5212Z" fill="currentColor"/>
<path d="M27.2371 34.9806V44.018H0V34.9806H27.2371Z" fill="currentColor"/>
`;
export default class IconService {
static getIcon = getIcon;
static getStatusIcon = getStatusIcon;
}
export function getIcon(code: IconCode) {
switch (code) {
case IconCode.TRASH:
return LUCIDE_TRASH_SVG;
case IconCode.TRASH_SINGLE:
return LUCIDE_TRASH_SVG;
case IconCode.TRASH_MULTIPLE:
return LUCIDE_TRASH_SVG;
case IconCode.KEY:
return LUCIDE_KEY_SVG;
case IconCode.LOCK:
return LUCIDE_LOCK_SVG;
case IconCode.FOCUS:
return LUCIDE_FOCUS_SVG;
case IconCode.STATUS:
return getStatusIcon(StatusCode.DRAFT);
case IconCode.STATUS_DRAFT:
return getStatusIcon(StatusCode.DRAFT);
case IconCode.STATUS_READY:
return getStatusIcon(StatusCode.READY);
case IconCode.STATUS_IN_PROGRESS:
return getStatusIcon(StatusCode.IN_PROGRESS);
case IconCode.STATUS_DONE:
return getStatusIcon(StatusCode.DONE);
}
}
export function getStatusClassString(code: StatusCode) {
return ["class=draft", "class=ready", "class=in-progress", "class=done"][
code
];
}
export function getStatusIcon(code: StatusCode) {
return `
<svg
class:is-pressed-up={isPressed}
class:is-pressed-down={isPressedDown}
${getStatusClassString(code)}
class="circle" viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10"/>
</svg>
`;
}
const LUCIDE_TRASH_SVG = `
<svg
class="lucide lucide-trash2-icon lucide-trash-2"
class:is-pressed-up={isPressed}
class:is-pressed-down={isPressedDown}
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
<path d="M3 6h18"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
`;
const LUCIDE_KEY_SVG = `
<svg
class="lucide lucide-key-round-icon lucide-key-round"
class:is-pressed-up={isPressed}
class:is-pressed-down={isPressedDown}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/>
<circle cx="16.5" cy="7.5" r=".5"/>
</svg>
`;
const LUCIDE_LOCK_SVG = `
<svg
class:is-pressed-up={isPressed}
class:is-pressed-down={isPressedDown}
viewBox="0 0 24 24">
<rect x="5" y="10" width="14" height="11" rx="2"/>
<path d="M8 10V7a4 4 0 1 1 8 0v3"/>
</svg>
`;
const LUCIDE_FOCUS_SVG = `
<svg
class:is-pressed-up={isPressed}
class:is-pressed-down={isPressedDown}
viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8"/>
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
</svg>
`;
const LUCIDE_CIRCLE_SVG = `
<svg
class:is-pressed-up={isPressed}
class:is-pressed-down={isPressedDown}
class="circle" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"/>
</svg>
`;

43
src/types.ts Normal file
View file

@ -0,0 +1,43 @@
import type { ProjectData } from "./ProjectData.svelte";
import { UIState } from "./pixi/GlobalState.svelte";
import type { EasingFunction } from "svelte/transition";
export enum IconCode {
TRASH,
KEY,
LOCK,
FOCUS,
STATUS,
TRASH_SINGLE,
TRASH_MULTIPLE,
STATUS_DRAFT,
STATUS_READY,
STATUS_IN_PROGRESS,
STATUS_DONE,
}
export enum StatusCode {
DRAFT,
READY,
IN_PROGRESS,
DONE,
}
export type TaskData = {
name: string;
taskId: number;
status: StatusCode;
deleted: boolean;
};
export type Context = {
uiState: UIState;
projectData: ProjectData;
};
export interface SlideParamsCustom {
delay?: number;
duration?: number;
easing?: EasingFunction;
axis?: "x" | "y" | "-x" | "-y";
}