Files
windmill/frontend/src/lib/components/graph/NoteTool.svelte
T
GuilhemandClaude Opus 5 e63072c216 fix(frontend): restore heading sizes in note markdown and keep group notes on id change (#11047)
* fix(frontend): restore heading sizes in note markdown and keep group notes on id change

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182GroR5mvi9ksRTMmBT7nE

* fix(frontend): scope the note cleanup deferral per note

Address local review nits: the header comment overstated the heading ramp (h3
sits at body size in the xs and sm scales), and the mid-update deferral bailed
out of cleanup for every group note rather than the one holding the unrendered
module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182GroR5mvi9ksRTMmBT7nE

* perf(frontend): traverse modules without collecting a discarded array

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182GroR5mvi9ksRTMmBT7nE

* test(frontend): show the three markdown prose presets in the kitchen sink

The Markdown tab rendered only the default preset, so a change to the shared
heading scale could not be compared across the surfaces that use it. The chat
sample gains headings for the same reason: it is the only place the assistant
bubble renders at panel width without an AI provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182GroR5mvi9ksRTMmBT7nE

* refactor(frontend): validate group notes against the flow, not the render

A group note's members are module ids, so the flow's own module list decides
whether one still exists. Validating against the rendered nodes instead needed
a special case for collapsed groups, and still dropped a live module in the
pass after its id changed. Both cases are the same mistake, and checking the
source of truth removes them together with the collapsedModuleIds parameter.

Path completion keeps working off the rendered graph, since it needs the edges,
and now skips a note whose members it cannot all see rather than dropping them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182GroR5mvi9ksRTMmBT7nE

* fix(frontend): name a new note "Note", not after its internal type

The prefill lands inside the user's own note, and "Free note" / "Group note"
are the serialized type, not words anyone says about a note they just drew.
The menus that create them already say "Add note".

The heading goes to h2 as well: h3 computes to the body size in the note scale,
so the prefill's own title did not read as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182GroR5mvi9ksRTMmBT7nE

* docs(frontend): describe what collapsedModuleIds is still for

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182GroR5mvi9ksRTMmBT7nE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:51:55 +00:00

217 lines
5.4 KiB
Svelte

<script lang="ts">
import { useSvelteFlow, type XYPosition } from '@xyflow/svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { DEFAULT_NOTE_COLOR, MIN_NOTE_WIDTH, MIN_NOTE_HEIGHT } from './noteColors'
import { StickyNote } from 'lucide-svelte'
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
interface Props {
exitNoteMode?: () => void
yOffset: number
}
let { exitNoteMode, yOffset }: Props = $props()
// Get NoteEditor context for direct note creation
const noteEditorContext = getNoteEditorContext()
const { screenToFlowPosition, getViewport } = useSvelteFlow()
let isDrawing = $state(false)
let startPosition: XYPosition | null = $state(null)
let endPosition: XYPosition | null = $state(null)
let rect: DOMRect | null = $state(null)
let contextMenuPosition: XYPosition | null = $state(null)
function onPointerDown(event: PointerEvent) {
// Capture pointer to continue tracking outside the element
const target = event.currentTarget as Element
target?.setPointerCapture?.(event.pointerId)
// Use page coordinates as reference
rect = target.getBoundingClientRect()
startPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
isDrawing = true
}
function onPointerMove(event: PointerEvent) {
if (event.buttons !== 1) return
// Use page coordinates as reference
const target = event.currentTarget as Element
rect = target.getBoundingClientRect()
endPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
}
function onPointerUp() {
if (!isDrawing || !startPosition || !endPosition || !rect) return
// We need to convert the start and end positions to absolute positions to then convert to flow positions
const absoluteStartPosition = {
x: startPosition.x + rect.left,
y: startPosition.y + rect.top
}
const absoluteEndPosition = {
x: endPosition.x + rect.left,
y: endPosition.y + rect.top
}
const flowPosition = screenToFlowPosition({
x: Math.min(absoluteStartPosition.x, absoluteEndPosition.x),
y: Math.min(absoluteStartPosition.y, absoluteEndPosition.y)
})
const position = {
x: flowPosition.x,
y: flowPosition.y - (yOffset || 0)
}
const zoom = getViewport().zoom
const size = {
width: Math.max(
MIN_NOTE_WIDTH,
Math.abs(absoluteEndPosition.x - absoluteStartPosition.x) / zoom
),
height: Math.max(
MIN_NOTE_HEIGHT,
Math.abs(absoluteEndPosition.y - absoluteStartPosition.y) / zoom
)
}
// Create the actual note using NoteEditor context
if (noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.addNote({
text: '## Note\nDouble click to edit me',
position,
size,
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
}
// Exit note mode after creating note
exitNoteMode?.()
// Reset state
isDrawing = false
startPosition = null
}
function handleAddStickyNote() {
if (!noteEditorContext?.noteEditor || !contextMenuPosition) return
noteEditorContext.noteEditor.addNote({
text: '## Note\nDouble click to edit me',
position: contextMenuPosition,
size: { width: 300, height: 200 },
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
// Clear the position after use
contextMenuPosition = null
exitNoteMode?.()
}
function handleItemClick(item: ContextMenuItem) {
if (item.id === 'add-sticky-note') {
handleAddStickyNote()
}
}
const contextMenuItems: ContextMenuItem[] = [
{
id: 'add-sticky-note',
label: 'Add sticky note',
icon: StickyNote,
onClick: handleAddStickyNote
}
]
const previewNote = $derived(
startPosition && endPosition
? {
position: {
x: Math.min(startPosition['x'], endPosition['x']),
y: Math.min(startPosition['y'], endPosition['y'])
},
size: {
width: Math.abs(endPosition['x'] - startPosition['x']),
height: Math.abs(endPosition['y'] - startPosition['y'])
}
}
: null
)
</script>
<ContextMenu items={contextMenuItems} onItemClick={handleItemClick}>
<div
class="tool-overlay"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
oncontextmenu={(e) => {
// Capture the position when context menu is triggered
const flowPosition = screenToFlowPosition({
x: e.clientX,
y: e.clientY
})
contextMenuPosition = {
x: flowPosition.x,
y: flowPosition.y - yOffset
}
}}
role="button"
tabindex="0"
aria-label="Click and drag to create a note, or right-click to add a sticky note"
onkeydown={(e) => {
if (e.key === 'Escape') {
if (isDrawing) {
// Cancel current drawing
isDrawing = false
startPosition = null
} else {
// Exit note mode
exitNoteMode?.()
}
}
}}
>
<!-- Preview note while drawing -->
{#if previewNote}
<div
class="absolute border-2 border-dashed border-lime-400 bg-lime-100 bg-opacity-50 rounded-md pointer-events-none"
style="
width: {previewNote.size.width}px;
height: {previewNote.size.height}px;
transform: translate({previewNote.position.x}px, {previewNote.position.y}px);
"
>
</div>
{/if}
</div>
</ContextMenu>
<style>
.tool-overlay {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
z-index: 4;
height: 100%;
width: 100%;
transform-origin: top left;
cursor: crosshair;
touch-action: none;
}
</style>