@webwriter/flowchart
Version:
Create programming flowcharts with interactive tasks. Use standardized Elements such as loops and Branchings.
1,152 lines (1,016 loc) • 75.8 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 { localized, msg } from "@lit/localize"
import LOCALIZE from "./localization/generated"
/**
* @summary Create programming flowcharts with interactive tasks. Use standardized Elements such as loops and Branchings.
*
* @tag webwriter-flowchart
* @tagname webwriter-flowchart
*
* @attr {string} graph-nodes - JSON string representing an array of GraphNode objects. When set, arrows are recomputed.
* @attr {number} height - Canvas height in pixels. Default 400.
* @attr {number} zoom-level - Zoom percentage in the range [50, 200]. Default 100.
* @attr {number} canvas-offset-x - Horizontal pan offset in world units.
* @attr {number} canvas-offset-y - Vertical pan offset in world units.
* @attr {boolean} allow-student-edit - Enables student editing (adding, dragging, deleting).
* @attr {boolean} allow-student-pan - Enables student panning and zoom interaction.
* @attr {string} font - Font family used for node labels. Default "Courier New".
* @attr {number} font-size - Font size used for node labels. Default 16.
* @attr {string} theme - Color theme; one of "standard" | "pastel" | "mono" | "s/w". Default "standard".
*
* @prop {GraphNode[]} graphNodes - Current list of graph nodes (programmatic API).
* @prop {Arrow[]} arrows - Current list of arrows between nodes (programmatic API).
* @prop {ItemList[]} taskList - Tasks shown in the task menu.
* @prop {ItemList[]} helpList - Hints shown in the help menu.
* @prop {number} zoomLevel - Current zoom percentage (50–200).
* @prop {number} canvasOffsetX - Horizontal pan offset (world units).
* @prop {number} canvasOffsetY - Vertical pan offset (world units).
* @prop {string} font - Font family used for labels.
* @prop {number} fontSize - Font size used for labels.
* @prop {string} theme - Color theme name.
* @prop {boolean} fullscreen - Whether the widget is currently in fullscreen mode.
* @prop {string} solutionMessage - Message shown in the solution prompt.
* @prop {boolean} showSolution - Whether the solution prompt is visible.
*
* @csspart options - Styles the settings sidebar (tool menu).
*
* @cssprop [--scaled-grid-size=50px] - Spacing between grid dots (derived from zoom).
* @cssprop [--scaled-grid-dot-size=1.5px] - Dot radius for the background grid (derived from zoom).
* @cssprop --offset-x - Internal canvas left offset (managed by the widget).
* @cssprop --offset-y - Internal canvas top offset (managed by the widget).
* @cssprop --widget-height - Workspace height in pixels.
*/
@customElement('webwriter-flowchart')
@localized()
export class FlowchartWidget extends LitElementWw {
public localize = LOCALIZE;
/**
* List of graph nodes comprising the flowchart.
* Reflected as the 'graph-nodes' attribute (expects JSON when set externally).
*/
@property({ type: Array, reflect: true, attribute: true }) accessor graphNodes: GraphNode[] = [];
/** @internal Currently selected/focused node. */
@property({ type: Object }) accessor selectedNode: GraphNode;
/** List of arrows connecting nodes. */
@property({ type: Array }) accessor arrows: Arrow[] = [];
/** @internal Currently selected/focused arrow. */
@property({ type: Object }) accessor selectedArrow: Arrow;
/**
* Get the current nodes.
* @returns {GraphNode[]} Array of nodes
*/
getGraphNodes = () => this.graphNodes;
/**
* Get the current arrows.
* @returns {Arrow[]} Array of arrows
*/
getArrows = () => this.arrows;
/** Tasks visible in the task menu. */
@property({ type: Array, reflect: true, attribute: true }) accessor taskList: ItemList[] = [];
/** Hints visible in the help menu. */
@property({ type: Array, reflect: true, attribute: true }) accessor helpList: ItemList[] = [];
/** Canvas height (px). */
@property({ type: Number, reflect: true, attribute: true }) accessor height: number = 400;
/** @internal Runtime canvas height; tracks drag-resize/fullscreen. */
@property({ type: Number }) accessor currentHeight: number = this.height;
/** @internal Runtime graph rendering settings. */
@property({ type: Object }) accessor graphSettings = { font: 'Courier New', fontSize: 16, theme: 'standard' };
/** Zoom level in percent [50–200]. */
@property({ type: Number, reflect: true, attribute: true }) accessor zoomLevel: number = 100;
/** @internal Base grid spacing (world units). */
private gridSize: number = 50;
/** @internal Background grid dot radius (world units). */
private dotSize: number = 1.5;
/** Horizontal pan offset (world units). */
@property({ type: Number, reflect: true, attribute: true }) accessor canvasOffsetX: number = 0;
/** Vertical pan offset (world units). */
@property({ type: Number, reflect: true, attribute: true }) accessor canvasOffsetY: number = 0;
/** Allow interactive editing (adding/dragging/deleting). */
@property({ type: Boolean, reflect: true, attribute: true }) accessor allowStudentEdit: boolean = false;
/** Allow panning/zooming interactions. */
@property({ type: Boolean, reflect: true, attribute: true }) accessor allowStudentPan: boolean = false;
/** Font family for node labels. */
@property({ type: String, reflect: true, attribute: true }) accessor font = 'Courier New';
/** Font size for node labels. */
@property({ type: Number, reflect: true, attribute: true }) accessor fontSize = 16;
/** Color theme name. */
@property({ type: String, reflect: true, attribute: true }) accessor theme = 'standard';
/** Whether the widget is in fullscreen mode. */
@property({ type: Boolean }) accessor fullscreen = false;
/** @internal Focus delegation for better keyboard support. */
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
/** @internal Canvas element reference. */
private canvas: HTMLCanvasElement;
/** @internal 2D drawing context for the canvas. */
private ctx: CanvasRenderingContext2D;
/** @internal Drag state. */
private isDragging = false;
/** @internal Node currently being dragged. */
private draggedNode: GraphNode;
/** @internal Drag offset from the pointer to node origin. */
private dragOffset = { x: 0, y: 0 };
/** @internal Group drag: the set of nodes being dragged. */
private draggedNodes: GraphNode[] = [];
/** @internal Arrow drawing state. */
private isDrawingArrow = false;
/** @internal Arrow start anchor. */
private arrowStart?: { node: GraphNode; anchor: number };
/** @internal Temporary arrow endpoint while dragging. */
private tempArrowEnd?: { x: number; y: number };
/** @internal Canvas panning (grab) state. */
private isGrabbing = false;
/** @internal Start pointer position for grab. */
private grabStartPosition?: { x: number; y: number };
/** @internal Start offset for grab. */
private grabStartOffset?: { x: number; y: number };
/** @internal Currently hovered anchor on a node. */
private hoveredAnchor?: { element: GraphNode; anchor: number };
/** @internal Whether an arrow anchor is hovered. */
private isArrowAnchorHovered: boolean;
/** @internal Path selection mode (for solution checking). */
private _isSelectingSequence = false;
/** @internal Currently selected path sequence. */
private selectedSequence: { id: string; order: number; type: string }[] = [];
/**
* Get the currently selected path sequence.
*
* @returns {Array<{ id: string; order: number; type: string }>} The currently selected sequence of path elements.
*/
getSelectedSequence = () => this.selectedSequence;
/** @internal Sequence button reference. */
private activeSequenceButton: HTMLButtonElement | null = null;
/** Get the active sequence button.
* @returns {HTMLButtonElement|null} */
getActiveSequenceButton = () => this.activeSequenceButton;
/** Set the active sequence button.
* @param {HTMLButtonElement|null} btn */
setActiveSequenceButton = (btn: HTMLButtonElement | null) => {
this.activeSequenceButton = btn;
};
/** Whether path-selection mode is active.
* @returns {boolean} */
get isSelectingSequence() {
return this._isSelectingSequence;
}
/** Set path-selection mode.
* @param {boolean} value */
set isSelectingSequence(value: boolean) {
const oldValue = this._isSelectingSequence;
this._isSelectingSequence = value;
if (oldValue !== value) {
//this.showSolutionMenu();
}
}
/** @internal prompt type and index for edit dialogs. */
private promptType: 'node' | 'arrow' | null;
/** @internal prompt index for edit dialogs. */
private promptIndex: number | null;
/** Message to show in the solution prompt. */
@property({ type: String }) accessor solutionMessage: string = '';
/** Whether to show the solution prompt. */
@property({ type: Boolean }) accessor showSolution: boolean = false;
/** @internal Multi-select of nodes (drag box). */
@property({ type: Array }) accessor selectedNodes: GraphNode[] = [];
/** @internal Selection rectangle during drag-select. */
private selectionRectangle?: { x: number; y: number; width: number; height: number };
/** @internal One-time offset gate for multi-drag. */
private checkOffset = true;
/** @internal Component style (provided by papWidgetStyles). */
static style = papWidgetStyles;
/** @internal Localized default labels per node type. */
static labels: Record<string, string> = {
"start": "Start",
"op": "Process",
"decision": "Decision",
"i/o": "Input/Output",
"sub": "Subprogram",
"connector": "",
"end": "End",
"text": "Comment"
};
/** @internal Scoped child elements used by the widget. */
static scopedElements = {
'custom-prompt': CustomPrompt,
'confirm-prompt': ConfirmPrompt
};
/**
* Returns whether the widget is currently in an editable state
* based on the `contenteditable` attribute.
* @returns {boolean}
*/
public isEditable(): boolean {
return this.contentEditable === 'true' || this.contentEditable === '';
}
render() {
return html`
<style>
${papWidgetStyles}
</style>
${this.isEditable() ? this.renderToolMenu() : ''}
<div class="workspace" @scroll="${this.handleScroll}">
<canvas
width="100%"
height="${this.currentHeight * (window.devicePixelRatio || 1)}"
@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, msg('Move the 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', msg)}
</button>
<!-- <button
@mouseenter="${(e) => createTooltip(e, msg('Tasks'))}"
@mouseleave="${removeTooltip}"
@click="${() => this.toggleMenu('task')}"
style=${!this.isEditable() && this.taskList?.length == 0 ? 'display:none' : ''}
>
${drawButton('task', 'tool', msg)}
</button>
<button
@mouseenter="${(e) => createTooltip(e, msg('Hints'))}"
@mouseleave="${removeTooltip}"
@click="${() => this.toggleMenu('help')}"
style=${!this.isEditable() && this.helpList?.length == 0 ? 'display:none' : ''}
>
${drawButton('help', 'tool', msg)}
</button>
<button
@mouseenter="${(e) => createTooltip(e, msg('Delete all'))}"
@mouseleave="${removeTooltip}"
@click="${this.showConfirmPrompt}"
style=${!this.allowStudentEdit ? 'display:none' : ''}
>
${drawButton('delete', 'tool', msg)}
</button> -->
<button
@mouseenter="${(e) => createTooltip(e, msg('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', msg(FlowchartWidget.labels["start"]))}">
${drawButton('start', 'flow', msg)}
</button>
<button @click="${() => this.addGraphNode('op', msg(FlowchartWidget.labels["op"]))}">${drawButton('op', 'flow', msg)}</button>
<button @click="${() => this.addGraphNode('decision', ' ' + msg(FlowchartWidget.labels["decision"]) + ' ')}">
${drawButton('decision', 'flow', msg)}
</button>
<button @click="${() => this.addGraphNode('i/o', msg(FlowchartWidget.labels["i/o"]))}">
${drawButton('i/o', 'flow', msg)}
</button>
<button @click="${() => this.addGraphNode('sub', msg(FlowchartWidget.labels["sub"]))}">
${drawButton('sub', 'flow', msg)}
</button>
<button @click="${() => this.addGraphNode('connector', '')}">
${drawButton('connector', 'flow', msg)}
</button>
<button @click="${() => this.addGraphNode('end', msg(FlowchartWidget.labels["end"]))}">${drawButton('end', 'flow', msg)}</button>
<button @click="${() => this.addGraphNode('text', msg(FlowchartWidget.labels["text"]))}">
${drawButton('text', 'flow', msg)}
</button>
</div>
<button class="show-flowchart-button hidden" @click="${() => this.toggleMenu('flow')}">+</button>
<div class="solution-menu hidden">
<div class="solution-titel">${msg('Check path')}</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">${msg('No tasks!')}</p>`
: renderTasks.bind(this)(this.taskList)}
<button class="add-task-button editMode" @click="${this.addTask}">
${drawButton('addTask', 'task', msg)}
</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">${msg('No hints!')}</p>`
: renderHelpList.bind(this)(this.helpList)}
<button class="add-help-button editMode" @click="${this.addHelp}">
${drawButton('addHelp', 'help', msg)}
</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', msg)}
</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', msg)}
</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()}">${msg('Delete')}</div>
</div>
<custom-prompt
label="${msg('Type in the new text:')}"
@submit="${(event: CustomEvent) => this.handlePromptSubmit(event)}"
@cancel="${this.hidePrompt}"
class="hidden"
></custom-prompt>
<confirm-prompt
label="${msg('Are you sure, that you want to delete everything?')}"
.onConfirm="${this.clearAll}"
.onCancel="${this.hidePrompt}"
class="hidden"
></confirm-prompt>
<div class="prompt ${this.showSolution ? '' : 'hidden'}">
<p>${this.solutionMessage}</p>
<button @click="${this.closeSolution}">${msg('Close')}</button>
</div>
</div>
<div
class="y-rezise"
@dragend="${this.handleYResizeEnd}"
draggable="true"
style=${(!this.allowStudentEdit && !this.hasAttribute("contenteditable")) || this.fullscreen ? 'display:none' : ''}
></div>
`;
}
/**
* Render the settings sidebar (tool menu).
* Provides controls for:
* - Font family and size (updates `font`, `fontSize`, and `graphSettings`)
* - Theme (updates `theme` and `graphSettings`)
* - Zoom (updates `zoomLevel` and applies zoom)
* - Edit/pan toggles (`allowStudentEdit`, `allowStudentPan`)
*
* The aside uses `part="options"` to expose a CSS part for styling.
* Visibility of the sidebar is controlled by the caller in `render()` (shown only in edit mode).
*
* @returns {import('lit').TemplateResult} Lit template for the settings sidebar.
* @internal
*/
private renderToolMenu() {
return html`<aside class="tool-menu" part="options">
<h2>${msg('Settings')}</h2>
<div class="setting-menu-container">
<div class="setting-item">
<label>${msg('Font:')}</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>${msg('Font size:')}</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>${msg('Theme:')}</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>${msg('Standard')}</option>
<option value="pastel">${msg('Pastel')}</option>
<option value="mono">${msg('Mono')}</option>
<option value="s/w">${msg('Black/White')}</option>
</select>
</div>
<div class="setting-item">
<label>${msg('Zoom:')}</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>${msg('Allow editing:')}</label>
<input
type="checkbox"
id="editable-checkbox"
@change="${(e) => {
this.allowStudentEdit = e.target.checked;
}}"
?checked="${this.allowStudentEdit}"
/>
</div>
<div class="setting-item">
<label>${msg('Allow moving:')}</label>
<input
type="checkbox"
id="panable-checkbox"
@change="${(e) => {
this.allowStudentPan = e.target.checked;
}}"
?checked="${this.allowStudentPan}"
/>
</div>
</aside>`;
}
// ------------------------ User interface Functionality ------------------------
/**
* Translate the current flowchart into either natural language or pseudocode.
* Builds a chat-style message array via `generateMessages()`, shows a busy cursor,
* calls the Netlify function `/.netlify/functions/translateFlowchart`, and writes the
* returned `translation` into the corresponding output textarea.
*
* @param {'natural'|'pseudo'} language - Target format for the translation.
* @returns {void}
*/
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';
});
}
/**
* Build a chat-style message array that encodes the current flowchart structure.
* The system message instructs the translator for either natural language
* or pseudocode. The user message enumerates all nodes and their connections:
* ID, node type, text, anchors, directions, and connected node IDs (with optional text).
*
* @param {'natural'|'pseudo'} language - Controls the system directive in the messages.
* @returns {{ role: string; content: string }[]} Messages suitable for chat-completion APIs.
*/
private generateMessages(language: 'natural' | 'pseudo'): Array<{ role: string; content: string }> {
let systemMessage: string;
if (language === 'natural') {
systemMessage =
'The following data represents a program flowchart. Describe the flowchart in simple, natural language.';
} else {
systemMessage =
'The following data represents a program flowchart. Generate pseudocode from the given data.';
}
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,
},
];
}
/**
* Toggle path-selection mode for solution checking.
* When turning off the mode, clears the current selected sequence and any selected
* node/arrow/rectangle, then triggers a redraw. Also toggles the `active` class on
* the `#select-button` element (if present).
*
* @returns {void}
*/
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();
}
/**
* Compare the currently selected path sequence with a task's expected sequence.
* If length and element-wise id/type match, shows a success message; otherwise
* shows a failure message. Uses `showSolutionWithMessage()` to display the result.
*
* @param {ItemList} task - Task item, optionally containing a `sequence` array of {id, order, type}.
* @returns {void}
*/
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!');
}
}
/**
* Show or hide one of the widget's overlay menus and move focus to the host.
*
* @param {'task'|'flow'|'context'|'preset'|'help'|'translate'|'setting'} menu - Menu identifier to toggle.
* @returns {void}
* @internal
*/
private toggleMenu(menu: 'task' | 'flow' | 'context' | 'preset' | 'help' | 'translate' | 'setting') {
toggleMenu(this, menu);
this.focus();
}
/**
* Display the context menu near the pointer when the user right-clicks a node or arrow (only in editable mode).
*
* @param {MouseEvent} event
* @returns {void}
* @internal
*/
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;
}
}
}
}
/**
* Programmatically set the selected path sequence.
* Overwrites the internal `selectedSequence` with the provided ordered descriptors.
*
* @param {{ id: string; order: number; type: string }[]} sequence - Ordered selection descriptors.
* @returns {void}
*/
setSelectedSequence = (sequence: { id: string; order: number; type: string }[]) => {
this.selectedSequence = sequence;
};
/**
* Append a new editable task with default title/content to the task list.
* Useful in edit mode to quickly scaffold tasks.
*
* @returns {void}
*/
private addTask() {
this.taskList = [...this.taskList, { titel: 'Title', content: 'Task' }];
}
/**
* Append a new editable hint with default title/content to the help list.
* Useful in edit mode to scaffold hints.
*
* @returns {void}
*/
private addHelp() {
this.helpList = [...this.helpList, { titel: 'Title', content: 'Hint' }];
}
/**
* Show or hide the solution menu depending on:
* - Whether path-selection mode is active,
* - Whether at least one task contains a non-empty `sequence`,
* - And whether the widget is not in editable mode.
*
* Adds/removes the `hidden` class on `.solution-menu`.
*
* @returns {void}
*/
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');
}
}
/**
* Toggle canvas panning ("grab") mode and clear node selection.
* Delegates to the `grabCanvas()` UI utility to update internal state.
*
* @returns {void}
*/
private grabCanvas() {
this.isGrabbing = grabCanvas(this, this.isGrabbing);
this.selectedNode = undefined;
}
// ------------------------ Reconnect Arrow Functionality ------------------------
/**
* Reconnect arrow endpoints after mutations to `graphNodes`,
* ensuring `arrow.from` and `arrow.to` point to the current node instances
* (matched by their `id`). Safe to call after nodes are added/removed/reordered.
*
* @returns {void}
*/
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 Functionality ------------------------
/**
* Full render pipeline:
* - Clears the canvas and applies the current zoom transform,
* - Draws the background grid (dots) honoring pan offsets,
* - Translates by pan offsets and reconnects arrows,
* - Recomputes arrow point geometry and draws arrows/nodes,
* - Draws anchors for the selected node/arrow,
* - Draws a temporary arrow while dragging, and a selection rectangle if active.
*
* @returns {void}
*/
private redrawCanvas() {
const dpi = window.devicePixelRatio || 1;
// 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 * dpi, scaleFactor * dpi);
// Draw the grid
const gridSize = this.gridSize; // base spacing between grid points (unscaled)
const dotSize = this.dotSize; // radius of each dot (unscaled)
const width = this.canvas.width / scaleFactor / dpi;
const height = this.canvas.height / scaleFactor / dpi;
this.ctx.fillStyle = "#104e8b";
// Draw dots in grid
for (let x = this.canvasOffsetX % gridSize; x < width; x += gridSize) {
for (let y = this.canvasOffsetY % gridSize; y < height; y += gridSize) {
this.ctx.beginPath();
this.ctx.arc(x, y, dotSize, 0, 2 * Math.PI);
this.ctx.fill();
}
}
this.ctx.translate(this.canvasOffsetX, this.canvasOffsetY)
// 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));
}
/** @internal Cyclic index used by `addGraphNode()` to stagger the placement of quickly inserted nodes, preventing complete overlap. Values rotate through 0 → 1 → 2 → 0 ... */
private addGraphNodeIndex = 0;
/**
* Adds a new graph node of the given type at the current viewport center.
* The node is appended to `graphNodes` and the canvas is redrawn.
* @param {'start'|'end'|'op'|'decision'|'connector'|'i/o'|'sub'|'text'} node - Node type.
* @param {string} text - Initial text label for the node.
* @returns {void}
*/
private addGraphNode(
node: 'start' | 'end' | 'op' | 'decision' | 'connector' | 'i/o' | 'sub' | 'text',
text: string
) {
const workspace = this.shadowRoot?.querySelector('.workspace') as HTMLElement;
const dpi = window.devicePixelRatio || 1;
const scaleFactor = this.zoomLevel / 100;
let centerX = (this.canvas.width / dpi * 0.4 + workspace.scrollLeft) / scaleFactor - this.canvasOffsetX;
let centerY = (this.canvas.height / dpi * 0.4 + workspace.scrollTop) / scaleFactor - this.canvasOffsetY;
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 ------------------------
/** @internal Handles mousedown on the canvas (selection, drag, arrow creation). */
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
const grabCoordinates = this.getMouseCoordinates(event, true)
this.grabStartPosition = { x: grabCoordinates.x, y: grabCoordinates.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 };
}
}
/** @internal Handles mouseup on the canvas (finalizing drag or arrow creation). */
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];
}
/** @internal Handles mousemove on the canvas (dragging/panning/hover anchors). */
private handleMouseMove(event: MouseEvent) {
const { x, y } = this.getMouseCoordinates(event);
if (this.selectionRectangle) {
this.selectionRectangle