diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 302685f371..fe803cce08 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -4,6 +4,8 @@
- Connections now support `link` [#1955](https://github.com/terrastruct/d2/pull/1955)
- Vars: vars in markdown blocks are substituted [#2218](https://github.com/terrastruct/d2/pull/2218)
- Markdown: Github-flavored tables work in `md` blocks [#2221](https://github.com/terrastruct/d2/pull/2221)
+- CLI: PNG output to stdout is supported using `--stdout-format png -` [#2260](https://github.com/terrastruct/d2/pull/2260)
+- `d2 fmt` now supports a `--check` flag [#2253](https://github.com/terrastruct/d2/pull/2253)
#### Improvements 🧹
@@ -20,3 +22,4 @@
- Imports: fixes using substitutions in `icon` values [#2207](https://github.com/terrastruct/d2/pull/2207)
- Markdown: fixes ampersands in URLs in markdown [#2219](https://github.com/terrastruct/d2/pull/2219)
- Globs: fixes edge case where globs with imported boards would create empty boards [#2247](https://github.com/terrastruct/d2/pull/2247)
+- Sequence diagrams: fixes alignment of notes when self messages are above it [#2264](https://github.com/terrastruct/d2/pull/2264)
diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1
index 1a5ebee02e..a3bf928c03 100644
--- a/ci/release/template/man/d2.1
+++ b/ci/release/template/man/d2.1
@@ -125,12 +125,18 @@ In watch mode, images used in icons are cached for subsequent compilations. This
.It Fl -timeout Ar 120
The maximum number of seconds that D2 runs for before timing out and exiting. When rendering a large diagram, it is recommended to increase this value
.Ns .
+.It Fl -check Ar false
+Check that the specified files are formatted correctly
+.Ns .
.It Fl h , -help
Print usage information and exit
.Ns .
.It Fl v , -version
Print version information and exit
.Ns .
+.It Fl -stdout-format Ar string
+Set the output format when writing to stdout. Supported formats are: png, svg. Only used when output is set to stdout (-)
+.Ns .
.El
.Sh SUBCOMMANDS
.Bl -tag -width Fl
@@ -180,6 +186,8 @@ See --font-semibold flag.
See --animate-interval flag.
.It Ev Sy D2_TIMEOUT
See --timeout flag.
+.It Ev Sy D2_CHECK
+See --check flag.
.El
.Bl -tag -width Ds
.It Ev Sy DEBUG
@@ -192,6 +200,8 @@ See -h[ost] flag.
See -p[ort] flag.
.It Ev Sy BROWSER
See --browser flag.
+.It Ev Sy D2_STDOUT_FORMAT
+See --stdout-format flag.
.El
.Sh SEE ALSO
.Xr d2plugin-tala 1
diff --git a/d2ast/keywords.go b/d2ast/keywords.go
index 7ad1fae651..65f67b7754 100644
--- a/d2ast/keywords.go
+++ b/d2ast/keywords.go
@@ -8,7 +8,6 @@ var ReservedKeywords map[string]struct{}
// Non Style/Holder keywords.
var SimpleReservedKeywords = map[string]struct{}{
"label": {},
- "desc": {},
"shape": {},
"icon": {},
"constraint": {},
@@ -31,17 +30,17 @@ var SimpleReservedKeywords = map[string]struct{}{
// ReservedKeywordHolders are reserved keywords that are meaningless on its own and must hold composites
var ReservedKeywordHolders = map[string]struct{}{
- "style": {},
- "source-arrowhead": {},
- "target-arrowhead": {},
+ "style": {},
}
// CompositeReservedKeywords are reserved keywords that can hold composites
var CompositeReservedKeywords = map[string]struct{}{
- "classes": {},
- "constraint": {},
- "label": {},
- "icon": {},
+ "source-arrowhead": {},
+ "target-arrowhead": {},
+ "classes": {},
+ "constraint": {},
+ "label": {},
+ "icon": {},
}
// StyleKeywords are reserved keywords which cannot exist outside of the "style" keyword
diff --git a/d2cli/export.go b/d2cli/export.go
index 6da34bdb89..dd3e5c54df 100644
--- a/d2cli/export.go
+++ b/d2cli/export.go
@@ -1,7 +1,9 @@
package d2cli
import (
+ "fmt"
"path/filepath"
+ "strings"
)
type exportExtension string
@@ -14,6 +16,24 @@ const SVG exportExtension = ".svg"
var SUPPORTED_EXTENSIONS = []exportExtension{SVG, PNG, PDF, PPTX, GIF}
+var STDOUT_FORMAT_MAP = map[string]exportExtension{
+ "png": PNG,
+ "svg": SVG,
+}
+
+var SUPPORTED_STDOUT_FORMATS = []string{"png", "svg"}
+
+func getOutputFormat(stdoutFormatFlag *string, outputPath string) (exportExtension, error) {
+ if *stdoutFormatFlag != "" {
+ format := strings.ToLower(*stdoutFormatFlag)
+ if ext, ok := STDOUT_FORMAT_MAP[format]; ok {
+ return ext, nil
+ }
+ return "", fmt.Errorf("%s is not a supported format. Supported formats are: %s", *stdoutFormatFlag, SUPPORTED_STDOUT_FORMATS)
+ }
+ return getExportExtension(outputPath), nil
+}
+
func getExportExtension(outputPath string) exportExtension {
ext := filepath.Ext(outputPath)
for _, kext := range SUPPORTED_EXTENSIONS {
diff --git a/d2cli/export_test.go b/d2cli/export_test.go
index eb7ac44ee5..6022c65d07 100644
--- a/d2cli/export_test.go
+++ b/d2cli/export_test.go
@@ -8,6 +8,7 @@ import (
func TestOutputFormat(t *testing.T) {
type testCase struct {
+ stdoutFormatFlag string
outputPath string
extension exportExtension
supportsDarkTheme bool
@@ -41,6 +42,15 @@ func TestOutputFormat(t *testing.T) {
requiresAnimationInterval: false,
requiresPngRender: false,
},
+ {
+ stdoutFormatFlag: "png",
+ outputPath: "-",
+ extension: PNG,
+ supportsDarkTheme: false,
+ supportsAnimation: false,
+ requiresAnimationInterval: false,
+ requiresPngRender: true,
+ },
{
outputPath: "/out.png",
extension: PNG,
@@ -78,7 +88,8 @@ func TestOutputFormat(t *testing.T) {
for _, tc := range testCases {
tc := tc
t.Run(tc.outputPath, func(t *testing.T) {
- extension := getExportExtension(tc.outputPath)
+ extension, err := getOutputFormat(&tc.stdoutFormatFlag, tc.outputPath)
+ assert.NoError(t, err)
assert.Equal(t, tc.extension, extension)
assert.Equal(t, tc.supportsAnimation, extension.supportsAnimation())
assert.Equal(t, tc.supportsDarkTheme, extension.supportsDarkTheme())
diff --git a/d2cli/fmt.go b/d2cli/fmt.go
index a2b8371589..61daf15ea0 100644
--- a/d2cli/fmt.go
+++ b/d2cli/fmt.go
@@ -12,9 +12,10 @@ import (
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2parser"
+ "oss.terrastruct.com/d2/lib/log"
)
-func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
+func fmtCmd(ctx context.Context, ms *xmain.State, check bool) (err error) {
defer xdefer.Errorf(&err, "failed to fmt")
ms.Opts = xmain.NewOpts(ms.Env, ms.Opts.Flags.Args()[1:])
@@ -22,6 +23,8 @@ func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
return xmain.UsageErrorf("fmt must be passed at least one file to be formatted")
}
+ unformattedCount := 0
+
for _, inputPath := range ms.Opts.Args {
if inputPath != "-" {
inputPath = ms.AbsPath(inputPath)
@@ -43,10 +46,25 @@ func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
output := []byte(d2format.Format(m))
if !bytes.Equal(output, input) {
- if err := ms.WritePath(inputPath, output); err != nil {
- return err
+ if check {
+ unformattedCount += 1
+ log.Warn(ctx, inputPath)
+ } else {
+ if err := ms.WritePath(inputPath, output); err != nil {
+ return err
+ }
}
}
}
+
+ if unformattedCount > 0 {
+ pluralFiles := "file"
+ if unformattedCount > 1 {
+ pluralFiles = "files"
+ }
+
+ return xmain.ExitErrorf(1, "found %d unformatted %s. Run d2 fmt to fix.", unformattedCount, pluralFiles)
+ }
+
return nil
}
diff --git a/d2cli/main.go b/d2cli/main.go
index eeefd6ae91..4f6ec54c2e 100644
--- a/d2cli/main.go
+++ b/d2cli/main.go
@@ -103,6 +103,11 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil {
return err
}
+ stdoutFormatFlag := ms.Opts.String("", "stdout-format", "", "", "output format when writing to stdout (svg, png). Usage: d2 input.d2 --stdout-format png - > output.png")
+ if err != nil {
+ return err
+ }
+
browserFlag := ms.Opts.String("BROWSER", "browser", "", "", "browser executable that watch opens. Setting to 0 opens no browser.")
centerFlag, err := ms.Opts.Bool("D2_CENTER", "center", "c", false, "center the SVG in the containing viewbox, such as your browser screen")
if err != nil {
@@ -119,6 +124,11 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
fontBoldFlag := ms.Opts.String("D2_FONT_BOLD", "font-bold", "", "", "path to .ttf file to use for the bold font. If none provided, Source Sans Pro Bold is used.")
fontSemiboldFlag := ms.Opts.String("D2_FONT_SEMIBOLD", "font-semibold", "", "", "path to .ttf file to use for the semibold font. If none provided, Source Sans Pro Semibold is used.")
+ checkFlag, err := ms.Opts.Bool("D2_CHECK", "check", "", false, "check that the specified files are formatted correctly.")
+ if err != nil {
+ return err
+ }
+
plugins, err := d2plugin.ListPlugins(ctx)
if err != nil {
return err
@@ -153,7 +163,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
themesCmd(ctx, ms)
return nil
case "fmt":
- return fmtCmd(ctx, ms)
+ return fmtCmd(ctx, ms, *checkFlag)
case "version":
if len(ms.Opts.Flags.Args()) > 1 {
return xmain.UsageErrorf("version subcommand accepts no arguments")
@@ -213,7 +223,12 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if filepath.Ext(outputPath) == ".ppt" {
return xmain.UsageErrorf("D2 does not support ppt exports, did you mean \"pptx\"?")
}
- outputFormat := getExportExtension(outputPath)
+
+ outputFormat, err := getOutputFormat(stdoutFormatFlag, outputPath)
+ if err != nil {
+ return xmain.UsageErrorf("%v", err)
+ }
+
if outputPath != "-" {
outputPath = ms.AbsPath(outputPath)
if *animateIntervalFlag > 0 && !outputFormat.supportsAnimation() {
@@ -325,6 +340,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
forceAppendix: *forceAppendixFlag,
pw: pw,
fontFamily: fontFamily,
+ outputFormat: outputFormat,
})
if err != nil {
return err
@@ -355,7 +371,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
ctx, cancel := timelib.WithTimeout(ctx, time.Minute*2)
defer cancel()
- _, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page)
+ _, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page, outputFormat)
if err != nil {
if written {
return fmt.Errorf("failed to fully compile (partial render written) %s: %w", ms.HumanPath(inputPath), err)
@@ -430,7 +446,7 @@ func RouterResolver(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plu
}
}
-func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) {
+func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page, ext exportExtension) (_ []byte, written bool, _ error) {
start := time.Now()
input, err := ms.ReadPath(inputPath)
if err != nil {
@@ -522,7 +538,6 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
return nil, false, err
}
- ext := getExportExtension(outputPath)
switch ext {
case GIF:
svg, pngs, err := renderPNGsForGIF(ctx, ms, plugin, renderOpts, ruler, page, inputPath, diagram)
@@ -598,9 +613,9 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
var boards [][]byte
var err error
if noChildren {
- boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
+ boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext)
} else {
- boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
+ boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext)
}
if err != nil {
return nil, false, err
@@ -739,7 +754,7 @@ func relink(currDiagramPath string, d *d2target.Diagram, linkToOutput map[string
return nil
}
-func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
+func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, ext exportExtension) ([][]byte, error) {
if diagram.Name != "" {
ext := filepath.Ext(outputPath)
outputPath = strings.TrimSuffix(outputPath, ext)
@@ -785,21 +800,21 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
var boards [][]byte
for _, dl := range diagram.Layers {
- childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl)
+ childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
if err != nil {
return nil, err
}
boards = append(boards, childrenBoards...)
}
for _, dl := range diagram.Scenarios {
- childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl)
+ childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
if err != nil {
return nil, err
}
boards = append(boards, childrenBoards...)
}
for _, dl := range diagram.Steps {
- childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl)
+ childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
if err != nil {
return nil, err
}
@@ -808,7 +823,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
if !diagram.IsFolderOnly {
start := time.Now()
- out, err := _render(ctx, ms, plugin, opts, inputPath, boardOutputPath, bundle, forceAppendix, page, ruler, diagram)
+ out, err := _render(ctx, ms, plugin, opts, inputPath, boardOutputPath, bundle, forceAppendix, page, ruler, diagram, ext)
if err != nil {
return boards, err
}
@@ -822,9 +837,9 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
return boards, nil
}
-func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
+func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([][]byte, error) {
start := time.Now()
- out, err := _render(ctx, ms, plugin, opts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
+ out, err := _render(ctx, ms, plugin, opts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, outputFormat)
if err != nil {
return [][]byte{}, err
}
@@ -835,8 +850,9 @@ func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration
return [][]byte{out}, nil
}
-func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
- toPNG := getExportExtension(outputPath) == PNG
+func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([]byte, error) {
+ toPNG := outputFormat == PNG
+
var scale *float64
if opts.Scale != nil {
scale = opts.Scale
diff --git a/d2cli/watch.go b/d2cli/watch.go
index 61b236348c..35188970e0 100644
--- a/d2cli/watch.go
+++ b/d2cli/watch.go
@@ -3,7 +3,6 @@ package d2cli
import (
"context"
"embed"
- _ "embed"
"errors"
"fmt"
"io/fs"
@@ -57,6 +56,7 @@ type watcherOpts struct {
forceAppendix bool
pw png.Playwright
fontFamily *d2fonts.FontFamily
+ outputFormat exportExtension
}
type watcher struct {
@@ -430,7 +430,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
if w.boardPath != "" {
boardPath = strings.Split(w.boardPath, string(os.PathSeparator))
}
- svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page)
+ svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page, w.outputFormat)
w.boardpathMu.Unlock()
errs := ""
if err != nil {
diff --git a/d2js/README.md b/d2js/README.md
new file mode 100644
index 0000000000..6728d72af6
--- /dev/null
+++ b/d2js/README.md
@@ -0,0 +1,30 @@
+# D2 as a Javascript library
+
+D2 is runnable as a Javascript library, on both the client and server side. This means you
+can run D2 entirely on the browser.
+
+This is achieved by a JS wrapper around a WASM file.
+
+## Install
+
+### NPM
+
+```sh
+npm install @terrastruct/d2
+```
+
+### Yarn
+
+```sh
+yarn add @terrastruct/d2
+```
+
+## Build
+
+```sh
+GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o main.wasm ./d2js
+```
+
+## API
+
+todo
diff --git a/d2js/d2wasm/api.go b/d2js/d2wasm/api.go
new file mode 100644
index 0000000000..e87386cd64
--- /dev/null
+++ b/d2js/d2wasm/api.go
@@ -0,0 +1,71 @@
+//go:build js && wasm
+
+package d2wasm
+
+import (
+ "encoding/json"
+ "fmt"
+ "runtime/debug"
+ "syscall/js"
+)
+
+type D2API struct {
+ exports map[string]js.Func
+}
+
+func NewD2API() *D2API {
+ return &D2API{
+ exports: make(map[string]js.Func),
+ }
+}
+
+func (api *D2API) Register(name string, fn func(args []js.Value) (interface{}, error)) {
+ api.exports[name] = wrapWASMCall(fn)
+}
+
+func (api *D2API) ExportTo(target js.Value) {
+ d2Namespace := make(map[string]interface{})
+ for name, fn := range api.exports {
+ d2Namespace[name] = fn
+ }
+ target.Set("d2", js.ValueOf(d2Namespace))
+}
+
+func wrapWASMCall(fn func(args []js.Value) (interface{}, error)) js.Func {
+ return js.FuncOf(func(this js.Value, args []js.Value) (result any) {
+ defer func() {
+ if r := recover(); r != nil {
+ resp := WASMResponse{
+ Error: &WASMError{
+ Message: fmt.Sprintf("panic recovered: %v\n%s", r, debug.Stack()),
+ Code: 500,
+ },
+ }
+ jsonResp, _ := json.Marshal(resp)
+ result = string(jsonResp)
+ }
+ }()
+
+ data, err := fn(args)
+ if err != nil {
+ wasmErr, ok := err.(*WASMError)
+ if !ok {
+ wasmErr = &WASMError{
+ Message: err.Error(),
+ Code: 500,
+ }
+ }
+ resp := WASMResponse{
+ Error: wasmErr,
+ }
+ jsonResp, _ := json.Marshal(resp)
+ return string(jsonResp)
+ }
+
+ resp := WASMResponse{
+ Data: data,
+ }
+ jsonResp, _ := json.Marshal(resp)
+ return string(jsonResp)
+ })
+}
diff --git a/d2js/d2wasm/functions.go b/d2js/d2wasm/functions.go
new file mode 100644
index 0000000000..d82965a69e
--- /dev/null
+++ b/d2js/d2wasm/functions.go
@@ -0,0 +1,292 @@
+//go:build js && wasm
+
+package d2wasm
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "syscall/js"
+
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2compiler"
+ "oss.terrastruct.com/d2/d2format"
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
+ "oss.terrastruct.com/d2/d2layouts/d2elklayout"
+ "oss.terrastruct.com/d2/d2lib"
+ "oss.terrastruct.com/d2/d2lsp"
+ "oss.terrastruct.com/d2/d2oracle"
+ "oss.terrastruct.com/d2/d2parser"
+ "oss.terrastruct.com/d2/d2renderers/d2fonts"
+ "oss.terrastruct.com/d2/d2renderers/d2svg"
+ "oss.terrastruct.com/d2/lib/log"
+ "oss.terrastruct.com/d2/lib/memfs"
+ "oss.terrastruct.com/d2/lib/textmeasure"
+ "oss.terrastruct.com/d2/lib/urlenc"
+ "oss.terrastruct.com/d2/lib/version"
+ "oss.terrastruct.com/util-go/go2"
+)
+
+func GetParentID(args []js.Value) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, &WASMError{Message: "missing id argument", Code: 400}
+ }
+
+ id := args[0].String()
+ mk, err := d2parser.ParseMapKey(id)
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 400}
+ }
+
+ if len(mk.Edges) > 0 {
+ return "", nil
+ }
+
+ if mk.Key != nil {
+ if len(mk.Key.Path) == 1 {
+ return "root", nil
+ }
+ mk.Key.Path = mk.Key.Path[:len(mk.Key.Path)-1]
+ return strings.Join(mk.Key.StringIDA(), "."), nil
+ }
+
+ return "", nil
+}
+
+func GetObjOrder(args []js.Value) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, &WASMError{Message: "missing dsl argument", Code: 400}
+ }
+
+ dsl := args[0].String()
+ g, _, err := d2compiler.Compile("", strings.NewReader(dsl), &d2compiler.CompileOptions{
+ UTF16Pos: true,
+ })
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 400}
+ }
+
+ objOrder, err := d2oracle.GetObjOrder(g, nil)
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 500}
+ }
+
+ return map[string]interface{}{
+ "order": objOrder,
+ }, nil
+}
+
+func GetRefRanges(args []js.Value) (interface{}, error) {
+ if len(args) < 4 {
+ return nil, &WASMError{Message: "missing required arguments", Code: 400}
+ }
+
+ var fs map[string]string
+ if err := json.Unmarshal([]byte(args[0].String()), &fs); err != nil {
+ return nil, &WASMError{Message: "invalid fs argument", Code: 400}
+ }
+
+ file := args[1].String()
+ key := args[2].String()
+
+ var boardPath []string
+ if err := json.Unmarshal([]byte(args[3].String()), &boardPath); err != nil {
+ return nil, &WASMError{Message: "invalid boardPath argument", Code: 400}
+ }
+
+ ranges, importRanges, err := d2lsp.GetRefRanges(file, fs, boardPath, key)
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 500}
+ }
+
+ return RefRangesResponse{
+ Ranges: ranges,
+ ImportRanges: importRanges,
+ }, nil
+}
+
+func Compile(args []js.Value) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, &WASMError{Message: "missing JSON argument", Code: 400}
+ }
+ var input CompileRequest
+ if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
+ return nil, &WASMError{Message: "invalid JSON input", Code: 400}
+ }
+
+ if input.FS == nil {
+ return nil, &WASMError{Message: "missing 'fs' field in input JSON", Code: 400}
+ }
+
+ if _, ok := input.FS["index"]; !ok {
+ return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400}
+ }
+
+ fs, err := memfs.New(input.FS)
+ if err != nil {
+ return nil, &WASMError{Message: fmt.Sprintf("invalid fs input: %s", err.Error()), Code: 400}
+ }
+
+ ruler, err := textmeasure.NewRuler()
+ if err != nil {
+ return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500}
+ }
+ ctx := log.WithDefault(context.Background())
+ layoutFunc := d2dagrelayout.DefaultLayout
+ if input.Opts != nil && input.Opts.Layout != nil {
+ switch *input.Opts.Layout {
+ case "dagre":
+ layoutFunc = d2dagrelayout.DefaultLayout
+ case "elk":
+ layoutFunc = d2elklayout.DefaultLayout
+ default:
+ return nil, &WASMError{Message: fmt.Sprintf("layout option '%s' not recognized", *input.Opts.Layout), Code: 400}
+ }
+ }
+ layoutResolver := func(engine string) (d2graph.LayoutGraph, error) {
+ return layoutFunc, nil
+ }
+
+ renderOpts := &d2svg.RenderOpts{}
+ var fontFamily *d2fonts.FontFamily
+ if input.Opts != nil && input.Opts.Sketch != nil {
+ fontFamily = go2.Pointer(d2fonts.HandDrawn)
+ renderOpts.Sketch = input.Opts.Sketch
+ }
+ if input.Opts != nil && input.Opts.ThemeID != nil {
+ renderOpts.ThemeID = input.Opts.ThemeID
+ }
+ diagram, g, err := d2lib.Compile(ctx, input.FS["index"], &d2lib.CompileOptions{
+ UTF16Pos: true,
+ FS: fs,
+ Ruler: ruler,
+ LayoutResolver: layoutResolver,
+ FontFamily: fontFamily,
+ }, renderOpts)
+ if err != nil {
+ if pe, ok := err.(*d2parser.ParseError); ok {
+ return nil, &WASMError{Message: pe.Error(), Code: 400}
+ }
+ return nil, &WASMError{Message: err.Error(), Code: 500}
+ }
+
+ input.FS["index"] = d2format.Format(g.AST)
+
+ return CompileResponse{
+ FS: input.FS,
+ Diagram: *diagram,
+ Graph: *g,
+ }, nil
+}
+
+func Render(args []js.Value) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, &WASMError{Message: "missing JSON argument", Code: 400}
+ }
+ var input RenderRequest
+ if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
+ return nil, &WASMError{Message: "invalid JSON input", Code: 400}
+ }
+
+ if input.Diagram == nil {
+ return nil, &WASMError{Message: "missing 'diagram' field in input JSON", Code: 400}
+ }
+
+ renderOpts := &d2svg.RenderOpts{}
+ if input.Opts != nil && input.Opts.Sketch != nil {
+ renderOpts.Sketch = input.Opts.Sketch
+ }
+ if input.Opts != nil && input.Opts.ThemeID != nil {
+ renderOpts.ThemeID = input.Opts.ThemeID
+ }
+ out, err := d2svg.Render(input.Diagram, renderOpts)
+ if err != nil {
+ return nil, &WASMError{Message: fmt.Sprintf("render failed: %s", err.Error()), Code: 500}
+ }
+
+ return out, nil
+}
+
+func GetBoardAtPosition(args []js.Value) (interface{}, error) {
+ if len(args) < 3 {
+ return nil, &WASMError{Message: "missing required arguments", Code: 400}
+ }
+
+ dsl := args[0].String()
+ line := args[1].Int()
+ column := args[2].Int()
+
+ boardPath, err := d2lsp.GetBoardAtPosition(dsl, d2ast.Position{
+ Line: line,
+ Column: column,
+ })
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 500}
+ }
+
+ return BoardPositionResponse{BoardPath: boardPath}, nil
+}
+
+func Encode(args []js.Value) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, &WASMError{Message: "missing script argument", Code: 400}
+ }
+
+ script := args[0].String()
+ encoded, err := urlenc.Encode(script)
+ // should never happen
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 500}
+ }
+
+ return map[string]string{"result": encoded}, nil
+}
+
+func Decode(args []js.Value) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, &WASMError{Message: "missing script argument", Code: 400}
+ }
+
+ script := args[0].String()
+ script, err := urlenc.Decode(script)
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 500}
+ }
+ return map[string]string{"result": script}, nil
+}
+
+func GetVersion(args []js.Value) (interface{}, error) {
+ return version.Version, nil
+}
+
+func GetCompletions(args []js.Value) (interface{}, error) {
+ if len(args) < 3 {
+ return nil, &WASMError{Message: "missing required arguments", Code: 400}
+ }
+
+ text := args[0].String()
+ line := args[1].Int()
+ column := args[2].Int()
+
+ completions, err := d2lsp.GetCompletionItems(text, line, column)
+ if err != nil {
+ return nil, &WASMError{Message: err.Error(), Code: 500}
+ }
+
+ // Convert to map for JSON serialization
+ items := make([]map[string]interface{}, len(completions))
+ for i, completion := range completions {
+ items[i] = map[string]interface{}{
+ "label": completion.Label,
+ "kind": int(completion.Kind),
+ "detail": completion.Detail,
+ "insertText": completion.InsertText,
+ }
+ }
+
+ return CompletionResponse{
+ Items: items,
+ }, nil
+}
diff --git a/d2js/d2wasm/types.go b/d2js/d2wasm/types.go
new file mode 100644
index 0000000000..a13b82baec
--- /dev/null
+++ b/d2js/d2wasm/types.go
@@ -0,0 +1,58 @@
+//go:build js && wasm
+
+package d2wasm
+
+import (
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2target"
+)
+
+type WASMResponse struct {
+ Data interface{} `json:"data,omitempty"`
+ Error *WASMError `json:"error,omitempty"`
+}
+
+type WASMError struct {
+ Message string `json:"message"`
+ Code int `json:"code"`
+}
+
+func (e *WASMError) Error() string {
+ return e.Message
+}
+
+type RefRangesResponse struct {
+ Ranges []d2ast.Range `json:"ranges"`
+ ImportRanges []d2ast.Range `json:"importRanges"`
+}
+
+type BoardPositionResponse struct {
+ BoardPath []string `json:"boardPath"`
+}
+
+type CompileRequest struct {
+ FS map[string]string `json:"fs"`
+ Opts *RenderOptions `json:"options"`
+}
+
+type RenderOptions struct {
+ Layout *string `json:"layout"`
+ Sketch *bool `json:"sketch"`
+ ThemeID *int64 `json:"themeID"`
+}
+
+type CompileResponse struct {
+ FS map[string]string `json:"fs"`
+ Diagram d2target.Diagram `json:"diagram"`
+ Graph d2graph.Graph `json:"graph"`
+}
+
+type CompletionResponse struct {
+ Items []map[string]interface{} `json:"items"`
+}
+
+type RenderRequest struct {
+ Diagram *d2target.Diagram `json:"diagram"`
+ Opts *RenderOptions `json:"options"`
+}
diff --git a/d2js/js.go b/d2js/js.go
new file mode 100644
index 0000000000..514fd5179d
--- /dev/null
+++ b/d2js/js.go
@@ -0,0 +1,31 @@
+//go:build js && wasm
+
+package main
+
+import (
+ "syscall/js"
+
+ "oss.terrastruct.com/d2/d2js/d2wasm"
+)
+
+func main() {
+ api := d2wasm.NewD2API()
+
+ api.Register("getCompletions", d2wasm.GetCompletions)
+ api.Register("getParentID", d2wasm.GetParentID)
+ api.Register("getObjOrder", d2wasm.GetObjOrder)
+ api.Register("getRefRanges", d2wasm.GetRefRanges)
+ api.Register("compile", d2wasm.Compile)
+ api.Register("render", d2wasm.Render)
+ api.Register("getBoardAtPosition", d2wasm.GetBoardAtPosition)
+ api.Register("encode", d2wasm.Encode)
+ api.Register("decode", d2wasm.Decode)
+ api.Register("version", d2wasm.GetVersion)
+
+ api.ExportTo(js.Global())
+
+ if cb := js.Global().Get("onWasmInitialized"); !cb.IsUndefined() {
+ cb.Invoke()
+ }
+ select {}
+}
diff --git a/d2layouts/d2sequence/sequence_diagram.go b/d2layouts/d2sequence/sequence_diagram.go
index 69eb199854..8943bfce6d 100644
--- a/d2layouts/d2sequence/sequence_diagram.go
+++ b/d2layouts/d2sequence/sequence_diagram.go
@@ -466,7 +466,12 @@ func (sd *sequenceDiagram) placeNotes() {
for _, msg := range sd.messages {
if sd.verticalIndices[msg.AbsID()] < verticalIndex {
- y += sd.yStep + float64(msg.LabelDimensions.Height)
+ if msg.Src == msg.Dst {
+ // For self-messages, account for the full vertical space they occupy
+ y += sd.yStep + math.Max(float64(msg.LabelDimensions.Height), MIN_MESSAGE_DISTANCE)*1.5
+ } else {
+ y += sd.yStep + float64(msg.LabelDimensions.Height)
+ }
}
}
for _, otherNote := range sd.notes {
diff --git a/d2lsp/completion.go b/d2lsp/completion.go
new file mode 100644
index 0000000000..cbdd2706c6
--- /dev/null
+++ b/d2lsp/completion.go
@@ -0,0 +1,500 @@
+// Completion implements lsp autocomplete features
+// Currently handles:
+// - Complete dot and inside maps for reserved keyword holders (style, labels, etc)
+// - Complete discrete values for keywords like shape
+// - Complete suggestions for formats for keywords like opacity
+package d2lsp
+
+import (
+ "strings"
+ "unicode"
+
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2parser"
+ "oss.terrastruct.com/d2/d2target"
+)
+
+type CompletionKind int
+
+const (
+ KeywordCompletion CompletionKind = iota
+ StyleCompletion
+ ShapeCompletion
+)
+
+type CompletionItem struct {
+ Label string
+ Kind CompletionKind
+ Detail string
+ InsertText string
+}
+
+func GetCompletionItems(text string, line, column int) ([]CompletionItem, error) {
+ ast, err := d2parser.Parse("", strings.NewReader(text), nil)
+ if err != nil {
+ ast, _ = d2parser.Parse("", strings.NewReader(getTextUntilPosition(text, line, column)), nil)
+ }
+
+ keyword := getKeywordContext(text, ast, line, column)
+ switch keyword {
+ case "style", "style.":
+ return getStyleCompletions(), nil
+ case "shape", "shape:":
+ return getShapeCompletions(), nil
+ case "shadow", "3d", "multiple", "animated", "bold", "italic", "underline", "filled", "double-border",
+ "shadow:", "3d:", "multiple:", "animated:", "bold:", "italic:", "underline:", "filled:", "double-border:":
+ return getBooleanCompletions(), nil
+ case "fill-pattern", "fill-pattern:":
+ return getFillPatternCompletions(), nil
+ case "text-transform", "text-transform:":
+ return getTextTransformCompletions(), nil
+ case "opacity", "stroke-width", "stroke-dash", "border-radius", "font-size",
+ "stroke", "fill", "font-color":
+ return getValueCompletions(keyword), nil
+ case "opacity:", "stroke-width:", "stroke-dash:", "border-radius:", "font-size:",
+ "stroke:", "fill:", "font-color:":
+ return getValueCompletions(keyword[:len(keyword)-1]), nil
+ case "width", "height", "top", "left":
+ return getValueCompletions(keyword), nil
+ case "width:", "height:", "top:", "left:":
+ return getValueCompletions(keyword[:len(keyword)-1]), nil
+ case "source-arrowhead", "target-arrowhead":
+ return getArrowheadCompletions(), nil
+ case "source-arrowhead.shape:", "target-arrowhead.shape:":
+ return getArrowheadShapeCompletions(), nil
+ case "label", "label.":
+ return getLabelCompletions(), nil
+ case "icon", "icon:":
+ return getIconCompletions(), nil
+ case "icon.":
+ return getLabelCompletions(), nil
+ case "near", "near:":
+ return getNearCompletions(), nil
+ case "tooltip:", "tooltip":
+ return getTooltipCompletions(), nil
+ case "direction:", "direction":
+ return getDirectionCompletions(), nil
+ default:
+ return nil, nil
+ }
+}
+
+func getTextUntilPosition(text string, line, column int) string {
+ lines := strings.Split(text, "\n")
+ if line >= len(lines) {
+ return text
+ }
+
+ result := strings.Join(lines[:line], "\n")
+ if len(result) > 0 {
+ result += "\n"
+ }
+ if column > len(lines[line]) {
+ result += lines[line]
+ } else {
+ result += lines[line][:column]
+ }
+ return result
+}
+
+func getKeywordContext(text string, m *d2ast.Map, line, column int) string {
+ if m == nil {
+ return ""
+ }
+ lines := strings.Split(text, "\n")
+
+ for _, n := range m.Nodes {
+ if n.MapKey == nil {
+ continue
+ }
+
+ var firstPart, lastPart string
+ var key *d2ast.KeyPath
+ if len(n.MapKey.Edges) > 0 {
+ key = n.MapKey.EdgeKey
+ } else {
+ key = n.MapKey.Key
+ }
+ if key != nil && len(key.Path) > 0 {
+ firstKey := key.Path[0].Unbox()
+ if !firstKey.IsUnquoted() {
+ continue
+ }
+ firstPart = firstKey.ScalarString()
+
+ pathLen := len(key.Path)
+ if pathLen > 1 {
+ lastKey := key.Path[pathLen-1].Unbox()
+ if lastKey.IsUnquoted() {
+ lastPart = lastKey.ScalarString()
+ _, isHolderLast := d2ast.ReservedKeywordHolders[lastPart]
+ if !isHolderLast {
+ _, isHolderLast = d2ast.CompositeReservedKeywords[lastPart]
+ }
+ keyRange := n.MapKey.Range
+ lineText := lines[keyRange.End.Line]
+ if isHolderLast && isAfterDot(lineText, column) {
+ return lastPart + "."
+ }
+ }
+ }
+ }
+ if _, isBoard := d2ast.BoardKeywords[firstPart]; isBoard {
+ firstPart = ""
+ }
+
+ _, isHolder := d2ast.ReservedKeywordHolders[firstPart]
+ if !isHolder {
+ _, isHolder = d2ast.CompositeReservedKeywords[firstPart]
+ }
+
+ // Check nested map
+ if n.MapKey.Value.Map != nil && isPositionInMap(line, column, n.MapKey.Value.Map) {
+ if nested := getKeywordContext(text, n.MapKey.Value.Map, line, column); nested != "" {
+ if isHolder {
+ // If we got a direct key completion from inside a holder's map,
+ // prefix it with the holder's name
+ if strings.HasSuffix(nested, ":") && !strings.Contains(nested, ".") {
+ return firstPart + "." + strings.TrimSuffix(nested, ":") + ":"
+ }
+ }
+ return nested
+ }
+ return firstPart
+ }
+
+ keyRange := n.MapKey.Range
+ if line != keyRange.End.Line {
+ continue
+ }
+
+ // 1) Skip if cursor is well above/below this key
+ if line < keyRange.Start.Line || line > keyRange.End.Line {
+ continue
+ }
+
+ // 2) If on the start line, skip if before the key
+ if line == keyRange.Start.Line && column < keyRange.Start.Column {
+ continue
+ }
+
+ // 3) If on the end line, allow up to keyRange.End.Column + 1
+ if line == keyRange.End.Line && column > keyRange.End.Column+1 {
+ continue
+ }
+
+ lineText := lines[keyRange.End.Line]
+
+ if isAfterColon(lineText, column) {
+ if key != nil && len(key.Path) > 1 {
+ if isHolder && (firstPart == "source-arrowhead" || firstPart == "target-arrowhead") {
+ return firstPart + "." + lastPart + ":"
+ }
+
+ _, isHolder := d2ast.ReservedKeywordHolders[lastPart]
+ if !isHolder {
+ return lastPart
+ }
+ }
+ return firstPart + ":"
+ }
+
+ if isAfterDot(lineText, column) && isHolder {
+ return firstPart
+ }
+ }
+
+ return ""
+}
+
+func isAfterDot(text string, pos int) bool {
+ return pos > 0 && pos <= len(text) && text[pos-1] == '.'
+}
+
+func isAfterColon(text string, pos int) bool {
+ if pos < 1 || pos > len(text) {
+ return false
+ }
+ i := pos - 1
+ for i >= 0 && unicode.IsSpace(rune(text[i])) {
+ i--
+ }
+ return i >= 0 && text[i] == ':'
+}
+
+func isPositionInMap(line, column int, m *d2ast.Map) bool {
+ if m == nil {
+ return false
+ }
+
+ mapRange := m.Range
+ if line < mapRange.Start.Line || line > mapRange.End.Line {
+ return false
+ }
+
+ if line == mapRange.Start.Line && column < mapRange.Start.Column {
+ return false
+ }
+ if line == mapRange.End.Line && column > mapRange.End.Column {
+ return false
+ }
+ return true
+}
+
+func getShapeCompletions() []CompletionItem {
+ items := make([]CompletionItem, 0, len(d2target.Shapes))
+ for _, shape := range d2target.Shapes {
+ item := CompletionItem{
+ Label: shape,
+ Kind: ShapeCompletion,
+ Detail: "shape",
+ InsertText: shape,
+ }
+ items = append(items, item)
+ }
+ return items
+}
+
+func getValueCompletions(property string) []CompletionItem {
+ switch property {
+ case "opacity":
+ return []CompletionItem{{
+ Label: "(number between 0.0 and 1.0)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. 0.4",
+ InsertText: "",
+ }}
+ case "stroke-width":
+ return []CompletionItem{{
+ Label: "(number between 0 and 15)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. 2",
+ InsertText: "",
+ }}
+ case "font-size":
+ return []CompletionItem{{
+ Label: "(number between 8 and 100)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. 14",
+ InsertText: "",
+ }}
+ case "stroke-dash":
+ return []CompletionItem{{
+ Label: "(number between 0 and 10)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. 5",
+ InsertText: "",
+ }}
+ case "border-radius":
+ return []CompletionItem{{
+ Label: "(number greater than or equal to 0)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. 4",
+ InsertText: "",
+ }}
+ case "font-color", "stroke", "fill":
+ return []CompletionItem{{
+ Label: "(color name or hex code)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. blue, #ff0000",
+ InsertText: "",
+ }}
+ case "width", "height", "top", "left":
+ return []CompletionItem{{
+ Label: "(pixels)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. 400",
+ InsertText: "",
+ }}
+ }
+ return nil
+}
+
+func getStyleCompletions() []CompletionItem {
+ items := make([]CompletionItem, 0, len(d2ast.StyleKeywords))
+ for keyword := range d2ast.StyleKeywords {
+ item := CompletionItem{
+ Label: keyword,
+ Kind: StyleCompletion,
+ Detail: "style property",
+ InsertText: keyword + ": ",
+ }
+ items = append(items, item)
+ }
+ return items
+}
+
+func getBooleanCompletions() []CompletionItem {
+ return []CompletionItem{
+ {
+ Label: "true",
+ Kind: KeywordCompletion,
+ Detail: "boolean",
+ InsertText: "true",
+ },
+ {
+ Label: "false",
+ Kind: KeywordCompletion,
+ Detail: "boolean",
+ InsertText: "false",
+ },
+ }
+}
+
+func getFillPatternCompletions() []CompletionItem {
+ items := make([]CompletionItem, 0, len(d2ast.FillPatterns))
+ for _, pattern := range d2ast.FillPatterns {
+ item := CompletionItem{
+ Label: pattern,
+ Kind: KeywordCompletion,
+ Detail: "fill pattern",
+ InsertText: pattern,
+ }
+ items = append(items, item)
+ }
+ return items
+}
+
+func getTextTransformCompletions() []CompletionItem {
+ items := make([]CompletionItem, 0, len(d2ast.TextTransforms))
+ for _, transform := range d2ast.TextTransforms {
+ item := CompletionItem{
+ Label: transform,
+ Kind: KeywordCompletion,
+ Detail: "text transform",
+ InsertText: transform,
+ }
+ items = append(items, item)
+ }
+ return items
+}
+
+func isOnEmptyLine(text string, line int) bool {
+ lines := strings.Split(text, "\n")
+ if line >= len(lines) {
+ return true
+ }
+
+ return strings.TrimSpace(lines[line]) == ""
+}
+
+func getLabelCompletions() []CompletionItem {
+ return []CompletionItem{{
+ Label: "near",
+ Kind: StyleCompletion,
+ Detail: "label position",
+ InsertText: "near: ",
+ }}
+}
+
+func getNearCompletions() []CompletionItem {
+ items := make([]CompletionItem, 0, len(d2ast.LabelPositionsArray)+1)
+
+ items = append(items, CompletionItem{
+ Label: "(object ID)",
+ Kind: KeywordCompletion,
+ Detail: "e.g. container.inner_shape",
+ InsertText: "",
+ })
+
+ for _, pos := range d2ast.LabelPositionsArray {
+ item := CompletionItem{
+ Label: pos,
+ Kind: KeywordCompletion,
+ Detail: "label position",
+ InsertText: pos,
+ }
+ items = append(items, item)
+ }
+ return items
+}
+
+func getTooltipCompletions() []CompletionItem {
+ return []CompletionItem{
+ {
+ Label: "(markdown)",
+ Kind: KeywordCompletion,
+ Detail: "markdown formatted text",
+ InsertText: "|md\n # Tooltip\n Hello world\n|",
+ },
+ }
+}
+
+func getIconCompletions() []CompletionItem {
+ return []CompletionItem{
+ {
+ Label: "(URL, e.g. https://icons.terrastruct.com/xyz.svg)",
+ Kind: KeywordCompletion,
+ Detail: "icon URL",
+ InsertText: "https://icons.terrastruct.com/essentials%2F073-add.svg",
+ },
+ }
+}
+
+func getDirectionCompletions() []CompletionItem {
+ directions := []string{"up", "down", "right", "left"}
+ items := make([]CompletionItem, len(directions))
+ for i, dir := range directions {
+ items[i] = CompletionItem{
+ Label: dir,
+ Kind: KeywordCompletion,
+ Detail: "direction",
+ InsertText: dir,
+ }
+ }
+ return items
+}
+
+func getArrowheadShapeCompletions() []CompletionItem {
+ arrowheads := []string{
+ "triangle",
+ "arrow",
+ "diamond",
+ "circle",
+ "cf-one", "cf-one-required",
+ "cf-many", "cf-many-required",
+ }
+
+ items := make([]CompletionItem, len(arrowheads))
+ details := map[string]string{
+ "triangle": "default",
+ "arrow": "like triangle but pointier",
+ "cf-one": "crows foot one",
+ "cf-one-required": "crows foot one (required)",
+ "cf-many": "crows foot many",
+ "cf-many-required": "crows foot many (required)",
+ }
+
+ for i, shape := range arrowheads {
+ detail := details[shape]
+ if detail == "" {
+ detail = "arrowhead shape"
+ }
+ items[i] = CompletionItem{
+ Label: shape,
+ Kind: ShapeCompletion,
+ Detail: detail,
+ InsertText: shape,
+ }
+ }
+ return items
+}
+
+func getArrowheadCompletions() []CompletionItem {
+ completions := []string{
+ "shape",
+ "label",
+ "style.filled",
+ }
+
+ items := make([]CompletionItem, len(completions))
+
+ for i, shape := range completions {
+ items[i] = CompletionItem{
+ Label: shape,
+ Kind: ShapeCompletion,
+ InsertText: shape,
+ }
+ }
+ return items
+}
diff --git a/d2lsp/completion_test.go b/d2lsp/completion_test.go
new file mode 100644
index 0000000000..9937ac3582
--- /dev/null
+++ b/d2lsp/completion_test.go
@@ -0,0 +1,421 @@
+package d2lsp
+
+import (
+ "testing"
+)
+
+func TestGetCompletionItems(t *testing.T) {
+ tests := []struct {
+ name string
+ text string
+ line int
+ column int
+ want []CompletionItem
+ wantErr bool
+ }{
+ {
+ name: "style dot suggestions",
+ text: "a.style.",
+ line: 0,
+ column: 8,
+ want: getStyleCompletions(),
+ },
+ {
+ name: "style map suggestions",
+ text: `a: {
+ style.
+}
+`,
+ line: 1,
+ column: 8,
+ want: getStyleCompletions(),
+ },
+ {
+ name: "3d style map suggestions",
+ text: `a.style: {
+ 3d:
+}
+`,
+ line: 1,
+ column: 5,
+ want: getBooleanCompletions(),
+ },
+ {
+ name: "fill pattern style map suggestions",
+ text: `a.style: {
+ fill-pattern:
+}
+`,
+ line: 1,
+ column: 15,
+ want: getFillPatternCompletions(),
+ },
+ {
+ name: "opacity style map suggestions",
+ text: `a.style: {
+ opacity:
+}
+`,
+ line: 1,
+ column: 10,
+ want: getValueCompletions("opacity"),
+ },
+ {
+ name: "width dot",
+ text: `a.width:`,
+ line: 0,
+ column: 8,
+ want: getValueCompletions("width"),
+ },
+ {
+ name: "layer shape",
+ text: `a
+
+layers: {
+ hey: {
+ go: {
+ shape:
+ }
+ }
+}
+`,
+ line: 5,
+ column: 12,
+ want: getShapeCompletions(),
+ },
+ {
+ name: "stroke width value",
+ text: `a.style.stroke-width: 1`,
+ line: 0,
+ column: 23,
+ want: nil,
+ },
+ {
+ name: "no style suggestions",
+ text: `a.style:
+`,
+ line: 0,
+ column: 8,
+ want: nil,
+ },
+ {
+ name: "style property suggestions",
+ text: "a -> b: { style. }",
+ line: 0,
+ column: 16,
+ want: getStyleCompletions(),
+ },
+ {
+ name: "style.opacity value hint",
+ text: "a -> b: { style.opacity: }",
+ line: 0,
+ column: 24,
+ want: getValueCompletions("opacity"),
+ },
+ {
+ name: "fill pattern completions",
+ text: "a -> b: { style.fill-pattern: }",
+ line: 0,
+ column: 29,
+ want: getFillPatternCompletions(),
+ },
+ {
+ name: "text transform completions",
+ text: "a -> b: { style.text-transform: }",
+ line: 0,
+ column: 31,
+ want: getTextTransformCompletions(),
+ },
+ {
+ name: "boolean property completions",
+ text: "a -> b: { style.shadow: }",
+ line: 0,
+ column: 23,
+ want: getBooleanCompletions(),
+ },
+ {
+ name: "near position completions",
+ text: "a -> b: { label.near: }",
+ line: 0,
+ column: 21,
+ want: getNearCompletions(),
+ },
+ {
+ name: "direction completions",
+ text: "a -> b: { direction: }",
+ line: 0,
+ column: 20,
+ want: getDirectionCompletions(),
+ },
+ {
+ name: "icon url completions",
+ text: "a -> b: { icon: }",
+ line: 0,
+ column: 15,
+ want: getIconCompletions(),
+ },
+ {
+ name: "icon dot url completions",
+ text: "a.icon:",
+ line: 0,
+ column: 7,
+ want: getIconCompletions(),
+ },
+ {
+ name: "icon near completions",
+ text: "a -> b: { icon.near: }",
+ line: 0,
+ column: 20,
+ want: getNearCompletions(),
+ },
+ {
+ name: "icon map",
+ text: `a.icon: {
+ # here
+}`,
+ line: 1,
+ column: 2,
+ want: nil,
+ },
+ {
+ name: "icon flat dot",
+ text: `a.icon.`,
+ line: 0,
+ column: 7,
+ want: getLabelCompletions(),
+ },
+ {
+ name: "label flat dot",
+ text: `a.label.`,
+ line: 0,
+ column: 8,
+ want: getLabelCompletions(),
+ },
+ {
+ name: "arrowhead completions - dot syntax",
+ text: "a -> b: { source-arrowhead. }",
+ line: 0,
+ column: 27,
+ want: getArrowheadCompletions(),
+ },
+ {
+ name: "arrowhead completions - colon syntax",
+ text: "a -> b: { source-arrowhead: }",
+ line: 0,
+ column: 27,
+ want: nil,
+ },
+ {
+ name: "arrowhead completions - map syntax",
+ text: `a -> b: {
+ source-arrowhead: {
+ # here
+ }
+}`,
+ line: 2,
+ column: 4,
+ want: getArrowheadCompletions(),
+ },
+ {
+ name: "arrowhead shape completions - flat dot syntax",
+ text: "(a -> b)[0].source-arrowhead.shape:",
+ line: 0,
+ column: 35,
+ want: getArrowheadShapeCompletions(),
+ },
+ {
+ name: "arrowhead shape completions - dot syntax",
+ text: "a -> b: { source-arrowhead.shape: }",
+ line: 0,
+ column: 33,
+ want: getArrowheadShapeCompletions(),
+ },
+ {
+ name: "arrowhead shape completions - map syntax",
+ text: "a -> b: { source-arrowhead: { shape: } }",
+ line: 0,
+ column: 36,
+ want: getArrowheadShapeCompletions(),
+ },
+ {
+ name: "width value hint",
+ text: "a -> b: { width: }",
+ line: 0,
+ column: 16,
+ want: getValueCompletions("width"),
+ },
+ {
+ name: "height value hint",
+ text: "a -> b: { height: }",
+ line: 0,
+ column: 17,
+ want: getValueCompletions("height"),
+ },
+ {
+ name: "tooltip markdown template",
+ text: "a -> b: { tooltip: }",
+ line: 0,
+ column: 18,
+ want: getTooltipCompletions(),
+ },
+ {
+ name: "tooltip dot markdown template",
+ text: "a.tooltip:",
+ line: 0,
+ column: 10,
+ want: getTooltipCompletions(),
+ },
+ {
+ name: "shape dot suggestions",
+ text: "a.shape:",
+ line: 0,
+ column: 8,
+ want: getShapeCompletions(),
+ },
+ {
+ name: "shape suggestions",
+ text: "a -> b: { shape: }",
+ line: 0,
+ column: 16,
+ want: getShapeCompletions(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := GetCompletionItems(tt.text, tt.line, tt.column)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetCompletionItems() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if len(got) != len(tt.want) {
+ t.Errorf("GetCompletionItems() got %d completions, want %d", len(got), len(tt.want))
+ return
+ }
+
+ // Create maps for easy comparison
+ gotMap := make(map[string]CompletionItem)
+ wantMap := make(map[string]CompletionItem)
+ for _, item := range got {
+ gotMap[item.Label] = item
+ }
+ for _, item := range tt.want {
+ wantMap[item.Label] = item
+ }
+
+ // Check that each completion exists and has correct properties
+ for label, wantItem := range wantMap {
+ gotItem, exists := gotMap[label]
+ if !exists {
+ t.Errorf("missing completion for %q", label)
+ continue
+ }
+ if gotItem.Kind != wantItem.Kind {
+ t.Errorf("completion %q Kind = %v, want %v", label, gotItem.Kind, wantItem.Kind)
+ }
+ if gotItem.Detail != wantItem.Detail {
+ t.Errorf("completion %q Detail = %v, want %v", label, gotItem.Detail, wantItem.Detail)
+ }
+ if gotItem.InsertText != wantItem.InsertText {
+ t.Errorf("completion %q InsertText = %v, want %v", label, gotItem.InsertText, wantItem.InsertText)
+ }
+ }
+ })
+ }
+}
+
+// Helper function to compare CompletionItem slices
+func equalCompletions(a, b []CompletionItem) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].Label != b[i].Label ||
+ a[i].Kind != b[i].Kind ||
+ a[i].Detail != b[i].Detail ||
+ a[i].InsertText != b[i].InsertText {
+ return false
+ }
+ }
+ return true
+}
+
+func TestGetArrowheadShapeCompletions(t *testing.T) {
+ got := getArrowheadShapeCompletions()
+
+ expectedLabels := []string{
+ "triangle", "arrow", "diamond", "circle",
+ "cf-one", "cf-one-required",
+ "cf-many", "cf-many-required",
+ }
+
+ if len(got) != len(expectedLabels) {
+ t.Errorf("getArrowheadShapeCompletions() returned %d items, want %d", len(got), len(expectedLabels))
+ return
+ }
+
+ for i, label := range expectedLabels {
+ if got[i].Label != label {
+ t.Errorf("completion[%d].Label = %v, want %v", i, got[i].Label, label)
+ }
+ if got[i].Kind != ShapeCompletion {
+ t.Errorf("completion[%d].Kind = %v, want ShapeCompletion", i, got[i].Kind)
+ }
+ if got[i].InsertText != label {
+ t.Errorf("completion[%d].InsertText = %v, want %v", i, got[i].InsertText, label)
+ }
+ }
+}
+
+func TestGetValueCompletions(t *testing.T) {
+ tests := []struct {
+ property string
+ wantLabel string
+ wantDetail string
+ }{
+ {
+ property: "opacity",
+ wantLabel: "(number between 0.0 and 1.0)",
+ wantDetail: "e.g. 0.4",
+ },
+ {
+ property: "stroke-width",
+ wantLabel: "(number between 0 and 15)",
+ wantDetail: "e.g. 2",
+ },
+ {
+ property: "font-size",
+ wantLabel: "(number between 8 and 100)",
+ wantDetail: "e.g. 14",
+ },
+ {
+ property: "width",
+ wantLabel: "(pixels)",
+ wantDetail: "e.g. 400",
+ },
+ {
+ property: "stroke",
+ wantLabel: "(color name or hex code)",
+ wantDetail: "e.g. blue, #ff0000",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.property, func(t *testing.T) {
+ got := getValueCompletions(tt.property)
+ if len(got) != 1 {
+ t.Fatalf("getValueCompletions(%s) returned %d items, want 1", tt.property, len(got))
+ }
+ if got[0].Label != tt.wantLabel {
+ t.Errorf("completion.Label = %v, want %v", got[0].Label, tt.wantLabel)
+ }
+ if got[0].Detail != tt.wantDetail {
+ t.Errorf("completion.Detail = %v, want %v", got[0].Detail, tt.wantDetail)
+ }
+ if got[0].InsertText != "" {
+ t.Errorf("completion.InsertText = %v, want empty string", got[0].InsertText)
+ }
+ })
+ }
+}
diff --git a/d2renderers/d2svg/appendix/testdata/diagram_wider_than_tooltip/sketch.exp.svg b/d2renderers/d2svg/appendix/testdata/diagram_wider_than_tooltip/sketch.exp.svg
index 5f20e45e55..607ec2fbbb 100644
--- a/d2renderers/d2svg/appendix/testdata/diagram_wider_than_tooltip/sketch.exp.svg
+++ b/d2renderers/d2svg/appendix/testdata/diagram_wider_than_tooltip/sketch.exp.svg
@@ -1,19 +1,19 @@
-
\ No newline at end of file
diff --git a/e2etests/testdata/stable/complex-layers/elk/board.exp.json b/e2etests/testdata/stable/complex-layers/elk/board.exp.json
index d8da04f901..59583bc63a 100644
--- a/e2etests/testdata/stable/complex-layers/elk/board.exp.json
+++ b/e2etests/testdata/stable/complex-layers/elk/board.exp.json
@@ -4,12 +4,54 @@
"fontFamily": "SourceSansPro",
"shapes": [
{
- "id": "window",
+ "id": "desc",
"type": "rectangle",
"pos": {
"x": 12,
"y": 12
},
+ "width": 261,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Multi-layer diagram of a home.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 216,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "window",
+ "type": "rectangle",
+ "pos": {
+ "x": 293,
+ "y": 12
+ },
"width": 103,
"height": 66,
"opacity": 1,
@@ -49,7 +91,7 @@
"id": "roof",
"type": "rectangle",
"pos": {
- "x": 135,
+ "x": 416,
"y": 12
},
"width": 75,
@@ -91,7 +133,7 @@
"id": "garage",
"type": "rectangle",
"pos": {
- "x": 230,
+ "x": 511,
"y": 12
},
"width": 94,
@@ -616,9 +658,52 @@
},
{
"name": "repair",
- "isFolderOnly": true,
+ "isFolderOnly": false,
"fontFamily": "SourceSansPro",
- "shapes": [],
+ "shapes": [
+ {
+ "id": "desc",
+ "type": "rectangle",
+ "pos": {
+ "x": 12,
+ "y": 12
+ },
+ "width": 200,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "How to repair a home.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 155,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ }
+ ],
"connections": [],
"root": {
"id": "",
@@ -668,10 +753,52 @@
"fontFamily": "SourceSansPro",
"shapes": [
{
- "id": "find contractors",
+ "id": "desc",
"type": "rectangle",
"pos": {
"x": 12,
+ "y": 62
+ },
+ "width": 200,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "How to repair a home.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 155,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "find contractors",
+ "type": "rectangle",
+ "pos": {
+ "x": 232,
"y": 12
},
"width": 341,
@@ -713,7 +840,7 @@
"id": "find contractors.craigslist",
"type": "rectangle",
"pos": {
- "x": 62,
+ "x": 282,
"y": 62
},
"width": 110,
@@ -755,7 +882,7 @@
"id": "find contractors.facebook",
"type": "rectangle",
"pos": {
- "x": 192,
+ "x": 412,
"y": 62
},
"width": 111,
@@ -843,10 +970,52 @@
"fontFamily": "SourceSansPro",
"shapes": [
{
- "id": "find contractors",
+ "id": "desc",
"type": "rectangle",
"pos": {
"x": 12,
+ "y": 62
+ },
+ "width": 200,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "How to repair a home.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 155,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "find contractors",
+ "type": "rectangle",
+ "pos": {
+ "x": 232,
"y": 12
},
"width": 341,
@@ -888,7 +1057,7 @@
"id": "find contractors.craigslist",
"type": "rectangle",
"pos": {
- "x": 62,
+ "x": 282,
"y": 62
},
"width": 110,
@@ -930,7 +1099,7 @@
"id": "find contractors.facebook",
"type": "rectangle",
"pos": {
- "x": 192,
+ "x": 412,
"y": 62
},
"width": 111,
@@ -972,7 +1141,7 @@
"id": "solicit quotes",
"type": "rectangle",
"pos": {
- "x": 112,
+ "x": 332,
"y": 248
},
"width": 140,
@@ -1038,11 +1207,11 @@
"link": "",
"route": [
{
- "x": 182.5,
+ "x": 402.5,
"y": 178
},
{
- "x": 182.5,
+ "x": 402.5,
"y": 248
}
],
@@ -1100,10 +1269,52 @@
"fontFamily": "SourceSansPro",
"shapes": [
{
- "id": "find contractors",
+ "id": "desc",
"type": "rectangle",
"pos": {
"x": 12,
+ "y": 62
+ },
+ "width": 200,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "How to repair a home.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 155,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "find contractors",
+ "type": "rectangle",
+ "pos": {
+ "x": 232,
"y": 12
},
"width": 341,
@@ -1145,7 +1356,7 @@
"id": "find contractors.craigslist",
"type": "rectangle",
"pos": {
- "x": 62,
+ "x": 282,
"y": 62
},
"width": 110,
@@ -1187,7 +1398,7 @@
"id": "find contractors.facebook",
"type": "rectangle",
"pos": {
- "x": 192,
+ "x": 412,
"y": 62
},
"width": 111,
@@ -1229,7 +1440,7 @@
"id": "solicit quotes",
"type": "rectangle",
"pos": {
- "x": 112,
+ "x": 332,
"y": 248
},
"width": 140,
@@ -1271,7 +1482,7 @@
"id": "obtain quotes",
"type": "rectangle",
"pos": {
- "x": 373,
+ "x": 593,
"y": 112
},
"width": 143,
@@ -1313,7 +1524,7 @@
"id": "negotiate",
"type": "rectangle",
"pos": {
- "x": 388,
+ "x": 608,
"y": 248
},
"width": 112,
@@ -1379,11 +1590,11 @@
"link": "",
"route": [
{
- "x": 182.5,
+ "x": 402.5,
"y": 178
},
{
- "x": 182.5,
+ "x": 402.5,
"y": 248
}
],
@@ -1418,11 +1629,11 @@
"link": "",
"route": [
{
- "x": 444.5,
+ "x": 664.5,
"y": 178
},
{
- "x": 444.5,
+ "x": 664.5,
"y": 248
}
],
@@ -1480,10 +1691,52 @@
"fontFamily": "SourceSansPro",
"shapes": [
{
- "id": "find contractors",
+ "id": "desc",
"type": "rectangle",
"pos": {
"x": 12,
+ "y": 62
+ },
+ "width": 200,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "How to repair a home.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 155,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "find contractors",
+ "type": "rectangle",
+ "pos": {
+ "x": 232,
"y": 12
},
"width": 341,
@@ -1525,7 +1778,7 @@
"id": "find contractors.craigslist",
"type": "rectangle",
"pos": {
- "x": 62,
+ "x": 282,
"y": 62
},
"width": 110,
@@ -1567,7 +1820,7 @@
"id": "find contractors.facebook",
"type": "rectangle",
"pos": {
- "x": 192,
+ "x": 412,
"y": 62
},
"width": 111,
@@ -1609,7 +1862,7 @@
"id": "solicit quotes",
"type": "rectangle",
"pos": {
- "x": 112,
+ "x": 332,
"y": 248
},
"width": 140,
@@ -1651,7 +1904,7 @@
"id": "obtain quotes",
"type": "rectangle",
"pos": {
- "x": 373,
+ "x": 593,
"y": 112
},
"width": 143,
@@ -1693,7 +1946,7 @@
"id": "negotiate",
"type": "rectangle",
"pos": {
- "x": 388,
+ "x": 608,
"y": 248
},
"width": 112,
@@ -1735,7 +1988,7 @@
"id": "book the best bid",
"type": "rectangle",
"pos": {
- "x": 361,
+ "x": 581,
"y": 384
},
"width": 167,
@@ -1801,11 +2054,11 @@
"link": "",
"route": [
{
- "x": 182.5,
+ "x": 402.5,
"y": 178
},
{
- "x": 182.5,
+ "x": 402.5,
"y": 248
}
],
@@ -1840,11 +2093,11 @@
"link": "",
"route": [
{
- "x": 444.5,
+ "x": 664.5,
"y": 178
},
{
- "x": 444.5,
+ "x": 664.5,
"y": 248
}
],
@@ -1879,11 +2132,11 @@
"link": "",
"route": [
{
- "x": 444.5,
+ "x": 664.5,
"y": 314
},
{
- "x": 444.5,
+ "x": 664.5,
"y": 384
}
],
@@ -1945,12 +2198,54 @@
"fontFamily": "SourceSansPro",
"shapes": [
{
- "id": "window",
+ "id": "desc",
"type": "rectangle",
"pos": {
"x": 12,
"y": 12
},
+ "width": 261,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Multi-layer diagram of a home.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 216,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "window",
+ "type": "rectangle",
+ "pos": {
+ "x": 293,
+ "y": 12
+ },
"width": 103,
"height": 66,
"opacity": 1,
@@ -1990,7 +2285,7 @@
"id": "roof",
"type": "rectangle",
"pos": {
- "x": 135,
+ "x": 416,
"y": 12
},
"width": 75,
@@ -2032,7 +2327,7 @@
"id": "garage",
"type": "rectangle",
"pos": {
- "x": 230,
+ "x": 511,
"y": 12
},
"width": 94,
@@ -2074,7 +2369,7 @@
"id": "water",
"type": "rectangle",
"pos": {
- "x": 344,
+ "x": 625,
"y": 12
},
"width": 88,
@@ -2116,7 +2411,7 @@
"id": "rain",
"type": "rectangle",
"pos": {
- "x": 452,
+ "x": 733,
"y": 12
},
"width": 73,
@@ -2158,7 +2453,7 @@
"id": "thunder",
"type": "rectangle",
"pos": {
- "x": 545,
+ "x": 826,
"y": 12
},
"width": 103,
diff --git a/e2etests/testdata/stable/complex-layers/elk/sketch.exp.svg b/e2etests/testdata/stable/complex-layers/elk/sketch.exp.svg
index 0fb3f8fadd..4f3a0e4956 100644
--- a/e2etests/testdata/stable/complex-layers/elk/sketch.exp.svg
+++ b/e2etests/testdata/stable/complex-layers/elk/sketch.exp.svg
@@ -1,17 +1,17 @@
-windowroofgarage
-
-
-
-
-blindsglass
+ 90.000000%, 100% {
+ opacity: 0;
+ }
+}@keyframes d2Transition-d2-469555219-9 {
+ 0%, 89.990000% {
+ opacity: 0;
+ }
+ 90.000000%, 100.000000% {
+ opacity: 1;
+ }
+}]]>Multi-layer diagram of a home.windowroofgarage
+
+
+
+
+
+blindsglass
-shinglesstarlinkutility hookup
+shinglesstarlinkutility hookup
-toolsvehicles
+toolsvehicles
-find contractorscraigslistfacebook
-
-
-
-
-find contractorssolicit quotescraigslistfacebook
-
-
-
-
-
-find contractorssolicit quotesobtain quotesnegotiatecraigslistfacebook
-
-
-
-
-
-
-
-find contractorssolicit quotesobtain quotesnegotiatebook the best bidcraigslistfacebook
-
-
-
-
-
-
-
-
-windowroofgaragewaterrainthunder
-
-
-
-
-
-
-
+How to repair a home.
+
+
+How to repair a home.find contractorscraigslistfacebook
+
+
+
+
+
+How to repair a home.find contractorssolicit quotescraigslistfacebook
+
+
+
+
+
+
+How to repair a home.find contractorssolicit quotesobtain quotesnegotiatecraigslistfacebook
+
+
+
+
+
+
+
+
+How to repair a home.find contractorssolicit quotesobtain quotesnegotiatebook the best bidcraigslistfacebook
+
+
+
+
+
+
+
+
+
+Multi-layer diagram of a home.windowroofgaragewaterrainthunder
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/e2etests/testdata/txtar/note-overlap/dagre/board.exp.json b/e2etests/testdata/txtar/note-overlap/dagre/board.exp.json
new file mode 100644
index 0000000000..62cfc035ac
--- /dev/null
+++ b/e2etests/testdata/txtar/note-overlap/dagre/board.exp.json
@@ -0,0 +1,481 @@
+{
+ "name": "",
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "alice",
+ "type": "rectangle",
+ "pos": {
+ "x": 12,
+ "y": 52
+ },
+ "width": 100,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "alice",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 32,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "bob",
+ "type": "rectangle",
+ "pos": {
+ "x": 162,
+ "y": 52
+ },
+ "width": 100,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "bob",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 26,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "bob.\"In the eyes of my dog, I'm a man.\"",
+ "type": "page",
+ "pos": {
+ "x": 81,
+ "y": 718
+ },
+ "width": 261,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "In the eyes of my dog, I'm a man.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 216,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 5,
+ "level": 2
+ }
+ ],
+ "connections": [
+ {
+ "id": "(alice -> bob)[0]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "bob",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 188
+ },
+ {
+ "x": 212,
+ "y": 188
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[0]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 258
+ },
+ {
+ "x": 142,
+ "y": 258
+ },
+ {
+ "x": 142,
+ "y": 303
+ },
+ {
+ "x": 62,
+ "y": 303
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[1]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 373
+ },
+ {
+ "x": 142,
+ "y": 373
+ },
+ {
+ "x": 142,
+ "y": 418
+ },
+ {
+ "x": 62,
+ "y": 418
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[2]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 488
+ },
+ {
+ "x": 142,
+ "y": 488
+ },
+ {
+ "x": 142,
+ "y": 533
+ },
+ {
+ "x": 62,
+ "y": 533
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[3]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 603
+ },
+ {
+ "x": 142,
+ "y": 603
+ },
+ {
+ "x": 142,
+ "y": 648
+ },
+ {
+ "x": 62,
+ "y": 648
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -- )[0]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice-lifeline-end-3851299086",
+ "dstArrow": "none",
+ "opacity": 1,
+ "strokeDash": 6,
+ "strokeWidth": 2,
+ "stroke": "B2",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 118
+ },
+ {
+ "x": 62,
+ "y": 854
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 1
+ },
+ {
+ "id": "(bob -- )[0]",
+ "src": "bob",
+ "srcArrow": "none",
+ "dst": "bob-lifeline-end-3036726343",
+ "dstArrow": "none",
+ "opacity": 1,
+ "strokeDash": 6,
+ "strokeWidth": 2,
+ "stroke": "B2",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 212,
+ "y": 118
+ },
+ {
+ "x": 212,
+ "y": 854
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 1
+ }
+ ],
+ "root": {
+ "id": "",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 0,
+ "height": 0,
+ "opacity": 0,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 0,
+ "fontFamily": "",
+ "language": "",
+ "color": "",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 0
+ }
+}
diff --git a/e2etests/testdata/txtar/note-overlap/dagre/sketch.exp.svg b/e2etests/testdata/txtar/note-overlap/dagre/sketch.exp.svg
new file mode 100644
index 0000000000..306fa47306
--- /dev/null
+++ b/e2etests/testdata/txtar/note-overlap/dagre/sketch.exp.svg
@@ -0,0 +1,108 @@
+alicebob Self-messagesSelf-messagesSelf-messagesSelf-messagesIn the eyes of my dog, I'm a man.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/e2etests/testdata/txtar/note-overlap/elk/board.exp.json b/e2etests/testdata/txtar/note-overlap/elk/board.exp.json
new file mode 100644
index 0000000000..62cfc035ac
--- /dev/null
+++ b/e2etests/testdata/txtar/note-overlap/elk/board.exp.json
@@ -0,0 +1,481 @@
+{
+ "name": "",
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "alice",
+ "type": "rectangle",
+ "pos": {
+ "x": 12,
+ "y": 52
+ },
+ "width": 100,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "alice",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 32,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "bob",
+ "type": "rectangle",
+ "pos": {
+ "x": 162,
+ "y": 52
+ },
+ "width": 100,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "bob",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 26,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "bob.\"In the eyes of my dog, I'm a man.\"",
+ "type": "page",
+ "pos": {
+ "x": 81,
+ "y": 718
+ },
+ "width": 261,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "In the eyes of my dog, I'm a man.",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 216,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 5,
+ "level": 2
+ }
+ ],
+ "connections": [
+ {
+ "id": "(alice -> bob)[0]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "bob",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 188
+ },
+ {
+ "x": 212,
+ "y": 188
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[0]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 258
+ },
+ {
+ "x": 142,
+ "y": 258
+ },
+ {
+ "x": 142,
+ "y": 303
+ },
+ {
+ "x": 62,
+ "y": 303
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[1]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 373
+ },
+ {
+ "x": 142,
+ "y": 373
+ },
+ {
+ "x": 142,
+ "y": 418
+ },
+ {
+ "x": 62,
+ "y": 418
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[2]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 488
+ },
+ {
+ "x": 142,
+ "y": 488
+ },
+ {
+ "x": 142,
+ "y": 533
+ },
+ {
+ "x": 62,
+ "y": 533
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -> alice)[3]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "Self-messages",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 95,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 603
+ },
+ {
+ "x": 142,
+ "y": 603
+ },
+ {
+ "x": 142,
+ "y": 648
+ },
+ {
+ "x": 62,
+ "y": 648
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 4
+ },
+ {
+ "id": "(alice -- )[0]",
+ "src": "alice",
+ "srcArrow": "none",
+ "dst": "alice-lifeline-end-3851299086",
+ "dstArrow": "none",
+ "opacity": 1,
+ "strokeDash": 6,
+ "strokeWidth": 2,
+ "stroke": "B2",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 62,
+ "y": 118
+ },
+ {
+ "x": 62,
+ "y": 854
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 1
+ },
+ {
+ "id": "(bob -- )[0]",
+ "src": "bob",
+ "srcArrow": "none",
+ "dst": "bob-lifeline-end-3036726343",
+ "dstArrow": "none",
+ "opacity": 1,
+ "strokeDash": 6,
+ "strokeWidth": 2,
+ "stroke": "B2",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 212,
+ "y": 118
+ },
+ {
+ "x": 212,
+ "y": 854
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 1
+ }
+ ],
+ "root": {
+ "id": "",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 0,
+ "height": 0,
+ "opacity": 0,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 0,
+ "fontFamily": "",
+ "language": "",
+ "color": "",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 0
+ }
+}
diff --git a/e2etests/testdata/txtar/note-overlap/elk/sketch.exp.svg b/e2etests/testdata/txtar/note-overlap/elk/sketch.exp.svg
new file mode 100644
index 0000000000..306fa47306
--- /dev/null
+++ b/e2etests/testdata/txtar/note-overlap/elk/sketch.exp.svg
@@ -0,0 +1,108 @@
+alicebob Self-messagesSelf-messagesSelf-messagesSelf-messagesIn the eyes of my dog, I'm a man.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/e2etests/txtar.txt b/e2etests/txtar.txt
index 004b0eae7e..a93f6518d1 100644
--- a/e2etests/txtar.txt
+++ b/e2etests/txtar.txt
@@ -738,3 +738,12 @@ logs: {shape: page; style.multiple: true}
network.data processor -> api server
+-- note-overlap --
+shape: sequence_diagram
+alice -> bob
+alice -> alice: "Self-messages"
+alice -> alice: "Self-messages"
+alice -> alice: "Self-messages"
+alice -> alice: "Self-messages"
+
+bob."In the eyes of my dog, I'm a man."