@webwriter/flowchart
Version:
Create programming flowcharts with interactive tasks. Use standardized Elements such as loops and Branchings.
1,124 lines (980 loc) • 62.7 kB
text/typescript
import { LitElementWw } from '@webwriter/lit';
import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { v4 as uuidv4 } from 'uuid';
import { GraphNode } from './src/definitions/GraphNode';
import { Arrow } from './src/definitions/Arrow';
import { ItemList } from './src/definitions/ItemList';
import { drawButton } from './src/modules/drawer/drawButton';
import { drawGraphNode, drawNodeAnchors } from './src/modules/drawer/drawGraphNode';
import { drawArrow, drawTempArrow, generateArrowPoints, drawArrowAnchor } from './src/modules/drawer/drawArrow';
import { drawSelectionField } from './src/modules/drawer/drawSelectionField';
import {
handleNodeDragStart,
handleArrowDragStart,
handleMultipleNodesDragStart,
} from './src/modules/handler/mouseDownHandler';
import { handleGrabRelease, handleNodeDragStop, handleArrowCreation } from './src/modules/handler/mouseUpHandler';
import { handleSequenceSelection } from './src/modules/handler/handleSequenceSelection';
import { handleGraphNodeDoubleClick, handleArrowDoubleClick } from './src/modules/handler/doubleClickHandler';
import { toggleMenu } from './src/modules/ui/toggleMenu';
import { addHelp, renderHelpList } from './src/modules/ui/helpMenu';
import { addTask, renderTasks } from './src/modules/ui/taskMenu';
import {
createTooltip,
removeTooltip,
updateDisabledState,
grabCanvas,
autoDeleteEmptyItems,
} from './src/modules/ui/generalUI';
import {
snapNodePosition,
removeOldConnection,
isNodeInRectangle,
findLastGraphNode,
findGraphNodeLastIndex,
} from './src/modules/helper/utilities';
import { isArrowClicked } from './src/modules/helper/arrowHelper';
import { getAnchors, highlightAnchor } from './src/modules/helper/anchorHelper';
import { createArrowsFromGraphNodes, updatePresetIds } from './src/modules/helper/presetHelper';
import { papWidgetStyles } from './src/modules/styles/styles';
import { CustomPrompt } from './src/components/custom-prompt';
import './src/components/custom-prompt';
import { ConfirmPrompt } from './src/components/confirm-prompt';
import './src/components/confirm-prompt';
import { PropertyValueMap } from '@lit/reactive-element';
@customElement('webwriter-flowchart')
export class FlowchartWidget extends LitElementWw {
@property({ type: Array, reflect: true, attribute: true }) accessor graphNodes: GraphNode[] = [];
@property({ type: Object }) accessor selectedNode: GraphNode;
@property({ type: Array }) accessor arrows: Arrow[] = [];
@property({ type: Object }) accessor selectedArrow: Arrow;
getGraphNodes = () => this.graphNodes;
getArrows = () => this.arrows;
@property({ type: Array, reflect: true, attribute: true }) accessor taskList: ItemList[] = [];
@property({ type: Array, reflect: true, attribute: true }) accessor helpList: ItemList[] = [];
@property({ type: Number, reflect: true, attribute: true }) accessor height: number = 400;
@property({ type: Number }) accessor currentHeight: number = this.height;
@property({ type: Object }) accessor graphSettings = { font: 'Courier New', fontSize: 16, theme: 'standard' };
@property({ type: Number, reflect: true, attribute: true }) accessor zoomLevel: number = 100; // in Prozent
private gridSize: number = 50;
private dotSize: number = 1.5;
@property({ type: Number, reflect: true, attribute: true }) accessor canvasOffsetX: number = 0;
@property({ type: Number, reflect: true, attribute: true }) accessor canvasOffsetY: number = 0;
@property({ type: Boolean, reflect: true, attribute: true }) accessor allowStudentEdit: boolean = false;
@property({ type: Boolean, reflect: true, attribute: true }) accessor allowStudentPan: boolean = false;
@property({ type: String, reflect: true, attribute: true }) accessor font = 'Courier New';
@property({ type: Number, reflect: true, attribute: true }) accessor fontSize = 16;
@property({ type: String, reflect: true, attribute: true }) accessor theme = 'standard';
@property({ type: Boolean }) accessor fullscreen = false;
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private isDragging = false;
private draggedNode: GraphNode;
private dragOffset = { x: 0, y: 0 };
private draggedNodes: GraphNode[] = [];
private isDrawingArrow = false;
private arrowStart?: { node: GraphNode; anchor: number };
private tempArrowEnd?: { x: number; y: number };
private isGrabbing = false;
private grabStartPosition?: { x: number; y: number };
private grabStartOffset?: { x: number; y: number };
private hoveredAnchor?: { element: GraphNode; anchor: number };
private isArrowAnchorHovered: boolean;
private _isSelectingSequence = false;
private selectedSequence: { id: string; order: number; type: string }[] = [];
getSelectedSequence = () => this.selectedSequence;
private activeSequenceButton: HTMLButtonElement | null = null;
getActiveSequenceButton = () => this.activeSequenceButton;
setActiveSequenceButton = (btn: HTMLButtonElement | null) => {
this.activeSequenceButton = btn;
};
get isSelectingSequence() {
return this._isSelectingSequence;
}
set isSelectingSequence(value: boolean) {
const oldValue = this._isSelectingSequence;
this._isSelectingSequence = value;
if (oldValue !== value) {
//this.showSolutionMenu();
}
}
private promptType: 'node' | 'arrow' | null;
private promptIndex: number | null;
@property({ type: String }) accessor solutionMessage: string = '';
@property({ type: Boolean }) accessor showSolution: boolean = false;
@property({ type: Array }) accessor selectedNodes: GraphNode[] = [];
private selectionRectangle?: { x: number; y: number; width: number; height: number };
private checkOffset = true;
static style = papWidgetStyles;
static scopedElements = {
'custom-prompt': CustomPrompt,
'confirm-prompt': ConfirmPrompt
};
public isEditable(): boolean {
return this.contentEditable === 'true' || this.contentEditable === '';
}
render() {
// console.log('render', this);
return html`
<style>
${papWidgetStyles}
</style>
${this.isEditable() ? this.renderToolMenu() : ''}
<div class="workspace" @scroll="${this.handleScroll}">
<canvas
width="100%"
height="${this.currentHeight}"
@mousedown="${this.handleMouseDown}"
@mouseup="${this.handleMouseUp}"
@mousemove="${this.handleMouseMove}"
@dblclick="${this.handleDoubleClick}"
@click="${(event: MouseEvent) => {
this.handleClick(event);
this.toggleMenu('context');
}}"
@contextmenu="${(event: MouseEvent) => {
event.preventDefault();
this.showContextMenu(event);
}}"
@wheel="${this.handleWheel}"
></canvas>
<div class="action-menu" style=${this.fullscreen ? 'top:10px;left:10px;' : ''}>
<button
id="grab-button"
@mouseenter="${(e) => createTooltip(e, 'Bewegen des Canvas')}"
@mouseleave="${removeTooltip}"
@click="${this.grabCanvas}"
class="${this.isGrabbing ? 'active' : ''}"
style=${(!this.allowStudentPan && !this.hasAttribute("contenteditable")) || (this.allowStudentPan && !this.allowStudentEdit && !this.hasAttribute("contenteditable")) ? 'display:none' : ''}
>
${drawButton('grab', 'tool')}
</button>
<!-- <button
@mouseenter="${(e) => createTooltip(e, 'Aufgabenmenü')}"
@mouseleave="${removeTooltip}"
@click="${() => this.toggleMenu('task')}"
style=${!this.isEditable() && this.taskList?.length == 0 ? 'display:none' : ''}
>
${drawButton('task', 'tool')}
</button>
<button
@mouseenter="${(e) => createTooltip(e, 'Hinweise')}"
@mouseleave="${removeTooltip}"
@click="${() => this.toggleMenu('help')}"
style=${!this.isEditable() && this.helpList?.length == 0 ? 'display:none' : ''}
>
${drawButton('help', 'tool')}
</button>
<button
@mouseenter="${(e) => createTooltip(e, 'Lösche alles')}"
@mouseleave="${removeTooltip}"
@click="${this.showConfirmPrompt}"
style=${!this.allowStudentEdit ? 'display:none' : ''}
>
${drawButton('delete', 'tool')}
</button> -->
<button
@mouseenter="${(e) => createTooltip(e, 'Fullscreen')}"
@mouseleave="${removeTooltip}"
@click="${this.toggleFullscreen}"
class="fullscreen-button"
>
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="14" viewBox="0 0 448 512">
<!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<path
d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"
/>
</svg>
</button>
</div>
<div class="flowchart-menu" style=${(!this.allowStudentEdit && !this.hasAttribute("contenteditable")) ? 'display:none' : ''}>
<button class="close-button" @click="${() => this.toggleMenu('flow')}">×</button>
<button @click="${() => this.addGraphNode('start', 'Start')}">
${drawButton('start', 'flow')}
</button>
<button @click="${() => this.addGraphNode('op', 'Operation')}">${drawButton('op', 'flow')}</button>
<button @click="${() => this.addGraphNode('decision', ' Verzweigung ')}">
${drawButton('decision', 'flow')}
</button>
<button @click="${() => this.addGraphNode('i/o', 'Ein-/Ausgabe')}">
${drawButton('i/o', 'flow')}
</button>
<button @click="${() => this.addGraphNode('sub', 'Unterprogramm')}">
${drawButton('sub', 'flow')}
</button>
<button @click="${() => this.addGraphNode('connector', '')}">
${drawButton('connector', 'flow')}
</button>
<button @click="${() => this.addGraphNode('end', 'Ende')}">${drawButton('end', 'flow')}</button>
<button @click="${() => this.addGraphNode('text', 'Kommentar')}">
${drawButton('text', 'flow')}
</button>
</div>
<button class="show-flowchart-button hidden" @click="${() => this.toggleMenu('flow')}">+</button>
<div class="solution-menu hidden">
<div class="solution-titel">Pfad überprüfen</div>
${this.taskList?.map((task) =>
task.sequence
? html`<button class="solution-button" @click="${() => this.checkSolution(task)}">
${task.titel}
</button>`
: ''
)}
</div>
<div class="task-menu hidden" style=${this.fullscreen ? 'top:10px;right:10px;' : ''}>
<button class="close-button" @click="${() => this.toggleMenu('task')}">×</button>
<div class="task-menu-wrapper">
${this.taskList?.length === 0
? html`<p class="no-tasks-message">Keine Aufgaben!</p>`
: renderTasks.bind(this)(this.taskList)}
<button class="add-task-button editMode" @click="${this.addTask}">
${drawButton('addTask', 'task')}
</button>
</div>
</div>
<div class="help-menu hidden" style=${this.fullscreen ? 'top:10px;right:10px;' : ''}>
<button class="close-button" @click="${() => this.toggleMenu('help')}">×</button>
${this.helpList?.length === 0
? html`<p class="no-help-message">Keine Hinweise!</p>`
: renderHelpList.bind(this)(this.helpList)}
<button class="add-help-button editMode" @click="${this.addHelp}">
${drawButton('addHelp', 'help')}
</button>
</div>
<div class="translate-menu hidden">
<button class="close-button" @click="${() => this.toggleMenu('translate')}">×</button>
<div class="translate-menu-container">
<button class="translate-button" @click="${() => this.translateFlowchart('natural')}">
${drawButton('naturalLanguage', 'translate')}
</button>
<textarea id="naturalLanguageOutput" class="output-textarea hidden" disabled></textarea>
</div>
<div class="translate-menu-container">
<button class="translate-button" @click="${() => this.translateFlowchart('pseudo')}">
${drawButton('pseudoCode', 'translate')}
</button>
<textarea id="pseudoCodeOutput" class="output-textarea hidden" disabled></textarea>
</div>
</div>
<div id="context-menu" class="context-menu">
<div class="context-menu-item" @click="${() => this.deleteSelectedObject()}">Löschen</div>
</div>
<custom-prompt
label="Geben Sie einen neuen Text ein:"
@submit="${(event: CustomEvent) => this.handlePromptSubmit(event)}"
@cancel="${this.hidePrompt}"
class="hidden"
></custom-prompt>
<confirm-prompt
label="Sind Sie sicher, dass Sie alles löschen möchten?"
.onConfirm="${this.clearAll}"
.onCancel="${this.hidePrompt}"
class="hidden"
></confirm-prompt>
<div class="prompt ${this.showSolution ? '' : 'hidden'}">
<p>${this.solutionMessage}</p>
<button @click="${this.closeSolution}">Schließen</button>
</div>
</div>
<div
class="y-rezise"
@dragend="${this.handleYResizeEnd}"
draggable="true"
style=${(!this.allowStudentEdit && !this.hasAttribute("contenteditable")) || this.fullscreen ? 'display:none' : ''}
></div>
`;
}
private renderToolMenu() {
return html`<aside class="tool-menu" part="options">
<h2>Einstellungen</h2>
<div class="setting-menu-container">
<div class="setting-item">
<label>Schriftart:</label>
<select id="font-selector"
@change="${(e) => {
this.font = e.target.value;
this.graphSettings.font = e.target.value;
this.redrawCanvas();
}}"
>
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New" selected>Courier New</option>
</select>
</div>
<div class="setting-item">
<label>Schriftgröße:</label>
<select id="font-size-selector"
@change="${(e) => {
this.fontSize = e.target.value;
this.graphSettings.fontSize = parseInt(e.target.value);
this.redrawCanvas();
}}"
>
<option value="12">12</option>
<option value="14">14</option>
<option value="16" selected>16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
</select>
</div>
<div class="setting-item">
<label>Farbthema:</label>
<select id="color-theme-selector"
@change="${(e) => {
this.theme = e.target.value;
this.graphSettings.theme = e.target.value;
this.redrawCanvas();
}}"
>
<option value="standard" selected>Standard</option>
<option value="pastel">Pastel</option>
<option value="mono">Mono</option>
<option value="s/w">Schwarz/Weiß</option>
</select>
</div>
<div class="setting-item">
<label>Zoomen:</label>
<div class="zoom-selector">
<button id="zoom-out-button" class="zoom-button"
@click="${(e) => {
this.zoomLevel = Math.max(this.zoomLevel - 10, 50); // Begrenze den Zoom auf 50%
this.applyZoom();
}}"
>-</button>
<span id="zoom-percentage" class="zoom-text">${this.zoomLevel}%</span>
<button id="zoom-in-button" class="zoom-button"
@click="${(e) => {
this.zoomLevel = Math.min(this.zoomLevel + 10, 200); // Begrenze den Zoom auf 200%
this.applyZoom();
}}"
>+</button>
</div>
</div>
<div class="setting-item">
<label>Bearbeiten erlauben:</label>
<input
type="checkbox"
id="editable-checkbox"
@change="${(e) => {
this.allowStudentEdit = e.target.checked;
}}"
?checked="${this.allowStudentEdit}"
/>
</div>
<div class="setting-item">
<label>Bewegen erlauben:</label>
<input
type="checkbox"
id="panable-checkbox"
@change="${(e) => {
this.allowStudentPan = e.target.checked;
if (e.target.checked) {
this.canvasOffsetX = 0;
this.canvasOffsetY = 0;
} else {
this.canvasOffsetX = parseFloat(this.canvas.style.getPropertyValue('--offset-x'));
this.canvasOffsetY = parseFloat(this.canvas.style.getPropertyValue('--offset-y'));
}
}}"
?checked="${this.allowStudentPan}"
/>
</div>
</aside>`;
}
// ------------------------ User interface Funktionen ------------------------
// private getUserSettings() {
// const fontSelector = this.shadowRoot?.querySelector('#font-selector') as HTMLSelectElement;
// const fontSizeSelector = this.shadowRoot?.querySelector('#font-size-selector') as HTMLSelectElement;
// const themeSelector = this.shadowRoot?.querySelector('#color-theme-selector') as HTMLSelectElement;
// this.graphSettings.font = fontSelector.value;
// this.graphSettings.fontSize = parseInt(fontSizeSelector.value);
// this.graphSettings.theme = themeSelector.value;
// }
// Variante ohne Netlify
// private translateFlowchart(language: 'natural' | 'pseudo') {
// const messages = this.generateMessages(language);
// document.body.style.cursor = 'wait';
// fetch('https://api.openai.com/v1/chat/completions', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': `Bearer `,
// },
// body: JSON.stringify({
// "model": "gpt-3.5-turbo",
// "messages": messages,
// "max_tokens": 2000,
// }),
// })
// .then(response => response.json())
// .then(data => {
// console.log(data)
// const text = data.choices[0].message['content'].trim();
// if (language === 'natural') {
// let textAreaElement = this.shadowRoot.getElementById('naturalLanguageOutput') as HTMLTextAreaElement;
// textAreaElement.value = text;
// textAreaElement.classList.remove('hidden');
// } else {
// let textAreaElement = this.shadowRoot.getElementById('pseudoCodeOutput') as HTMLTextAreaElement;
// textAreaElement.value = text;
// textAreaElement.classList.remove('hidden');
// }
// })
// .finally(() => {
// document.body.style.cursor = 'auto';
// });;
// }
private translateFlowchart(language: 'natural' | 'pseudo') {
const messages = this.generateMessages(language);
const translateButtons = this.shadowRoot.querySelectorAll('.translate-button');
translateButtons.forEach((button: HTMLElement) => (button.style.cursor = 'wait'));
document.body.style.cursor = 'wait';
fetch('/.netlify/functions/translateFlowchart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: messages,
max_tokens: 2000,
}),
})
.then((response) => response.json())
.then((data) => {
console.log(data);
if (language === 'natural') {
let textAreaElement = this.shadowRoot.getElementById(
'naturalLanguageOutput'
) as HTMLTextAreaElement;
textAreaElement.value = data.translation;
textAreaElement.classList.remove('hidden');
} else {
let textAreaElement = this.shadowRoot.getElementById('pseudoCodeOutput') as HTMLTextAreaElement;
textAreaElement.value = data.translation;
textAreaElement.classList.remove('hidden');
}
})
.finally(() => {
translateButtons.forEach((button: HTMLElement) => (button.style.cursor = 'pointer'));
document.body.style.cursor = 'auto';
});
}
private generateMessages(language: 'natural' | 'pseudo'): Array<{ role: string; content: string }> {
let systemMessage: string;
if (language === 'natural') {
systemMessage =
'Die folgenden Daten stellen ein Programmablaufplan dar. Beschreibe den Ablaufplan in einfachen natürlichen Worten.';
} else {
systemMessage =
'Die folgenden Daten stellen ein Programmablaufplan dar. Erzeuge aus den gegebenen Daten Pseudocode.';
}
let userMessage: string = '';
// Füge dem Prompt this.graphNodes hinzu
this.graphNodes.forEach((node) => {
userMessage += '\nID: ' + node.id;
userMessage += '\nNode: ' + node.node;
userMessage += '\nText: ' + node.text;
if (node.connections) {
userMessage += '\nConnections: ';
node.connections.forEach((connection) => {
userMessage += '\nAnchor: ' + connection.anchor;
userMessage += '\nDirection: ' + connection.direction;
userMessage += '\nConnected To ID: ' + connection.connectedToId;
if (connection.text) {
userMessage += '\nText: ' + connection.text;
}
});
}
userMessage += '\n';
});
return [
{
role: 'system',
content: systemMessage,
},
{
role: 'user',
content: userMessage,
},
];
}
selectSequence() {
// Setze css style von Icon auf aktiv
const selectButton = this.shadowRoot.getElementById('select-button');
!this.isSelectingSequence ? selectButton?.classList.add('active') : selectButton?.classList.remove('active');
this.isSelectingSequence = !this.isSelectingSequence;
if (!this.isSelectingSequence) {
this.selectedSequence = [];
}
// Deaktive alles ausgewählten Graphelemente
this.selectedNode = undefined;
this.selectedArrow = undefined;
this.selectionRectangle = undefined;
this.redrawCanvas();
}
checkSolution(task: ItemList) {
// Prüfe, ob die Längen der ausgewählten Sequenz und der Aufgabensequenz übereinstimmen
if (task.sequence && this.selectedSequence.length === task.sequence.length) {
for (let i = 0; i < this.selectedSequence.length; i++) {
// Prüfe, ob die IDs und der Typ jeder Sequenz übereinstimmen
if (
this.selectedSequence[i].id !== task.sequence[i].id ||
this.selectedSequence[i].type !== task.sequence[i].type
) {
this.showSolutionWithMessage('Der ausgewählte Pfad ist leider falsch!');
return;
}
}
this.showSolutionWithMessage('Der ausgewählte Pfad ist korrekt!');
} else {
this.showSolutionWithMessage('Der ausgewählte Pfad ist leider falsch!');
}
}
// Zeige oder verstecke die angefragten Benutzeroberflächen
private toggleMenu(menu: 'task' | 'flow' | 'context' | 'preset' | 'help' | 'translate' | 'setting') {
toggleMenu(this, menu);
this.focus();
}
// Zeige das Kontextmenü an, wenn ein Element angeklickt wurde
private showContextMenu(event: MouseEvent) {
if ((!this.allowStudentEdit && !this.hasAttribute("contenteditable"))) {
return;
}
const rect = this.getBoundingClientRect()
const { x, y } = this.getMouseCoordinates(event);
// Finde den angeklickten Knoten oder Verbindung und speichere sie
const clickedNode = findLastGraphNode(this.ctx, this.graphNodes, x, y);
const clickedArrowIndex = this.arrows.findIndex((arrow) => isArrowClicked(x, y, arrow.points));
// Falls ein Element angeklickt wurde, wird das Kontextmenü angezeigt
if (clickedNode || clickedArrowIndex !== -1) {
const contextMenu = this.shadowRoot.getElementById('context-menu');
if (contextMenu) {
contextMenu.style.display = 'block';
contextMenu.style.left = event.clientX - rect.left +"px";
contextMenu.style.top = event.clientY - rect.top +"px";
if (clickedNode) {
this.selectedNode = clickedNode;
this.selectedArrow = undefined;
} else {
this.selectedArrow = this.arrows[clickedArrowIndex];
this.selectedNode = undefined;
}
}
}
}
setSelectedSequence = (sequence: { id: string; order: number; type: string }[]) => {
this.selectedSequence = sequence;
};
private addTask() {
this.taskList = [...this.taskList, { titel: 'Titel', content: 'Aufgabe' }];
}
private addHelp() {
this.helpList = [...this.helpList, { titel: 'Titel', content: 'Hinweis' }];
}
private showSolutionMenu() {
const solutionMenuElement = this.shadowRoot?.querySelector('.solution-menu');
if (!solutionMenuElement) {
return;
}
// Prüfen, ob es eine Aufgabe mit einer Sequence gibt
const taskWithSequenceExists = this.taskList.some((task) => task.sequence?.length);
if (this.isSelectingSequence && taskWithSequenceExists && !this.isEditable()) {
solutionMenuElement.classList.remove('hidden');
} else {
solutionMenuElement.classList.add('hidden');
}
}
// Aktiviere Bewegungsmodus für das Canvas
private grabCanvas() {
this.isGrabbing = grabCanvas(this, this.isGrabbing);
this.selectedNode = undefined;
}
// ------------------------ Reconnect Arrow Funktionen ------------------------
private reconnectArrows() {
this.arrows.forEach((arrow) => {
const fromId = arrow.from.id;
const toId = arrow.to.id;
const fromNode = this.graphNodes.find((node) => node.id === fromId);
const toNode = this.graphNodes.find((node) => node.id === toId);
if (fromNode && toNode) {
arrow.from = fromNode;
arrow.to = toNode;
}
});
}
// ------------------------ Drawer Funktionen ------------------------
private redrawCanvas() {
// Bereinige das Canvas und berücksichtigt den Zoom Faktor
const scaleFactor = this.zoomLevel / 100;
this.ctx.resetTransform();
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.scale(scaleFactor, scaleFactor);
// this.getUserSettings();
this.reconnectArrows();
// Zeichne alle Verbindungen
this.arrows.forEach((arrow) => {
const isSelected = arrow === this.selectedArrow;
arrow.points = generateArrowPoints(this.ctx, arrow);
drawArrow(this.ctx, arrow, this.graphSettings, isSelected, this.selectedSequence);
});
// Zeichne alle Knoten
this.graphNodes?.forEach((element) => {
drawGraphNode(this.ctx, element, this.graphSettings, this.selectedNodes, this.selectedSequence);
});
// Zeichne die Ankerpunkte für das ausgewählte Element, falls vorhanden
if (this.selectedNode) {
drawNodeAnchors(this.ctx, this.selectedNode, this.hoveredAnchor);
}
// Zeichne Ankerpunkte des Pfeils, wenn dieser ausgewählt ist
this.arrows.forEach((arrow) => {
const isSelected = arrow === this.selectedArrow;
if (isSelected) {
drawArrowAnchor(this.ctx, arrow, this.isArrowAnchorHovered, this.graphSettings);
}
});
//Zeichne eine temporäre Verbindung beim ziehen zwischen zwei Elementen, falls vorhanden
if (this.isDrawingArrow && this.arrowStart && this.tempArrowEnd) {
drawTempArrow(this.ctx, this.arrowStart, this.tempArrowEnd);
}
if (this.selectionRectangle) {
drawSelectionField(this.ctx, this.selectionRectangle);
}
// Speichere die aktuellen Knoten und Verbindungen als Attribute
// this.setAttribute('graph-nodes', JSON.stringify(this.graphNodes));
// this.setAttribute('task-list', JSON.stringify(this.taskList));
// this.setAttribute('help-list', JSON.stringify(this.helpList));
}
// Speichere die Position für den nächsten Knoten
private addGraphNodeIndex = 0;
private addGraphNode(
node: 'start' | 'end' | 'op' | 'decision' | 'connector' | 'i/o' | 'sub' | 'text',
text: string
) {
const workspace = this.shadowRoot?.querySelector('.workspace') as HTMLElement;
let centerX = this.canvas.width * 0.45 + workspace.scrollLeft;
let centerY = this.canvas.height * 0.45 + workspace.scrollTop;
switch (this.addGraphNodeIndex) {
case 0:
centerX += 0;
centerY += 0;
break;
case 1:
centerX -= 40;
centerY += 20;
break;
case 2:
centerX += 40;
centerY += 40;
break;
default:
centerX += 0;
centerY += 0;
}
const element: GraphNode = {
id: uuidv4(),
node: node,
text: text,
x: centerX,
y: centerY,
};
this.addGraphNodeIndex = (this.addGraphNodeIndex + 1) % 3;
this.graphNodes = [...this.graphNodes, element];
this.reconnectArrows();
drawGraphNode(this.ctx, element, this.graphSettings, this.selectedNodes, this.selectedSequence);
}
// ------------------------ Mouse-Events ------------------------
private handleMouseDown(event: MouseEvent) {
const { x, y } = this.getMouseCoordinates(event);
const nodeUnderCursor = findLastGraphNode(this.ctx, this.graphNodes, x, y);
if (!nodeUnderCursor || !this.selectedNodes.includes(nodeUnderCursor)) {
this.selectedNodes = [];
this.draggedNodes = [];
}
// Handhabung wenn Knoten gezogen wird
if (!this.isGrabbing) {
if ((!this.allowStudentEdit && !this.hasAttribute("contenteditable"))) {
return;
}
if (this.selectedNodes.length > 1) {
const { draggedNodes, isDragging, dragOffset } = handleMultipleNodesDragStart(
this.ctx,
x,
y,
this.selectedNodes,
this.selectedArrow
);
this.draggedNodes = draggedNodes;
this.isDragging = isDragging;
this.dragOffset = dragOffset;
} else {
const { draggedNode, isDragging, dragOffset } = handleNodeDragStart(
this.ctx,
x,
y,
this.graphNodes,
this.selectedArrow
);
this.draggedNode = draggedNode;
this.isDragging = isDragging;
this.dragOffset = dragOffset;
}
}
if (this.isGrabbing) {
// Update Offset von Canvwas wenn dieser gezogen wird
this.grabStartPosition = { x, y };
const offsetX = parseFloat(this.canvas.style.getPropertyValue('--offset-x'));
const offsetY = parseFloat(this.canvas.style.getPropertyValue('--offset-y'));
this.grabStartOffset = { x: offsetX, y: offsetY };
} else {
// Wenn ein Pfeil gezogen wird, wird ein temporärer gestrichelter Pfeil gezeichnet
const { arrowToMove, arrowStart } = handleArrowDragStart(
this.ctx,
x,
y,
this.graphNodes,
this.selectedArrow,
this.handleAnchorClick.bind(this)
);
if (arrowToMove && arrowStart) {
this.arrowStart = arrowStart;
this.arrows = this.arrows.filter((arrow) => arrow !== arrowToMove);
}
}
if (!nodeUnderCursor && !this.isGrabbing && !this.selectedNode) {
if ((!this.allowStudentEdit && !this.hasAttribute("contenteditable"))) {
return;
}
this.selectionRectangle = { x, y, width: 0, height: 0 };
}
}
private handleMouseUp(event: MouseEvent) {
if (this.selectionRectangle) {
this.selectionRectangle = undefined;
} else if (this.isGrabbing && this.grabStartPosition) {
// Setze die Grabposition des Canvas zurück, nachdem dieser gezogen wurde
const { grabStartPosition, grabStartOffset } = handleGrabRelease();
this.grabStartPosition = grabStartPosition;
this.grabStartOffset = grabStartOffset;
} else {
if (this.isDragging) {
// Füge diese Zeile hinzu, um die Knotenposition basierend auf dem Schwellenwert zu aktualisieren
if (this.selectedNodes.length === 0) {
snapNodePosition(this.ctx, this.draggedNode, this.graphNodes, 8);
}
// Setze die Informationen zurück, nachdem ein Knoten gezogen wurde
const { isDragging } = handleNodeDragStop();
this.isDragging = isDragging;
} else if (this.isDrawingArrow) {
// Erstelle ggf. die Pfeilverbindung, nachdem ein Pfeil losgelassen wurde
const { x, y } = this.getMouseCoordinates(event);
const { tempArrowEnd, arrowStart, arrows } = handleArrowCreation(
this.ctx,
x,
y,
this.arrowStart,
this.graphNodes,
this.arrows
);
this.tempArrowEnd = tempArrowEnd;
this.arrowStart = arrowStart;
this.arrows = arrows;
this.isDrawingArrow = false;
}
}
// Resette einmalige Schranke fürs draggen mehrerer Knoten
this.checkOffset = true;
this.graphNodes = [...this.graphNodes];
}
private handleMouseMove(event: MouseEvent) {
const { x, y } = this.getMouseCoordinates(event);
if (this.selectionRectangle) {
this.selectionRectangle.width = x - this.selectionRectangle.x;
this.selectionRectangle.height = y - this.selectionRectangle.y;
this.selectedNodes = this.graphNodes.filter((node) =>
isNodeInRectangle(this.ctx, node, this.selectionRectangle)
);
this.redrawCanvas();
} else if (this.isGrabbing && this.grabStartPosition && this.grabStartOffset) {
const deltaX = x - this.grabStartPosition.x;
const deltaY = y - this.grabStartPosition.y;
// Aktualisiere die Koordinaten der Knoten und Verbindungen
this.graphNodes.forEach((element) => {
element.x += deltaX;
element.y += deltaY;
});
this.arrows.forEach((arrow) => {
if (arrow.points) {
arrow.points.forEach((point) => {
point.x += deltaX;
point.y += deltaY;
});
}
});
// Aktualisiere das Canvas anhand der Mausbewegung
const offsetX = parseFloat(this.canvas.style.getPropertyValue('--offset-x'));
const offsetY = parseFloat(this.canvas.style.getPropertyValue('--offset-y'));
this.canvas.style.setProperty('--offset-x', `${offsetX + (deltaX * this.zoomLevel) / 100}px`);
this.canvas.style.setProperty('--offset-y', `${offsetY + (deltaY * this.zoomLevel) / 100}px`);
// Zeichne das aktualisierte Canvas
this.redrawCanvas();
// Aktualisiere die grabStartPosition auf die aktuelle Mausposition
this.grabStartPosition = { x, y };
} else {
if (this.isDragging && this.draggedNodes.length > 1) {
let deltaX: number;
let deltaY: number;
if (this.checkOffset) {
deltaX = this.dragOffset.x;
deltaY = this.dragOffset.y;
const nodeUnderCursor = findLastGraphNode(this.ctx, this.graphNodes, x, y);
this.draggedNodes.forEach((node) => {
node.x = node.x + deltaX + (nodeUnderCursor.x - x);
node.y = node.y + deltaY + (nodeUnderCursor.y - y);
});
this.checkOffset = false;
} else {
deltaX = x - this.dragOffset.x;
deltaY = y - this.dragOffset.y;
this.draggedNodes.forEach((node) => {
node.x += deltaX;
node.y += deltaY;
});
}
this.dragOffset = { x, y };
this.redrawCanvas();
} else if (this.isDragging && this.draggedNode) {
this.draggedNode.x = x - this.dragOffset.x;
this.draggedNode.y = y - this.dragOffset.y;
this.redrawCanvas();
} else if (this.isDrawingArrow && this.arrowStart && (this.selectedNode || this.selectedArrow)) {
const { x, y } = this.getMouseCoordinates(event);
this.tempArrowEnd = { x, y };
this.redrawCanvas();
}
}
// Highlighte den Ankerpunkt, falls der Benutzer über diesen kommt
const { hoveredAnchor, isArrowAnchorHovered } = highlightAnchor(
this.ctx,
this.selectedNode,
this.selectedArrow,
x,
y
);
this.hoveredAnchor = hoveredAnchor;
this.isArrowAnchorHovered = isArrowAnchorHovered;
this.redrawCanvas();
}
private handleClick(event: MouseEvent) {
const { x, y } = this.getMouseCoordinates(event);
if (this.isSelectingSequence) {
handleSequenceSelection(this.ctx, this.selectedSequence, this.graphNodes, this.arrows, x, y);
this.redrawCanvas();
} else {
if (!this.isGrabbing) {
if ((!this.allowStudentEdit && !this.hasAttribute("contenteditable"))) {
return;
}
if (this.draggedNodes.length === 0) {
// Setze das angeklickte Element, oder entferne die Auswahl, wenn kein Element angeklickt wurde
this.selectedNode = findLastGraphNode(this.ctx, this.graphNodes, x, y);
const selectedNodeIndex = this.graphNodes.lastIndexOf(this.selectedNode);
// Packe das ausgewählte Element ans Ende des Arrays, damit es über den anderen Elementen erscheint
if (this.selectedNode && !this.isDragging) {
this.graphNodes.splice(selectedNodeIndex, 1);
this.graphNodes.push(this.selectedNode);
}
this.updateAnchorListeners();
}
// Finde den angeklickte Pfeilindex, oder entferne die Auswahl, wenn kein Pfeil angeklickt wurde
const selectedArrowIndex = this.arrows.findIndex((arrow) => isArrowClicked(x, y, arrow.points));
// Wenn ein Pfeil angeklickt wurde, setze die property selectedArrow auf den angeklickten Pfeil
// und verändere die Reihenfolge im Array, damit der angeklickte Pfeil immer vollständig gefärbt angezeigt wird
if (selectedArrowIndex !== -1) {
this.selectedArrow = this.arrows[selectedArrowIndex];
this.arrows.splice(selectedArrowIndex, 1);
this.arrows.push(this.selectedArrow);
this.redrawCanvas();
} else if (this.selectedArrow) {
this.selectedArrow = undefined;
this.redrawCanvas();
}
// Zeichne den Canvas neu, um die aktualisierte Auswahl anzuzeigen
this.redrawCanvas();
}
}
}
private handleDoubleClick(event: MouseEvent) {
if ((!this.allowStudentEdit && !this.hasAttribute("contenteditable"))) {
return;
}
const { x, y } = this.getMouseCoordinates(event);
const clickedNodeIndex = findGraphNodeLastIndex(this.ctx, this.graphNodes, x, y);
const selectedArrowIndex = this.arrows.findIndex((arrow) => isArrowClicked(x, y, arrow.points));
if (clickedNodeIndex !== -1 && this.graphNodes[clickedNodeIndex].node !== 'connector') {
handleGraphNodeDoubleClick(clickedNodeIndex, (type, index) => this.showCustomPrompt(type, index));
} else if (selectedArrowIndex !== -1) {
handleArrowDoubleClick(selectedArrowIndex, (type, index) => this.showCustomPrompt(type, index));
}
}
private handleAnchorClick(node: GraphNode, anchor: number) {
this.isDrawingArrow = true;
this.arrowStart = { node, anchor };
}
private anchorMouseDownEvent: ((event: MouseEvent) => void) | null = null;
private updateAnchorListeners() {
if (this.selectedNode && this.selectedNode.node !== 'text') {
const anchors = getAnchors(this.ctx, this.selectedNode, 15);
// Entferne zuerst den bestehenden mousedown-EventListener, falls vorhanden
if (this.anchorMouseDownEvent && this.canvas) {
this.canvas.removeEventListener('mousedown', this.anchorMouseDownEvent);
this.anchorMouseDownEvent = null;
}
// Erstelle den neuen EventListener und speicher ihn in der anchorMouseDownEvent-Variable
this.anchorMouseDownEvent = (event) => {
const { x, y } = this.getMouseCoordinates(event);
anchors.forEach((position, index) => {
const distance = Math.sqrt((position.x - x) ** 2 + (position.y - y) ** 2);
if (distance <= 8) {
this.handleAnchorClick(this.selectedNode, index);
}
});
};
// Füge den neuen EventListener hinzu
if (this.canvas) {
this.canvas.addEventListener('mousedown', this.anchorMouseDownEvent);
}
}
}
// ------------------------ Lifecycle ------------------------
firstUpdated() {
// console.log('firstUpdated');
// console.log('this', this.taskList.length);
this.canvas = this.shadowRoot?.querySelector('canvas') as HTMLCanvasElement;
this.canvas.width = this.clientWidth;
this.canvas.height = this.currentHeight;
const workspace = this.shadowRoot.querySelector('.workspace') as HTMLElement;
workspace.style.setProperty('--widget-height', `${this.currentHeight}px`);
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
this.updateCanvasOffset(); // Offset aktualisieren
this.arrows = createArrowsFromGraphNodes(this.arrows, this.graphNodes);
this.graphSettings.font = this.font;
this.graphSettings.fontSize = this.fontSize;
this.graphSettings.theme = this.theme;
this.applyZoom();
this.redrawCanvas();
if(this.allowStudentPan && !this.allowStudentEdit && !this.hasAttribute("contenteditable")){
this.isGrabbing = true
}
// Help Prelist
// helpPresets.forEach((item) => {
// this.helpList.push(item);
// addHelp(this, this.helpList);
// });
}
connectedCallback() {
super.connectedCallb