From e64772e5af3adf1112fed5ca9bf151240d67139f Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 30 Sep 2026 19:23:45 +0200 Subject: [PATCH] Import custom objects from Microsoft Whiteboard A custom object is a widget from a plug-in, named by its kind. The web client registers only examples and draws a gray box for anything else, so the export holds the box and the kind. It becomes a rectangle of its size in that gray, with the kind as its text. Its size is the first one given in pixels, because the widget itself fills its box. Read from the web client's custom widget component, because no sample holds one. It joins the 1.7.4 notes, which is not released yet. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- docs/microsoft-whiteboard-import.md | 11 ++-- .../Import/MicrosoftWhiteboardExport.cs | 59 +++++++++++++++++++ .../MicrosoftWhiteboardSmokeTests.cs | 30 ++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f636352..43159bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,11 +24,11 @@ Templates from boards made with the Windows 10 app of Microsoft Whiteboard now c with their title and the notes, lists, text, pictures, shapes, and ink placed on them. They used to be listed as left out. -### Loop components, apps, work items, and frames from Microsoft Whiteboard +### Loop components, apps, and other objects from Microsoft Whiteboard A Loop component now comes across as a link to it, an app made by Copilot as an empty panel of its size, an Azure DevOps work item as a text container with its title, owner, -and state, and a frame Copilot drew as a panel with its title. They used to be listed as -left out. +and state, a frame Copilot drew as a panel with its title, and a custom object as a gray +box that names it. They used to be listed as left out. ## 1.7.3 - 30 September 2026 diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index b163b31..886c349 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -73,6 +73,7 @@ no model of the board beyond what it draws. | `AppIframeHost` | An app that Copilot made, running in a sandboxed `iframe`. The export replaces the frame with a `#faf9f8` panel and keeps the `div.appFrameContainer` with its `width` and `height` and its header, whose `span.appFrameHeaderTitle` says *App* in the board's language. The app's name, code, and state are not in the export. Not seen in a sample; read from the web client's app frame component and export code. | | `WorkItem` | An Azure DevOps work item: a `div.WorkItem` card, 220 × 145 pixels in the stylesheet, white with a 4-pixel gold border on its left. A `span.WorkItemTextField` holds a `strong` with the type's `img.WorkItemIcon` (its `alt` names the type) and the ID, followed by the title; then come who it is assigned to and, in `span.WorkItemText`, the word *State* in the board's language and the state. The web client draws a placeholder instead when its `EnableWorkItems` flag is off. Not seen in a sample; read from the web client's `WorkItemConnected`. | | `Frame` | A frame Copilot drew around objects it grouped: a `section.whiteboardFrame` with its `width` and `height` and a theme class, `whiteboardFrame--blue`, `--green`, `--purple`, `--orange`, or neutral without one. The stylesheet gives it a 3-pixel border, a translucent fill, 14-pixel corners, and a 44-pixel title bar with bold 15-pixel text in `span.whiteboardFrame__titleText`, padded 14 pixels in. A pill names the first source and counts the rest; the full list is in a popover that is drawn only when open. The web client draws nothing when its `EnableFrames` flag is off. Not seen in a sample; read from the web client's frame component. | +| `CustomElement` | A widget from a plug-in, named by its kind in `data-custom-widget-kind`. The web client registers only two examples, `example.counter` and `example.embed`; for anything else, and for every kind when its `EnableCustomElement` flag is off, it draws a gray box that fills the object and says the widget is not available or disabled. The box is at least 120 × 80 pixels, and its own size is `100%`, so the object's size is the first one given in pixels. Not seen in a sample; read from the web client's custom widget component. | | `CommentThread` | A pin, described above. | ### Ink @@ -139,6 +140,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | App made by Copilot | `ShapeBoardObject` rectangle of the frame's size in the panel's color, with the header's title as its text | | Work item | Markdown `TextBoardObject` titled *Work item*: the type and ID in bold before the title, then who it is assigned to and its state, as wide as the card | | Copilot frame | `ShapeBoardObject` rectangle with the theme's border and translucent fill, and its title as a bold label in the theme's text color. Its sources are left out | +| Custom object | `ShapeBoardObject` rectangle of its size in the gray the web client draws, with *Custom object* and the kind as its text | | Link card | Markdown `TextBoardObject` titled *Link* | | List | Markdown `TextBoardObject` titled *List*: the list's title in bold, then a Markdown list of bullets, or one line per item with ☒ for a done task and ☐ for an open one | | Comment thread | Markdown `TextBoardObject` titled *Comment*, in a column to the right of the board | @@ -243,13 +245,14 @@ parses in about 160 ms. - SVG pictures, which the Windows client refuses to insert - Groups, which the Windows client does not offer - GIFs, lists, ink tables, legacy stickers, legacy templates, Loop components, apps, work - items, and Copilot frames, which are read from the web client's code, not from a sample + items, Copilot frames, and custom objects, which are read from the web client's code, not + from a sample The web client's code names every type an export can hold. Its `data-whiteboard-type` values, as of version 26.10910.101 in September 2026, are the types above and these, -which the importer reports and leaves out: `CustomElement`, an `Unknown` that holds no -table, live content without a Loop element, an app frame without its box, and a work item -or a frame drawn as a placeholder. +which the importer reports and leaves out: an `Unknown` that holds no table, live +content without a Loop element, an app frame without its box, a work item or a frame +drawn as a placeholder, and a custom object without a kind. The tokenizer yields text that follows an end tag as a tag named `#text`, because a work item's title comes after the bold ID. diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index 87e84d5..ad80c8e 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -31,6 +31,15 @@ public sealed partial class MicrosoftWhiteboardExport private const string LoopTitle = "Loop"; private const string WorkItemTitle = "Work item"; + /// + /// The gray box the web client draws for a custom object it cannot show, and the + /// smallest size it gives one. + /// + private const uint CustomElementPanelArgb = 0xFFF3F2F1; + private const uint CustomElementOutlineArgb = 0xFFE1DFDD; + private const uint CustomElementTextArgb = 0xFF605E5C; + private static readonly PointD CustomElementMinimum = new(120, 80); + /// /// A work item card in the web client's stylesheet. /// @@ -288,6 +297,17 @@ void Skip(string what, int count = 1) items.Add(link); } + break; + case "CustomElement": + if (ReadCustomElement(anchor, tags) is { } custom) + { + items.Add(custom); + } + else + { + Skip(anchor.Type); + } + break; case "WorkItem": if (ReadWorkItem(anchor, tags) is { } workItem) @@ -1369,6 +1389,45 @@ private static IEnumerable ReadFrame(Anchor anchor, Lis } } + /// + /// A custom object is a widget from a plug-in, named by its kind in + /// data-custom-widget-kind. The web client registers only examples, and + /// draws a gray box saying the widget is disabled or not available for anything + /// else, so the widget itself is not in the export. It becomes a rectangle of the + /// object's size in that gray, with the kind as its text. + /// + private static MicrosoftWhiteboardShape? ReadCustomElement(Anchor anchor, List tags) + { + var kind = tags.Select(tag => tag.Attribute("data-custom-widget-kind")) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + if (kind is null) + { + return null; + } + + // The widget fills its box, so the size is the first one given in pixels. + var sized = tags.FirstOrDefault(tag => + PixelLength(tag.Attribute("style"), "width") > 0 && PixelLength(tag.Attribute("style"), "height") > 0); + var width = PixelLength(sized?.Attribute("style"), "width") ?? CustomElementMinimum.X; + var height = PixelLength(sized?.Attribute("style"), "height") ?? CustomElementMinimum.Y; + return new MicrosoftWhiteboardShape( + anchor.ToCanvas(anchor.Centered ? default : new PointD(width / 2, height / 2)), + width * anchor.Scale, + height * anchor.Scale, + anchor.AngleDegrees, + ShapeKind.Rectangle, + CustomElementOutlineArgb, + CustomElementPanelArgb, + anchor.Scale, + $"Custom object\n{kind.Trim()}", + new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 14 * anchor.Scale, CustomElementTextArgb, false, false, false)); + } + + private static double? PixelLength(string? style, string property) => + StyleValue(style, property) is { } value && value.EndsWith("px", StringComparison.OrdinalIgnoreCase) + ? StyleLength(style, property) + : null; + /// /// A connector's SVG draws its route from the anchor, and its head as a small /// path moved to one end. A route with corners is kept as a straight line. diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index 46bc98f..dc774f0 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -143,6 +143,7 @@ public static void Run() RunTemplate(); RunLive(); RunCards(); + RunCustom(); } /// @@ -657,6 +658,35 @@ private static void RunCards() Assert(board.Items.Count == 3, "A frame's sources are left out."); } + /// + /// Custom objects as the web client draws them when it cannot show the widget: a + /// gray box that fills the object and names its kind. + /// + private const string CustomPage = """ +
+
+
Widget "example.counter" is disabled
+
+
+
Widget "contoso.poll" is not available
+
+
+
+ """; + + private static void RunCustom() + { + var board = MicrosoftWhiteboardExport.Parse(CustomPage, "Custom"); + Assert(board.Skipped.Count == 1 && board.Skipped["CustomElement"] == 1, + "A custom object without a kind is still reported."); + Assert(board.Items[0] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 240, Height: 160, FillArgb: 0xFFF3F2F1, Text: "Custom object\nexample.counter" } counter && + counter.Center == new PointD(220, 180), + "A custom object is a gray box of its size that names its kind."); + Assert(board.Items[1] is MicrosoftWhiteboardShape { Width: 120, Height: 80, Text: "Custom object\ncontoso.poll" } poll && + poll.Center == new PointD(1000, 1000), + "A custom object without a size in pixels takes the smallest box, and a percentage is not a size."); + } + private static void RunArchive() { var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));