@react-x11/components 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +979 -21
- package/dist/ansi/cast.d.ts +48 -0
- package/dist/ansi/cast.d.ts.map +1 -0
- package/dist/ansi/cast.js +135 -0
- package/dist/ansi/cast.js.map +1 -0
- package/dist/ansi/index.d.ts +10 -0
- package/dist/ansi/index.d.ts.map +1 -0
- package/dist/ansi/index.js +27 -0
- package/dist/ansi/index.js.map +1 -0
- package/dist/ansi/palette.d.ts +79 -0
- package/dist/ansi/palette.d.ts.map +1 -0
- package/dist/ansi/palette.js +155 -0
- package/dist/ansi/palette.js.map +1 -0
- package/dist/ansi/parse.d.ts +114 -0
- package/dist/ansi/parse.d.ts.map +1 -0
- package/dist/ansi/parse.js +687 -0
- package/dist/ansi/parse.js.map +1 -0
- package/dist/ansi/sgr.d.ts +87 -0
- package/dist/ansi/sgr.d.ts.map +1 -0
- package/dist/ansi/sgr.js +302 -0
- package/dist/ansi/sgr.js.map +1 -0
- package/dist/ansi/utf8.d.ts +15 -0
- package/dist/ansi/utf8.d.ts.map +1 -0
- package/dist/ansi/utf8.js +82 -0
- package/dist/ansi/utf8.js.map +1 -0
- package/dist/calendar/Calendar.d.ts +136 -0
- package/dist/calendar/Calendar.d.ts.map +1 -0
- package/dist/calendar/Calendar.js +460 -0
- package/dist/calendar/Calendar.js.map +1 -0
- package/dist/calendar/DatePicker.d.ts +52 -0
- package/dist/calendar/DatePicker.d.ts.map +1 -0
- package/dist/calendar/DatePicker.js +257 -0
- package/dist/calendar/DatePicker.js.map +1 -0
- package/dist/calendar/dates.d.ts +90 -0
- package/dist/calendar/dates.d.ts.map +1 -0
- package/dist/calendar/dates.js +236 -0
- package/dist/calendar/dates.js.map +1 -0
- package/dist/calendar/index.d.ts +8 -0
- package/dist/calendar/index.d.ts.map +1 -0
- package/dist/calendar/index.js +15 -0
- package/dist/calendar/index.js.map +1 -0
- package/dist/charts/components.d.ts +141 -0
- package/dist/charts/components.d.ts.map +1 -0
- package/dist/charts/components.js +580 -0
- package/dist/charts/components.js.map +1 -0
- package/dist/charts/data.d.ts +198 -0
- package/dist/charts/data.d.ts.map +1 -0
- package/dist/charts/data.js +616 -0
- package/dist/charts/data.js.map +1 -0
- package/dist/charts/hx.d.ts +21 -0
- package/dist/charts/hx.d.ts.map +1 -0
- package/dist/charts/hx.js +15 -0
- package/dist/charts/hx.js.map +1 -0
- package/dist/charts/index.d.ts +27 -0
- package/dist/charts/index.d.ts.map +1 -0
- package/dist/charts/index.js +38 -0
- package/dist/charts/index.js.map +1 -0
- package/dist/charts/node.d.ts +96 -0
- package/dist/charts/node.d.ts.map +1 -0
- package/dist/charts/node.js +720 -0
- package/dist/charts/node.js.map +1 -0
- package/dist/charts/render.d.ts +153 -0
- package/dist/charts/render.d.ts.map +1 -0
- package/dist/charts/render.js +762 -0
- package/dist/charts/render.js.map +1 -0
- package/dist/charts/scale.d.ts +42 -0
- package/dist/charts/scale.d.ts.map +1 -0
- package/dist/charts/scale.js +173 -0
- package/dist/charts/scale.js.map +1 -0
- package/dist/charts/spec.d.ts +91 -0
- package/dist/charts/spec.d.ts.map +1 -0
- package/dist/charts/spec.js +33 -0
- package/dist/charts/spec.js.map +1 -0
- package/dist/charts/timers.d.ts +6 -0
- package/dist/charts/timers.d.ts.map +1 -0
- package/dist/charts/timers.js +14 -0
- package/dist/charts/timers.js.map +1 -0
- package/dist/code/hx.d.ts +24 -0
- package/dist/code/hx.d.ts.map +1 -0
- package/dist/code/hx.js +25 -0
- package/dist/code/hx.js.map +1 -0
- package/dist/code/index.d.ts +45 -0
- package/dist/code/index.d.ts.map +1 -0
- package/dist/code/index.js +93 -0
- package/dist/code/index.js.map +1 -0
- package/dist/code-editor/completion.d.ts +26 -0
- package/dist/code-editor/completion.d.ts.map +1 -0
- package/dist/code-editor/completion.js +129 -0
- package/dist/code-editor/completion.js.map +1 -0
- package/dist/code-editor/doc.d.ts +35 -0
- package/dist/code-editor/doc.d.ts.map +1 -0
- package/dist/code-editor/doc.js +162 -0
- package/dist/code-editor/doc.js.map +1 -0
- package/dist/code-editor/hx.d.ts +13 -0
- package/dist/code-editor/hx.d.ts.map +1 -0
- package/dist/code-editor/hx.js +10 -0
- package/dist/code-editor/hx.js.map +1 -0
- package/dist/code-editor/index.d.ts +88 -0
- package/dist/code-editor/index.d.ts.map +1 -0
- package/dist/code-editor/index.js +401 -0
- package/dist/code-editor/index.js.map +1 -0
- package/dist/code-editor/node.d.ts +343 -0
- package/dist/code-editor/node.d.ts.map +1 -0
- package/dist/code-editor/node.js +1521 -0
- package/dist/code-editor/node.js.map +1 -0
- package/dist/code-language/hljs.d.ts +47 -0
- package/dist/code-language/hljs.d.ts.map +1 -0
- package/dist/code-language/hljs.js +211 -0
- package/dist/code-language/hljs.js.map +1 -0
- package/dist/code-language/index.d.ts +22 -0
- package/dist/code-language/index.d.ts.map +1 -0
- package/dist/code-language/index.js +20 -0
- package/dist/code-language/index.js.map +1 -0
- package/dist/code-language/languages/glsl.d.ts +4 -0
- package/dist/code-language/languages/glsl.d.ts.map +1 -0
- package/dist/code-language/languages/glsl.js +99 -0
- package/dist/code-language/languages/glsl.js.map +1 -0
- package/dist/code-language/languages/javascript.d.ts +10 -0
- package/dist/code-language/languages/javascript.d.ts.map +1 -0
- package/dist/code-language/languages/javascript.js +360 -0
- package/dist/code-language/languages/javascript.js.map +1 -0
- package/dist/code-language/languages/json.d.ts +4 -0
- package/dist/code-language/languages/json.d.ts.map +1 -0
- package/dist/code-language/languages/json.js +68 -0
- package/dist/code-language/languages/json.js.map +1 -0
- package/dist/code-language/languages/shell.d.ts +4 -0
- package/dist/code-language/languages/shell.d.ts.map +1 -0
- package/dist/code-language/languages/shell.js +189 -0
- package/dist/code-language/languages/shell.js.map +1 -0
- package/dist/code-language/languages/sql.d.ts +11 -0
- package/dist/code-language/languages/sql.d.ts.map +1 -0
- package/dist/code-language/languages/sql.js +133 -0
- package/dist/code-language/languages/sql.js.map +1 -0
- package/dist/code-language/lezer.d.ts +24 -0
- package/dist/code-language/lezer.d.ts.map +1 -0
- package/dist/code-language/lezer.js +148 -0
- package/dist/code-language/lezer.js.map +1 -0
- package/dist/code-language/registry.d.ts +19 -0
- package/dist/code-language/registry.d.ts.map +1 -0
- package/dist/code-language/registry.js +56 -0
- package/dist/code-language/registry.js.map +1 -0
- package/dist/code-language/runs.d.ts +35 -0
- package/dist/code-language/runs.d.ts.map +1 -0
- package/dist/code-language/runs.js +88 -0
- package/dist/code-language/runs.js.map +1 -0
- package/dist/code-language/stream.d.ts +70 -0
- package/dist/code-language/stream.d.ts.map +1 -0
- package/dist/code-language/stream.js +243 -0
- package/dist/code-language/stream.js.map +1 -0
- package/dist/code-language/textmate.d.ts +27 -0
- package/dist/code-language/textmate.d.ts.map +1 -0
- package/dist/code-language/textmate.js +86 -0
- package/dist/code-language/textmate.js.map +1 -0
- package/dist/code-language/theme.d.ts +26 -0
- package/dist/code-language/theme.d.ts.map +1 -0
- package/dist/code-language/theme.js +108 -0
- package/dist/code-language/theme.js.map +1 -0
- package/dist/code-language/timers.d.ts +7 -0
- package/dist/code-language/timers.d.ts.map +1 -0
- package/dist/code-language/timers.js +22 -0
- package/dist/code-language/timers.js.map +1 -0
- package/dist/code-language/types.d.ts +173 -0
- package/dist/code-language/types.d.ts.map +1 -0
- package/dist/code-language/types.js +41 -0
- package/dist/code-language/types.js.map +1 -0
- package/dist/codeblock/index.d.ts +69 -0
- package/dist/codeblock/index.d.ts.map +1 -0
- package/dist/codeblock/index.js +85 -0
- package/dist/codeblock/index.js.map +1 -0
- package/dist/color-picker/ColorField.d.ts +39 -0
- package/dist/color-picker/ColorField.d.ts.map +1 -0
- package/dist/color-picker/ColorField.js +239 -0
- package/dist/color-picker/ColorField.js.map +1 -0
- package/dist/color-picker/ColorPicker.d.ts +109 -0
- package/dist/color-picker/ColorPicker.d.ts.map +1 -0
- package/dist/color-picker/ColorPicker.js +757 -0
- package/dist/color-picker/ColorPicker.js.map +1 -0
- package/dist/color-picker/color.d.ts +105 -0
- package/dist/color-picker/color.d.ts.map +1 -0
- package/dist/color-picker/color.js +313 -0
- package/dist/color-picker/color.js.map +1 -0
- package/dist/color-picker/index.d.ts +8 -0
- package/dist/color-picker/index.d.ts.map +1 -0
- package/dist/color-picker/index.js +19 -0
- package/dist/color-picker/index.js.map +1 -0
- package/dist/desktop-calendar/eds.d.ts +102 -0
- package/dist/desktop-calendar/eds.d.ts.map +1 -0
- package/dist/desktop-calendar/eds.js +356 -0
- package/dist/desktop-calendar/eds.js.map +1 -0
- package/dist/desktop-calendar/ical.d.ts +63 -0
- package/dist/desktop-calendar/ical.d.ts.map +1 -0
- package/dist/desktop-calendar/ical.js +57 -0
- package/dist/desktop-calendar/ical.js.map +1 -0
- package/dist/desktop-calendar/index.d.ts +72 -0
- package/dist/desktop-calendar/index.d.ts.map +1 -0
- package/dist/desktop-calendar/index.js +213 -0
- package/dist/desktop-calendar/index.js.map +1 -0
- package/dist/embed/client.d.ts +121 -0
- package/dist/embed/client.d.ts.map +1 -0
- package/dist/embed/client.js +203 -0
- package/dist/embed/client.js.map +1 -0
- package/dist/embed/host.d.ts +96 -0
- package/dist/embed/host.d.ts.map +1 -0
- package/dist/embed/host.js +266 -0
- package/dist/embed/host.js.map +1 -0
- package/dist/embed/index.d.ts +5 -0
- package/dist/embed/index.d.ts.map +1 -0
- package/dist/embed/index.js +13 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/timers.d.ts +6 -0
- package/dist/embed/timers.d.ts.map +1 -0
- package/dist/embed/timers.js +16 -0
- package/dist/embed/timers.js.map +1 -0
- package/dist/flow/draw.d.ts +45 -0
- package/dist/flow/draw.d.ts.map +1 -0
- package/dist/flow/draw.js +295 -0
- package/dist/flow/draw.js.map +1 -0
- package/dist/flow/index.d.ts +64 -0
- package/dist/flow/index.d.ts.map +1 -0
- package/dist/flow/index.js +306 -0
- package/dist/flow/index.js.map +1 -0
- package/dist/flow/model.d.ts +158 -0
- package/dist/flow/model.d.ts.map +1 -0
- package/dist/flow/model.js +566 -0
- package/dist/flow/model.js.map +1 -0
- package/dist/flow/node.d.ts +429 -0
- package/dist/flow/node.d.ts.map +1 -0
- package/dist/flow/node.js +3025 -0
- package/dist/flow/node.js.map +1 -0
- package/dist/flow/paths.d.ts +43 -0
- package/dist/flow/paths.d.ts.map +1 -0
- package/dist/flow/paths.js +327 -0
- package/dist/flow/paths.js.map +1 -0
- package/dist/flow/types.d.ts +561 -0
- package/dist/flow/types.d.ts.map +1 -0
- package/dist/flow/types.js +2 -0
- package/dist/flow/types.js.map +1 -0
- package/dist/formula/hx.d.ts +24 -0
- package/dist/formula/hx.d.ts.map +1 -0
- package/dist/formula/hx.js +25 -0
- package/dist/formula/hx.js.map +1 -0
- package/dist/formula/index.d.ts +74 -0
- package/dist/formula/index.d.ts.map +1 -0
- package/dist/formula/index.js +155 -0
- package/dist/formula/index.js.map +1 -0
- package/dist/formula/katex.d.ts +78 -0
- package/dist/formula/katex.d.ts.map +1 -0
- package/dist/formula/katex.js +130 -0
- package/dist/formula/katex.js.map +1 -0
- package/dist/formula/layout.d.ts +103 -0
- package/dist/formula/layout.d.ts.map +1 -0
- package/dist/formula/layout.js +716 -0
- package/dist/formula/layout.js.map +1 -0
- package/dist/formula/node.d.ts +50 -0
- package/dist/formula/node.d.ts.map +1 -0
- package/dist/formula/node.js +351 -0
- package/dist/formula/node.js.map +1 -0
- package/dist/html/controls.d.ts +48 -0
- package/dist/html/controls.d.ts.map +1 -0
- package/dist/html/controls.js +195 -0
- package/dist/html/controls.js.map +1 -0
- package/dist/html/css/cascade.d.ts +51 -0
- package/dist/html/css/cascade.d.ts.map +1 -0
- package/dist/html/css/cascade.js +552 -0
- package/dist/html/css/cascade.js.map +1 -0
- package/dist/html/css/parse.d.ts +74 -0
- package/dist/html/css/parse.d.ts.map +1 -0
- package/dist/html/css/parse.js +464 -0
- package/dist/html/css/parse.js.map +1 -0
- package/dist/html/css/style.d.ts +142 -0
- package/dist/html/css/style.d.ts.map +1 -0
- package/dist/html/css/style.js +1145 -0
- package/dist/html/css/style.js.map +1 -0
- package/dist/html/css/ua.d.ts +5 -0
- package/dist/html/css/ua.d.ts.map +1 -0
- package/dist/html/css/ua.js +171 -0
- package/dist/html/css/ua.js.map +1 -0
- package/dist/html/css/values.d.ts +78 -0
- package/dist/html/css/values.d.ts.map +1 -0
- package/dist/html/css/values.js +330 -0
- package/dist/html/css/values.js.map +1 -0
- package/dist/html/dom.d.ts +112 -0
- package/dist/html/dom.d.ts.map +1 -0
- package/dist/html/dom.js +374 -0
- package/dist/html/dom.js.map +1 -0
- package/dist/html/hx.d.ts +24 -0
- package/dist/html/hx.d.ts.map +1 -0
- package/dist/html/hx.js +29 -0
- package/dist/html/hx.js.map +1 -0
- package/dist/html/index.d.ts +137 -0
- package/dist/html/index.d.ts.map +1 -0
- package/dist/html/index.js +339 -0
- package/dist/html/index.js.map +1 -0
- package/dist/html/layout/block.d.ts +58 -0
- package/dist/html/layout/block.d.ts.map +1 -0
- package/dist/html/layout/block.js +724 -0
- package/dist/html/layout/block.js.map +1 -0
- package/dist/html/layout/boxes.d.ts +261 -0
- package/dist/html/layout/boxes.d.ts.map +1 -0
- package/dist/html/layout/boxes.js +725 -0
- package/dist/html/layout/boxes.js.map +1 -0
- package/dist/html/layout/flex.d.ts +16 -0
- package/dist/html/layout/flex.d.ts.map +1 -0
- package/dist/html/layout/flex.js +242 -0
- package/dist/html/layout/flex.js.map +1 -0
- package/dist/html/layout/floats.d.ts +50 -0
- package/dist/html/layout/floats.d.ts.map +1 -0
- package/dist/html/layout/floats.js +110 -0
- package/dist/html/layout/floats.js.map +1 -0
- package/dist/html/layout/inline.d.ts +44 -0
- package/dist/html/layout/inline.d.ts.map +1 -0
- package/dist/html/layout/inline.js +700 -0
- package/dist/html/layout/inline.js.map +1 -0
- package/dist/html/layout/table.d.ts +5 -0
- package/dist/html/layout/table.d.ts.map +1 -0
- package/dist/html/layout/table.js +340 -0
- package/dist/html/layout/table.js.map +1 -0
- package/dist/html/node.d.ts +149 -0
- package/dist/html/node.d.ts.map +1 -0
- package/dist/html/node.js +861 -0
- package/dist/html/node.js.map +1 -0
- package/dist/html/paint.d.ts +48 -0
- package/dist/html/paint.d.ts.map +1 -0
- package/dist/html/paint.js +495 -0
- package/dist/html/paint.js.map +1 -0
- package/dist/html/resources.d.ts +48 -0
- package/dist/html/resources.d.ts.map +1 -0
- package/dist/html/resources.js +156 -0
- package/dist/html/resources.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +92 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/heights.d.ts +81 -0
- package/dist/internal/heights.d.ts.map +1 -0
- package/dist/internal/heights.js +193 -0
- package/dist/internal/heights.js.map +1 -0
- package/dist/internal/hx.d.ts +24 -0
- package/dist/internal/hx.d.ts.map +1 -0
- package/dist/internal/hx.js +27 -0
- package/dist/internal/hx.js.map +1 -0
- package/dist/internal/text.d.ts +6 -0
- package/dist/internal/text.d.ts.map +1 -0
- package/dist/internal/text.js +44 -0
- package/dist/internal/text.js.map +1 -0
- package/dist/internal/timers.d.ts +8 -0
- package/dist/internal/timers.d.ts.map +1 -0
- package/dist/internal/timers.js +22 -0
- package/dist/internal/timers.js.map +1 -0
- package/dist/internal/widget.d.ts +40 -0
- package/dist/internal/widget.d.ts.map +1 -0
- package/dist/internal/widget.js +44 -0
- package/dist/internal/widget.js.map +1 -0
- package/dist/markdown/ast.d.ts +126 -0
- package/dist/markdown/ast.d.ts.map +1 -0
- package/dist/markdown/ast.js +8 -0
- package/dist/markdown/ast.js.map +1 -0
- package/dist/markdown/hx.d.ts +24 -0
- package/dist/markdown/hx.d.ts.map +1 -0
- package/dist/markdown/hx.js +25 -0
- package/dist/markdown/hx.js.map +1 -0
- package/dist/markdown/index.d.ts +90 -0
- package/dist/markdown/index.d.ts.map +1 -0
- package/dist/markdown/index.js +413 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +8 -0
- package/dist/markdown/parse.d.ts.map +1 -0
- package/dist/markdown/parse.js +911 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown/spans.d.ts +20 -0
- package/dist/markdown/spans.d.ts.map +1 -0
- package/dist/markdown/spans.js +96 -0
- package/dist/markdown/spans.js.map +1 -0
- package/dist/media-player/backends.d.ts +64 -0
- package/dist/media-player/backends.d.ts.map +1 -0
- package/dist/media-player/backends.js +110 -0
- package/dist/media-player/backends.js.map +1 -0
- package/dist/media-player/control.d.ts +40 -0
- package/dist/media-player/control.d.ts.map +1 -0
- package/dist/media-player/control.js +44 -0
- package/dist/media-player/control.js.map +1 -0
- package/dist/media-player/hx.d.ts +24 -0
- package/dist/media-player/hx.d.ts.map +1 -0
- package/dist/media-player/hx.js +25 -0
- package/dist/media-player/hx.js.map +1 -0
- package/dist/media-player/index.d.ts +107 -0
- package/dist/media-player/index.d.ts.map +1 -0
- package/dist/media-player/index.js +219 -0
- package/dist/media-player/index.js.map +1 -0
- package/dist/media-player/mpv.d.ts +4 -0
- package/dist/media-player/mpv.d.ts.map +1 -0
- package/dist/media-player/mpv.js +120 -0
- package/dist/media-player/mpv.js.map +1 -0
- package/dist/media-player/vlc.d.ts +8 -0
- package/dist/media-player/vlc.d.ts.map +1 -0
- package/dist/media-player/vlc.js +83 -0
- package/dist/media-player/vlc.js.map +1 -0
- package/dist/richtext/index.d.ts +19 -0
- package/dist/richtext/index.d.ts.map +1 -0
- package/dist/richtext/index.js +4 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/richtext/links.d.ts +12 -0
- package/dist/richtext/links.d.ts.map +1 -0
- package/dist/richtext/links.js +64 -0
- package/dist/richtext/links.js.map +1 -0
- package/dist/richtext/menu.d.ts +11 -0
- package/dist/richtext/menu.d.ts.map +1 -0
- package/dist/richtext/menu.js +43 -0
- package/dist/richtext/menu.js.map +1 -0
- package/dist/richtext/node.d.ts +151 -0
- package/dist/richtext/node.d.ts.map +1 -0
- package/dist/richtext/node.js +255 -0
- package/dist/richtext/node.js.map +1 -0
- package/dist/richtext/runs.d.ts +92 -0
- package/dist/richtext/runs.d.ts.map +1 -0
- package/dist/richtext/runs.js +158 -0
- package/dist/richtext/runs.js.map +1 -0
- package/dist/table/hx.d.ts +24 -0
- package/dist/table/hx.d.ts.map +1 -0
- package/dist/table/hx.js +25 -0
- package/dist/table/hx.js.map +1 -0
- package/dist/table/index.d.ts +169 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +844 -0
- package/dist/table/index.js.map +1 -0
- package/dist/table/rows.d.ts +136 -0
- package/dist/table/rows.d.ts.map +1 -0
- package/dist/table/rows.js +146 -0
- package/dist/table/rows.js.map +1 -0
- package/dist/terminal/backends.d.ts +105 -0
- package/dist/terminal/backends.d.ts.map +1 -0
- package/dist/terminal/backends.js +185 -0
- package/dist/terminal/backends.js.map +1 -0
- package/dist/terminal/hx.d.ts +24 -0
- package/dist/terminal/hx.d.ts.map +1 -0
- package/dist/terminal/hx.js +25 -0
- package/dist/terminal/hx.js.map +1 -0
- package/dist/terminal/index.d.ts +160 -0
- package/dist/terminal/index.d.ts.map +1 -0
- package/dist/terminal/index.js +344 -0
- package/dist/terminal/index.js.map +1 -0
- package/dist/terminal/title.d.ts +24 -0
- package/dist/terminal/title.d.ts.map +1 -0
- package/dist/terminal/title.js +129 -0
- package/dist/terminal/title.js.map +1 -0
- package/dist/terminal/vt/colors.d.ts +63 -0
- package/dist/terminal/vt/colors.d.ts.map +1 -0
- package/dist/terminal/vt/colors.js +203 -0
- package/dist/terminal/vt/colors.js.map +1 -0
- package/dist/terminal/vt/diff.d.ts +117 -0
- package/dist/terminal/vt/diff.d.ts.map +1 -0
- package/dist/terminal/vt/diff.js +358 -0
- package/dist/terminal/vt/diff.js.map +1 -0
- package/dist/terminal/vt/fonts.d.ts +92 -0
- package/dist/terminal/vt/fonts.d.ts.map +1 -0
- package/dist/terminal/vt/fonts.js +178 -0
- package/dist/terminal/vt/fonts.js.map +1 -0
- package/dist/terminal/vt/index.d.ts +78 -0
- package/dist/terminal/vt/index.d.ts.map +1 -0
- package/dist/terminal/vt/index.js +312 -0
- package/dist/terminal/vt/index.js.map +1 -0
- package/dist/terminal/vt/keys.d.ts +74 -0
- package/dist/terminal/vt/keys.d.ts.map +1 -0
- package/dist/terminal/vt/keys.js +309 -0
- package/dist/terminal/vt/keys.js.map +1 -0
- package/dist/terminal/vt/mouse.d.ts +44 -0
- package/dist/terminal/vt/mouse.d.ts.map +1 -0
- package/dist/terminal/vt/mouse.js +106 -0
- package/dist/terminal/vt/mouse.js.map +1 -0
- package/dist/terminal/vt/node.d.ts +246 -0
- package/dist/terminal/vt/node.d.ts.map +1 -0
- package/dist/terminal/vt/node.js +1068 -0
- package/dist/terminal/vt/node.js.map +1 -0
- package/dist/terminal/vt/pty.d.ts +81 -0
- package/dist/terminal/vt/pty.d.ts.map +1 -0
- package/dist/terminal/vt/pty.js +201 -0
- package/dist/terminal/vt/pty.js.map +1 -0
- package/dist/terminal/vt/renderer.d.ts +179 -0
- package/dist/terminal/vt/renderer.d.ts.map +1 -0
- package/dist/terminal/vt/renderer.js +357 -0
- package/dist/terminal/vt/renderer.js.map +1 -0
- package/dist/terminal/vt/timers.d.ts +6 -0
- package/dist/terminal/vt/timers.d.ts.map +1 -0
- package/dist/terminal/vt/timers.js +16 -0
- package/dist/terminal/vt/timers.js.map +1 -0
- package/dist/terminal/vt/xterm.d.ts +142 -0
- package/dist/terminal/vt/xterm.d.ts.map +1 -0
- package/dist/terminal/vt/xterm.js +32 -0
- package/dist/terminal/vt/xterm.js.map +1 -0
- package/dist/terminal-output/hx.d.ts +24 -0
- package/dist/terminal-output/hx.d.ts.map +1 -0
- package/dist/terminal-output/hx.js +25 -0
- package/dist/terminal-output/hx.js.map +1 -0
- package/dist/terminal-output/index.d.ts +92 -0
- package/dist/terminal-output/index.d.ts.map +1 -0
- package/dist/terminal-output/index.js +179 -0
- package/dist/terminal-output/index.js.map +1 -0
- package/dist/terminal-output/runs.d.ts +27 -0
- package/dist/terminal-output/runs.d.ts.map +1 -0
- package/dist/terminal-output/runs.js +66 -0
- package/dist/terminal-output/runs.js.map +1 -0
- package/dist/three/canvas.d.ts +46 -0
- package/dist/three/canvas.d.ts.map +1 -0
- package/dist/three/canvas.js +305 -0
- package/dist/three/canvas.js.map +1 -0
- package/dist/three/events.d.ts +64 -0
- package/dist/three/events.d.ts.map +1 -0
- package/dist/three/events.js +146 -0
- package/dist/three/events.js.map +1 -0
- package/dist/three/geometries.d.ts +89 -0
- package/dist/three/geometries.d.ts.map +1 -0
- package/dist/three/geometries.js +345 -0
- package/dist/three/geometries.js.map +1 -0
- package/dist/three/globals.d.ts +7 -0
- package/dist/three/globals.d.ts.map +1 -0
- package/dist/three/globals.js +20 -0
- package/dist/three/globals.js.map +1 -0
- package/dist/three/hx.d.ts +9 -0
- package/dist/three/hx.d.ts.map +1 -0
- package/dist/three/hx.js +10 -0
- package/dist/three/hx.js.map +1 -0
- package/dist/three/index.d.ts +22 -0
- package/dist/three/index.d.ts.map +1 -0
- package/dist/three/index.js +24 -0
- package/dist/three/index.js.map +1 -0
- package/dist/three/jsx-dev-runtime.d.ts +19 -0
- package/dist/three/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/three/jsx-dev-runtime.js +2 -0
- package/dist/three/jsx-dev-runtime.js.map +1 -0
- package/dist/three/jsx-runtime.d.ts +19 -0
- package/dist/three/jsx-runtime.d.ts.map +1 -0
- package/dist/three/jsx-runtime.js +2 -0
- package/dist/three/jsx-runtime.js.map +1 -0
- package/dist/three/jsx.d.ts +198 -0
- package/dist/three/jsx.d.ts.map +1 -0
- package/dist/three/jsx.js +2 -0
- package/dist/three/jsx.js.map +1 -0
- package/dist/three/materials.d.ts +125 -0
- package/dist/three/materials.d.ts.map +1 -0
- package/dist/three/materials.js +229 -0
- package/dist/three/materials.js.map +1 -0
- package/dist/three/math.d.ts +146 -0
- package/dist/three/math.d.ts.map +1 -0
- package/dist/three/math.js +464 -0
- package/dist/three/math.js.map +1 -0
- package/dist/three/objects.d.ts +185 -0
- package/dist/three/objects.d.ts.map +1 -0
- package/dist/three/objects.js +309 -0
- package/dist/three/objects.js.map +1 -0
- package/dist/three/passes.d.ts +54 -0
- package/dist/three/passes.d.ts.map +1 -0
- package/dist/three/passes.js +78 -0
- package/dist/three/passes.js.map +1 -0
- package/dist/three/postprocess.d.ts +85 -0
- package/dist/three/postprocess.d.ts.map +1 -0
- package/dist/three/postprocess.js +503 -0
- package/dist/three/postprocess.js.map +1 -0
- package/dist/three/raycast.d.ts +24 -0
- package/dist/three/raycast.d.ts.map +1 -0
- package/dist/three/raycast.js +138 -0
- package/dist/three/raycast.js.map +1 -0
- package/dist/three/reconciler.d.ts +48 -0
- package/dist/three/reconciler.d.ts.map +1 -0
- package/dist/three/reconciler.js +416 -0
- package/dist/three/reconciler.js.map +1 -0
- package/dist/three/renderer-direct.d.ts +102 -0
- package/dist/three/renderer-direct.d.ts.map +1 -0
- package/dist/three/renderer-direct.js +782 -0
- package/dist/three/renderer-direct.js.map +1 -0
- package/dist/three/renderer-indirect.d.ts +68 -0
- package/dist/three/renderer-indirect.d.ts.map +1 -0
- package/dist/three/renderer-indirect.js +438 -0
- package/dist/three/renderer-indirect.js.map +1 -0
- package/dist/three/store.d.ts +81 -0
- package/dist/three/store.d.ts.map +1 -0
- package/dist/three/store.js +110 -0
- package/dist/three/store.js.map +1 -0
- package/dist/timeline/hx.d.ts +18 -0
- package/dist/timeline/hx.d.ts.map +1 -0
- package/dist/timeline/hx.js +10 -0
- package/dist/timeline/hx.js.map +1 -0
- package/dist/timeline/index.d.ts +152 -0
- package/dist/timeline/index.d.ts.map +1 -0
- package/dist/timeline/index.js +383 -0
- package/dist/timeline/index.js.map +1 -0
- package/dist/tray-host/hx.d.ts +24 -0
- package/dist/tray-host/hx.d.ts.map +1 -0
- package/dist/tray-host/hx.js +25 -0
- package/dist/tray-host/hx.js.map +1 -0
- package/dist/tray-host/index.d.ts +110 -0
- package/dist/tray-host/index.d.ts.map +1 -0
- package/dist/tray-host/index.js +191 -0
- package/dist/tray-host/index.js.map +1 -0
- package/dist/tray-host/manager.d.ts +137 -0
- package/dist/tray-host/manager.d.ts.map +1 -0
- package/dist/tray-host/manager.js +462 -0
- package/dist/tray-host/manager.js.map +1 -0
- package/dist/tray-host/notify.d.ts +22 -0
- package/dist/tray-host/notify.d.ts.map +1 -0
- package/dist/tray-host/notify.js +74 -0
- package/dist/tray-host/notify.js.map +1 -0
- package/dist/tray-host/protocol.d.ts +106 -0
- package/dist/tray-host/protocol.d.ts.map +1 -0
- package/dist/tray-host/protocol.js +191 -0
- package/dist/tray-host/protocol.js.map +1 -0
- package/dist/tree/hx.d.ts +24 -0
- package/dist/tree/hx.d.ts.map +1 -0
- package/dist/tree/hx.js +25 -0
- package/dist/tree/hx.js.map +1 -0
- package/dist/tree/index.d.ts +221 -0
- package/dist/tree/index.d.ts.map +1 -0
- package/dist/tree/index.js +716 -0
- package/dist/tree/index.js.map +1 -0
- package/dist/tree/internal.d.ts +18 -0
- package/dist/tree/internal.d.ts.map +1 -0
- package/dist/tree/internal.js +66 -0
- package/dist/tree/internal.js.map +1 -0
- package/dist/tree/rows.d.ts +177 -0
- package/dist/tree/rows.d.ts.map +1 -0
- package/dist/tree/rows.js +179 -0
- package/dist/tree/rows.js.map +1 -0
- package/package.json +166 -12
- package/src/ansi/cast.ts +159 -0
- package/src/ansi/index.ts +53 -0
- package/src/ansi/palette.ts +226 -0
- package/src/ansi/parse.ts +780 -0
- package/src/ansi/sgr.ts +380 -0
- package/src/ansi/utf8.ts +84 -0
- package/src/calendar/Calendar.ts +744 -0
- package/src/calendar/DatePicker.ts +368 -0
- package/src/calendar/dates.ts +307 -0
- package/src/calendar/index.ts +51 -0
- package/src/charts/components.ts +864 -0
- package/src/charts/data.ts +776 -0
- package/src/charts/hx.ts +39 -0
- package/src/charts/index.ts +99 -0
- package/src/charts/node.ts +932 -0
- package/src/charts/render.ts +982 -0
- package/src/charts/scale.ts +214 -0
- package/src/charts/spec.ts +122 -0
- package/src/charts/timers.ts +24 -0
- package/src/code/hx.ts +51 -0
- package/src/code/index.ts +174 -0
- package/src/code-editor/completion.ts +161 -0
- package/src/code-editor/doc.ts +198 -0
- package/src/code-editor/hx.ts +26 -0
- package/src/code-editor/index.ts +615 -0
- package/src/code-editor/node.ts +1926 -0
- package/src/code-language/hljs.ts +302 -0
- package/src/code-language/index.ts +51 -0
- package/src/code-language/languages/glsl.ts +120 -0
- package/src/code-language/languages/javascript.ts +419 -0
- package/src/code-language/languages/json.ts +72 -0
- package/src/code-language/languages/shell.ts +221 -0
- package/src/code-language/languages/sql.ts +165 -0
- package/src/code-language/lezer.ts +190 -0
- package/src/code-language/registry.ts +66 -0
- package/src/code-language/runs.ts +128 -0
- package/src/code-language/stream.ts +306 -0
- package/src/code-language/textmate.ts +134 -0
- package/src/code-language/theme.ts +124 -0
- package/src/code-language/timers.ts +36 -0
- package/src/code-language/types.ts +252 -0
- package/src/codeblock/index.ts +146 -0
- package/src/color-picker/ColorField.ts +333 -0
- package/src/color-picker/ColorPicker.ts +1069 -0
- package/src/color-picker/color.ts +399 -0
- package/src/color-picker/index.ts +50 -0
- package/src/desktop-calendar/eds.ts +484 -0
- package/src/desktop-calendar/ical.ts +119 -0
- package/src/desktop-calendar/index.ts +287 -0
- package/src/embed/client.ts +311 -0
- package/src/embed/host.ts +432 -0
- package/src/embed/index.ts +33 -0
- package/src/embed/timers.ts +32 -0
- package/src/flow/draw.ts +418 -0
- package/src/flow/index.ts +473 -0
- package/src/flow/model.ts +687 -0
- package/src/flow/node.ts +3694 -0
- package/src/flow/paths.ts +403 -0
- package/src/flow/types.ts +634 -0
- package/src/formula/hx.ts +51 -0
- package/src/formula/index.ts +243 -0
- package/src/formula/katex.ts +209 -0
- package/src/formula/layout.ts +876 -0
- package/src/formula/node.ts +467 -0
- package/src/html/controls.ts +244 -0
- package/src/html/css/cascade.ts +606 -0
- package/src/html/css/parse.ts +500 -0
- package/src/html/css/style.ts +1371 -0
- package/src/html/css/ua.ts +174 -0
- package/src/html/css/values.ts +358 -0
- package/src/html/dom.ts +410 -0
- package/src/html/hx.ts +55 -0
- package/src/html/index.ts +545 -0
- package/src/html/layout/block.ts +876 -0
- package/src/html/layout/boxes.ts +981 -0
- package/src/html/layout/flex.ts +297 -0
- package/src/html/layout/floats.ts +125 -0
- package/src/html/layout/inline.ts +870 -0
- package/src/html/layout/table.ts +380 -0
- package/src/html/node.ts +967 -0
- package/src/html/paint.ts +605 -0
- package/src/html/resources.ts +214 -0
- package/src/index.ts +638 -0
- package/src/internal/heights.ts +211 -0
- package/src/internal/hx.ts +53 -0
- package/src/internal/text.ts +44 -0
- package/src/internal/timers.ts +44 -0
- package/src/internal/widget.ts +92 -0
- package/src/markdown/ast.ts +163 -0
- package/src/markdown/hx.ts +51 -0
- package/src/markdown/index.ts +686 -0
- package/src/markdown/parse.ts +1037 -0
- package/src/markdown/spans.ts +143 -0
- package/src/media-player/backends.ts +155 -0
- package/src/media-player/control.ts +70 -0
- package/src/media-player/hx.ts +51 -0
- package/src/media-player/index.ts +378 -0
- package/src/media-player/mpv.ts +146 -0
- package/src/media-player/vlc.ts +97 -0
- package/src/richtext/index.ts +42 -0
- package/src/richtext/links.ts +78 -0
- package/src/richtext/menu.ts +49 -0
- package/src/richtext/node.ts +406 -0
- package/src/richtext/runs.ts +253 -0
- package/src/table/hx.ts +51 -0
- package/src/table/index.ts +1219 -0
- package/src/table/rows.ts +275 -0
- package/src/terminal/backends.ts +242 -0
- package/src/terminal/hx.ts +51 -0
- package/src/terminal/index.ts +574 -0
- package/src/terminal/title.ts +172 -0
- package/src/terminal/vt/colors.ts +247 -0
- package/src/terminal/vt/diff.ts +467 -0
- package/src/terminal/vt/fonts.ts +233 -0
- package/src/terminal/vt/index.ts +443 -0
- package/src/terminal/vt/keys.ts +369 -0
- package/src/terminal/vt/mouse.ts +144 -0
- package/src/terminal/vt/node.ts +1242 -0
- package/src/terminal/vt/pty.ts +334 -0
- package/src/terminal/vt/renderer.ts +555 -0
- package/src/terminal/vt/timers.ts +31 -0
- package/src/terminal/vt/xterm.ts +193 -0
- package/src/terminal-output/hx.ts +51 -0
- package/src/terminal-output/index.ts +340 -0
- package/src/terminal-output/runs.ts +79 -0
- package/src/three/canvas.ts +451 -0
- package/src/three/events.ts +197 -0
- package/src/three/geometries.ts +429 -0
- package/src/three/globals.ts +34 -0
- package/src/three/hx.ts +22 -0
- package/src/three/index.ts +98 -0
- package/src/three/jsx-dev-runtime.ts +27 -0
- package/src/three/jsx-runtime.ts +38 -0
- package/src/three/jsx.ts +275 -0
- package/src/three/materials.ts +270 -0
- package/src/three/math.ts +585 -0
- package/src/three/objects.ts +397 -0
- package/src/three/passes.ts +90 -0
- package/src/three/postprocess.ts +677 -0
- package/src/three/raycast.ts +189 -0
- package/src/three/react-reconciler.d.ts +50 -0
- package/src/three/reconciler.ts +624 -0
- package/src/three/renderer-direct.ts +1055 -0
- package/src/three/renderer-indirect.ts +570 -0
- package/src/three/store.ts +207 -0
- package/src/timeline/hx.ts +31 -0
- package/src/timeline/index.ts +603 -0
- package/src/tray-host/hx.ts +51 -0
- package/src/tray-host/index.ts +331 -0
- package/src/tray-host/manager.ts +639 -0
- package/src/tray-host/notify.ts +102 -0
- package/src/tray-host/protocol.ts +235 -0
- package/src/tree/hx.ts +51 -0
- package/src/tree/index.ts +1087 -0
- package/src/tree/internal.ts +87 -0
- package/src/tree/rows.ts +334 -0
- package/src/index.d.ts +0 -2
- package/src/index.js +0 -9
- package/src/sparkline/index.d.ts +0 -29
- package/src/sparkline/index.js +0 -50
- package/src/sparkline/node.js +0 -72
|
@@ -0,0 +1,3025 @@
|
|
|
1
|
+
// The retained node behind `<flowgraph>`: the viewport, every gesture, and
|
|
2
|
+
// all of the drawing.
|
|
3
|
+
//
|
|
4
|
+
// **Why one node draws the whole graph.** react-flow gives each node a DOM
|
|
5
|
+
// subtree and pans and zooms with a CSS transform on their common parent, so
|
|
6
|
+
// the browser moves ten thousand boxes for free. This renderer has no
|
|
7
|
+
// transform — `style` is yoga plus paint, and zoom is not among them — so
|
|
8
|
+
// the same design here would mean re-laying-out and re-rendering every node
|
|
9
|
+
// on every pointer step of a pan, through React and through yoga, sixty
|
|
10
|
+
// times a second. Drawing the graph instead makes the viewport arithmetic:
|
|
11
|
+
// panning is two numbers and a repaint of one node's rect, zoom scales the
|
|
12
|
+
// text along with everything else, and React is not involved at all unless
|
|
13
|
+
// the graph itself changed. `<codeeditor>` in this package is the same call
|
|
14
|
+
// made for the same reason.
|
|
15
|
+
//
|
|
16
|
+
// **How input arrives.** Through the default-action seam (react-x11#266):
|
|
17
|
+
// `defaultMouseDown`/`Drag`/`Up`, `defaultKeyDown`, `defaultContextMenu` run
|
|
18
|
+
// after the app's own handlers and not at all if one of them called
|
|
19
|
+
// `preventDefault()`. The wheel and plain pointer motion have no such seam
|
|
20
|
+
// (filed as react-x11#302), so `<Flow>` forwards those two through
|
|
21
|
+
// `handleWheel`/`handleHover` — with the same veto, checked here.
|
|
22
|
+
import * as ntk from 'react-x11/ntk';
|
|
23
|
+
import { Node } from 'react-x11/node';
|
|
24
|
+
import { tint } from 'react-x11/style';
|
|
25
|
+
import { ctrlChordLetter, keysymOf, XK_BACKSPACE, XK_DELETE, XK_DOWN, XK_ESCAPE, XK_HOME, XK_LEFT, XK_RIGHT, XK_UP, } from 'react-x11/keysyms';
|
|
26
|
+
import { createPainter, measureText } from './draw.js';
|
|
27
|
+
import { boundsOf, canConnect, clamp, DEFAULT_MARKER_SIZE, DEFAULT_MAX_ZOOM, DEFAULT_MIN_ZOOM, EDGE_SLOP, EDGE_STEP_OFFSET, fitViewport, handleAnchor, HANDLE_RADIUS, HANDLE_SLOP, gripPoint, inflateRect, intersectRects, measureNode, MIN_NODE_HEIGHT, MIN_NODE_WIDTH, NODE_BODY_INSET, NODE_DESC_SIZE, NODE_HEADER, NODE_LABEL_SIZE, NODE_PAD_X, NODE_PAD_Y, normalizeBackground, normalizeMarker, orientConnection, rectContains, rectsOverlap, RENDER_ZOOM, RESIZE_DIRECTIONS, RESIZE_GRIP, RESIZE_SLOP, resizeRect, resolveHandles, resolvePalette, snapTo, unionRects, ZOOM_STEP, } from './model.js';
|
|
28
|
+
import { distanceToPath, edgePath, endAngle, pathBounds, pointAtFraction, startAngle, trimEnd, } from './paths.js';
|
|
29
|
+
/** Registration key, `kind` and JSX tag, one string — react-x11 rejects a
|
|
30
|
+
* node whose `kind` is not the name it was registered under, because `kind`
|
|
31
|
+
* is what paint order, the test queries and the DEV style assertion all
|
|
32
|
+
* match on. */
|
|
33
|
+
export const ELEMENT = 'flowgraph';
|
|
34
|
+
/** Timers, through `globalThis`: `src/` compiles with `types: []` so a Node
|
|
35
|
+
* global that wandered in would become an implicit `@types/node` dependency
|
|
36
|
+
* a consumer has to satisfy. */
|
|
37
|
+
const timers = globalThis;
|
|
38
|
+
/** How often the dash on an animated edge moves. Slow enough that a graph
|
|
39
|
+
* full of them is not a repaint storm, fast enough to read as motion. */
|
|
40
|
+
const ANIMATION_MS = 60;
|
|
41
|
+
const ANIMATION_SPEED = 1.4; // px of dash travel per tick, at zoom 1
|
|
42
|
+
const DEFAULT_DASH = [7, 5];
|
|
43
|
+
/** Below these zooms the pane stops drawing detail nobody could read — the
|
|
44
|
+
* cheapest optimisation there is, and the one that keeps a zoomed-out
|
|
45
|
+
* overview interactive. */
|
|
46
|
+
const LABEL_ZOOM = 0.45;
|
|
47
|
+
const HANDLE_ZOOM = 0.5;
|
|
48
|
+
const DESC_ZOOM = 0.6;
|
|
49
|
+
/** Screen pixels the pointer may travel before a press becomes a drag. */
|
|
50
|
+
const DRAG_THRESHOLD = 3;
|
|
51
|
+
/** The grid never draws denser than this on screen, whatever the zoom. */
|
|
52
|
+
const MIN_GRID_PX = 16;
|
|
53
|
+
/** See `StrokeBuckets.BATCH_MIN`. */
|
|
54
|
+
const MARKER_BATCH_MIN = 24;
|
|
55
|
+
/**
|
|
56
|
+
* How far outside its own box a node's ink can land: handles (capped at
|
|
57
|
+
* 6 × 1.4 plus their outline), resize grips, the selection border, a pixel
|
|
58
|
+
* of antialiasing. Damage rects grow by this before they are invalidated,
|
|
59
|
+
* and cull tests grow their target by the same amount — the two must agree,
|
|
60
|
+
* or a moved node leaves crumbs of its old handles behind.
|
|
61
|
+
*/
|
|
62
|
+
const CULL_MARGIN = 16;
|
|
63
|
+
/**
|
|
64
|
+
* The props whose change means different pixels. `applyProps` repaints when
|
|
65
|
+
* one of these moves and stays quiet otherwise — the event handlers are
|
|
66
|
+
* recreated on every render of the component above, and a full repaint per
|
|
67
|
+
* re-render is what made dragging repaint the world twice.
|
|
68
|
+
*
|
|
69
|
+
* Compared by shallow value, not identity: `background={{ variant: 'dots' }}`
|
|
70
|
+
* written inline is a new object on every render of the app, and treating
|
|
71
|
+
* that as a change would put the full repaint right back.
|
|
72
|
+
*/
|
|
73
|
+
const VISUAL_PROPS = [
|
|
74
|
+
'background',
|
|
75
|
+
'minimap',
|
|
76
|
+
'controls',
|
|
77
|
+
'palette',
|
|
78
|
+
'viewport',
|
|
79
|
+
'minZoom',
|
|
80
|
+
'maxZoom',
|
|
81
|
+
'nodesConnectable',
|
|
82
|
+
'nodesResizable',
|
|
83
|
+
'disabled',
|
|
84
|
+
];
|
|
85
|
+
const CONTROL_SIZE = 26;
|
|
86
|
+
const PANEL_MARGIN = 10;
|
|
87
|
+
const MINIMAP_W = 190;
|
|
88
|
+
const MINIMAP_H = 130;
|
|
89
|
+
const NO_HOVER = { nodeId: null, handle: null, edgeId: null };
|
|
90
|
+
/** The rectangle a box-selection gesture spans on screen. */
|
|
91
|
+
function selectBoxRect(g) {
|
|
92
|
+
return {
|
|
93
|
+
x: Math.min(g.startX, g.x),
|
|
94
|
+
y: Math.min(g.startY, g.y),
|
|
95
|
+
width: Math.abs(g.x - g.startX),
|
|
96
|
+
height: Math.abs(g.y - g.startY),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The props whose repaints this element claims for itself — everything
|
|
101
|
+
* `applyProps` below either diffs into a damage rect (`nodes`, `edges`…),
|
|
102
|
+
* repaints in full when it truly changed (the visual list), or that changes
|
|
103
|
+
* behaviour without touching a pixel (`snapToGrid`, the gesture switches).
|
|
104
|
+
* Declared to `registerElement` as `selfDamagedProps`, so core's per-commit
|
|
105
|
+
* `paintChanged` contributes no damage for them — without the declaration,
|
|
106
|
+
* every drag step is a full-pane repaint again, because the commit's new
|
|
107
|
+
* `nodes` array identity would damage the whole node.
|
|
108
|
+
*/
|
|
109
|
+
export const SELF_DAMAGED_PROPS = [
|
|
110
|
+
'nodes',
|
|
111
|
+
'edges',
|
|
112
|
+
'nodeTypes',
|
|
113
|
+
'defaultEdgeOptions',
|
|
114
|
+
...VISUAL_PROPS,
|
|
115
|
+
'defaultViewport',
|
|
116
|
+
'fitView',
|
|
117
|
+
'fitViewOptions',
|
|
118
|
+
'isValidConnection',
|
|
119
|
+
'connectionMode',
|
|
120
|
+
'nodesDraggable',
|
|
121
|
+
'elementsSelectable',
|
|
122
|
+
'panOnDrag',
|
|
123
|
+
'zoomOnScroll',
|
|
124
|
+
'zoomOnDoubleClick',
|
|
125
|
+
'selectionOnDrag',
|
|
126
|
+
'deleteOnKey',
|
|
127
|
+
'snapToGrid',
|
|
128
|
+
'snapGrid',
|
|
129
|
+
];
|
|
130
|
+
/** Two edges saying the same thing, whatever the object identity. `style`
|
|
131
|
+
* and the markers compare one level deep; `data` is the app's and compares
|
|
132
|
+
* by identity, the same rule the node diff applies. */
|
|
133
|
+
function edgeValueEqual(a, b) {
|
|
134
|
+
return (a.source === b.source &&
|
|
135
|
+
a.target === b.target &&
|
|
136
|
+
(a.sourceHandle ?? null) === (b.sourceHandle ?? null) &&
|
|
137
|
+
(a.targetHandle ?? null) === (b.targetHandle ?? null) &&
|
|
138
|
+
a.type === b.type &&
|
|
139
|
+
a.label === b.label &&
|
|
140
|
+
a.animated === b.animated &&
|
|
141
|
+
a.selected === b.selected &&
|
|
142
|
+
a.hidden === b.hidden &&
|
|
143
|
+
a.selectable === b.selectable &&
|
|
144
|
+
a.deletable === b.deletable &&
|
|
145
|
+
a.zIndex === b.zIndex &&
|
|
146
|
+
a.data === b.data &&
|
|
147
|
+
shallowEqual(a.style, b.style) &&
|
|
148
|
+
shallowEqual(a.markerEnd, b.markerEnd) &&
|
|
149
|
+
shallowEqual(a.markerStart, b.markerStart));
|
|
150
|
+
}
|
|
151
|
+
/** One level of value equality, for the object-shaped props above. */
|
|
152
|
+
function shallowEqual(a, b) {
|
|
153
|
+
if (Object.is(a, b))
|
|
154
|
+
return true;
|
|
155
|
+
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
|
|
156
|
+
return false;
|
|
157
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
158
|
+
return false;
|
|
159
|
+
const ka = Object.keys(a);
|
|
160
|
+
const kb = Object.keys(b);
|
|
161
|
+
if (ka.length !== kb.length)
|
|
162
|
+
return false;
|
|
163
|
+
for (const key of ka) {
|
|
164
|
+
if (!Object.is(a[key], b[key])) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/** Union where either side may be missing. */
|
|
171
|
+
function unionMaybe(a, b) {
|
|
172
|
+
if (!a)
|
|
173
|
+
return b;
|
|
174
|
+
if (!b)
|
|
175
|
+
return a;
|
|
176
|
+
return unionRects(a, b);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Edge strokes, grouped by the pen that will draw them.
|
|
180
|
+
*
|
|
181
|
+
* Everything a graph strokes is the same two or three pens — the default
|
|
182
|
+
* edge, the selected one, the animated dash — so grouping collapses a
|
|
183
|
+
* per-edge request into a per-pen one. The key has to carry the dash *and*
|
|
184
|
+
* its offset: two edges marching out of phase cannot share a path, because
|
|
185
|
+
* the offset is set on the context, not on the subpath.
|
|
186
|
+
*/
|
|
187
|
+
class StrokeBuckets {
|
|
188
|
+
byPen = new Map();
|
|
189
|
+
bucket(stroke, lineWidth, dash, phase, zoom) {
|
|
190
|
+
const scaled = dash?.map((d) => d * zoom);
|
|
191
|
+
const key = `${stroke}|${lineWidth}|${scaled?.join(',') ?? ''}|${phase}`;
|
|
192
|
+
let entry = this.byPen.get(key);
|
|
193
|
+
if (!entry) {
|
|
194
|
+
entry = {
|
|
195
|
+
options: {
|
|
196
|
+
stroke,
|
|
197
|
+
lineWidth,
|
|
198
|
+
dash: scaled,
|
|
199
|
+
dashOffset: phase ? -phase * zoom : 0,
|
|
200
|
+
},
|
|
201
|
+
runs: [],
|
|
202
|
+
};
|
|
203
|
+
this.byPen.set(key, entry);
|
|
204
|
+
}
|
|
205
|
+
return entry.runs;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Below this many edges in one pen, they are stroked one at a time.
|
|
209
|
+
*
|
|
210
|
+
* A path's mask is its bounding box, so batching scattered geometry trades
|
|
211
|
+
* many small masks for one the size of the pane — about three quarters of
|
|
212
|
+
* a megabyte at a normal window size. That is a large win at seven hundred
|
|
213
|
+
* edges (measured: 3.9 MB a frame down to 1.3) and a loss at twenty, where
|
|
214
|
+
* the individual masks never add up to a paneful. The threshold is where
|
|
215
|
+
* they start to.
|
|
216
|
+
*/
|
|
217
|
+
static BATCH_MIN = 24;
|
|
218
|
+
paint(painter) {
|
|
219
|
+
for (const { options, runs } of this.byPen.values()) {
|
|
220
|
+
if (runs.length >= StrokeBuckets.BATCH_MIN) {
|
|
221
|
+
painter.strokeRuns(runs, options);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
for (const run of runs)
|
|
225
|
+
painter.polyline(run, options);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Are these the same handle? Anchors are rebuilt from the node on every read
|
|
232
|
+
* — a drag moves them between commits — so identity says nothing, and the
|
|
233
|
+
* `id` alone says too little: the node every graph starts with has *two*
|
|
234
|
+
* handles with no id at all. Node, id, type and side together are what
|
|
235
|
+
* distinguish them.
|
|
236
|
+
*/
|
|
237
|
+
function sameHandle(a, b) {
|
|
238
|
+
return (a != null &&
|
|
239
|
+
a.nodeId === b.nodeId &&
|
|
240
|
+
(a.id ?? null) === (b.id ?? null) &&
|
|
241
|
+
a.type === b.type &&
|
|
242
|
+
a.position === b.position);
|
|
243
|
+
}
|
|
244
|
+
export class FlowGraphNode extends Node {
|
|
245
|
+
// --- viewport, owned here unless the `viewport` prop takes it over ------
|
|
246
|
+
_vp = { x: 0, y: 0, zoom: 1 };
|
|
247
|
+
_fitPending = false;
|
|
248
|
+
// --- derived graph, rebuilt only when the arrays change -----------------
|
|
249
|
+
_nodesSeen;
|
|
250
|
+
_typesSeen;
|
|
251
|
+
_edgesSeen;
|
|
252
|
+
_edgeDefaultsSeen;
|
|
253
|
+
_fontSeen = '';
|
|
254
|
+
_entries = [];
|
|
255
|
+
_byId = new Map();
|
|
256
|
+
_edgesByNode = new Map();
|
|
257
|
+
/** The prop array the built edges came from, for the per-edge diff. */
|
|
258
|
+
_edgesRaw = [];
|
|
259
|
+
/** Paint order: `zIndex`, then selection, so a selected node is not hidden
|
|
260
|
+
* under one it overlaps. Hit testing walks it backwards. */
|
|
261
|
+
_order = [];
|
|
262
|
+
_edges = [];
|
|
263
|
+
// --- interaction --------------------------------------------------------
|
|
264
|
+
_gesture = null;
|
|
265
|
+
_hover = NO_HOVER;
|
|
266
|
+
/** Where a drag is putting each node it moves. Read in preference to
|
|
267
|
+
* `node.position`, so the pane is never a frame behind the pointer even
|
|
268
|
+
* when the app applies the changes it is being sent. Cleared on release,
|
|
269
|
+
* which is also how a refused drag snaps back. */
|
|
270
|
+
_dragTo = null;
|
|
271
|
+
/** The box a resize is currently making, for the same reason. */
|
|
272
|
+
_resizeTo = null;
|
|
273
|
+
/** What was last handed to `onNodeBodies`, so the React half is told only
|
|
274
|
+
* when something actually moved. */
|
|
275
|
+
_bodiesKey = '';
|
|
276
|
+
_dashPhase = 0;
|
|
277
|
+
_animTimer = null;
|
|
278
|
+
/** Inside `paint`, where an invalidation would only schedule a redraw of
|
|
279
|
+
* the frame being drawn. */
|
|
280
|
+
_painting = false;
|
|
281
|
+
/** Inside a live input dispatch — what makes a body emission `sync`.
|
|
282
|
+
* Motion and the wheel run at continuous priority, whose React updates
|
|
283
|
+
* can trail the pane's own painting by frames; an emission made under
|
|
284
|
+
* this flag asks the receiver to commit before the dispatch returns. */
|
|
285
|
+
_gestureSync = false;
|
|
286
|
+
/** The scene has been offered to assistive tech at least once — items
|
|
287
|
+
* only exist once layout has placed them, which no commit marks. */
|
|
288
|
+
_sceneAnnounced = false;
|
|
289
|
+
/** The damage rect of the pass being painted, or null for a full one —
|
|
290
|
+
* what the drawing subroutines cull against. */
|
|
291
|
+
_frameClip = null;
|
|
292
|
+
/** Where the animated edges were last frame, so the dash timer can
|
|
293
|
+
* invalidate that box instead of the whole pane. */
|
|
294
|
+
_animBox = null;
|
|
295
|
+
_textCache = new Map();
|
|
296
|
+
/** The grid's repeating tile: one surface for as long as the pitch
|
|
297
|
+
* holds, re-rendered in place when the phase or the colours move. */
|
|
298
|
+
_gridTile = null;
|
|
299
|
+
constructor(props, app) {
|
|
300
|
+
super(ELEMENT, props, app);
|
|
301
|
+
// Without this nothing focuses the pane and no key ever reaches it; an
|
|
302
|
+
// app's `focusable`/`tabIndex` still overrides either way.
|
|
303
|
+
this.focusableByDefault = true;
|
|
304
|
+
// What the element is to a screen reader when the app says nothing —
|
|
305
|
+
// the scene below fills in what is inside it.
|
|
306
|
+
this.a11yRole = 'group';
|
|
307
|
+
const initial = (props.defaultViewport ?? props.viewport);
|
|
308
|
+
if (initial)
|
|
309
|
+
this._vp = { ...initial };
|
|
310
|
+
this._fitPending = props.fitView === true;
|
|
311
|
+
}
|
|
312
|
+
// --- props ---------------------------------------------------------------
|
|
313
|
+
_prop(name) {
|
|
314
|
+
return this.props[name];
|
|
315
|
+
}
|
|
316
|
+
_bool(name, fallback) {
|
|
317
|
+
const v = this.props[name];
|
|
318
|
+
return typeof v === 'boolean' ? v : fallback;
|
|
319
|
+
}
|
|
320
|
+
_num(name, fallback) {
|
|
321
|
+
const v = this.props[name];
|
|
322
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
|
323
|
+
}
|
|
324
|
+
get _minZoom() {
|
|
325
|
+
return this._num('minZoom', DEFAULT_MIN_ZOOM);
|
|
326
|
+
}
|
|
327
|
+
get _maxZoom() {
|
|
328
|
+
return this._num('maxZoom', DEFAULT_MAX_ZOOM);
|
|
329
|
+
}
|
|
330
|
+
_nodes() {
|
|
331
|
+
const v = this.props.nodes;
|
|
332
|
+
return Array.isArray(v) ? v : [];
|
|
333
|
+
}
|
|
334
|
+
_rawEdges() {
|
|
335
|
+
const v = this.props.edges;
|
|
336
|
+
return Array.isArray(v) ? v : [];
|
|
337
|
+
}
|
|
338
|
+
// --- text ----------------------------------------------------------------
|
|
339
|
+
_fonts() {
|
|
340
|
+
return (this.app?.fonts ?? null);
|
|
341
|
+
}
|
|
342
|
+
_themeString(token) {
|
|
343
|
+
const v = this.theme?.[token];
|
|
344
|
+
return typeof v === 'string' ? v : undefined;
|
|
345
|
+
}
|
|
346
|
+
_textOptions() {
|
|
347
|
+
return {
|
|
348
|
+
fonts: this._fonts(),
|
|
349
|
+
family: this.style.fontFamily ??
|
|
350
|
+
this._themeString('fontFamily') ??
|
|
351
|
+
'sans-serif',
|
|
352
|
+
color: this._palette().text,
|
|
353
|
+
cache: this._textCache,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
_measure = (text, options) => measureText(this._textOptions(), text, options).width;
|
|
357
|
+
_palette() {
|
|
358
|
+
return resolvePalette(this.theme, this._prop('palette'));
|
|
359
|
+
}
|
|
360
|
+
// --- the derived graph ---------------------------------------------------
|
|
361
|
+
/**
|
|
362
|
+
* Rebuild the node index when — and only when — one of its inputs changed
|
|
363
|
+
* identity. Sizes come out of the font stack, so the face is an input too:
|
|
364
|
+
* a theme switch that changes it has to re-measure or every node keeps the
|
|
365
|
+
* width it had under the old one.
|
|
366
|
+
*/
|
|
367
|
+
_sync() {
|
|
368
|
+
const types = this.props.nodeTypes;
|
|
369
|
+
const opts = this._textOptions();
|
|
370
|
+
const fontKey = `${opts.family}|${this._fonts() ? 1 : 0}`;
|
|
371
|
+
if (types !== this._typesSeen || fontKey !== this._fontSeen) {
|
|
372
|
+
this._typesSeen = types;
|
|
373
|
+
this._fontSeen = fontKey;
|
|
374
|
+
this._nodesSeen = this.props.nodes;
|
|
375
|
+
this._rebuildNodes();
|
|
376
|
+
}
|
|
377
|
+
else if (this.props.nodes !== this._nodesSeen) {
|
|
378
|
+
this._applyNodes(this._nodes());
|
|
379
|
+
}
|
|
380
|
+
const defaults = this.props.defaultEdgeOptions;
|
|
381
|
+
if (defaults !== this._edgeDefaultsSeen) {
|
|
382
|
+
this._edgeDefaultsSeen = defaults;
|
|
383
|
+
this._edgesSeen = this.props.edges;
|
|
384
|
+
this._rebuildEdges();
|
|
385
|
+
}
|
|
386
|
+
else if (this.props.edges !== this._edgesSeen) {
|
|
387
|
+
this._applyEdges(this._rawEdges());
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Fold a new `edges` array into the built list, the cheap way when that
|
|
392
|
+
* is honest — the `_applyNodes` treatment, for the other array.
|
|
393
|
+
*
|
|
394
|
+
* An app that writes `edges={[…]}` inline hands over a fresh array of
|
|
395
|
+
* fresh objects on every render, most of them value-identical to the
|
|
396
|
+
* last. Those keep their built entry (same object, so everything
|
|
397
|
+
* downstream that compares by identity stays quiet). An edge that truly
|
|
398
|
+
* changed is rebuilt alone and claims its own route, old and new. Adds,
|
|
399
|
+
* removes and reorders fall back to the full rebuild.
|
|
400
|
+
*/
|
|
401
|
+
_applyEdges(raw) {
|
|
402
|
+
const prev = this._edgesRaw;
|
|
403
|
+
this._edgesSeen = this.props.edges;
|
|
404
|
+
if (prev.length !== raw.length) {
|
|
405
|
+
this._rebuildEdges();
|
|
406
|
+
return 'full';
|
|
407
|
+
}
|
|
408
|
+
const defaults = this._prop('defaultEdgeOptions');
|
|
409
|
+
let damage = null;
|
|
410
|
+
let adjacencyDirty = false;
|
|
411
|
+
for (let i = 0; i < raw.length; i++) {
|
|
412
|
+
const next = raw[i];
|
|
413
|
+
const old = prev[i];
|
|
414
|
+
if (next === old)
|
|
415
|
+
continue;
|
|
416
|
+
if (next.id !== old.id) {
|
|
417
|
+
this._rebuildEdges();
|
|
418
|
+
return 'full';
|
|
419
|
+
}
|
|
420
|
+
if (edgeValueEqual(next, old)) {
|
|
421
|
+
// a fresh literal saying the same thing: keep the built entry
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const wasBox = this._edgeCoarseBox(this._edges[i]);
|
|
425
|
+
this._edges[i] = defaults
|
|
426
|
+
? { ...defaults, ...next }
|
|
427
|
+
: next;
|
|
428
|
+
this._edgesRaw[i] = next;
|
|
429
|
+
const isBox = this._edgeCoarseBox(this._edges[i]);
|
|
430
|
+
if (wasBox)
|
|
431
|
+
damage = damage ? unionRects(damage, wasBox) : wasBox;
|
|
432
|
+
if (isBox)
|
|
433
|
+
damage = damage ? unionRects(damage, isBox) : isBox;
|
|
434
|
+
if (next.source !== old.source || next.target !== old.target) {
|
|
435
|
+
adjacencyDirty = true;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// keep the raw array in step even where entries were kept
|
|
439
|
+
this._edgesRaw = raw;
|
|
440
|
+
if (adjacencyDirty)
|
|
441
|
+
this._rebuildAdjacency();
|
|
442
|
+
return damage;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Fold a new `nodes` array into the existing entries, the cheap way when
|
|
446
|
+
* that is honest.
|
|
447
|
+
*
|
|
448
|
+
* During a drag the array is rebuilt by the app on every pointer step, and
|
|
449
|
+
* the only thing in it that changed is one node's `position`. Re-measuring
|
|
450
|
+
* and re-sorting three hundred nodes per step — the full `_rebuildNodes` —
|
|
451
|
+
* was most of a drag frame's CPU. So: if every node differs from its entry
|
|
452
|
+
* in nothing but `position`, the entries are updated in place (same
|
|
453
|
+
* objects, same order, same measured sizes) and the return value is the
|
|
454
|
+
* screen box that actually changed — the moved nodes, old and new, plus
|
|
455
|
+
* every edge that touches them. Anything else — a label, a size, a
|
|
456
|
+
* selection, an add or remove — falls back to the full rebuild, because it
|
|
457
|
+
* can change measurement or paint order.
|
|
458
|
+
*
|
|
459
|
+
* Returns `'full'` after a rebuild, a damage rect after an in-place fold,
|
|
460
|
+
* and `null` when nothing visual changed at all.
|
|
461
|
+
*/
|
|
462
|
+
_applyNodes(nodes) {
|
|
463
|
+
const prev = this._entries;
|
|
464
|
+
this._nodesSeen = this.props.nodes;
|
|
465
|
+
let structural = nodes.length !== prev.length;
|
|
466
|
+
if (!structural) {
|
|
467
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
468
|
+
const n = nodes[i];
|
|
469
|
+
const o = prev[i].node;
|
|
470
|
+
if (n === o)
|
|
471
|
+
continue;
|
|
472
|
+
if (n.id !== o.id ||
|
|
473
|
+
n.type !== o.type ||
|
|
474
|
+
n.selected !== o.selected ||
|
|
475
|
+
n.hidden !== o.hidden ||
|
|
476
|
+
n.zIndex !== o.zIndex ||
|
|
477
|
+
n.handles !== o.handles ||
|
|
478
|
+
n.sourcePosition !== o.sourcePosition ||
|
|
479
|
+
n.targetPosition !== o.targetPosition ||
|
|
480
|
+
n.resizable !== o.resizable ||
|
|
481
|
+
n.connectable !== o.connectable) {
|
|
482
|
+
structural = true;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (structural) {
|
|
488
|
+
this._rebuildNodes();
|
|
489
|
+
return 'full';
|
|
490
|
+
}
|
|
491
|
+
let damage = null;
|
|
492
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
493
|
+
const entry = prev[i];
|
|
494
|
+
const next = nodes[i];
|
|
495
|
+
const old = entry.node;
|
|
496
|
+
if (next === old)
|
|
497
|
+
continue;
|
|
498
|
+
const moved = next.position.x !== old.position.x ||
|
|
499
|
+
next.position.y !== old.position.y;
|
|
500
|
+
// `data`, an explicit size or a paint style are the node's own to
|
|
501
|
+
// change: they re-measure and repaint *this* node, never the graph. A
|
|
502
|
+
// keystroke into a mounted node's textarea patches `data` on every
|
|
503
|
+
// character — as a structural change that was a full re-measure and a
|
|
504
|
+
// full-pane repaint per keypress, which is what "typing feels slow"
|
|
505
|
+
// turned out to mean.
|
|
506
|
+
const reshaped = next.data !== old.data ||
|
|
507
|
+
next.width !== old.width ||
|
|
508
|
+
next.height !== old.height;
|
|
509
|
+
const restyled = next.style !== old.style;
|
|
510
|
+
if (!moved && !reshaped && !restyled) {
|
|
511
|
+
// a behavioural flag (draggable, deletable…) — nothing drawn reads it
|
|
512
|
+
entry.node = next;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const widthBefore = entry.width;
|
|
516
|
+
const heightBefore = entry.height;
|
|
517
|
+
let box = moved ? this._nodeDamage(entry) : this._screenRect(entry);
|
|
518
|
+
entry.node = next;
|
|
519
|
+
if (reshaped) {
|
|
520
|
+
const size = measureNode(next, entry.type, this._measure);
|
|
521
|
+
entry.width = size.width;
|
|
522
|
+
entry.height = size.height;
|
|
523
|
+
entry.specs = resolveHandles(next, entry.type);
|
|
524
|
+
}
|
|
525
|
+
const grew = entry.width !== widthBefore || entry.height !== heightBefore;
|
|
526
|
+
// The edges ride along only when an endpoint actually moved — a
|
|
527
|
+
// label edit that kept the box is the box's own business, and a
|
|
528
|
+
// keystroke that unioned its node's edges swept half the layer's
|
|
529
|
+
// neighbours into every repaint.
|
|
530
|
+
box = unionRects(box, moved || grew ? this._nodeDamage(entry) : this._screenRect(entry));
|
|
531
|
+
damage = damage ? unionRects(damage, box) : box;
|
|
532
|
+
}
|
|
533
|
+
return damage ? inflateRect(damage, CULL_MARGIN) : null;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* The screen box a node's redraw touches: its own rect and every edge on
|
|
537
|
+
* it — what a move must invalidate, before and after.
|
|
538
|
+
*
|
|
539
|
+
* Edge bounds come from the real routed geometry, not the coarse box: the
|
|
540
|
+
* coarse slack is sized for a bezier that *might* bulge, which in a dense
|
|
541
|
+
* graph sweeps dozens of neighbours into every drag step's damage. The
|
|
542
|
+
* exact path costs what drawing the edge costs, and a moved node's edges
|
|
543
|
+
* are about to be drawn anyway. Past a fan-out worth of edges the coarse
|
|
544
|
+
* box wins again — one huge union is one huge union either way, and the
|
|
545
|
+
* geometry walk stops being free.
|
|
546
|
+
*/
|
|
547
|
+
_nodeDamage(entry) {
|
|
548
|
+
let box = this._screenRect(entry);
|
|
549
|
+
const edges = this._edgesByNode.get(entry.node.id) ?? [];
|
|
550
|
+
const tight = edges.length <= 16;
|
|
551
|
+
for (const edge of edges) {
|
|
552
|
+
const geometry = tight ? this._edgeGeometry(edge) : null;
|
|
553
|
+
const bounds = geometry
|
|
554
|
+
? pathBounds(geometry.points)
|
|
555
|
+
: this._edgeCoarseBox(edge);
|
|
556
|
+
if (bounds)
|
|
557
|
+
box = unionRects(box, bounds);
|
|
558
|
+
}
|
|
559
|
+
return box;
|
|
560
|
+
}
|
|
561
|
+
_rebuildNodes() {
|
|
562
|
+
const registry = this._prop('nodeTypes');
|
|
563
|
+
const entries = [];
|
|
564
|
+
const byId = new Map();
|
|
565
|
+
for (const node of this._nodes()) {
|
|
566
|
+
if (!node || typeof node.id !== 'string' || !node.position)
|
|
567
|
+
continue;
|
|
568
|
+
const type = registry?.[node.type ?? 'default'];
|
|
569
|
+
const { width, height } = measureNode(node, type, this._measure);
|
|
570
|
+
const entry = {
|
|
571
|
+
node,
|
|
572
|
+
width,
|
|
573
|
+
height,
|
|
574
|
+
specs: resolveHandles(node, type),
|
|
575
|
+
type,
|
|
576
|
+
};
|
|
577
|
+
entries.push(entry);
|
|
578
|
+
byId.set(node.id, entry);
|
|
579
|
+
}
|
|
580
|
+
this._entries = entries;
|
|
581
|
+
this._byId = byId;
|
|
582
|
+
this._order = entries
|
|
583
|
+
.map((entry, index) => ({ entry, index }))
|
|
584
|
+
.sort((a, b) => {
|
|
585
|
+
const az = a.entry.node.zIndex ?? 0;
|
|
586
|
+
const bz = b.entry.node.zIndex ?? 0;
|
|
587
|
+
if (az !== bz)
|
|
588
|
+
return az - bz;
|
|
589
|
+
const as = a.entry.node.selected ? 1 : 0;
|
|
590
|
+
const bs = b.entry.node.selected ? 1 : 0;
|
|
591
|
+
if (as !== bs)
|
|
592
|
+
return as - bs;
|
|
593
|
+
return a.index - b.index; // stable: declaration order breaks ties
|
|
594
|
+
})
|
|
595
|
+
.map((e) => e.entry);
|
|
596
|
+
}
|
|
597
|
+
_rebuildEdges() {
|
|
598
|
+
const defaults = this._prop('defaultEdgeOptions');
|
|
599
|
+
const raw = this._rawEdges();
|
|
600
|
+
this._edgesRaw = raw;
|
|
601
|
+
this._edges = defaults
|
|
602
|
+
? raw.map((edge) => ({ ...defaults, ...edge }))
|
|
603
|
+
: raw;
|
|
604
|
+
this._rebuildAdjacency();
|
|
605
|
+
}
|
|
606
|
+
/** who touches whom — what a moved node's damage has to include */
|
|
607
|
+
_rebuildAdjacency() {
|
|
608
|
+
const byNode = new Map();
|
|
609
|
+
const push = (id, edge) => {
|
|
610
|
+
const list = byNode.get(id);
|
|
611
|
+
if (list)
|
|
612
|
+
list.push(edge);
|
|
613
|
+
else
|
|
614
|
+
byNode.set(id, [edge]);
|
|
615
|
+
};
|
|
616
|
+
for (const edge of this._edges) {
|
|
617
|
+
push(edge.source, edge);
|
|
618
|
+
if (edge.target !== edge.source)
|
|
619
|
+
push(edge.target, edge);
|
|
620
|
+
}
|
|
621
|
+
this._edgesByNode = byNode;
|
|
622
|
+
}
|
|
623
|
+
// --- coordinates ---------------------------------------------------------
|
|
624
|
+
_viewport() {
|
|
625
|
+
return this._prop('viewport') ?? this._vp;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The pane proper: `abs` inset by border and padding, off core's own
|
|
629
|
+
* resolved layout (react-x11#254) — so a `<Flow style={{ borderWidth: 1,
|
|
630
|
+
* padding: 8 }}>` keeps its border, and its padding means what it does on
|
|
631
|
+
* a `<box>`.
|
|
632
|
+
*/
|
|
633
|
+
_pane() {
|
|
634
|
+
return this.contentBox();
|
|
635
|
+
}
|
|
636
|
+
/** Graph point to window pixels. */
|
|
637
|
+
_toScreen(p) {
|
|
638
|
+
const v = this._viewport();
|
|
639
|
+
const pane = this._pane();
|
|
640
|
+
return {
|
|
641
|
+
x: pane.x + p.x * v.zoom + v.x,
|
|
642
|
+
y: pane.y + p.y * v.zoom + v.y,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** Window pixels to graph point. */
|
|
646
|
+
_toGraph(x, y) {
|
|
647
|
+
const v = this._viewport();
|
|
648
|
+
const pane = this._pane();
|
|
649
|
+
return {
|
|
650
|
+
x: (x - pane.x - v.x) / v.zoom,
|
|
651
|
+
y: (y - pane.y - v.y) / v.zoom,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/** Where a node is *now*: mid-drag that is not where its props say. */
|
|
655
|
+
_positionOf(node) {
|
|
656
|
+
return this._dragTo?.get(node.id) ?? node.position;
|
|
657
|
+
}
|
|
658
|
+
/** A node's box in graph space — including whatever a gesture in flight
|
|
659
|
+
* is making of it, which is what keeps the pane from being a frame behind
|
|
660
|
+
* the pointer even when the app is applying the changes it is sent. */
|
|
661
|
+
rectOf(entry) {
|
|
662
|
+
const resizing = this._resizeTo;
|
|
663
|
+
if (resizing && resizing.id === entry.node.id)
|
|
664
|
+
return resizing.rect;
|
|
665
|
+
const p = this._positionOf(entry.node);
|
|
666
|
+
return { x: p.x, y: p.y, width: entry.width, height: entry.height };
|
|
667
|
+
}
|
|
668
|
+
/** The strip a `render` node keeps for its title and for dragging. Zero
|
|
669
|
+
* for a node whose body the pane draws itself. */
|
|
670
|
+
_headerHeight(entry) {
|
|
671
|
+
if (!entry.type?.render)
|
|
672
|
+
return 0;
|
|
673
|
+
return entry.type.headerHeight ?? NODE_HEADER;
|
|
674
|
+
}
|
|
675
|
+
_resizable(entry) {
|
|
676
|
+
return ((entry.node.resizable ?? this._bool('nodesResizable', false)) !== false);
|
|
677
|
+
}
|
|
678
|
+
/** The grips a node offers right now: none unless it is selected and
|
|
679
|
+
* resizable, because eight dots on every node is a graph nobody can
|
|
680
|
+
* read. */
|
|
681
|
+
_grips(entry) {
|
|
682
|
+
if (!entry.node.selected || !this._resizable(entry))
|
|
683
|
+
return [];
|
|
684
|
+
if (this._viewport().zoom < HANDLE_ZOOM)
|
|
685
|
+
return [];
|
|
686
|
+
if (!this._connectable(entry))
|
|
687
|
+
return RESIZE_DIRECTIONS;
|
|
688
|
+
// Both families live on the border, and a side-centred handle sits
|
|
689
|
+
// exactly on a side-centred grip. The handle wins the hit test, so a
|
|
690
|
+
// grip drawn under one is a control that does not work: drop it. The
|
|
691
|
+
// corners — which is what a resize actually reaches for — are never the
|
|
692
|
+
// ones lost, unless a handle was put there on purpose.
|
|
693
|
+
const rect = this.rectOf(entry);
|
|
694
|
+
const anchors = this._handlesOf(entry);
|
|
695
|
+
const near = RESIZE_GRIP + RESIZE_SLOP;
|
|
696
|
+
return RESIZE_DIRECTIONS.filter((dir) => {
|
|
697
|
+
const at = gripPoint(rect, dir);
|
|
698
|
+
return !anchors.some((a) => Math.abs(a.x - at.x) <= near && Math.abs(a.y - at.y) <= near);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
_handlesOf(entry) {
|
|
702
|
+
const rect = this.rectOf(entry);
|
|
703
|
+
return entry.specs.map((spec) => handleAnchor(entry.node.id, rect, spec));
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* A node's box on screen, **on whole pixels**.
|
|
707
|
+
*
|
|
708
|
+
* The rounding is not cosmetic. ntk draws a rounded box as cached corner
|
|
709
|
+
* glyphs plus `FillRectangles` when its geometry is integral, and
|
|
710
|
+
* rasterizes a mask it has to `PutImage` when it is not — and a zoom of
|
|
711
|
+
* 0.42 makes every one of them fractional. On a 300-node graph that was
|
|
712
|
+
* six hundred mask uploads a frame and about four megabytes on the wire;
|
|
713
|
+
* `react-x11/debug`'s trace names it as `fell back … fractional`.
|
|
714
|
+
*
|
|
715
|
+
* Hit testing reads the same rect, so what is drawn and what is clicked
|
|
716
|
+
* still agree to the pixel.
|
|
717
|
+
*/
|
|
718
|
+
_screenRect(entry) {
|
|
719
|
+
const v = this._viewport();
|
|
720
|
+
const rect = this.rectOf(entry);
|
|
721
|
+
const p = this._toScreen(rect);
|
|
722
|
+
const x = Math.round(p.x);
|
|
723
|
+
const y = Math.round(p.y);
|
|
724
|
+
return {
|
|
725
|
+
x,
|
|
726
|
+
y,
|
|
727
|
+
// rounded as edges rather than as a size, so two nodes that share a
|
|
728
|
+
// column still share it after rounding
|
|
729
|
+
width: Math.round(p.x + rect.width * v.zoom) - x,
|
|
730
|
+
height: Math.round(p.y + rect.height * v.zoom) - y,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
_visible() {
|
|
734
|
+
const pane = this._pane();
|
|
735
|
+
return pane.width > 0 && pane.height > 0;
|
|
736
|
+
}
|
|
737
|
+
// --- viewport control ----------------------------------------------------
|
|
738
|
+
_repaint(reason = 'content') {
|
|
739
|
+
this.invalidate(false, this.abs, reason);
|
|
740
|
+
}
|
|
741
|
+
_applyViewport(next) {
|
|
742
|
+
const zoom = clamp(next.zoom, this._minZoom, this._maxZoom);
|
|
743
|
+
const previous = this._viewport();
|
|
744
|
+
const v = { x: next.x, y: next.y, zoom };
|
|
745
|
+
const controlled = this.props.viewport !== undefined;
|
|
746
|
+
if (!controlled)
|
|
747
|
+
this._vp = v;
|
|
748
|
+
this._prop('onViewportChange')?.(v);
|
|
749
|
+
// The `fitView` that runs at the top of a paint has already changed what
|
|
750
|
+
// this frame will draw, so asking for another one would only draw the
|
|
751
|
+
// same picture twice.
|
|
752
|
+
if (!controlled && !this._painting) {
|
|
753
|
+
if (!this._blitPan(previous, v))
|
|
754
|
+
this._repaint('scroll');
|
|
755
|
+
// same-frame compositing for mounted bodies — see `_dragStep`
|
|
756
|
+
this._emitBodies();
|
|
757
|
+
}
|
|
758
|
+
this.notifyA11ySceneChanged();
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* A pan is a scroll in every way but the bookkeeping, and react-x11#303
|
|
762
|
+
* made the bookkeeping public: `scrollContents` claims the pane, arms the
|
|
763
|
+
* frame to blit the surviving band, and narrows the claim to the strip
|
|
764
|
+
* the shift exposed — which `paintDamage()` then hands to `paint`, so the
|
|
765
|
+
* existing culling draws the sliver and nothing else.
|
|
766
|
+
*
|
|
767
|
+
* The furniture — minimap, zoom controls — is pinned to the pane while
|
|
768
|
+
* its pixels would ride the blit, so its bands are carved out of the
|
|
769
|
+
* region that shifts and claimed as ordinary damage. The blit gate tests
|
|
770
|
+
* foreign claims against the *rect* (react-x11#309/#310), so a claim
|
|
771
|
+
* sitting edge to edge with it leaves the frame a blit: the middle of
|
|
772
|
+
* the pane is copied, the strips repaint with the new viewport, and the
|
|
773
|
+
* panels repaint in place.
|
|
774
|
+
*
|
|
775
|
+
* Still a full repaint when:
|
|
776
|
+
* - the zoom moved (scaling is not a blit) or the shift is fractional —
|
|
777
|
+
* every real pan gesture is whole pixels;
|
|
778
|
+
* - mounted node bodies exist: they are core-owned siblings whose
|
|
779
|
+
* gesture-time commits claim *inside* the rect every step, which
|
|
780
|
+
* declines the blit anyway — bailing early skips the churn;
|
|
781
|
+
* - the furniture bands would eat the pane (a tiny pane, or panels on
|
|
782
|
+
* both the top and the bottom of a short one).
|
|
783
|
+
*/
|
|
784
|
+
_blitPan(previous, next) {
|
|
785
|
+
if (next.zoom !== previous.zoom)
|
|
786
|
+
return false;
|
|
787
|
+
if (this._bodiesKey !== '')
|
|
788
|
+
return false;
|
|
789
|
+
const dx = Math.round(next.x - previous.x);
|
|
790
|
+
const dy = Math.round(next.y - previous.y);
|
|
791
|
+
if (dx === 0 && dy === 0)
|
|
792
|
+
return true; // sub-pixel: nothing to show yet
|
|
793
|
+
if (next.x - previous.x !== dx || next.y - previous.y !== dy)
|
|
794
|
+
return false;
|
|
795
|
+
const pane = this._pane();
|
|
796
|
+
// The horizontal bands the furniture lives in. Full-width, because the
|
|
797
|
+
// graph beside a panel must repaint too — it does not ride the blit.
|
|
798
|
+
let topBand = 0;
|
|
799
|
+
let bottomBand = 0;
|
|
800
|
+
const claim = (panel, position) => {
|
|
801
|
+
if ((position ?? 'bottom-right').startsWith('top')) {
|
|
802
|
+
topBand = Math.max(topBand, panel.y + panel.height - pane.y);
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
bottomBand = Math.max(bottomBand, pane.y + pane.height - panel.y);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
const map = this._miniMapOptions();
|
|
809
|
+
if (map) {
|
|
810
|
+
claim(this._corner(map.position, map.width ?? MINIMAP_W, map.height ?? MINIMAP_H, 'bottom-right'), map.position);
|
|
811
|
+
}
|
|
812
|
+
const buttons = this._controlButtons();
|
|
813
|
+
if (buttons.length > 0) {
|
|
814
|
+
const first = buttons[0].rect;
|
|
815
|
+
const last = buttons[buttons.length - 1].rect;
|
|
816
|
+
claim(unionRects(first, last), this._controlsOptions()?.position ?? 'bottom-left');
|
|
817
|
+
}
|
|
818
|
+
const blit = {
|
|
819
|
+
x: pane.x,
|
|
820
|
+
y: pane.y + topBand,
|
|
821
|
+
width: pane.width,
|
|
822
|
+
height: pane.height - topBand - bottomBand,
|
|
823
|
+
};
|
|
824
|
+
if (blit.width < 64 || blit.height < 64)
|
|
825
|
+
return false;
|
|
826
|
+
if (Math.abs(dx) >= blit.width || Math.abs(dy) >= blit.height) {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
this.scrollContents(blit, dx, dy);
|
|
830
|
+
if (topBand > 0) {
|
|
831
|
+
this.invalidate(false, { x: pane.x, y: pane.y, width: pane.width, height: topBand }, 'scroll');
|
|
832
|
+
}
|
|
833
|
+
if (bottomBand > 0) {
|
|
834
|
+
this.invalidate(false, {
|
|
835
|
+
x: pane.x,
|
|
836
|
+
y: blit.y + blit.height,
|
|
837
|
+
width: pane.width,
|
|
838
|
+
height: bottomBand,
|
|
839
|
+
}, 'scroll');
|
|
840
|
+
}
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
/** Zoom about a point that must not move — the pointer under a wheel, the
|
|
844
|
+
* pane's centre for a keyboard zoom. */
|
|
845
|
+
_zoomAbout(factor, screenX, screenY) {
|
|
846
|
+
const v = this._viewport();
|
|
847
|
+
const zoom = clamp(v.zoom * factor, this._minZoom, this._maxZoom);
|
|
848
|
+
if (zoom === v.zoom)
|
|
849
|
+
return;
|
|
850
|
+
const pane = this._pane();
|
|
851
|
+
const px = screenX - pane.x;
|
|
852
|
+
const py = screenY - pane.y;
|
|
853
|
+
const k = zoom / v.zoom;
|
|
854
|
+
this._applyViewport({
|
|
855
|
+
zoom,
|
|
856
|
+
x: px - (px - v.x) * k,
|
|
857
|
+
y: py - (py - v.y) * k,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
_fit(options) {
|
|
861
|
+
if (!this._visible())
|
|
862
|
+
return;
|
|
863
|
+
this._sync();
|
|
864
|
+
const pane = this._pane();
|
|
865
|
+
const only = options?.nodes && new Set(options.nodes.map((n) => n.id));
|
|
866
|
+
const rects = [];
|
|
867
|
+
for (const entry of this._entries) {
|
|
868
|
+
if (entry.node.hidden)
|
|
869
|
+
continue;
|
|
870
|
+
if (only && !only.has(entry.node.id))
|
|
871
|
+
continue;
|
|
872
|
+
rects.push(this.rectOf(entry));
|
|
873
|
+
}
|
|
874
|
+
const bounds = boundsOf(rects);
|
|
875
|
+
if (!bounds)
|
|
876
|
+
return;
|
|
877
|
+
this._applyViewport(fitViewport(bounds, { width: pane.width, height: pane.height }, options, {
|
|
878
|
+
minZoom: this._minZoom,
|
|
879
|
+
maxZoom: this._maxZoom,
|
|
880
|
+
}));
|
|
881
|
+
}
|
|
882
|
+
// --- the imperative surface (`FlowInstance`) -----------------------------
|
|
883
|
+
getViewport() {
|
|
884
|
+
return { ...this._viewport() };
|
|
885
|
+
}
|
|
886
|
+
setViewport(viewport) {
|
|
887
|
+
const v = this._viewport();
|
|
888
|
+
this._applyViewport({
|
|
889
|
+
x: viewport.x ?? v.x,
|
|
890
|
+
y: viewport.y ?? v.y,
|
|
891
|
+
zoom: viewport.zoom ?? v.zoom,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
zoomIn(step = ZOOM_STEP) {
|
|
895
|
+
const pane = this._pane();
|
|
896
|
+
this._zoomAbout(step, pane.x + pane.width / 2, pane.y + pane.height / 2);
|
|
897
|
+
}
|
|
898
|
+
zoomOut(step = ZOOM_STEP) {
|
|
899
|
+
this.zoomIn(1 / step);
|
|
900
|
+
}
|
|
901
|
+
zoomTo(zoom) {
|
|
902
|
+
const v = this._viewport();
|
|
903
|
+
const pane = this._pane();
|
|
904
|
+
this._zoomAbout(zoom / v.zoom, pane.x + pane.width / 2, pane.y + pane.height / 2);
|
|
905
|
+
}
|
|
906
|
+
fitView(options) {
|
|
907
|
+
this._fit(options ?? this._prop('fitViewOptions'));
|
|
908
|
+
}
|
|
909
|
+
setCenter(x, y, options) {
|
|
910
|
+
const v = this._viewport();
|
|
911
|
+
const zoom = clamp(options?.zoom ?? v.zoom, this._minZoom, this._maxZoom);
|
|
912
|
+
const pane = this._pane();
|
|
913
|
+
this._applyViewport({
|
|
914
|
+
zoom,
|
|
915
|
+
x: pane.width / 2 - x * zoom,
|
|
916
|
+
y: pane.height / 2 - y * zoom,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
screenToFlowPosition(point) {
|
|
920
|
+
return this._toGraph(point.x, point.y);
|
|
921
|
+
}
|
|
922
|
+
flowToScreenPosition(point) {
|
|
923
|
+
return this._toScreen(point);
|
|
924
|
+
}
|
|
925
|
+
getNodeBounds(id) {
|
|
926
|
+
this._sync();
|
|
927
|
+
const entry = this._byId.get(id);
|
|
928
|
+
return entry ? this.rectOf(entry) : null;
|
|
929
|
+
}
|
|
930
|
+
getNodesBounds(ids) {
|
|
931
|
+
this._sync();
|
|
932
|
+
const only = ids && new Set(ids);
|
|
933
|
+
const rects = [];
|
|
934
|
+
for (const entry of this._entries) {
|
|
935
|
+
if (only && !only.has(entry.node.id))
|
|
936
|
+
continue;
|
|
937
|
+
rects.push(this.rectOf(entry));
|
|
938
|
+
}
|
|
939
|
+
return boundsOf(rects);
|
|
940
|
+
}
|
|
941
|
+
// --- change emission -----------------------------------------------------
|
|
942
|
+
_emitNodes(changes) {
|
|
943
|
+
if (changes.length === 0)
|
|
944
|
+
return;
|
|
945
|
+
this._prop('onNodesChange')?.(changes);
|
|
946
|
+
}
|
|
947
|
+
_emitEdges(changes) {
|
|
948
|
+
if (changes.length === 0)
|
|
949
|
+
return;
|
|
950
|
+
this._prop('onEdgesChange')?.(changes);
|
|
951
|
+
}
|
|
952
|
+
/** Make this the selection. `additive` toggles instead, which is what
|
|
953
|
+
* Shift and Ctrl do everywhere else. */
|
|
954
|
+
_select(target, additive) {
|
|
955
|
+
if (!this._bool('elementsSelectable', true))
|
|
956
|
+
return;
|
|
957
|
+
const nodeChanges = [];
|
|
958
|
+
const edgeChanges = [];
|
|
959
|
+
const isNode = target?.kind === 'node';
|
|
960
|
+
const isEdge = target?.kind === 'edge';
|
|
961
|
+
for (const entry of this._entries) {
|
|
962
|
+
const hit = isNode && entry.node.id === target.id;
|
|
963
|
+
const selected = entry.node.selected ?? false;
|
|
964
|
+
const next = hit
|
|
965
|
+
? additive
|
|
966
|
+
? !selected
|
|
967
|
+
: true
|
|
968
|
+
: additive
|
|
969
|
+
? selected
|
|
970
|
+
: false;
|
|
971
|
+
if (next !== selected) {
|
|
972
|
+
nodeChanges.push({ type: 'select', id: entry.node.id, selected: next });
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
for (const edge of this._edges) {
|
|
976
|
+
const hit = isEdge && edge.id === target.id;
|
|
977
|
+
const selected = edge.selected ?? false;
|
|
978
|
+
const next = hit
|
|
979
|
+
? additive
|
|
980
|
+
? !selected
|
|
981
|
+
: true
|
|
982
|
+
: additive
|
|
983
|
+
? selected
|
|
984
|
+
: false;
|
|
985
|
+
if (next !== selected) {
|
|
986
|
+
edgeChanges.push({ type: 'select', id: edge.id, selected: next });
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
this._emitNodes(nodeChanges);
|
|
990
|
+
this._emitEdges(edgeChanges);
|
|
991
|
+
}
|
|
992
|
+
_selectedNodeIds() {
|
|
993
|
+
const ids = [];
|
|
994
|
+
for (const entry of this._entries) {
|
|
995
|
+
if (entry.node.selected)
|
|
996
|
+
ids.push(entry.node.id);
|
|
997
|
+
}
|
|
998
|
+
return ids;
|
|
999
|
+
}
|
|
1000
|
+
// --- hit testing ---------------------------------------------------------
|
|
1001
|
+
/** The topmost node under a window point, or null. Walks paint order
|
|
1002
|
+
* backwards, which is what "topmost" means. */
|
|
1003
|
+
_nodeAt(x, y) {
|
|
1004
|
+
for (let i = this._order.length - 1; i >= 0; i--) {
|
|
1005
|
+
const entry = this._order[i];
|
|
1006
|
+
if (entry.node.hidden)
|
|
1007
|
+
continue;
|
|
1008
|
+
if (rectContains(this._screenRect(entry), { x, y }))
|
|
1009
|
+
return entry;
|
|
1010
|
+
}
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
_handleAt(x, y, accept) {
|
|
1014
|
+
const zoom = this._viewport().zoom;
|
|
1015
|
+
const reach = Math.max(7, (HANDLE_RADIUS + HANDLE_SLOP) * zoom);
|
|
1016
|
+
for (let i = this._order.length - 1; i >= 0; i--) {
|
|
1017
|
+
const entry = this._order[i];
|
|
1018
|
+
if (entry.node.hidden)
|
|
1019
|
+
continue;
|
|
1020
|
+
if (!this._connectable(entry))
|
|
1021
|
+
continue;
|
|
1022
|
+
// cheap reject: the handles are on the node's border, so nothing more
|
|
1023
|
+
// than `reach` outside its box can be one
|
|
1024
|
+
const rect = this._screenRect(entry);
|
|
1025
|
+
if (x < rect.x - reach ||
|
|
1026
|
+
x > rect.x + rect.width + reach ||
|
|
1027
|
+
y < rect.y - reach ||
|
|
1028
|
+
y > rect.y + rect.height + reach) {
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
for (const anchor of this._handlesOf(entry)) {
|
|
1032
|
+
if (accept && !accept(anchor, entry))
|
|
1033
|
+
continue;
|
|
1034
|
+
const s = this._toScreen(anchor);
|
|
1035
|
+
if (Math.hypot(s.x - x, s.y - y) <= reach)
|
|
1036
|
+
return anchor;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* A resize grip under the point, if any. Tested *after* the connection
|
|
1043
|
+
* handles: the two families both live on the border and a side-centred
|
|
1044
|
+
* handle sits exactly on a side-centred grip, so one of them has to give
|
|
1045
|
+
* way, and it should be the one whose corners are still reachable.
|
|
1046
|
+
*/
|
|
1047
|
+
_gripAt(x, y) {
|
|
1048
|
+
const zoom = this._viewport().zoom;
|
|
1049
|
+
const reach = Math.max(6, (RESIZE_GRIP + RESIZE_SLOP) * zoom);
|
|
1050
|
+
for (let i = this._order.length - 1; i >= 0; i--) {
|
|
1051
|
+
const entry = this._order[i];
|
|
1052
|
+
if (entry.node.hidden)
|
|
1053
|
+
continue;
|
|
1054
|
+
const grips = this._grips(entry);
|
|
1055
|
+
if (grips.length === 0)
|
|
1056
|
+
continue;
|
|
1057
|
+
const rect = this._screenRect(entry);
|
|
1058
|
+
if (x < rect.x - reach ||
|
|
1059
|
+
x > rect.x + rect.width + reach ||
|
|
1060
|
+
y < rect.y - reach ||
|
|
1061
|
+
y > rect.y + rect.height + reach) {
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
for (const dir of grips) {
|
|
1065
|
+
const at = gripPoint(rect, dir);
|
|
1066
|
+
if (Math.abs(at.x - x) <= reach && Math.abs(at.y - y) <= reach) {
|
|
1067
|
+
return { entry, dir };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
_edgeAt(x, y) {
|
|
1074
|
+
const reach = Math.max(5, EDGE_SLOP * this._viewport().zoom);
|
|
1075
|
+
const point = { x, y };
|
|
1076
|
+
for (let i = this._edges.length - 1; i >= 0; i--) {
|
|
1077
|
+
const edge = this._edges[i];
|
|
1078
|
+
if (edge.hidden)
|
|
1079
|
+
continue;
|
|
1080
|
+
const box = this._edgeCoarseBox(edge);
|
|
1081
|
+
if (!box)
|
|
1082
|
+
continue;
|
|
1083
|
+
if (!rectContains(box, point))
|
|
1084
|
+
continue;
|
|
1085
|
+
const geometry = this._edgeGeometry(edge);
|
|
1086
|
+
if (!geometry)
|
|
1087
|
+
continue;
|
|
1088
|
+
if (distanceToPath(geometry.points, point) <= reach)
|
|
1089
|
+
return edge;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* A box the edge cannot leave, from its two nodes alone — no path built.
|
|
1095
|
+
* Hover runs this over every edge on every pointer step, and building the
|
|
1096
|
+
* real geometry to reject it would make motion cost what drawing does.
|
|
1097
|
+
*
|
|
1098
|
+
* The slack covers the two ways a route leaves the span between its nodes:
|
|
1099
|
+
* a step edge by a fixed offset, a bezier by its shoulder, which grows as
|
|
1100
|
+
* the square root of the gap (see `paths.ts`) — `8` where the shoulder
|
|
1101
|
+
* uses `6.25`, so the bound is generous rather than tight.
|
|
1102
|
+
*/
|
|
1103
|
+
_edgeCoarseBox(edge) {
|
|
1104
|
+
const source = this._byId.get(edge.source);
|
|
1105
|
+
const target = this._byId.get(edge.target);
|
|
1106
|
+
if (!source || !target)
|
|
1107
|
+
return null;
|
|
1108
|
+
if (source.node.hidden || target.node.hidden)
|
|
1109
|
+
return null;
|
|
1110
|
+
const zoom = this._viewport().zoom;
|
|
1111
|
+
const a = this._screenRect(source);
|
|
1112
|
+
const b = this._screenRect(target);
|
|
1113
|
+
const span = Math.hypot(a.x + a.width / 2 - (b.x + b.width / 2), a.y + a.height / 2 - (b.y + b.height / 2));
|
|
1114
|
+
const slack = Math.max(5, EDGE_SLOP * zoom) +
|
|
1115
|
+
Math.max(EDGE_STEP_OFFSET * 3 * zoom, 8 * Math.sqrt(zoom * span));
|
|
1116
|
+
const x = Math.min(a.x, b.x) - slack;
|
|
1117
|
+
const y = Math.min(a.y, b.y) - slack;
|
|
1118
|
+
return {
|
|
1119
|
+
x,
|
|
1120
|
+
y,
|
|
1121
|
+
width: Math.max(a.x + a.width, b.x + b.width) + slack - x,
|
|
1122
|
+
height: Math.max(a.y + a.height, b.y + b.height) + slack - y,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
/** The drawn handle, in screen pixels. Capped as well as floored: a dot
|
|
1126
|
+
* that scaled all the way up would be a saucer at 2.5×, and the thing it
|
|
1127
|
+
* marks — a point on the border — does not get bigger. */
|
|
1128
|
+
_handleRadius() {
|
|
1129
|
+
return clamp(HANDLE_RADIUS * this._viewport().zoom, 2.5, 6);
|
|
1130
|
+
}
|
|
1131
|
+
_connectable(entry) {
|
|
1132
|
+
return ((entry.node.connectable ?? this._bool('nodesConnectable', true)) !== false);
|
|
1133
|
+
}
|
|
1134
|
+
_draggable(entry) {
|
|
1135
|
+
return ((entry.node.draggable ?? this._bool('nodesDraggable', true)) !== false);
|
|
1136
|
+
}
|
|
1137
|
+
// --- edge geometry -------------------------------------------------------
|
|
1138
|
+
/** Pick the handle an edge attaches to: the one it names, else the only
|
|
1139
|
+
* one of the right type, else the one facing the other end. */
|
|
1140
|
+
_endpoint(entry, handleId, type, towards) {
|
|
1141
|
+
const anchors = this._handlesOf(entry);
|
|
1142
|
+
if (handleId != null) {
|
|
1143
|
+
const named = anchors.find((a) => a.id === handleId);
|
|
1144
|
+
if (named)
|
|
1145
|
+
return named;
|
|
1146
|
+
}
|
|
1147
|
+
const typed = anchors.filter((a) => a.type === type);
|
|
1148
|
+
const candidates = typed.length > 0 ? typed : anchors;
|
|
1149
|
+
if (candidates.length === 0)
|
|
1150
|
+
return null;
|
|
1151
|
+
if (candidates.length === 1 || !towards)
|
|
1152
|
+
return candidates[0];
|
|
1153
|
+
let best = candidates[0];
|
|
1154
|
+
let bestDistance = Infinity;
|
|
1155
|
+
for (const anchor of candidates) {
|
|
1156
|
+
const d = Math.hypot(anchor.x - towards.x, anchor.y - towards.y);
|
|
1157
|
+
if (d < bestDistance) {
|
|
1158
|
+
bestDistance = d;
|
|
1159
|
+
best = anchor;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return best;
|
|
1163
|
+
}
|
|
1164
|
+
/** An edge's polyline, in **screen** pixels, plus the two ends it joins.
|
|
1165
|
+
* Null when either end is missing — an edge to a node that was removed is
|
|
1166
|
+
* a normal state during an edit, not an error. */
|
|
1167
|
+
_edgeGeometry(edge) {
|
|
1168
|
+
const sourceEntry = this._byId.get(edge.source);
|
|
1169
|
+
const targetEntry = this._byId.get(edge.target);
|
|
1170
|
+
if (!sourceEntry || !targetEntry)
|
|
1171
|
+
return null;
|
|
1172
|
+
if (sourceEntry.node.hidden || targetEntry.node.hidden)
|
|
1173
|
+
return null;
|
|
1174
|
+
const sourceCentre = this._centre(sourceEntry);
|
|
1175
|
+
const targetCentre = this._centre(targetEntry);
|
|
1176
|
+
const from = this._endpoint(sourceEntry, edge.sourceHandle, 'source', targetCentre);
|
|
1177
|
+
const to = this._endpoint(targetEntry, edge.targetHandle, 'target', sourceCentre);
|
|
1178
|
+
if (!from || !to)
|
|
1179
|
+
return null;
|
|
1180
|
+
const zoom = this._viewport().zoom;
|
|
1181
|
+
const s = this._toScreen(from);
|
|
1182
|
+
const t = this._toScreen(to);
|
|
1183
|
+
const points = edgePath(edge.type, { x: s.x, y: s.y, position: from.position }, { x: t.x, y: t.y, position: to.position }, {
|
|
1184
|
+
stepOffset: EDGE_STEP_OFFSET * zoom,
|
|
1185
|
+
radius: 8 * zoom,
|
|
1186
|
+
scale: zoom,
|
|
1187
|
+
loop: edge.source === edge.target,
|
|
1188
|
+
});
|
|
1189
|
+
return { points, from, to };
|
|
1190
|
+
}
|
|
1191
|
+
_centre(entry) {
|
|
1192
|
+
const r = this.rectOf(entry);
|
|
1193
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
1194
|
+
}
|
|
1195
|
+
// --- pane furniture geometry --------------------------------------------
|
|
1196
|
+
_corner(where, width, height, fallback) {
|
|
1197
|
+
const at = where ?? fallback;
|
|
1198
|
+
const right = at.endsWith('right');
|
|
1199
|
+
const bottom = at.startsWith('bottom');
|
|
1200
|
+
const pane = this._pane();
|
|
1201
|
+
return {
|
|
1202
|
+
x: right
|
|
1203
|
+
? pane.x + pane.width - width - PANEL_MARGIN
|
|
1204
|
+
: pane.x + PANEL_MARGIN,
|
|
1205
|
+
y: bottom
|
|
1206
|
+
? pane.y + pane.height - height - PANEL_MARGIN
|
|
1207
|
+
: pane.y + PANEL_MARGIN,
|
|
1208
|
+
width,
|
|
1209
|
+
height,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
_controlsOptions() {
|
|
1213
|
+
const value = this.props.controls;
|
|
1214
|
+
if (value === false)
|
|
1215
|
+
return null;
|
|
1216
|
+
return typeof value === 'object' && value !== null
|
|
1217
|
+
? value
|
|
1218
|
+
: {};
|
|
1219
|
+
}
|
|
1220
|
+
_controlButtons() {
|
|
1221
|
+
const options = this._controlsOptions();
|
|
1222
|
+
if (!options)
|
|
1223
|
+
return [];
|
|
1224
|
+
const actions = [];
|
|
1225
|
+
if (options.showZoom !== false)
|
|
1226
|
+
actions.push('in', 'out');
|
|
1227
|
+
if (options.showFitView !== false)
|
|
1228
|
+
actions.push('fit');
|
|
1229
|
+
if (actions.length === 0)
|
|
1230
|
+
return [];
|
|
1231
|
+
const panel = this._corner(options.position, CONTROL_SIZE, CONTROL_SIZE * actions.length, 'bottom-left');
|
|
1232
|
+
return actions.map((action, i) => ({
|
|
1233
|
+
action,
|
|
1234
|
+
rect: {
|
|
1235
|
+
x: panel.x,
|
|
1236
|
+
y: panel.y + i * CONTROL_SIZE,
|
|
1237
|
+
width: CONTROL_SIZE,
|
|
1238
|
+
height: CONTROL_SIZE,
|
|
1239
|
+
},
|
|
1240
|
+
}));
|
|
1241
|
+
}
|
|
1242
|
+
_miniMapOptions() {
|
|
1243
|
+
const value = this.props.minimap;
|
|
1244
|
+
if (!value)
|
|
1245
|
+
return null;
|
|
1246
|
+
return typeof value === 'object' ? value : {};
|
|
1247
|
+
}
|
|
1248
|
+
/** The minimap panel and the graph-to-panel transform, or null when there
|
|
1249
|
+
* is no minimap or nothing to put in it. */
|
|
1250
|
+
_miniMap() {
|
|
1251
|
+
const options = this._miniMapOptions();
|
|
1252
|
+
if (!options)
|
|
1253
|
+
return null;
|
|
1254
|
+
const width = options.width ?? MINIMAP_W;
|
|
1255
|
+
const height = options.height ?? MINIMAP_H;
|
|
1256
|
+
const pane = this._pane();
|
|
1257
|
+
if (pane.width < width * 1.6 || pane.height < height * 1.6) {
|
|
1258
|
+
// A minimap that covers the pane it summarises is worse than none.
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
const rects = [];
|
|
1262
|
+
for (const entry of this._entries) {
|
|
1263
|
+
if (!entry.node.hidden)
|
|
1264
|
+
rects.push(this.rectOf(entry));
|
|
1265
|
+
}
|
|
1266
|
+
// The viewport joins the bounds, so panning off the graph still shows
|
|
1267
|
+
// where you are rather than pinning the box to an edge.
|
|
1268
|
+
const v = this._viewport();
|
|
1269
|
+
rects.push({
|
|
1270
|
+
x: -v.x / v.zoom,
|
|
1271
|
+
y: -v.y / v.zoom,
|
|
1272
|
+
width: pane.width / v.zoom,
|
|
1273
|
+
height: pane.height / v.zoom,
|
|
1274
|
+
});
|
|
1275
|
+
const bounds = boundsOf(rects);
|
|
1276
|
+
if (!bounds)
|
|
1277
|
+
return null;
|
|
1278
|
+
const pad = Math.max(bounds.width, bounds.height) * 0.05 + 10;
|
|
1279
|
+
const padded = {
|
|
1280
|
+
x: bounds.x - pad,
|
|
1281
|
+
y: bounds.y - pad,
|
|
1282
|
+
width: bounds.width + pad * 2,
|
|
1283
|
+
height: bounds.height + pad * 2,
|
|
1284
|
+
};
|
|
1285
|
+
const panel = this._corner(options.position, width, height, 'bottom-right');
|
|
1286
|
+
const scale = Math.min(panel.width / Math.max(1, padded.width), panel.height / Math.max(1, padded.height));
|
|
1287
|
+
return { panel, bounds: padded, scale, options };
|
|
1288
|
+
}
|
|
1289
|
+
_miniToGraph(map, x, y) {
|
|
1290
|
+
const ox = map.panel.x + (map.panel.width - map.bounds.width * map.scale) / 2;
|
|
1291
|
+
const oy = map.panel.y + (map.panel.height - map.bounds.height * map.scale) / 2;
|
|
1292
|
+
return {
|
|
1293
|
+
x: (x - ox) / map.scale + map.bounds.x,
|
|
1294
|
+
y: (y - oy) / map.scale + map.bounds.y,
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
_miniToScreen(map, p) {
|
|
1298
|
+
const ox = map.panel.x + (map.panel.width - map.bounds.width * map.scale) / 2;
|
|
1299
|
+
const oy = map.panel.y + (map.panel.height - map.bounds.height * map.scale) / 2;
|
|
1300
|
+
return {
|
|
1301
|
+
x: ox + (p.x - map.bounds.x) * map.scale,
|
|
1302
|
+
y: oy + (p.y - map.bounds.y) * map.scale,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
// --- gestures ------------------------------------------------------------
|
|
1306
|
+
defaultMouseDown(ev) {
|
|
1307
|
+
if (this.props.disabled)
|
|
1308
|
+
return;
|
|
1309
|
+
this.focus();
|
|
1310
|
+
this._sync();
|
|
1311
|
+
if (ev.button === 3)
|
|
1312
|
+
return; // the context menu is its own seam
|
|
1313
|
+
const { x, y } = ev;
|
|
1314
|
+
// 1. the pane's own furniture, which is over everything else
|
|
1315
|
+
for (const button of this._controlButtons()) {
|
|
1316
|
+
if (rectContains(button.rect, { x, y })) {
|
|
1317
|
+
if (button.action === 'in')
|
|
1318
|
+
this.zoomIn();
|
|
1319
|
+
else if (button.action === 'out')
|
|
1320
|
+
this.zoomOut();
|
|
1321
|
+
else
|
|
1322
|
+
this.fitView();
|
|
1323
|
+
ev.preventDefault();
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
const map = this._miniMap();
|
|
1328
|
+
if (map && rectContains(map.panel, { x, y })) {
|
|
1329
|
+
this._gesture = { kind: 'minimap' };
|
|
1330
|
+
const p = this._miniToGraph(map, x, y);
|
|
1331
|
+
this.setCenter(p.x, p.y);
|
|
1332
|
+
ev.capturePointer();
|
|
1333
|
+
ev.preventDefault();
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
// 2. a handle, which sits proud of its node and so is tested first
|
|
1337
|
+
if (ev.button === 1 && this._bool('nodesConnectable', true)) {
|
|
1338
|
+
const handle = this._handleAt(x, y);
|
|
1339
|
+
if (handle) {
|
|
1340
|
+
this._gesture = {
|
|
1341
|
+
kind: 'connect',
|
|
1342
|
+
from: handle,
|
|
1343
|
+
pointer: this._toGraph(x, y),
|
|
1344
|
+
to: null,
|
|
1345
|
+
valid: false,
|
|
1346
|
+
};
|
|
1347
|
+
this._prop('onConnectStart')?.({
|
|
1348
|
+
nodeId: handle.nodeId,
|
|
1349
|
+
handleId: handle.id ?? null,
|
|
1350
|
+
handleType: handle.type,
|
|
1351
|
+
});
|
|
1352
|
+
ev.capturePointer();
|
|
1353
|
+
ev.preventDefault();
|
|
1354
|
+
this._repaint('style-state');
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
// 3. a resize grip on a selected node
|
|
1359
|
+
if (ev.button === 1) {
|
|
1360
|
+
const grip = this._gripAt(x, y);
|
|
1361
|
+
if (grip) {
|
|
1362
|
+
this._gesture = {
|
|
1363
|
+
kind: 'resize',
|
|
1364
|
+
id: grip.entry.node.id,
|
|
1365
|
+
dir: grip.dir,
|
|
1366
|
+
startX: x,
|
|
1367
|
+
startY: y,
|
|
1368
|
+
origin: this.rectOf(grip.entry),
|
|
1369
|
+
};
|
|
1370
|
+
ev.capturePointer();
|
|
1371
|
+
ev.preventDefault();
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
// 4. a node
|
|
1376
|
+
const entry = ev.button === 1 ? this._nodeAt(x, y) : null;
|
|
1377
|
+
if (entry) {
|
|
1378
|
+
const additive = ev.shiftKey || ev.ctrlKey;
|
|
1379
|
+
const selectable = (entry.node.selectable ?? this._bool('elementsSelectable', true)) !==
|
|
1380
|
+
false;
|
|
1381
|
+
// A press inside an existing multi-selection keeps it, so that
|
|
1382
|
+
// dragging the group does not first collapse it to one node.
|
|
1383
|
+
if (selectable && !(entry.node.selected && !additive)) {
|
|
1384
|
+
this._select({ kind: 'node', id: entry.node.id }, additive);
|
|
1385
|
+
}
|
|
1386
|
+
// The gesture is armed even for a node nothing may move: it is also
|
|
1387
|
+
// what turns a press-and-release into `onNodeClick`, and a node that
|
|
1388
|
+
// cannot be dragged can still be clicked. It simply moves nothing.
|
|
1389
|
+
const ids = entry.node.selected && this._draggable(entry)
|
|
1390
|
+
? Array.from(new Set([...this._selectedNodeIds(), entry.node.id]))
|
|
1391
|
+
: this._draggable(entry)
|
|
1392
|
+
? [entry.node.id]
|
|
1393
|
+
: [];
|
|
1394
|
+
const origin = new Map();
|
|
1395
|
+
for (const id of ids) {
|
|
1396
|
+
const target = this._byId.get(id);
|
|
1397
|
+
if (target && this._draggable(target)) {
|
|
1398
|
+
origin.set(id, { ...this._positionOf(target.node) });
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
this._gesture = {
|
|
1402
|
+
kind: 'drag',
|
|
1403
|
+
ids: Array.from(origin.keys()),
|
|
1404
|
+
primary: entry.node.id,
|
|
1405
|
+
origin,
|
|
1406
|
+
startX: x,
|
|
1407
|
+
startY: y,
|
|
1408
|
+
moved: false,
|
|
1409
|
+
};
|
|
1410
|
+
ev.capturePointer();
|
|
1411
|
+
ev.preventDefault();
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
// 5. an edge
|
|
1415
|
+
if (ev.button === 1) {
|
|
1416
|
+
const edge = this._edgeAt(x, y);
|
|
1417
|
+
if (edge) {
|
|
1418
|
+
if ((edge.selectable ?? this._bool('elementsSelectable', true)) !== false) {
|
|
1419
|
+
this._select({ kind: 'edge', id: edge.id }, ev.shiftKey || ev.ctrlKey);
|
|
1420
|
+
}
|
|
1421
|
+
this._gesture = { kind: 'edge', id: edge.id, startX: x, startY: y };
|
|
1422
|
+
ev.capturePointer();
|
|
1423
|
+
ev.preventDefault();
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// 6. the empty pane: a box selection, or a pan
|
|
1428
|
+
const boxSelect = ev.button === 1 &&
|
|
1429
|
+
(ev.shiftKey || this._bool('selectionOnDrag', false)) &&
|
|
1430
|
+
this._bool('elementsSelectable', true);
|
|
1431
|
+
if (boxSelect) {
|
|
1432
|
+
this._gesture = {
|
|
1433
|
+
kind: 'select',
|
|
1434
|
+
startX: x,
|
|
1435
|
+
startY: y,
|
|
1436
|
+
x,
|
|
1437
|
+
y,
|
|
1438
|
+
base: ev.shiftKey ? new Set(this._selectedNodeIds()) : new Set(),
|
|
1439
|
+
};
|
|
1440
|
+
if (!ev.shiftKey)
|
|
1441
|
+
this._select(null, false);
|
|
1442
|
+
ev.capturePointer();
|
|
1443
|
+
ev.preventDefault();
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const v = this._viewport();
|
|
1447
|
+
this._gesture = {
|
|
1448
|
+
kind: 'pan',
|
|
1449
|
+
pans: ev.button === 2 || this._bool('panOnDrag', true),
|
|
1450
|
+
startX: x,
|
|
1451
|
+
startY: y,
|
|
1452
|
+
vx: v.x,
|
|
1453
|
+
vy: v.y,
|
|
1454
|
+
};
|
|
1455
|
+
ev.capturePointer();
|
|
1456
|
+
ev.preventDefault();
|
|
1457
|
+
}
|
|
1458
|
+
defaultMouseDrag(ev) {
|
|
1459
|
+
const gesture = this._gesture;
|
|
1460
|
+
if (!gesture)
|
|
1461
|
+
return;
|
|
1462
|
+
ev.preventDefault();
|
|
1463
|
+
this._gestureSync = true;
|
|
1464
|
+
try {
|
|
1465
|
+
this._dragDispatch(gesture, ev);
|
|
1466
|
+
}
|
|
1467
|
+
finally {
|
|
1468
|
+
this._gestureSync = false;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
_dragDispatch(gesture, ev) {
|
|
1472
|
+
switch (gesture.kind) {
|
|
1473
|
+
case 'pan':
|
|
1474
|
+
if (!gesture.pans)
|
|
1475
|
+
return;
|
|
1476
|
+
this._applyViewport({
|
|
1477
|
+
...this._viewport(),
|
|
1478
|
+
x: gesture.vx + (ev.x - gesture.startX),
|
|
1479
|
+
y: gesture.vy + (ev.y - gesture.startY),
|
|
1480
|
+
});
|
|
1481
|
+
return;
|
|
1482
|
+
case 'drag':
|
|
1483
|
+
this._dragStep(gesture, ev);
|
|
1484
|
+
return;
|
|
1485
|
+
case 'connect':
|
|
1486
|
+
this._connectStep(gesture, ev);
|
|
1487
|
+
return;
|
|
1488
|
+
case 'resize':
|
|
1489
|
+
this._resizeStep(gesture, ev);
|
|
1490
|
+
return;
|
|
1491
|
+
case 'select': {
|
|
1492
|
+
const oldBox = selectBoxRect(gesture);
|
|
1493
|
+
gesture.x = ev.x;
|
|
1494
|
+
gesture.y = ev.y;
|
|
1495
|
+
this._selectBox(gesture);
|
|
1496
|
+
// The box outline is all that moves this step; a node crossing the
|
|
1497
|
+
// boundary changes `selected`, and that repaints through the
|
|
1498
|
+
// controlled round trip like any other selection change.
|
|
1499
|
+
this.invalidate(false, inflateRect(unionRects(oldBox, selectBoxRect(gesture)), CULL_MARGIN), 'style-state');
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
case 'minimap': {
|
|
1503
|
+
const map = this._miniMap();
|
|
1504
|
+
if (!map)
|
|
1505
|
+
return;
|
|
1506
|
+
const p = this._miniToGraph(map, ev.x, ev.y);
|
|
1507
|
+
this.setCenter(p.x, p.y);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
defaultMouseUp(ev) {
|
|
1513
|
+
const gesture = this._gesture;
|
|
1514
|
+
this._gesture = null;
|
|
1515
|
+
if (!gesture)
|
|
1516
|
+
return;
|
|
1517
|
+
if (gesture.kind === 'drag') {
|
|
1518
|
+
const to = this._dragTo;
|
|
1519
|
+
this._dragTo = null;
|
|
1520
|
+
if (gesture.moved && to && gesture.ids.length > 0) {
|
|
1521
|
+
// The settling change: `dragging: false` is what tells an app this
|
|
1522
|
+
// is the position worth persisting.
|
|
1523
|
+
this._emitNodes(gesture.ids.map((id) => ({
|
|
1524
|
+
type: 'position',
|
|
1525
|
+
id,
|
|
1526
|
+
position: to.get(id) ?? this._byId.get(id)?.node.position,
|
|
1527
|
+
dragging: false,
|
|
1528
|
+
})));
|
|
1529
|
+
const primary = this._byId.get(gesture.primary);
|
|
1530
|
+
if (primary) {
|
|
1531
|
+
this._prop('onNodeDragStop')?.(ev, primary.node);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
else {
|
|
1535
|
+
this._clickNode(ev, gesture.primary);
|
|
1536
|
+
}
|
|
1537
|
+
this._repaint();
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
if (gesture.kind === 'connect') {
|
|
1541
|
+
const connection = gesture.to && gesture.valid
|
|
1542
|
+
? orientConnection({
|
|
1543
|
+
nodeId: gesture.from.nodeId,
|
|
1544
|
+
handleId: gesture.from.id ?? null,
|
|
1545
|
+
type: gesture.from.type,
|
|
1546
|
+
}, {
|
|
1547
|
+
nodeId: gesture.to.nodeId,
|
|
1548
|
+
handleId: gesture.to.id ?? null,
|
|
1549
|
+
type: gesture.to.type,
|
|
1550
|
+
})
|
|
1551
|
+
: null;
|
|
1552
|
+
if (connection) {
|
|
1553
|
+
this._prop('onConnect')?.(connection);
|
|
1554
|
+
}
|
|
1555
|
+
this._prop('onConnectEnd')?.(connection);
|
|
1556
|
+
this._repaint('style-state');
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (gesture.kind === 'pan') {
|
|
1560
|
+
const moved = Math.abs(ev.x - gesture.startX) > DRAG_THRESHOLD ||
|
|
1561
|
+
Math.abs(ev.y - gesture.startY) > DRAG_THRESHOLD;
|
|
1562
|
+
if (!moved && ev.button === 1) {
|
|
1563
|
+
this._select(null, false);
|
|
1564
|
+
this._prop('onPaneClick')?.(ev);
|
|
1565
|
+
if (ev.detail === 2 && this._bool('zoomOnDoubleClick', true)) {
|
|
1566
|
+
this._zoomAbout(ZOOM_STEP, ev.x, ev.y);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
if (gesture.kind === 'resize') {
|
|
1572
|
+
this._resizeStep(gesture, ev, true);
|
|
1573
|
+
this._resizeTo = null;
|
|
1574
|
+
this._repaint();
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (gesture.kind === 'edge') {
|
|
1578
|
+
const moved = Math.abs(ev.x - gesture.startX) > DRAG_THRESHOLD ||
|
|
1579
|
+
Math.abs(ev.y - gesture.startY) > DRAG_THRESHOLD;
|
|
1580
|
+
const edge = this._edges.find((e) => e.id === gesture.id);
|
|
1581
|
+
if (!moved && edge) {
|
|
1582
|
+
this._prop('onEdgeClick')?.(ev, edge);
|
|
1583
|
+
}
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (gesture.kind === 'select' || gesture.kind === 'minimap') {
|
|
1587
|
+
this._repaint('style-state');
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
defaultContextMenu(ev) {
|
|
1591
|
+
if (this.props.disabled)
|
|
1592
|
+
return;
|
|
1593
|
+
this._sync();
|
|
1594
|
+
const entry = this._nodeAt(ev.x, ev.y);
|
|
1595
|
+
if (entry) {
|
|
1596
|
+
this._prop('onNodeContextMenu')?.(ev, entry.node);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const edge = this._edgeAt(ev.x, ev.y);
|
|
1600
|
+
if (edge) {
|
|
1601
|
+
this._prop('onEdgeContextMenu')?.(ev, edge);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
this._prop('onPaneContextMenu')?.(ev);
|
|
1605
|
+
}
|
|
1606
|
+
_clickNode(ev, id) {
|
|
1607
|
+
const entry = this._byId.get(id);
|
|
1608
|
+
if (!entry)
|
|
1609
|
+
return;
|
|
1610
|
+
if (ev.detail === 2) {
|
|
1611
|
+
this._prop('onNodeDoubleClick')?.(ev, entry.node);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
this._prop('onNodeClick')?.(ev, entry.node);
|
|
1615
|
+
}
|
|
1616
|
+
_dragStep(gesture, ev) {
|
|
1617
|
+
const zoom = this._viewport().zoom;
|
|
1618
|
+
const dx = (ev.x - gesture.startX) / zoom;
|
|
1619
|
+
const dy = (ev.y - gesture.startY) / zoom;
|
|
1620
|
+
if (!gesture.moved &&
|
|
1621
|
+
Math.abs(ev.x - gesture.startX) < DRAG_THRESHOLD &&
|
|
1622
|
+
Math.abs(ev.y - gesture.startY) < DRAG_THRESHOLD) {
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (!gesture.moved) {
|
|
1626
|
+
gesture.moved = true;
|
|
1627
|
+
const primary = this._byId.get(gesture.primary);
|
|
1628
|
+
if (primary) {
|
|
1629
|
+
this._prop('onNodeDragStart')?.(ev, primary.node);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
const snap = this._bool('snapToGrid', false);
|
|
1633
|
+
const grid = this._prop('snapGrid') ?? [16, 16];
|
|
1634
|
+
const to = new Map();
|
|
1635
|
+
const changes = [];
|
|
1636
|
+
for (const id of gesture.ids) {
|
|
1637
|
+
const origin = gesture.origin.get(id);
|
|
1638
|
+
if (!origin)
|
|
1639
|
+
continue;
|
|
1640
|
+
let x = origin.x + dx;
|
|
1641
|
+
let y = origin.y + dy;
|
|
1642
|
+
if (snap) {
|
|
1643
|
+
x = snapTo(x, grid[0]);
|
|
1644
|
+
y = snapTo(y, grid[1]);
|
|
1645
|
+
}
|
|
1646
|
+
to.set(id, { x, y });
|
|
1647
|
+
changes.push({
|
|
1648
|
+
type: 'position',
|
|
1649
|
+
id,
|
|
1650
|
+
position: { x, y },
|
|
1651
|
+
dragging: true,
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
// Old places first, new places second: the union is everything this
|
|
1655
|
+
// step uncovers plus everything it covers, and nothing else — with the
|
|
1656
|
+
// edges that follow the moved nodes, coarse, on both sides.
|
|
1657
|
+
let damage = null;
|
|
1658
|
+
for (const id of gesture.ids) {
|
|
1659
|
+
const entry = this._byId.get(id);
|
|
1660
|
+
if (entry)
|
|
1661
|
+
damage = unionMaybe(damage, this._nodeDamage(entry));
|
|
1662
|
+
}
|
|
1663
|
+
this._dragTo = to;
|
|
1664
|
+
for (const id of gesture.ids) {
|
|
1665
|
+
const entry = this._byId.get(id);
|
|
1666
|
+
if (entry)
|
|
1667
|
+
damage = unionMaybe(damage, this._nodeDamage(entry));
|
|
1668
|
+
}
|
|
1669
|
+
this._emitNodes(changes);
|
|
1670
|
+
if (damage) {
|
|
1671
|
+
this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'content');
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
this._repaint();
|
|
1675
|
+
}
|
|
1676
|
+
// Inside the gesture dispatch, deliberately: the body-rect setState this
|
|
1677
|
+
// triggers is a discrete-priority update, so React commits the moved
|
|
1678
|
+
// overlay box before the frame runs — the drawn card and the mounted
|
|
1679
|
+
// body land in the same frame. Emitted only from paint, the commit
|
|
1680
|
+
// chases the frame and the body is always one step behind the card.
|
|
1681
|
+
this._emitBodies();
|
|
1682
|
+
this.notifyA11ySceneChanged();
|
|
1683
|
+
}
|
|
1684
|
+
/** The box a resize is making, reported as it goes. A grip that moves the
|
|
1685
|
+
* top or the left edge moves the node too, so both changes go out — an
|
|
1686
|
+
* app that applied only one would watch the node crawl. */
|
|
1687
|
+
_resizeStep(gesture, ev, settling = false) {
|
|
1688
|
+
const entry = this._byId.get(gesture.id);
|
|
1689
|
+
if (!entry)
|
|
1690
|
+
return;
|
|
1691
|
+
const zoom = this._viewport().zoom;
|
|
1692
|
+
const grid = this._prop('snapGrid') ?? [16, 16];
|
|
1693
|
+
const snap = this._bool('snapToGrid', false)
|
|
1694
|
+
? (value, axis) => snapTo(value, grid[axis])
|
|
1695
|
+
: undefined;
|
|
1696
|
+
const rect = resizeRect(gesture.origin, gesture.dir, (ev.x - gesture.startX) / zoom, (ev.y - gesture.startY) / zoom, {
|
|
1697
|
+
minWidth: entry.node.minWidth ?? MIN_NODE_WIDTH,
|
|
1698
|
+
minHeight: entry.node.minHeight ?? MIN_NODE_HEIGHT,
|
|
1699
|
+
}, snap);
|
|
1700
|
+
let damage = this._nodeDamage(entry);
|
|
1701
|
+
this._resizeTo = { id: gesture.id, rect };
|
|
1702
|
+
damage = unionRects(damage, this._nodeDamage(entry));
|
|
1703
|
+
const changes = [
|
|
1704
|
+
{
|
|
1705
|
+
type: 'dimensions',
|
|
1706
|
+
id: gesture.id,
|
|
1707
|
+
dimensions: { width: rect.width, height: rect.height },
|
|
1708
|
+
resizing: !settling,
|
|
1709
|
+
},
|
|
1710
|
+
];
|
|
1711
|
+
if (rect.x !== entry.node.position.x || rect.y !== entry.node.position.y) {
|
|
1712
|
+
changes.push({
|
|
1713
|
+
type: 'position',
|
|
1714
|
+
id: gesture.id,
|
|
1715
|
+
position: { x: rect.x, y: rect.y },
|
|
1716
|
+
dragging: !settling,
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
this._emitNodes(changes);
|
|
1720
|
+
this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'content');
|
|
1721
|
+
this._emitBodies();
|
|
1722
|
+
}
|
|
1723
|
+
_connectStep(gesture, ev) {
|
|
1724
|
+
gesture.pointer = this._toGraph(ev.x, ev.y);
|
|
1725
|
+
const mode = this._prop('connectionMode') === 'loose'
|
|
1726
|
+
? 'loose'
|
|
1727
|
+
: 'strict';
|
|
1728
|
+
const from = {
|
|
1729
|
+
nodeId: gesture.from.nodeId,
|
|
1730
|
+
type: gesture.from.type,
|
|
1731
|
+
handleId: gesture.from.id ?? null,
|
|
1732
|
+
};
|
|
1733
|
+
// A handle if the pointer is on one, else the nearest usable handle of
|
|
1734
|
+
// the node it is over — dropping on a node is what people try first, and
|
|
1735
|
+
// refusing it teaches nothing.
|
|
1736
|
+
let to = this._handleAt(ev.x, ev.y, (anchor) => canConnect(from, { nodeId: anchor.nodeId, type: anchor.type }, mode)) ?? null;
|
|
1737
|
+
if (!to) {
|
|
1738
|
+
const entry = this._nodeAt(ev.x, ev.y);
|
|
1739
|
+
if (entry && this._connectable(entry) && entry.node.id !== from.nodeId) {
|
|
1740
|
+
to = this._endpoint(entry, null, from.type === 'source' ? 'target' : 'source', gesture.from);
|
|
1741
|
+
if (to &&
|
|
1742
|
+
!canConnect(from, { nodeId: to.nodeId, type: to.type }, mode)) {
|
|
1743
|
+
to = null;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
const oldBox = gesture.box ?? null;
|
|
1748
|
+
const oldTarget = gesture.to ? this._byId.get(gesture.to.nodeId) : null;
|
|
1749
|
+
gesture.to = to;
|
|
1750
|
+
gesture.valid = to
|
|
1751
|
+
? this._validConnection(orientConnection(from, {
|
|
1752
|
+
nodeId: to.nodeId,
|
|
1753
|
+
handleId: to.id ?? null,
|
|
1754
|
+
type: to.type,
|
|
1755
|
+
}))
|
|
1756
|
+
: false;
|
|
1757
|
+
// the line's own bounds, plus the node whose handle lights up (old and
|
|
1758
|
+
// new — the one that stops glowing needs repainting too)
|
|
1759
|
+
let box = pathBounds(this._connectionPath(gesture));
|
|
1760
|
+
if (oldTarget)
|
|
1761
|
+
box = unionRects(box, this._screenRect(oldTarget));
|
|
1762
|
+
const target = to ? this._byId.get(to.nodeId) : null;
|
|
1763
|
+
if (target)
|
|
1764
|
+
box = unionRects(box, this._screenRect(target));
|
|
1765
|
+
gesture.box = box;
|
|
1766
|
+
this.invalidate(false, inflateRect(oldBox ? unionRects(oldBox, box) : box, CULL_MARGIN), 'style-state');
|
|
1767
|
+
}
|
|
1768
|
+
/** The pending connection's polyline — one builder for the paint and for
|
|
1769
|
+
* the damage it has to claim, so the two cannot disagree. */
|
|
1770
|
+
_connectionPath(gesture) {
|
|
1771
|
+
const v = this._viewport();
|
|
1772
|
+
const from = this._toScreen(gesture.from);
|
|
1773
|
+
const target = gesture.to ?? gesture.pointer;
|
|
1774
|
+
const to = this._toScreen(target);
|
|
1775
|
+
const toPosition = gesture.to?.position ??
|
|
1776
|
+
(gesture.from.position === 'left'
|
|
1777
|
+
? 'right'
|
|
1778
|
+
: gesture.from.position === 'right'
|
|
1779
|
+
? 'left'
|
|
1780
|
+
: gesture.from.position === 'top'
|
|
1781
|
+
? 'bottom'
|
|
1782
|
+
: 'top');
|
|
1783
|
+
return edgePath('bezier', { x: from.x, y: from.y, position: gesture.from.position }, { x: to.x, y: to.y, position: toPosition }, {
|
|
1784
|
+
stepOffset: EDGE_STEP_OFFSET * v.zoom,
|
|
1785
|
+
radius: 8 * v.zoom,
|
|
1786
|
+
scale: v.zoom,
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
_validConnection(connection) {
|
|
1790
|
+
const check = this._prop('isValidConnection');
|
|
1791
|
+
return check ? check(connection) !== false : true;
|
|
1792
|
+
}
|
|
1793
|
+
_selectBox(gesture) {
|
|
1794
|
+
const a = this._toGraph(gesture.startX, gesture.startY);
|
|
1795
|
+
const b = this._toGraph(gesture.x, gesture.y);
|
|
1796
|
+
const box = {
|
|
1797
|
+
x: Math.min(a.x, b.x),
|
|
1798
|
+
y: Math.min(a.y, b.y),
|
|
1799
|
+
width: Math.abs(a.x - b.x),
|
|
1800
|
+
height: Math.abs(a.y - b.y),
|
|
1801
|
+
};
|
|
1802
|
+
const changes = [];
|
|
1803
|
+
for (const entry of this._entries) {
|
|
1804
|
+
if (entry.node.hidden)
|
|
1805
|
+
continue;
|
|
1806
|
+
if ((entry.node.selectable ?? this._bool('elementsSelectable', true)) ===
|
|
1807
|
+
false) {
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
const inside = rectsOverlap(box, this.rectOf(entry));
|
|
1811
|
+
const next = inside || gesture.base.has(entry.node.id);
|
|
1812
|
+
if (next !== (entry.node.selected ?? false)) {
|
|
1813
|
+
changes.push({ type: 'select', id: entry.node.id, selected: next });
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
this._emitNodes(changes);
|
|
1817
|
+
}
|
|
1818
|
+
// --- input with no default-action seam -----------------------------------
|
|
1819
|
+
/** Wheel: zoom about the pointer, or pan when zooming is off. The seam
|
|
1820
|
+
* runs it after the app's own `onWheel` and not at all if that vetoed;
|
|
1821
|
+
* consuming it here is what keeps the scroll chain out of the pane. */
|
|
1822
|
+
defaultWheel(ev) {
|
|
1823
|
+
if (this.props.disabled)
|
|
1824
|
+
return;
|
|
1825
|
+
this._gestureSync = true;
|
|
1826
|
+
try {
|
|
1827
|
+
this._wheelDispatch(ev);
|
|
1828
|
+
}
|
|
1829
|
+
finally {
|
|
1830
|
+
this._gestureSync = false;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
_wheelDispatch(ev) {
|
|
1834
|
+
this._sync();
|
|
1835
|
+
const zooming = ev.ctrlKey || this._bool('zoomOnScroll', true);
|
|
1836
|
+
if (zooming) {
|
|
1837
|
+
// X delivers a notch as ±48; e^(48·0.002) is about a 10% step, which
|
|
1838
|
+
// is roughly what a browser does for one detent.
|
|
1839
|
+
this._zoomAbout(Math.exp(-ev.deltaY * 0.002), ev.x, ev.y);
|
|
1840
|
+
}
|
|
1841
|
+
else {
|
|
1842
|
+
const v = this._viewport();
|
|
1843
|
+
this._applyViewport({ ...v, x: v.x - ev.deltaX, y: v.y - ev.deltaY });
|
|
1844
|
+
}
|
|
1845
|
+
ev.preventDefault();
|
|
1846
|
+
}
|
|
1847
|
+
/** Pointer motion with no button down: hover highlighting. The seam skips
|
|
1848
|
+
* delivery while a capture is in force, so the gesture guard is belt to
|
|
1849
|
+
* its braces. */
|
|
1850
|
+
defaultMouseMove(ev) {
|
|
1851
|
+
if (this.props.disabled || this._gesture)
|
|
1852
|
+
return;
|
|
1853
|
+
this._sync();
|
|
1854
|
+
const handle = this._bool('nodesConnectable', true)
|
|
1855
|
+
? this._handleAt(ev.x, ev.y)
|
|
1856
|
+
: null;
|
|
1857
|
+
const entry = handle ? null : this._nodeAt(ev.x, ev.y);
|
|
1858
|
+
const edge = handle || entry ? null : this._edgeAt(ev.x, ev.y);
|
|
1859
|
+
const next = {
|
|
1860
|
+
nodeId: handle?.nodeId ?? entry?.node.id ?? null,
|
|
1861
|
+
handle,
|
|
1862
|
+
edgeId: edge?.id ?? null,
|
|
1863
|
+
};
|
|
1864
|
+
if (next.nodeId === this._hover.nodeId &&
|
|
1865
|
+
next.edgeId === this._hover.edgeId &&
|
|
1866
|
+
(next.handle?.id ?? null) === (this._hover.handle?.id ?? null) &&
|
|
1867
|
+
(next.handle?.nodeId ?? null) === (this._hover.handle?.nodeId ?? null)) {
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const damage = unionMaybe(this._hoverDamage(this._hover), this._hoverDamage(next));
|
|
1871
|
+
this._hover = next;
|
|
1872
|
+
if (damage) {
|
|
1873
|
+
this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'style-state');
|
|
1874
|
+
}
|
|
1875
|
+
else {
|
|
1876
|
+
this._repaint('style-state');
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
defaultMouseLeave() {
|
|
1880
|
+
if (this._hover === NO_HOVER)
|
|
1881
|
+
return;
|
|
1882
|
+
const damage = this._hoverDamage(this._hover);
|
|
1883
|
+
this._hover = NO_HOVER;
|
|
1884
|
+
if (damage) {
|
|
1885
|
+
this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'style-state');
|
|
1886
|
+
}
|
|
1887
|
+
else {
|
|
1888
|
+
this._repaint('style-state');
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
/** What a hover state lights up on screen: the node (whose border and
|
|
1892
|
+
* handles restyle), or the edge. Null when it points at nothing. */
|
|
1893
|
+
_hoverDamage(state) {
|
|
1894
|
+
const nodeId = state.handle?.nodeId ?? state.nodeId;
|
|
1895
|
+
if (nodeId) {
|
|
1896
|
+
const entry = this._byId.get(nodeId);
|
|
1897
|
+
return entry ? this._screenRect(entry) : null;
|
|
1898
|
+
}
|
|
1899
|
+
if (state.edgeId) {
|
|
1900
|
+
const edge = this._edges.find((e) => e.id === state.edgeId);
|
|
1901
|
+
return edge ? this._edgeCoarseBox(edge) : null;
|
|
1902
|
+
}
|
|
1903
|
+
return null;
|
|
1904
|
+
}
|
|
1905
|
+
// --- what a screen reader meets (react-x11#304) --------------------------
|
|
1906
|
+
/**
|
|
1907
|
+
* The graph, described: every visible node as an item a screen reader
|
|
1908
|
+
* can reach, name and activate. Edges ride on each node's description
|
|
1909
|
+
* rather than as items of their own — "what is this connected to" is the
|
|
1910
|
+
* useful question, and a hundred unlabelled `link` items between the
|
|
1911
|
+
* nodes would only bury them.
|
|
1912
|
+
*
|
|
1913
|
+
* Called once per question an AT asks, so it answers from the entries the
|
|
1914
|
+
* pane already holds — no measuring, no layout.
|
|
1915
|
+
*/
|
|
1916
|
+
a11yScene() {
|
|
1917
|
+
this._sync();
|
|
1918
|
+
const pane = this._pane();
|
|
1919
|
+
const items = [];
|
|
1920
|
+
for (const entry of this._order) {
|
|
1921
|
+
if (entry.node.hidden)
|
|
1922
|
+
continue;
|
|
1923
|
+
const rect = this._screenRect(entry);
|
|
1924
|
+
if (!rectsOverlap(rect, pane))
|
|
1925
|
+
continue;
|
|
1926
|
+
const data = entry.node.data;
|
|
1927
|
+
const out = this._edgesByNode.get(entry.node.id) ?? [];
|
|
1928
|
+
const degree = out.length;
|
|
1929
|
+
items.push({
|
|
1930
|
+
id: entry.node.id,
|
|
1931
|
+
rect,
|
|
1932
|
+
role: 'listitem',
|
|
1933
|
+
name: data?.label ?? entry.node.id,
|
|
1934
|
+
description: degree === 0
|
|
1935
|
+
? (data?.description ?? undefined)
|
|
1936
|
+
: `${data?.description ? `${data.description}. ` : ''}${degree} connection${degree === 1 ? '' : 's'}`,
|
|
1937
|
+
states: { selected: entry.node.selected ?? false },
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
return items;
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* An AT activated a node: select it, the way a click would — but by id
|
|
1944
|
+
* rather than by synthesizing a pointer at its rect, which would also
|
|
1945
|
+
* arm a drag.
|
|
1946
|
+
*/
|
|
1947
|
+
a11ySceneAction(id, action) {
|
|
1948
|
+
if (action !== 'activate')
|
|
1949
|
+
return false;
|
|
1950
|
+
if (!this._byId.has(id))
|
|
1951
|
+
return false;
|
|
1952
|
+
this._select({ kind: 'node', id }, false);
|
|
1953
|
+
return true;
|
|
1954
|
+
}
|
|
1955
|
+
// --- keyboard ------------------------------------------------------------
|
|
1956
|
+
defaultKeyDown(ev) {
|
|
1957
|
+
if (this.props.disabled)
|
|
1958
|
+
return;
|
|
1959
|
+
this._gestureSync = true;
|
|
1960
|
+
try {
|
|
1961
|
+
this._keyDispatch(ev);
|
|
1962
|
+
}
|
|
1963
|
+
finally {
|
|
1964
|
+
this._gestureSync = false;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
_keyDispatch(ev) {
|
|
1968
|
+
this._sync();
|
|
1969
|
+
const { keysym } = ev;
|
|
1970
|
+
if (ev.ctrlKey) {
|
|
1971
|
+
const letter = ctrlChordLetter(ev);
|
|
1972
|
+
if (letter === keysymOf('a')) {
|
|
1973
|
+
this._selectAll();
|
|
1974
|
+
ev.preventDefault();
|
|
1975
|
+
}
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if ((keysym === XK_DELETE || keysym === XK_BACKSPACE) &&
|
|
1979
|
+
this._bool('deleteOnKey', true)) {
|
|
1980
|
+
this._deleteSelection();
|
|
1981
|
+
ev.preventDefault();
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
if (keysym === XK_ESCAPE) {
|
|
1985
|
+
if (this._gesture) {
|
|
1986
|
+
this._gesture = null;
|
|
1987
|
+
this._dragTo = null;
|
|
1988
|
+
this._resizeTo = null;
|
|
1989
|
+
this._repaint();
|
|
1990
|
+
}
|
|
1991
|
+
else {
|
|
1992
|
+
this._select(null, false);
|
|
1993
|
+
}
|
|
1994
|
+
ev.preventDefault();
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (keysym === XK_HOME || ev.key === '0') {
|
|
1998
|
+
this.fitView();
|
|
1999
|
+
ev.preventDefault();
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
if (ev.key === '+' || ev.key === '=') {
|
|
2003
|
+
this.zoomIn();
|
|
2004
|
+
ev.preventDefault();
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (ev.key === '-' || ev.key === '_') {
|
|
2008
|
+
this.zoomOut();
|
|
2009
|
+
ev.preventDefault();
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
const step = ev.shiftKey ? 10 : 1;
|
|
2013
|
+
let dx = 0;
|
|
2014
|
+
let dy = 0;
|
|
2015
|
+
if (keysym === XK_LEFT)
|
|
2016
|
+
dx = -step;
|
|
2017
|
+
else if (keysym === XK_RIGHT)
|
|
2018
|
+
dx = step;
|
|
2019
|
+
else if (keysym === XK_UP)
|
|
2020
|
+
dy = -step;
|
|
2021
|
+
else if (keysym === XK_DOWN)
|
|
2022
|
+
dy = step;
|
|
2023
|
+
if (dx === 0 && dy === 0)
|
|
2024
|
+
return;
|
|
2025
|
+
const ids = this._selectedNodeIds();
|
|
2026
|
+
if (ids.length === 0) {
|
|
2027
|
+
// Nothing selected: the arrows pan, which is the only thing left for
|
|
2028
|
+
// them to mean and is what every canvas does.
|
|
2029
|
+
const v = this._viewport();
|
|
2030
|
+
this._applyViewport({ ...v, x: v.x - dx * 20, y: v.y - dy * 20 });
|
|
2031
|
+
ev.preventDefault();
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
this._emitNodes(ids.map((id) => {
|
|
2035
|
+
const node = this._byId.get(id).node;
|
|
2036
|
+
return {
|
|
2037
|
+
type: 'position',
|
|
2038
|
+
id,
|
|
2039
|
+
position: { x: node.position.x + dx, y: node.position.y + dy },
|
|
2040
|
+
dragging: false,
|
|
2041
|
+
};
|
|
2042
|
+
}));
|
|
2043
|
+
ev.preventDefault();
|
|
2044
|
+
}
|
|
2045
|
+
_selectAll() {
|
|
2046
|
+
if (!this._bool('elementsSelectable', true))
|
|
2047
|
+
return;
|
|
2048
|
+
this._emitNodes(this._entries
|
|
2049
|
+
.filter((e) => !e.node.selected && !e.node.hidden)
|
|
2050
|
+
.map((e) => ({
|
|
2051
|
+
type: 'select',
|
|
2052
|
+
id: e.node.id,
|
|
2053
|
+
selected: true,
|
|
2054
|
+
})));
|
|
2055
|
+
this._emitEdges(this._edges
|
|
2056
|
+
.filter((e) => !e.selected && !e.hidden)
|
|
2057
|
+
.map((e) => ({ type: 'select', id: e.id, selected: true })));
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Remove what is selected — and, with the nodes, the edges that would
|
|
2061
|
+
* otherwise dangle. Emitted as changes like everything else, so an app
|
|
2062
|
+
* that wants a confirmation step simply does not apply them.
|
|
2063
|
+
*/
|
|
2064
|
+
_deleteSelection() {
|
|
2065
|
+
const nodeIds = this._entries
|
|
2066
|
+
.filter((e) => e.node.selected && e.node.deletable !== false)
|
|
2067
|
+
.map((e) => e.node.id);
|
|
2068
|
+
const gone = new Set(nodeIds);
|
|
2069
|
+
const edgeIds = this._edges
|
|
2070
|
+
.filter((e) => e.deletable !== false &&
|
|
2071
|
+
(e.selected || gone.has(e.source) || gone.has(e.target)))
|
|
2072
|
+
.map((e) => e.id);
|
|
2073
|
+
this._emitEdges(edgeIds.map((id) => ({ type: 'remove', id })));
|
|
2074
|
+
this._emitNodes(nodeIds.map((id) => ({ type: 'remove', id })));
|
|
2075
|
+
}
|
|
2076
|
+
// --- lifecycle -----------------------------------------------------------
|
|
2077
|
+
applyProps(nextProps, prevProps) {
|
|
2078
|
+
const before = prevProps ?? this.props;
|
|
2079
|
+
super.applyProps(nextProps, prevProps);
|
|
2080
|
+
// This used to repaint the pane on every prop change, which was honest
|
|
2081
|
+
// and ruinous: the component above recreates its handler props on every
|
|
2082
|
+
// render, so each drag step repainted the world once for the gesture and
|
|
2083
|
+
// once for the commit it caused. Now the commit is billed for what it
|
|
2084
|
+
// changed — a moved node repaints the box it moved through, an edit to
|
|
2085
|
+
// anything else on the visual list repaints in full, and handler churn
|
|
2086
|
+
// repaints nothing.
|
|
2087
|
+
let damage = null;
|
|
2088
|
+
if (!shallowEqual(nextProps.nodeTypes, before.nodeTypes) ||
|
|
2089
|
+
!shallowEqual(nextProps.defaultEdgeOptions, before.defaultEdgeOptions)) {
|
|
2090
|
+
this._sync();
|
|
2091
|
+
damage = 'full';
|
|
2092
|
+
}
|
|
2093
|
+
else {
|
|
2094
|
+
if (nextProps.edges !== before.edges) {
|
|
2095
|
+
damage = this._applyEdges(this._rawEdges());
|
|
2096
|
+
}
|
|
2097
|
+
if (damage !== 'full' && nextProps.nodes !== before.nodes) {
|
|
2098
|
+
const nodeDamage = this._applyNodes(this._nodes());
|
|
2099
|
+
damage =
|
|
2100
|
+
nodeDamage === 'full' || damage === null
|
|
2101
|
+
? nodeDamage
|
|
2102
|
+
: nodeDamage === null
|
|
2103
|
+
? damage
|
|
2104
|
+
: unionRects(damage, nodeDamage);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
// `fitView` is a one-shot: turning it on later refits, and it stays off
|
|
2108
|
+
// through every unrelated re-render in between.
|
|
2109
|
+
if (nextProps.fitView === true && before.fitView !== true) {
|
|
2110
|
+
this._fitPending = true;
|
|
2111
|
+
damage = 'full';
|
|
2112
|
+
}
|
|
2113
|
+
if (damage !== 'full') {
|
|
2114
|
+
for (const key of VISUAL_PROPS) {
|
|
2115
|
+
if (!shallowEqual(nextProps[key], before[key])) {
|
|
2116
|
+
damage = 'full';
|
|
2117
|
+
break;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
if (damage === 'full')
|
|
2122
|
+
this._repaint('props');
|
|
2123
|
+
else if (damage)
|
|
2124
|
+
this.invalidate(false, damage, 'props');
|
|
2125
|
+
// Core re-reads the scene for aria-prop commits; a `nodes`/`edges`
|
|
2126
|
+
// change is invisible to it, so the re-read is asked for by name.
|
|
2127
|
+
// Free when no assistive technology is listening.
|
|
2128
|
+
if (damage)
|
|
2129
|
+
this.notifyA11ySceneChanged();
|
|
2130
|
+
}
|
|
2131
|
+
destroySubtree() {
|
|
2132
|
+
this._stopAnimation();
|
|
2133
|
+
this._dropGridTile();
|
|
2134
|
+
super.destroySubtree();
|
|
2135
|
+
}
|
|
2136
|
+
_startAnimation() {
|
|
2137
|
+
if (this._animTimer != null)
|
|
2138
|
+
return;
|
|
2139
|
+
this._animTimer =
|
|
2140
|
+
timers.setInterval?.(() => {
|
|
2141
|
+
this._dashPhase += ANIMATION_SPEED;
|
|
2142
|
+
// the box the last paint saw animated edges in, not the pane: a
|
|
2143
|
+
// marching dash should not cost a full grid repaint per tick
|
|
2144
|
+
this.invalidate(false, this._animBox ?? this.abs, 'animation');
|
|
2145
|
+
}, ANIMATION_MS) ?? null;
|
|
2146
|
+
}
|
|
2147
|
+
_stopAnimation() {
|
|
2148
|
+
if (this._animTimer == null)
|
|
2149
|
+
return;
|
|
2150
|
+
timers.clearInterval?.(this._animTimer);
|
|
2151
|
+
this._animTimer = null;
|
|
2152
|
+
}
|
|
2153
|
+
// --- painting ------------------------------------------------------------
|
|
2154
|
+
paint(ctx) {
|
|
2155
|
+
// What this pass is repainting. The renderer paints each damage rect as
|
|
2156
|
+
// its own clipped pass; content outside it survives on the window, so
|
|
2157
|
+
// everything we skip here is content the last frame already drew — the
|
|
2158
|
+
// window's backing is the composition cache, and this rect is the dirty
|
|
2159
|
+
// state. Null means a full pass.
|
|
2160
|
+
const clip = this.paintDamage();
|
|
2161
|
+
// The pane's own border and background need redrawing only when the
|
|
2162
|
+
// pass reaches them: a drag deep inside the pane should not restroke a
|
|
2163
|
+
// rounded border whose mask is the size of the pane.
|
|
2164
|
+
const sNum = (v) => (typeof v === 'number' ? v : 0);
|
|
2165
|
+
const ring = sNum(this.style.borderWidth) + sNum(this.style.borderRadius) + 2;
|
|
2166
|
+
const inner = inflateRect(this.abs, -ring);
|
|
2167
|
+
const insideInner = clip != null &&
|
|
2168
|
+
inner.width > 0 &&
|
|
2169
|
+
inner.height > 0 &&
|
|
2170
|
+
clip.x >= inner.x &&
|
|
2171
|
+
clip.y >= inner.y &&
|
|
2172
|
+
clip.x + clip.width <= inner.x + inner.width &&
|
|
2173
|
+
clip.y + clip.height <= inner.y + inner.height;
|
|
2174
|
+
// background, border and the node's own box
|
|
2175
|
+
if (!insideInner)
|
|
2176
|
+
super.paint(ctx);
|
|
2177
|
+
if (!this._visible())
|
|
2178
|
+
return;
|
|
2179
|
+
const painter = createPainter(ctx, this._textOptions());
|
|
2180
|
+
if (!painter)
|
|
2181
|
+
return; // a backend with no path API: geometry only
|
|
2182
|
+
this._sync();
|
|
2183
|
+
this._painting = true;
|
|
2184
|
+
if (this._fitPending) {
|
|
2185
|
+
if (clip) {
|
|
2186
|
+
// a partial pass cannot show a refit — everything outside its rect
|
|
2187
|
+
// would keep the old viewport — so keep the flag and come back full
|
|
2188
|
+
this._repaint('scroll');
|
|
2189
|
+
}
|
|
2190
|
+
else {
|
|
2191
|
+
// Deferred to the first paint that has a size: `fitView` is asked
|
|
2192
|
+
// for before layout has run, and framing a graph in a zero-sized
|
|
2193
|
+
// pane is not an answer.
|
|
2194
|
+
this._fitPending = false;
|
|
2195
|
+
this._fit(this._prop('fitViewOptions'));
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
this._frameClip = this._fitPending ? this._pane() : clip;
|
|
2199
|
+
const palette = this._palette();
|
|
2200
|
+
const { x, y, width, height } = this._pane();
|
|
2201
|
+
painter.save();
|
|
2202
|
+
// `Node.paint` clips *children*, and this element has none — its drawing
|
|
2203
|
+
// happens after `super.paint` returned, outside any clip of its own. The
|
|
2204
|
+
// clip is the content box rather than `abs` because the border has been
|
|
2205
|
+
// stroked already, inside the box, and drawing over it would leave a
|
|
2206
|
+
// `borderWidth: 1` looking like half of one.
|
|
2207
|
+
const radius = Math.max(0, (this.style.borderRadius ?? 0) -
|
|
2208
|
+
(this.style.borderWidth ?? 0));
|
|
2209
|
+
// The rounded clip only when this pass can actually reach a corner: a
|
|
2210
|
+
// non-rectangular clip forfeits ntk's rounded-box fast path for every
|
|
2211
|
+
// fill under it, which multiplies a rounded *pane* into a per-card
|
|
2212
|
+
// trapezoid pass. A keystroke's or a drag's damage is interior almost
|
|
2213
|
+
// always, and an interior pass under a plain rect clip cannot touch
|
|
2214
|
+
// the corners it is being protected from.
|
|
2215
|
+
const nearCorner = radius > 0 &&
|
|
2216
|
+
(!this._frameClip ||
|
|
2217
|
+
[
|
|
2218
|
+
{ x, y },
|
|
2219
|
+
{ x: x + width - radius, y },
|
|
2220
|
+
{ x, y: y + height - radius },
|
|
2221
|
+
{ x: x + width - radius, y: y + height - radius },
|
|
2222
|
+
].some((corner) => rectsOverlap({ x: corner.x, y: corner.y, width: radius, height: radius }, this._frameClip)));
|
|
2223
|
+
painter.clipRect(x, y, width, height, nearCorner ? radius : 0);
|
|
2224
|
+
// The fill and the grid draw only the region this pass repaints — on a
|
|
2225
|
+
// full pass that is the pane, and on a drag it is the sliver that moved.
|
|
2226
|
+
const region = this._frameClip
|
|
2227
|
+
? intersectRects({ x, y, width, height }, this._frameClip)
|
|
2228
|
+
: { x, y, width, height };
|
|
2229
|
+
if (region) {
|
|
2230
|
+
// The style's own colour if it set one — `super.paint` already filled
|
|
2231
|
+
// it, and repeating it costs one rectangle and keeps the two agreeing.
|
|
2232
|
+
painter.rect(region.x, region.y, region.width, region.height, 0, {
|
|
2233
|
+
fill: this.style.backgroundColor ??
|
|
2234
|
+
palette.background,
|
|
2235
|
+
});
|
|
2236
|
+
this._paintGrid(painter, palette, region);
|
|
2237
|
+
}
|
|
2238
|
+
const animated = this._paintEdges(painter, palette);
|
|
2239
|
+
this._paintNodes(painter, palette);
|
|
2240
|
+
this._paintConnection(painter, palette);
|
|
2241
|
+
this._paintSelectionBox(painter, palette);
|
|
2242
|
+
this._paintMiniMap(painter, palette);
|
|
2243
|
+
this._paintControls(painter, palette);
|
|
2244
|
+
painter.restore();
|
|
2245
|
+
this._painting = false;
|
|
2246
|
+
this._frameClip = null;
|
|
2247
|
+
// The timer exists only while something on screen needs it.
|
|
2248
|
+
if (animated)
|
|
2249
|
+
this._startAnimation();
|
|
2250
|
+
else
|
|
2251
|
+
this._stopAnimation();
|
|
2252
|
+
if (!this._sceneAnnounced) {
|
|
2253
|
+
// The first paint with a size is the first moment the items have
|
|
2254
|
+
// rects; before it the scene is honestly empty.
|
|
2255
|
+
this._sceneAnnounced = true;
|
|
2256
|
+
this.notifyA11ySceneChanged();
|
|
2257
|
+
}
|
|
2258
|
+
// Last, and outside the drawing: this is what tells the React half where
|
|
2259
|
+
// to put the node bodies it mounts, and it is answered from the geometry
|
|
2260
|
+
// this frame just used — the same numbers, never a second derivation.
|
|
2261
|
+
this._emitBodies();
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Where every mounted node body belongs, relative to the pane's own
|
|
2265
|
+
* top-left. Sent only when the list changed, so a repaint that moved
|
|
2266
|
+
* nothing does not re-render React — but a pan does, once per frame,
|
|
2267
|
+
* which is the cost `render` is documented to carry.
|
|
2268
|
+
*/
|
|
2269
|
+
_emitBodies() {
|
|
2270
|
+
const notify = this._prop('onNodeBodies');
|
|
2271
|
+
if (!notify)
|
|
2272
|
+
return;
|
|
2273
|
+
const v = this._viewport();
|
|
2274
|
+
const pane = this._pane();
|
|
2275
|
+
const bodies = [];
|
|
2276
|
+
for (const entry of this._order) {
|
|
2277
|
+
if (entry.node.hidden || !this._mounted(entry))
|
|
2278
|
+
continue;
|
|
2279
|
+
const rect = this._screenRect(entry);
|
|
2280
|
+
if (!rectsOverlap(rect, pane))
|
|
2281
|
+
continue;
|
|
2282
|
+
const header = this._headerHeight(entry) * v.zoom;
|
|
2283
|
+
const inset = NODE_BODY_INSET * v.zoom;
|
|
2284
|
+
const width = rect.width - inset * 2;
|
|
2285
|
+
const height = rect.height - header - inset;
|
|
2286
|
+
if (width <= 1 || height <= 1)
|
|
2287
|
+
continue;
|
|
2288
|
+
bodies.push({
|
|
2289
|
+
id: entry.node.id,
|
|
2290
|
+
// pane-relative, because that is what an absolutely positioned box
|
|
2291
|
+
// beside the pane is laid out against
|
|
2292
|
+
x: Math.round(rect.x + inset - pane.x),
|
|
2293
|
+
y: Math.round(rect.y + header - pane.y),
|
|
2294
|
+
width: Math.round(width),
|
|
2295
|
+
height: Math.round(height),
|
|
2296
|
+
zoom: v.zoom,
|
|
2297
|
+
selected: entry.node.selected ?? false,
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
const key = bodies
|
|
2301
|
+
.map((b) => `${b.id}:${b.x},${b.y},${b.width},${b.height},${b.selected}`)
|
|
2302
|
+
.join('|');
|
|
2303
|
+
if (key === this._bodiesKey)
|
|
2304
|
+
return;
|
|
2305
|
+
this._bodiesKey = key;
|
|
2306
|
+
notify(bodies, this._gestureSync);
|
|
2307
|
+
}
|
|
2308
|
+
/** The grid, drawn only inside `region` — the pass's damage on a partial
|
|
2309
|
+
* repaint, the pane on a full one. Alignment stays anchored to the
|
|
2310
|
+
* viewport origin, so the region never changes where a dot falls. */
|
|
2311
|
+
_paintGrid(painter, palette, region) {
|
|
2312
|
+
const options = normalizeBackground(this.props.background);
|
|
2313
|
+
if (options.variant === 'none')
|
|
2314
|
+
return;
|
|
2315
|
+
const v = this._viewport();
|
|
2316
|
+
let step = options.gap * v.zoom;
|
|
2317
|
+
if (!(step > 0))
|
|
2318
|
+
return;
|
|
2319
|
+
// Doubling rather than clamping keeps the grid *aligned* to the graph
|
|
2320
|
+
// while zooming out: every visible line is still a real one.
|
|
2321
|
+
while (step < MIN_GRID_PX)
|
|
2322
|
+
step *= 2;
|
|
2323
|
+
const color = options.color ?? palette.grid;
|
|
2324
|
+
const pane = this._pane();
|
|
2325
|
+
const { x, y, width, height } = region;
|
|
2326
|
+
const originX = pane.x + v.x;
|
|
2327
|
+
const originY = pane.y + v.y;
|
|
2328
|
+
if (this._paintGridPattern(painter, options, step, color, region))
|
|
2329
|
+
return;
|
|
2330
|
+
const firstX = originX + Math.ceil((x - originX) / step) * step;
|
|
2331
|
+
const firstY = originY + Math.ceil((y - originY) / step) * step;
|
|
2332
|
+
if (options.variant === 'lines') {
|
|
2333
|
+
const runs = [];
|
|
2334
|
+
for (let gx = firstX; gx <= x + width; gx += step) {
|
|
2335
|
+
runs.push([
|
|
2336
|
+
{ x: gx, y },
|
|
2337
|
+
{ x: gx, y: y + height },
|
|
2338
|
+
]);
|
|
2339
|
+
}
|
|
2340
|
+
for (let gy = firstY; gy <= y + height; gy += step) {
|
|
2341
|
+
runs.push([
|
|
2342
|
+
{ x, y: gy },
|
|
2343
|
+
{ x: x + width, y: gy },
|
|
2344
|
+
]);
|
|
2345
|
+
}
|
|
2346
|
+
painter.strokeRuns(runs, { stroke: color, lineWidth: 1 });
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (options.variant === 'cross') {
|
|
2350
|
+
const arm = Math.max(2, options.size * 3 * v.zoom);
|
|
2351
|
+
const runs = [];
|
|
2352
|
+
for (let gx = firstX; gx <= x + width; gx += step) {
|
|
2353
|
+
for (let gy = firstY; gy <= y + height; gy += step) {
|
|
2354
|
+
runs.push([
|
|
2355
|
+
{ x: gx - arm, y: gy },
|
|
2356
|
+
{ x: gx + arm, y: gy },
|
|
2357
|
+
]);
|
|
2358
|
+
runs.push([
|
|
2359
|
+
{ x: gx, y: gy - arm },
|
|
2360
|
+
{ x: gx, y: gy + arm },
|
|
2361
|
+
]);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
painter.strokeRuns(runs, { stroke: color, lineWidth: 1 });
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const centres = [];
|
|
2368
|
+
for (let gx = firstX; gx <= x + width; gx += step) {
|
|
2369
|
+
for (let gy = firstY; gy <= y + height; gy += step) {
|
|
2370
|
+
centres.push({ x: gx, y: gy });
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
painter.dots(centres, Math.max(1, Math.round(options.size * 2 * v.zoom)), color);
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* The grid as one repeating fill (ntk#263): a step-sized tile drawn once,
|
|
2377
|
+
* a `createPattern('repeat')` over it, and one composite for the whole
|
|
2378
|
+
* region — where the runs path pays a region-sized coverage mask.
|
|
2379
|
+
*
|
|
2380
|
+
* Two decisions with reasons:
|
|
2381
|
+
*
|
|
2382
|
+
* - **Integral device steps only.** The tile is a pixmap, so its size is
|
|
2383
|
+
* whole pixels; at a fractional step the pattern would drift against
|
|
2384
|
+
* the graph's own coordinates — against `snapToGrid`, against node
|
|
2385
|
+
* positions — by a fraction per tile, and a grid that slides under the
|
|
2386
|
+
* content it grids is worse than a slower exact one.
|
|
2387
|
+
* - **The phase lives in the tile, not in a picture transform.** An
|
|
2388
|
+
* untransformed repeat is anchored to the window origin, so the grid's
|
|
2389
|
+
* alignment is baked in by drawing the mark at `origin mod tile` — and
|
|
2390
|
+
* re-baking when the origin moves. A `setTransform` translate says the
|
|
2391
|
+
* same thing in one request, but a *transformed* repeat forfeits the
|
|
2392
|
+
* server's untransformed fast path — on the in-process test server
|
|
2393
|
+
* that was ~180 ms a frame of per-pixel arithmetic, and re-rendering a
|
|
2394
|
+
* 24px tile is a handful of requests on any server.
|
|
2395
|
+
*/
|
|
2396
|
+
_paintGridPattern(painter, options, step, color, region) {
|
|
2397
|
+
const tileSize = Math.round(step);
|
|
2398
|
+
if (Math.abs(step - tileSize) > 0.01 || tileSize < 2)
|
|
2399
|
+
return false;
|
|
2400
|
+
const raw = painter.raw;
|
|
2401
|
+
if (!raw || typeof raw.createPattern !== 'function')
|
|
2402
|
+
return false;
|
|
2403
|
+
const v = this._viewport();
|
|
2404
|
+
const pane = this._pane();
|
|
2405
|
+
const mod = (a, m) => ((a % m) + m) % m;
|
|
2406
|
+
const px = Math.round(mod(pane.x + v.x, tileSize)) % tileSize;
|
|
2407
|
+
const py = Math.round(mod(pane.y + v.y, tileSize)) % tileSize;
|
|
2408
|
+
const key = `${options.variant}|${Math.round(options.size * v.zoom * 4)}|${color}|${px},${py}`;
|
|
2409
|
+
let tile = this._gridTile;
|
|
2410
|
+
if (!tile || tile.size !== tileSize) {
|
|
2411
|
+
this._dropGridTile();
|
|
2412
|
+
const surface = this._makeGridSurface(tileSize);
|
|
2413
|
+
if (!surface)
|
|
2414
|
+
return false;
|
|
2415
|
+
tile = {
|
|
2416
|
+
key: '',
|
|
2417
|
+
size: tileSize,
|
|
2418
|
+
surface,
|
|
2419
|
+
pattern: raw.createPattern(surface, 'repeat'),
|
|
2420
|
+
};
|
|
2421
|
+
this._gridTile = tile;
|
|
2422
|
+
}
|
|
2423
|
+
if (tile.key !== key) {
|
|
2424
|
+
tile.key = key;
|
|
2425
|
+
this._renderGridTile(tile, options, v.zoom, color, px, py);
|
|
2426
|
+
}
|
|
2427
|
+
raw.fillStyle = tile.pattern;
|
|
2428
|
+
raw.fillRect?.(region.x, region.y, region.width, region.height);
|
|
2429
|
+
return true;
|
|
2430
|
+
}
|
|
2431
|
+
_makeGridSurface(tileSize) {
|
|
2432
|
+
const ctor = ntk.Surface;
|
|
2433
|
+
if (typeof ctor !== 'function')
|
|
2434
|
+
return null;
|
|
2435
|
+
try {
|
|
2436
|
+
return new ctor(this.app, {
|
|
2437
|
+
width: tileSize,
|
|
2438
|
+
height: tileSize,
|
|
2439
|
+
format: 'argb32',
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
catch {
|
|
2443
|
+
return null;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
/** Draw one grid cell with the mark at the phase point, wrapped — a mark
|
|
2447
|
+
* near an edge is drawn again a tile over, so the seam never cuts it. */
|
|
2448
|
+
_renderGridTile(tile, options, zoom, color, px, py) {
|
|
2449
|
+
const t = tile.size;
|
|
2450
|
+
tile.surface.render((ctx) => {
|
|
2451
|
+
ctx.clearRect?.(0, 0, t, t);
|
|
2452
|
+
ctx.fillStyle = color;
|
|
2453
|
+
for (let i = -1; i <= 1; i++) {
|
|
2454
|
+
for (let j = -1; j <= 1; j++) {
|
|
2455
|
+
const cx = px + i * t;
|
|
2456
|
+
const cy = py + j * t;
|
|
2457
|
+
if (options.variant === 'lines') {
|
|
2458
|
+
if (j === 0)
|
|
2459
|
+
ctx.fillRect(cx - 0.5, 0, 1, t);
|
|
2460
|
+
if (i === 0)
|
|
2461
|
+
ctx.fillRect(0, cy - 0.5, t, 1);
|
|
2462
|
+
}
|
|
2463
|
+
else if (options.variant === 'cross') {
|
|
2464
|
+
const arm = Math.max(2, options.size * 3 * zoom);
|
|
2465
|
+
ctx.fillRect(cx - arm, cy - 0.5, arm * 2, 1);
|
|
2466
|
+
ctx.fillRect(cx - 0.5, cy - arm, 1, arm * 2);
|
|
2467
|
+
}
|
|
2468
|
+
else {
|
|
2469
|
+
const dot = Math.max(1, Math.round(options.size * 2 * zoom));
|
|
2470
|
+
ctx.fillRect(cx - dot / 2, cy - dot / 2, dot, dot);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
_dropGridTile() {
|
|
2477
|
+
const tile = this._gridTile;
|
|
2478
|
+
if (!tile)
|
|
2479
|
+
return;
|
|
2480
|
+
tile.pattern._picture?.destroy?.();
|
|
2481
|
+
tile.surface.destroy?.();
|
|
2482
|
+
this._gridTile = null;
|
|
2483
|
+
}
|
|
2484
|
+
/** Draws every visible edge; answers whether any of them is animated, so
|
|
2485
|
+
* the caller knows whether to keep a timer alive. */
|
|
2486
|
+
_paintEdges(painter, palette) {
|
|
2487
|
+
const v = this._viewport();
|
|
2488
|
+
const pane = this._pane();
|
|
2489
|
+
const labels = v.zoom >= LABEL_ZOOM;
|
|
2490
|
+
let animated = false;
|
|
2491
|
+
// Collect, then draw. A stroke has no fast path in ntk — every one is a
|
|
2492
|
+
// mask rasterized in JS, uploaded with `PutImage` and composited — so a
|
|
2493
|
+
// graph of seven hundred edges was seven hundred round trips and about
|
|
2494
|
+
// four megabytes a frame. Bucketed by the style they will be stroked
|
|
2495
|
+
// with, the same graph is one path per distinct pen, which for almost
|
|
2496
|
+
// every graph is one.
|
|
2497
|
+
const strokes = new StrokeBuckets();
|
|
2498
|
+
const markers = new Map();
|
|
2499
|
+
const labelChips = [];
|
|
2500
|
+
const pendingLabels = [];
|
|
2501
|
+
const clip = this._frameClip;
|
|
2502
|
+
let animBox = null;
|
|
2503
|
+
for (const edge of this._edges) {
|
|
2504
|
+
if (edge.hidden)
|
|
2505
|
+
continue;
|
|
2506
|
+
// two rejects: one from the nodes alone, one from the route it took
|
|
2507
|
+
const coarse = this._edgeCoarseBox(edge);
|
|
2508
|
+
if (!coarse || !rectsOverlap(coarse, pane))
|
|
2509
|
+
continue;
|
|
2510
|
+
// Tracked before the damage skip, deliberately: whether the dash
|
|
2511
|
+
// timer runs is a question about the viewport, not about what this
|
|
2512
|
+
// particular pass repaints — deciding it after the skip is how a drag
|
|
2513
|
+
// in one corner would stop the dash marching in the other. The box
|
|
2514
|
+
// the ticks invalidate is collected below from the *drawn* geometry:
|
|
2515
|
+
// the coarse box carries the bezier slack, and a tick that repaints
|
|
2516
|
+
// slack repaints a card-sized halo of neighbours sixteen times a
|
|
2517
|
+
// second.
|
|
2518
|
+
if (edge.animated)
|
|
2519
|
+
animated = true;
|
|
2520
|
+
if (clip && !rectsOverlap(coarse, clip))
|
|
2521
|
+
continue;
|
|
2522
|
+
const geometry = this._edgeGeometry(edge);
|
|
2523
|
+
if (!geometry)
|
|
2524
|
+
continue;
|
|
2525
|
+
if (!rectsOverlap(pathBounds(geometry.points), pane))
|
|
2526
|
+
continue;
|
|
2527
|
+
const selected = edge.selected ?? false;
|
|
2528
|
+
const hovered = this._hover.edgeId === edge.id;
|
|
2529
|
+
const stroke = edge.style?.stroke ??
|
|
2530
|
+
(selected
|
|
2531
|
+
? palette.edgeSelected
|
|
2532
|
+
: hovered
|
|
2533
|
+
? palette.text
|
|
2534
|
+
: palette.edge);
|
|
2535
|
+
const lineWidth = Math.max(1, (edge.style?.strokeWidth ?? (selected ? 2 : 1.5)) * v.zoom);
|
|
2536
|
+
// `markerEnd` left out means an arrow: a directed graph whose edges do
|
|
2537
|
+
// not say which way they point is a set of lines. `null` opts out.
|
|
2538
|
+
const markerEnd = normalizeMarker(edge.markerEnd === undefined ? 'arrowclosed' : edge.markerEnd);
|
|
2539
|
+
const markerStart = normalizeMarker(edge.markerStart);
|
|
2540
|
+
// The tip stops short of the handle rather than at it: the handle dot
|
|
2541
|
+
// is drawn *over* the edges, with the nodes, so an arrow aimed at the
|
|
2542
|
+
// handle's centre is an arrow mostly hidden under a white circle.
|
|
2543
|
+
const inset = v.zoom >= HANDLE_ZOOM ? this._handleRadius() + 1 : 1;
|
|
2544
|
+
const last = geometry.points[geometry.points.length - 1];
|
|
2545
|
+
const outAngle = endAngle(geometry.points);
|
|
2546
|
+
const endTip = {
|
|
2547
|
+
x: last.x - Math.cos(outAngle) * inset,
|
|
2548
|
+
y: last.y - Math.sin(outAngle) * inset,
|
|
2549
|
+
};
|
|
2550
|
+
let points = geometry.points;
|
|
2551
|
+
if (markerEnd) {
|
|
2552
|
+
// and the stroke stops behind the head, so a filled triangle is a
|
|
2553
|
+
// triangle rather than a triangle with a line through it
|
|
2554
|
+
points = trimEnd(points, (markerEnd.size ?? DEFAULT_MARKER_SIZE) * v.zoom * 0.8 + inset);
|
|
2555
|
+
}
|
|
2556
|
+
const dash = edge.style?.dash ?? (edge.animated ? DEFAULT_DASH : undefined);
|
|
2557
|
+
if (edge.animated) {
|
|
2558
|
+
const tight = inflateRect(pathBounds(geometry.points), CULL_MARGIN);
|
|
2559
|
+
animBox = animBox ? unionRects(animBox, tight) : tight;
|
|
2560
|
+
}
|
|
2561
|
+
strokes
|
|
2562
|
+
.bucket(stroke, lineWidth, dash, edge.animated ? this._dashPhase : 0, v.zoom)
|
|
2563
|
+
.push(points);
|
|
2564
|
+
if (markerEnd) {
|
|
2565
|
+
this._collectMarker(markers, endTip, outAngle, markerEnd.type, markerEnd.color ?? stroke, (markerEnd.size ?? DEFAULT_MARKER_SIZE) * v.zoom, lineWidth);
|
|
2566
|
+
}
|
|
2567
|
+
if (markerStart) {
|
|
2568
|
+
const inAngle = startAngle(geometry.points);
|
|
2569
|
+
this._collectMarker(markers, {
|
|
2570
|
+
x: geometry.points[0].x - Math.cos(inAngle) * inset,
|
|
2571
|
+
y: geometry.points[0].y - Math.sin(inAngle) * inset,
|
|
2572
|
+
}, inAngle, markerStart.type, markerStart.color ?? stroke, (markerStart.size ?? DEFAULT_MARKER_SIZE) * v.zoom, lineWidth);
|
|
2573
|
+
}
|
|
2574
|
+
if (labels && edge.label) {
|
|
2575
|
+
const at = pointAtFraction(geometry.points, 0.5);
|
|
2576
|
+
const size = Math.max(8, 11 * v.zoom);
|
|
2577
|
+
const metrics = painter.measureText(edge.label, { size });
|
|
2578
|
+
const padX = 5 * v.zoom;
|
|
2579
|
+
const padY = 2 * v.zoom;
|
|
2580
|
+
// The chip is collected with the rest; the text is not, because a
|
|
2581
|
+
// glyph run is already one request and nothing is gained by holding
|
|
2582
|
+
// it. Both still land above every edge, which is the point of the
|
|
2583
|
+
// chip.
|
|
2584
|
+
labelChips.push({
|
|
2585
|
+
rect: {
|
|
2586
|
+
x: Math.round(at.x - metrics.width / 2 - padX),
|
|
2587
|
+
y: Math.round(at.y - metrics.height / 2 - padY),
|
|
2588
|
+
width: Math.round(metrics.width + padX * 2),
|
|
2589
|
+
height: Math.round(metrics.height + padY * 2),
|
|
2590
|
+
},
|
|
2591
|
+
radius: Math.max(2, Math.round(3 * v.zoom)),
|
|
2592
|
+
fill: edge.style?.labelBackground ?? tint(palette.background, 0.92),
|
|
2593
|
+
});
|
|
2594
|
+
pendingLabels.push({
|
|
2595
|
+
text: edge.label,
|
|
2596
|
+
x: at.x,
|
|
2597
|
+
y: at.y,
|
|
2598
|
+
size,
|
|
2599
|
+
color: edge.style?.labelColor ?? palette.text,
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
strokes.paint(painter);
|
|
2604
|
+
for (const bucket of markers.values()) {
|
|
2605
|
+
// the same threshold, for the same reason: a handful of arrowheads
|
|
2606
|
+
// scattered over the pane is cheaper drawn as a handful
|
|
2607
|
+
if (bucket.filled.length >= MARKER_BATCH_MIN) {
|
|
2608
|
+
painter.polygons(bucket.filled, { fill: bucket.color });
|
|
2609
|
+
}
|
|
2610
|
+
else {
|
|
2611
|
+
for (const head of bucket.filled) {
|
|
2612
|
+
painter.polygon(head, { fill: bucket.color });
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
if (bucket.open.length >= MARKER_BATCH_MIN) {
|
|
2616
|
+
painter.strokeRuns(bucket.open, {
|
|
2617
|
+
stroke: bucket.color,
|
|
2618
|
+
lineWidth: bucket.lineWidth,
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
else {
|
|
2622
|
+
for (const head of bucket.open) {
|
|
2623
|
+
painter.strokeRuns([head], {
|
|
2624
|
+
stroke: bucket.color,
|
|
2625
|
+
lineWidth: bucket.lineWidth,
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
for (const chip of labelChips) {
|
|
2631
|
+
painter.rect(chip.rect.x, chip.rect.y, chip.rect.width, chip.rect.height, chip.radius, { fill: chip.fill });
|
|
2632
|
+
}
|
|
2633
|
+
for (const l of pendingLabels) {
|
|
2634
|
+
painter.text(l.text, l.x, l.y, {
|
|
2635
|
+
size: l.size,
|
|
2636
|
+
color: l.color,
|
|
2637
|
+
align: 'center',
|
|
2638
|
+
baseline: 'middle',
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
// A pass that drew an animated edge knows exactly where its dash is; a
|
|
2642
|
+
// pass that culled them all keeps the previous box — the endpoints did
|
|
2643
|
+
// not move, or the move's own damage would have redrawn them here.
|
|
2644
|
+
if (animBox)
|
|
2645
|
+
this._animBox = animBox;
|
|
2646
|
+
return animated;
|
|
2647
|
+
}
|
|
2648
|
+
/** An arrowhead's three or four points, added to the pile for its colour
|
|
2649
|
+
* rather than drawn — see {@link StrokeBuckets} for why. */
|
|
2650
|
+
_collectMarker(markers, at, angle, type, color, size, lineWidth) {
|
|
2651
|
+
const spread = 0.42; // radians off the shaft — a ~24° half-angle
|
|
2652
|
+
const back = {
|
|
2653
|
+
x: at.x - Math.cos(angle) * size,
|
|
2654
|
+
y: at.y - Math.sin(angle) * size,
|
|
2655
|
+
};
|
|
2656
|
+
const left = {
|
|
2657
|
+
x: at.x - Math.cos(angle - spread) * size,
|
|
2658
|
+
y: at.y - Math.sin(angle - spread) * size,
|
|
2659
|
+
};
|
|
2660
|
+
const right = {
|
|
2661
|
+
x: at.x - Math.cos(angle + spread) * size,
|
|
2662
|
+
y: at.y - Math.sin(angle + spread) * size,
|
|
2663
|
+
};
|
|
2664
|
+
const key = `${color}|${lineWidth}`;
|
|
2665
|
+
let bucket = markers.get(key);
|
|
2666
|
+
if (!bucket) {
|
|
2667
|
+
bucket = { color, lineWidth, filled: [], open: [] };
|
|
2668
|
+
markers.set(key, bucket);
|
|
2669
|
+
}
|
|
2670
|
+
if (type === 'arrowclosed')
|
|
2671
|
+
bucket.filled.push([at, left, back, right]);
|
|
2672
|
+
else
|
|
2673
|
+
bucket.open.push([left, at, right]);
|
|
2674
|
+
}
|
|
2675
|
+
_paintNodes(painter, palette) {
|
|
2676
|
+
const dragging = this._dragTo;
|
|
2677
|
+
const order = [];
|
|
2678
|
+
const deferred = [];
|
|
2679
|
+
for (const entry of this._order) {
|
|
2680
|
+
// a dragged node comes to the top
|
|
2681
|
+
if (dragging?.has(entry.node.id))
|
|
2682
|
+
deferred.push(entry);
|
|
2683
|
+
else
|
|
2684
|
+
order.push(entry);
|
|
2685
|
+
}
|
|
2686
|
+
order.push(...deferred);
|
|
2687
|
+
// Nodes are drawn one at a time, in z-order, and both halves of that
|
|
2688
|
+
// were measured rather than assumed — see `_paintHandles` for why
|
|
2689
|
+
// batching a card or a handle costs more than it saves.
|
|
2690
|
+
for (const entry of order)
|
|
2691
|
+
this._paintNode(painter, palette, entry);
|
|
2692
|
+
}
|
|
2693
|
+
_paintNode(painter, palette, entry) {
|
|
2694
|
+
if (entry.node.hidden)
|
|
2695
|
+
return;
|
|
2696
|
+
const rect = this._screenRect(entry);
|
|
2697
|
+
if (!rectsOverlap(rect, this._pane()))
|
|
2698
|
+
return;
|
|
2699
|
+
// inflated by the margin its handles and grips can ink outside the box —
|
|
2700
|
+
// the same margin every invalidate grew by, so the two agree
|
|
2701
|
+
const clip = this._frameClip;
|
|
2702
|
+
if (clip && !rectsOverlap(inflateRect(rect, CULL_MARGIN), clip))
|
|
2703
|
+
return;
|
|
2704
|
+
const v = this._viewport();
|
|
2705
|
+
const selected = entry.node.selected ?? false;
|
|
2706
|
+
const hovered = this._hover.nodeId === entry.node.id;
|
|
2707
|
+
const handles = this._handlesOf(entry).map((anchor) => {
|
|
2708
|
+
const s = this._toScreen(anchor);
|
|
2709
|
+
return { ...anchor, x: s.x, y: s.y };
|
|
2710
|
+
});
|
|
2711
|
+
if (entry.type?.paint) {
|
|
2712
|
+
// Never batched: a type that draws its own body is drawing whatever it
|
|
2713
|
+
// likes, in an order only it knows.
|
|
2714
|
+
entry.type.paint({
|
|
2715
|
+
node: entry.node,
|
|
2716
|
+
rect,
|
|
2717
|
+
zoom: v.zoom,
|
|
2718
|
+
selected,
|
|
2719
|
+
hovered,
|
|
2720
|
+
palette,
|
|
2721
|
+
painter,
|
|
2722
|
+
handles,
|
|
2723
|
+
});
|
|
2724
|
+
}
|
|
2725
|
+
else {
|
|
2726
|
+
this._paintCardShape(painter, palette, entry, rect, selected, hovered);
|
|
2727
|
+
this._paintCardInk(painter, palette, entry, rect);
|
|
2728
|
+
}
|
|
2729
|
+
if (this._connectable(entry) && (v.zoom >= HANDLE_ZOOM || hovered)) {
|
|
2730
|
+
this._paintHandles(painter, palette, handles);
|
|
2731
|
+
}
|
|
2732
|
+
this._paintGrips(painter, palette, entry, rect);
|
|
2733
|
+
}
|
|
2734
|
+
_mounted(entry) {
|
|
2735
|
+
return entry.type?.render != null && this._viewport().zoom >= RENDER_ZOOM;
|
|
2736
|
+
}
|
|
2737
|
+
_paintGrips(painter, palette, entry, rect) {
|
|
2738
|
+
const grips = this._grips(entry);
|
|
2739
|
+
if (grips.length === 0)
|
|
2740
|
+
return;
|
|
2741
|
+
const size = Math.max(4, RESIZE_GRIP * 2 * this._viewport().zoom);
|
|
2742
|
+
for (const dir of grips) {
|
|
2743
|
+
const at = gripPoint(rect, dir);
|
|
2744
|
+
painter.rect(at.x - size / 2, at.y - size / 2, size, size, 1, {
|
|
2745
|
+
fill: palette.nodeBackground,
|
|
2746
|
+
stroke: palette.accent,
|
|
2747
|
+
lineWidth: 1.5,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
/** The built-in node: a card with a label, an optional second line and an
|
|
2752
|
+
* optional accent stripe down its leading edge. */
|
|
2753
|
+
/** The card itself: one rounded box, and the accent stripe if it has one.
|
|
2754
|
+
* With a `batch` the box joins the pile for its appearance instead of
|
|
2755
|
+
* being drawn on its own. */
|
|
2756
|
+
_paintCardShape(painter, palette, entry, rect, selected, hovered) {
|
|
2757
|
+
const v = this._viewport();
|
|
2758
|
+
const style = entry.node.style;
|
|
2759
|
+
const radius = Math.round((style?.borderRadius ?? 6) * v.zoom);
|
|
2760
|
+
const border = selected
|
|
2761
|
+
? palette.accent
|
|
2762
|
+
: hovered
|
|
2763
|
+
? tint(palette.accent, 0.55)
|
|
2764
|
+
: (style?.borderColor ?? palette.nodeBorder);
|
|
2765
|
+
const options = {
|
|
2766
|
+
fill: style?.background ?? palette.nodeBackground,
|
|
2767
|
+
stroke: border,
|
|
2768
|
+
lineWidth: Math.max(1, Math.round((style?.borderWidth ?? (selected ? 2 : 1)) * v.zoom)),
|
|
2769
|
+
};
|
|
2770
|
+
painter.rect(rect.x, rect.y, rect.width, rect.height, radius, options);
|
|
2771
|
+
if (style?.accent) {
|
|
2772
|
+
// Inset past the rounded corners instead of clipped to them: a
|
|
2773
|
+
// non-rectangular clip forfeits ntk's rounded-box fast path for every
|
|
2774
|
+
// fill under it — measured as a pixmap create/free and a trapezoid
|
|
2775
|
+
// pass per card per repaint.
|
|
2776
|
+
painter.rect(rect.x + Math.max(1, v.zoom), rect.y + radius, Math.max(2, 3 * v.zoom), rect.height - radius * 2, 0, { fill: style.accent });
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
/** The label and its second line — the half of a card that has to come
|
|
2780
|
+
* after every card when they are batched. */
|
|
2781
|
+
_paintCardInk(painter, palette, entry, rect) {
|
|
2782
|
+
const v = this._viewport();
|
|
2783
|
+
if (v.zoom < LABEL_ZOOM)
|
|
2784
|
+
return;
|
|
2785
|
+
const style = entry.node.style;
|
|
2786
|
+
const header = this._mounted(entry) ? this._headerHeight(entry) : 0;
|
|
2787
|
+
const data = entry.node.data;
|
|
2788
|
+
const label = data?.label ?? entry.node.id;
|
|
2789
|
+
const description = data?.description;
|
|
2790
|
+
const color = style?.color ?? palette.text;
|
|
2791
|
+
const padX = NODE_PAD_X * v.zoom;
|
|
2792
|
+
const centre = rect.x + rect.width / 2;
|
|
2793
|
+
const maxWidth = Math.max(8, rect.width - padX * 2);
|
|
2794
|
+
const showDescription = Boolean(description) && v.zoom >= DESC_ZOOM;
|
|
2795
|
+
if (header > 0) {
|
|
2796
|
+
// The title bar of a node whose body is somebody else's: left-aligned,
|
|
2797
|
+
// with a hairline under it so the strip reads as the thing to grab.
|
|
2798
|
+
const band = header * v.zoom;
|
|
2799
|
+
painter.text(label, rect.x + padX, rect.y + band / 2, {
|
|
2800
|
+
size: NODE_LABEL_SIZE * v.zoom,
|
|
2801
|
+
weight: 'bold',
|
|
2802
|
+
color,
|
|
2803
|
+
baseline: 'middle',
|
|
2804
|
+
maxWidth,
|
|
2805
|
+
});
|
|
2806
|
+
painter.strokeRuns([
|
|
2807
|
+
[
|
|
2808
|
+
{ x: rect.x + 1, y: rect.y + band },
|
|
2809
|
+
{ x: rect.x + rect.width - 1, y: rect.y + band },
|
|
2810
|
+
],
|
|
2811
|
+
], { stroke: palette.nodeBorder, lineWidth: 1 });
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
if (!showDescription) {
|
|
2815
|
+
painter.text(label, centre, rect.y + rect.height / 2, {
|
|
2816
|
+
size: NODE_LABEL_SIZE * v.zoom,
|
|
2817
|
+
weight: 'bold',
|
|
2818
|
+
color,
|
|
2819
|
+
align: 'center',
|
|
2820
|
+
baseline: 'middle',
|
|
2821
|
+
maxWidth,
|
|
2822
|
+
});
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
const top = rect.y + NODE_PAD_Y * v.zoom;
|
|
2826
|
+
painter.text(label, centre, top, {
|
|
2827
|
+
size: NODE_LABEL_SIZE * v.zoom,
|
|
2828
|
+
weight: 'bold',
|
|
2829
|
+
color,
|
|
2830
|
+
align: 'center',
|
|
2831
|
+
maxWidth,
|
|
2832
|
+
});
|
|
2833
|
+
painter.text(description, centre, top + NODE_LABEL_SIZE * 1.35 * v.zoom, {
|
|
2834
|
+
size: NODE_DESC_SIZE * v.zoom,
|
|
2835
|
+
color: palette.dim,
|
|
2836
|
+
align: 'center',
|
|
2837
|
+
maxWidth,
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* Handles are drawn one disc at a time, and that is the measured answer
|
|
2842
|
+
* rather than the obvious one.
|
|
2843
|
+
*
|
|
2844
|
+
* Batching them into a single path halved the requests and made the frame
|
|
2845
|
+
* *slower*: a path's mask is its bounding box, so forty dots scattered
|
|
2846
|
+
* across the pane rasterize to a paneful of mask — three quarters of a
|
|
2847
|
+
* megabyte — where forty small ones cost a kilobyte each. Batching pays
|
|
2848
|
+
* for the edges because an edge already spans that box; it does not pay
|
|
2849
|
+
* for anything small and scattered.
|
|
2850
|
+
*/
|
|
2851
|
+
_paintHandles(painter, palette, handles) {
|
|
2852
|
+
const v = this._viewport();
|
|
2853
|
+
const radius = this._handleRadius();
|
|
2854
|
+
const gesture = this._gesture;
|
|
2855
|
+
const connecting = gesture?.kind === 'connect' ? gesture : null;
|
|
2856
|
+
const lineWidth = Math.max(1, 1.5 * v.zoom);
|
|
2857
|
+
for (const anchor of handles) {
|
|
2858
|
+
const active = sameHandle(this._hover.handle, anchor) ||
|
|
2859
|
+
sameHandle(connecting?.to ?? null, anchor) ||
|
|
2860
|
+
sameHandle(connecting?.from ?? null, anchor);
|
|
2861
|
+
const options = {
|
|
2862
|
+
fill: active ? palette.accent : palette.nodeBackground,
|
|
2863
|
+
stroke: active ? palette.accent : palette.handle,
|
|
2864
|
+
lineWidth,
|
|
2865
|
+
};
|
|
2866
|
+
painter.circle(anchor.x, anchor.y, active ? radius * 1.4 : radius, options);
|
|
2867
|
+
if (anchor.label && v.zoom >= DESC_ZOOM) {
|
|
2868
|
+
const outside = anchor.position === 'left' || anchor.position === 'top';
|
|
2869
|
+
painter.text(anchor.label, anchor.x +
|
|
2870
|
+
(anchor.position === 'left'
|
|
2871
|
+
? -radius - 4 * v.zoom
|
|
2872
|
+
: radius + 4 * v.zoom), anchor.y, {
|
|
2873
|
+
size: 10 * v.zoom,
|
|
2874
|
+
color: palette.dim,
|
|
2875
|
+
align: outside ? 'right' : 'left',
|
|
2876
|
+
baseline: 'middle',
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
_paintConnection(painter, palette) {
|
|
2882
|
+
const gesture = this._gesture;
|
|
2883
|
+
if (gesture?.kind !== 'connect')
|
|
2884
|
+
return;
|
|
2885
|
+
const v = this._viewport();
|
|
2886
|
+
const target = gesture.to ?? gesture.pointer;
|
|
2887
|
+
const to = this._toScreen(target);
|
|
2888
|
+
const points = this._connectionPath(gesture);
|
|
2889
|
+
const invalid = gesture.to != null && !gesture.valid;
|
|
2890
|
+
painter.polyline(points, {
|
|
2891
|
+
stroke: invalid ? palette.dim : palette.accent,
|
|
2892
|
+
lineWidth: Math.max(1.5, 2 * v.zoom),
|
|
2893
|
+
dash: gesture.to && gesture.valid ? undefined : [5 * v.zoom, 4 * v.zoom],
|
|
2894
|
+
});
|
|
2895
|
+
painter.circle(to.x, to.y, this._handleRadius(), {
|
|
2896
|
+
fill: invalid ? palette.dim : palette.accent,
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
_paintSelectionBox(painter, palette) {
|
|
2900
|
+
const gesture = this._gesture;
|
|
2901
|
+
if (gesture?.kind !== 'select')
|
|
2902
|
+
return;
|
|
2903
|
+
const x = Math.min(gesture.startX, gesture.x);
|
|
2904
|
+
const y = Math.min(gesture.startY, gesture.y);
|
|
2905
|
+
painter.rect(x, y, Math.abs(gesture.x - gesture.startX), Math.abs(gesture.y - gesture.startY), 2, { fill: palette.selection, stroke: palette.accent, lineWidth: 1 });
|
|
2906
|
+
}
|
|
2907
|
+
_paintMiniMap(painter, palette) {
|
|
2908
|
+
const opts = this._miniMapOptions();
|
|
2909
|
+
if (!opts)
|
|
2910
|
+
return;
|
|
2911
|
+
// The panel cull comes before `_miniMap()`, which walks every node to
|
|
2912
|
+
// find the graph's bounds — during a drag that walk per pass would cost
|
|
2913
|
+
// more than the panel it skips. The dot for a mid-drag node goes stale
|
|
2914
|
+
// until the release repaints in full; that is the trade, and it is
|
|
2915
|
+
// deliberate.
|
|
2916
|
+
const clip = this._frameClip;
|
|
2917
|
+
if (clip) {
|
|
2918
|
+
const quick = this._corner(opts.position, opts.width ?? MINIMAP_W, opts.height ?? MINIMAP_H, 'bottom-right');
|
|
2919
|
+
if (!rectsOverlap(quick, clip))
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
const map = this._miniMap();
|
|
2923
|
+
if (!map)
|
|
2924
|
+
return;
|
|
2925
|
+
const { panel, options } = map;
|
|
2926
|
+
painter.rect(panel.x, panel.y, panel.width, panel.height, 4, {
|
|
2927
|
+
fill: tint(palette.surface, 0.92),
|
|
2928
|
+
stroke: palette.surfaceBorder,
|
|
2929
|
+
lineWidth: 1,
|
|
2930
|
+
});
|
|
2931
|
+
painter.save();
|
|
2932
|
+
painter.clipRect(panel.x, panel.y, panel.width, panel.height, 4);
|
|
2933
|
+
const nodeColor = options.nodeColor;
|
|
2934
|
+
for (const entry of this._entries) {
|
|
2935
|
+
if (entry.node.hidden)
|
|
2936
|
+
continue;
|
|
2937
|
+
const rect = this.rectOf(entry);
|
|
2938
|
+
const at = this._miniToScreen(map, rect);
|
|
2939
|
+
const fill = typeof nodeColor === 'function'
|
|
2940
|
+
? nodeColor(entry.node)
|
|
2941
|
+
: (nodeColor ??
|
|
2942
|
+
(entry.node.selected ? palette.accent : palette.nodeBorder));
|
|
2943
|
+
painter.rect(at.x, at.y, Math.max(1, rect.width * map.scale), Math.max(1, rect.height * map.scale), 0, { fill });
|
|
2944
|
+
}
|
|
2945
|
+
// The viewport, as an outline over a wash on everything outside it —
|
|
2946
|
+
// cheaper than four mask rectangles and reads the same.
|
|
2947
|
+
const v = this._viewport();
|
|
2948
|
+
const paneRect = this._pane();
|
|
2949
|
+
const view = this._miniToScreen(map, {
|
|
2950
|
+
x: -v.x / v.zoom,
|
|
2951
|
+
y: -v.y / v.zoom,
|
|
2952
|
+
});
|
|
2953
|
+
painter.rect(view.x, view.y, (paneRect.width / v.zoom) * map.scale, (paneRect.height / v.zoom) * map.scale, 2, {
|
|
2954
|
+
fill: options.maskColor ?? tint(palette.accent, 0.1),
|
|
2955
|
+
stroke: palette.accent,
|
|
2956
|
+
lineWidth: 1,
|
|
2957
|
+
});
|
|
2958
|
+
painter.restore();
|
|
2959
|
+
}
|
|
2960
|
+
_paintControls(painter, palette) {
|
|
2961
|
+
const buttons = this._controlButtons();
|
|
2962
|
+
if (buttons.length === 0)
|
|
2963
|
+
return;
|
|
2964
|
+
const clip = this._frameClip;
|
|
2965
|
+
if (clip && !buttons.some((b) => rectsOverlap(b.rect, clip)))
|
|
2966
|
+
return;
|
|
2967
|
+
const first = buttons[0].rect;
|
|
2968
|
+
const last = buttons[buttons.length - 1].rect;
|
|
2969
|
+
painter.rect(first.x, first.y, first.width, last.y + last.height - first.y, 4, {
|
|
2970
|
+
fill: tint(palette.surface, 0.94),
|
|
2971
|
+
stroke: palette.surfaceBorder,
|
|
2972
|
+
lineWidth: 1,
|
|
2973
|
+
});
|
|
2974
|
+
for (let i = 0; i < buttons.length; i++) {
|
|
2975
|
+
const { rect, action } = buttons[i];
|
|
2976
|
+
if (i > 0) {
|
|
2977
|
+
painter.strokeRuns([
|
|
2978
|
+
[
|
|
2979
|
+
{ x: rect.x + 4, y: rect.y },
|
|
2980
|
+
{ x: rect.x + rect.width - 4, y: rect.y },
|
|
2981
|
+
],
|
|
2982
|
+
], { stroke: palette.surfaceBorder, lineWidth: 1 });
|
|
2983
|
+
}
|
|
2984
|
+
const cx = rect.x + rect.width / 2;
|
|
2985
|
+
const cy = rect.y + rect.height / 2;
|
|
2986
|
+
const arm = 5;
|
|
2987
|
+
const runs = [];
|
|
2988
|
+
if (action === 'in' || action === 'out') {
|
|
2989
|
+
runs.push([
|
|
2990
|
+
{ x: cx - arm, y: cy },
|
|
2991
|
+
{ x: cx + arm, y: cy },
|
|
2992
|
+
]);
|
|
2993
|
+
if (action === 'in') {
|
|
2994
|
+
runs.push([
|
|
2995
|
+
{ x: cx, y: cy - arm },
|
|
2996
|
+
{ x: cx, y: cy + arm },
|
|
2997
|
+
]);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
else {
|
|
3001
|
+
// a frame with its sides opened out: "fit what is there into this"
|
|
3002
|
+
const a = 5;
|
|
3003
|
+
runs.push([
|
|
3004
|
+
{ x: cx - a, y: cy - a + 3 },
|
|
3005
|
+
{ x: cx - a, y: cy - a },
|
|
3006
|
+
{ x: cx - a + 3, y: cy - a },
|
|
3007
|
+
], [
|
|
3008
|
+
{ x: cx + a - 3, y: cy - a },
|
|
3009
|
+
{ x: cx + a, y: cy - a },
|
|
3010
|
+
{ x: cx + a, y: cy - a + 3 },
|
|
3011
|
+
], [
|
|
3012
|
+
{ x: cx + a, y: cy + a - 3 },
|
|
3013
|
+
{ x: cx + a, y: cy + a },
|
|
3014
|
+
{ x: cx + a - 3, y: cy + a },
|
|
3015
|
+
], [
|
|
3016
|
+
{ x: cx - a + 3, y: cy + a },
|
|
3017
|
+
{ x: cx - a, y: cy + a },
|
|
3018
|
+
{ x: cx - a, y: cy + a - 3 },
|
|
3019
|
+
]);
|
|
3020
|
+
}
|
|
3021
|
+
painter.strokeRuns(runs, { stroke: palette.text, lineWidth: 1.5 });
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
//# sourceMappingURL=node.js.map
|