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"));