From 2a21c830b13d4391503f1bb83cee066c3332a27e Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 30 Sep 2026 19:48:26 +0200 Subject: [PATCH] Import placeholders from Microsoft Whiteboard When a feature is off for whoever exports the board, the web client draws a placeholder box in place of a table, a template, or another object, with a title and a description and nothing of the content. Each becomes a white rectangle of the placeholder's size with a light outline and its two lines. A template whose children were drawn is read child by child, and a child can be a placeholder. Read from the web client's placeholder component and stylesheet, 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 | 5 ++ docs/microsoft-whiteboard-import.md | 10 +++- site/export-microsoft-whiteboard.html | 2 +- .../Import/MicrosoftWhiteboardExport.cs | 57 +++++++++++++++++++ .../MicrosoftWhiteboardSmokeTests.cs | 43 ++++++++++++++ 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7853ebe..82a9ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ box that names it. They used to be listed as left out. Reactions on a sticky note now come across as a last line in the note, with each reaction's symbol and count. +### Placeholders from Microsoft Whiteboard +A table, template, or other object that Microsoft Whiteboard exported as a placeholder now +comes across as a box with the placeholder's message, where it used to be listed as left +out. The export does not hold what the placeholder stands for. + ## 1.7.3 - 30 September 2026 ### Microsoft Whiteboard imports closer to the original diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index 28f8558..17bc7ef 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -74,6 +74,7 @@ no model of the board beyond what it draws. | `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. | +| Any type drawn as a placeholder | When a feature is off for whoever exports the board, the web client draws a `div.unknownObject` in place of a table, a template, a work item, or another kind: a title and a description in the board's language, in `div.unknownObjectText`, and nothing of the content. The stylesheet makes it at least 448 × 170 pixels, white, with a shadow and no border, and the title 20 pixels. Not seen in a sample; read from the web client's placeholder component and stylesheet. | | `CommentThread` | A pin, described above. | ### Ink @@ -141,6 +142,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | 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 | +| Placeholder | `ShapeBoardObject` white rectangle of the placeholder's size with a light outline, because a shadow does not carry over, and its two lines as its text. A template whose children were drawn is read child by child, and a child can be a placeholder | | 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 | @@ -225,6 +227,8 @@ parses in about 160 ms. - **Elbow connectors are straight.** Their ends stay where they were and stay bound. - **A turned picture cannot be turned back**, because it is an SVG of the turned picture. - **A link card loses its preview picture.** The link and its description are kept. +- **Content drawn as a placeholder** stays a placeholder. The export holds the box and its + message, not the table or template it stands for. - **Reactions** are a line of text in the note, not pills, and who reacted is left out. Reactions on anything other than a note are counted and left out. - **Comments** become text containers beside the board, not pins on their objects. @@ -251,9 +255,9 @@ parses in about 160 ms. 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: 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. +which the importer reports and leaves out: an `Unknown` that holds neither a table nor a +placeholder, live content without a Loop element, an app frame without its box, a work +item or a frame drawn as nothing at all, 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/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index 8bb9f47..a779958 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -228,7 +228,7 @@

What comes across

  • Loop components as links to them, and Azure DevOps work items with their title, owner, and state.
  • Comments, as text beside the board with the names of their authors.
  • -

    A highlighter stroke has one width, and Rainbow and Galaxy ink become one color. Elbow connectors become straight, and reactions on notes become a line of text in the note. The background color is left out. After the import, a message lists anything the board did not bring across.

    +

    A highlighter stroke has one width, and Rainbow and Galaxy ink become one color. Elbow connectors become straight, and reactions on notes become a line of text in the note. The background color is left out, and an object the export shows as a placeholder stays one. After the import, a message lists anything the board did not bring across.

    SQLBI Whiteboard keeps each board in a file on your PC and has no real-time collaboration. Compare lists the differences from Microsoft Whiteboard.

    diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index b6ede55..cdbab80 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -51,6 +51,11 @@ public sealed partial class MicrosoftWhiteboardExport private const uint CustomElementTextArgb = 0xFF605E5C; private static readonly PointD CustomElementMinimum = new(120, 80); + /// + /// The smallest box the web client's stylesheet gives content it cannot show. + /// + private static readonly PointD PlaceholderSize = new(448, 170); + /// /// A work item card in the web client's stylesheet. /// @@ -241,6 +246,15 @@ void Skip(string what, int count = 1) foreach (var (anchor, tags) in SplitAnchors(HtmlTags.StartTags(html, start))) { + // What the web client could not draw, because a feature was off for whoever + // exported the board, is a placeholder box that says so. The box is what the + // page shows, so it is what comes across. + if (ReadPlaceholder(anchor, tags) is { } placeholder) + { + items.Add(placeholder); + continue; + } + switch (anchor.Type) { case "InkGroup": @@ -1151,6 +1165,12 @@ private static IEnumerable ReadTemplateChild(Anchor pla bool Has(Func test) => content.Any(test); HtmlTag? First(Func test) => content.FirstOrDefault(test); + if (ReadPlaceholder(place, content) is { } placeholder) + { + yield return placeholder; + yield break; + } + if (Has(tag => tag.HasClass("templateTitle"))) { var title = ReadText(content, 0, content.Count); @@ -1470,6 +1490,43 @@ private static IEnumerable ReadFrame(Anchor anchor, Lis ? StyleLength(style, property) : null; + /// + /// The box the web client draws for content it cannot show: a table or a template + /// when those features are off, and the same for work items and other kinds. It + /// holds a title and a description in the board's language and nothing of the + /// content, and the stylesheet makes it at least 448 by 170 pixels, white, with a + /// shadow. It becomes a white rectangle of that size with a light outline, because + /// a shadow does not carry over, and the two lines as its text. A template whose + /// children were drawn is read child by child, and a child can be a placeholder. + /// + private static MicrosoftWhiteboardShape? ReadPlaceholder(Anchor anchor, List tags) + { + var index = tags.FindIndex(tag => tag.Name == "div" && tag.HasClass("unknownObject")); + if (index < 0 || tags.Any(tag => tag.Name == "div" && tag.HasClass("topLeft") && + StyleValue(tag.Attribute("style"), "left") is not null)) + { + return null; + } + + var text = string.Join('\n', tags.Skip(index) + .Where(tag => tag.HasClass("unknownObjectText")) + .Select(tag => tag.Text.Trim()) + .Where(line => line.Length > 0)); + var width = PlaceholderSize.X; + var height = PlaceholderSize.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, + 0xFFFFFFFF, + anchor.Scale, + text, + new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 16 * anchor.Scale, Black, false, false, false)); + } + /// /// 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 2d6d641..df5a32f 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -144,6 +144,7 @@ public static void Run() RunLive(); RunCards(); RunCustom(); + RunPlaceholders(); } /// @@ -690,6 +691,48 @@ private static void RunCustom() "A custom object without a size in pixels takes the smallest box, and a percentage is not a size."); } + /// + /// Content the web client drew as a placeholder because a feature was off: a table, + /// a template, and a table inside a template whose other children were drawn. + /// + private const string PlaceholderPage = """ +
    +
    +
    +
    This content can't be shown
    Update Microsoft Whiteboard to see it.
    +
    +
    +
    +
    This content can't be shown
    Update Microsoft Whiteboard to see it.
    +
    +
    +
    +
    +
    Plan
    +
    +
    +
    This content can't be shown
    Update Microsoft Whiteboard to see it.
    +
    +
    +
    + """; + + private static void RunPlaceholders() + { + var board = MicrosoftWhiteboardExport.Parse(PlaceholderPage, "Placeholders"); + Assert(board.Skipped.Count == 0, "A placeholder comes across as what the page shows, so nothing is left out."); + + const string Text = "This content can't be shown\nUpdate Microsoft Whiteboard to see it."; + Assert(board.Items[0] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 448, Height: 170, FillArgb: 0xFFFFFFFF, Text: Text } table && + table.Center == new PointD(324, 185), + "A table drawn as a placeholder is a white box of the placeholder's size with its two lines."); + Assert(board.Items[1] is MicrosoftWhiteboardShape { Text: Text } template && template.Center == new PointD(1000, 0), + "A template drawn as a placeholder is the same box, centered when its anchor is."); + Assert(board.Items.OfType().Single() is { Text: "Plan" } && + board.Items[^1] is MicrosoftWhiteboardShape { Text: Text } child && child.Center == new PointD(274, 1145), + "In a template whose children were drawn, a child drawn as a placeholder is the box, placed from its corner."); + } + private static void RunArchive() { var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));