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 @@ -customerissuerstoreLike starbucks or somethingacquirerI'm not sure what this isnetworkcustomer bankstore bankinitial transactionpayment processor behind the scenessimplified 1 banana please$10 dollarsthinking: wow, inflationchecks bank accountSavings: $11I can do that, here's my cardRun this cardProcess to card issuerProcess this payment$10 debit$10 creditAn error in judgement is about to occurLike starbucks or something1I'm not sure what this is2 + .d2-4183732618 .fill-N1{fill:#0A0F25;} + .d2-4183732618 .fill-N2{fill:#676C7E;} + .d2-4183732618 .fill-N3{fill:#9499AB;} + .d2-4183732618 .fill-N4{fill:#CFD2DD;} + .d2-4183732618 .fill-N5{fill:#DEE1EB;} + .d2-4183732618 .fill-N6{fill:#EEF1F8;} + .d2-4183732618 .fill-N7{fill:#FFFFFF;} + .d2-4183732618 .fill-B1{fill:#0D32B2;} + .d2-4183732618 .fill-B2{fill:#0D32B2;} + .d2-4183732618 .fill-B3{fill:#E3E9FD;} + .d2-4183732618 .fill-B4{fill:#E3E9FD;} + .d2-4183732618 .fill-B5{fill:#EDF0FD;} + .d2-4183732618 .fill-B6{fill:#F7F8FE;} + .d2-4183732618 .fill-AA2{fill:#4A6FF3;} + .d2-4183732618 .fill-AA4{fill:#EDF0FD;} + .d2-4183732618 .fill-AA5{fill:#F7F8FE;} + .d2-4183732618 .fill-AB4{fill:#EDF0FD;} + .d2-4183732618 .fill-AB5{fill:#F7F8FE;} + .d2-4183732618 .stroke-N1{stroke:#0A0F25;} + .d2-4183732618 .stroke-N2{stroke:#676C7E;} + .d2-4183732618 .stroke-N3{stroke:#9499AB;} + .d2-4183732618 .stroke-N4{stroke:#CFD2DD;} + .d2-4183732618 .stroke-N5{stroke:#DEE1EB;} + .d2-4183732618 .stroke-N6{stroke:#EEF1F8;} + .d2-4183732618 .stroke-N7{stroke:#FFFFFF;} + .d2-4183732618 .stroke-B1{stroke:#0D32B2;} + .d2-4183732618 .stroke-B2{stroke:#0D32B2;} + .d2-4183732618 .stroke-B3{stroke:#E3E9FD;} + .d2-4183732618 .stroke-B4{stroke:#E3E9FD;} + .d2-4183732618 .stroke-B5{stroke:#EDF0FD;} + .d2-4183732618 .stroke-B6{stroke:#F7F8FE;} + .d2-4183732618 .stroke-AA2{stroke:#4A6FF3;} + .d2-4183732618 .stroke-AA4{stroke:#EDF0FD;} + .d2-4183732618 .stroke-AA5{stroke:#F7F8FE;} + .d2-4183732618 .stroke-AB4{stroke:#EDF0FD;} + .d2-4183732618 .stroke-AB5{stroke:#F7F8FE;} + .d2-4183732618 .background-color-N1{background-color:#0A0F25;} + .d2-4183732618 .background-color-N2{background-color:#676C7E;} + .d2-4183732618 .background-color-N3{background-color:#9499AB;} + .d2-4183732618 .background-color-N4{background-color:#CFD2DD;} + .d2-4183732618 .background-color-N5{background-color:#DEE1EB;} + .d2-4183732618 .background-color-N6{background-color:#EEF1F8;} + .d2-4183732618 .background-color-N7{background-color:#FFFFFF;} + .d2-4183732618 .background-color-B1{background-color:#0D32B2;} + .d2-4183732618 .background-color-B2{background-color:#0D32B2;} + .d2-4183732618 .background-color-B3{background-color:#E3E9FD;} + .d2-4183732618 .background-color-B4{background-color:#E3E9FD;} + .d2-4183732618 .background-color-B5{background-color:#EDF0FD;} + .d2-4183732618 .background-color-B6{background-color:#F7F8FE;} + .d2-4183732618 .background-color-AA2{background-color:#4A6FF3;} + .d2-4183732618 .background-color-AA4{background-color:#EDF0FD;} + .d2-4183732618 .background-color-AA5{background-color:#F7F8FE;} + .d2-4183732618 .background-color-AB4{background-color:#EDF0FD;} + .d2-4183732618 .background-color-AB5{background-color:#F7F8FE;} + .d2-4183732618 .color-N1{color:#0A0F25;} + .d2-4183732618 .color-N2{color:#676C7E;} + .d2-4183732618 .color-N3{color:#9499AB;} + .d2-4183732618 .color-N4{color:#CFD2DD;} + .d2-4183732618 .color-N5{color:#DEE1EB;} + .d2-4183732618 .color-N6{color:#EEF1F8;} + .d2-4183732618 .color-N7{color:#FFFFFF;} + .d2-4183732618 .color-B1{color:#0D32B2;} + .d2-4183732618 .color-B2{color:#0D32B2;} + .d2-4183732618 .color-B3{color:#E3E9FD;} + .d2-4183732618 .color-B4{color:#E3E9FD;} + .d2-4183732618 .color-B5{color:#EDF0FD;} + .d2-4183732618 .color-B6{color:#F7F8FE;} + .d2-4183732618 .color-AA2{color:#4A6FF3;} + .d2-4183732618 .color-AA4{color:#EDF0FD;} + .d2-4183732618 .color-AA5{color:#F7F8FE;} + .d2-4183732618 .color-AB4{color:#EDF0FD;} + .d2-4183732618 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]>customerissuerstoreLike starbucks or somethingacquirerI'm not sure what this isnetworkcustomer bankstore bankinitial transactionpayment processor behind the scenessimplified 1 banana please$10 dollarsthinking: wow, inflationchecks bank accountSavings: $11I can do that, here's my cardRun this cardProcess to card issuerProcess this payment$10 debit$10 creditAn error in judgement is about to occurLike starbucks or something1I'm not sure what this is2 @@ -122,7 +122,7 @@ - + 1Like starbucks or something 2I'm not sure what this is windowroofgarage - - - - -blindsglass + 90.000000%, 100% { + opacity: 0; + } +}@keyframes d2Transition-d2-2913974970-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/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."