Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 7 additions & 4 deletions docs/microsoft-whiteboard-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public sealed partial class MicrosoftWhiteboardExport
private const string LoopTitle = "Loop";
private const string WorkItemTitle = "Work item";

/// <summary>
/// The gray box the web client draws for a custom object it cannot show, and the
/// smallest size it gives one.
/// </summary>
private const uint CustomElementPanelArgb = 0xFFF3F2F1;
private const uint CustomElementOutlineArgb = 0xFFE1DFDD;
private const uint CustomElementTextArgb = 0xFF605E5C;
private static readonly PointD CustomElementMinimum = new(120, 80);

/// <summary>
/// A work item card in the web client's stylesheet.
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1369,6 +1389,45 @@ private static IEnumerable<MicrosoftWhiteboardItem> ReadFrame(Anchor anchor, Lis
}
}

/// <summary>
/// A custom object is a widget from a plug-in, named by its kind in
/// <c>data-custom-widget-kind</c>. 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.
/// </summary>
private static MicrosoftWhiteboardShape? ReadCustomElement(Anchor anchor, List<HtmlTag> 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;

/// <summary>
/// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ public static void Run()
RunTemplate();
RunLive();
RunCards();
RunCustom();
}

/// <summary>
Expand Down Expand Up @@ -657,6 +658,35 @@ private static void RunCards()
Assert(board.Items.Count == 3, "A frame's sources are left out.");
}

/// <summary>
/// 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.
/// </summary>
private const string CustomPage = """
<html><body><div id="canvasContent" class="contentOrigin">
<div class="anchor align topLeft" data-whiteboard-type="CustomElement" style="left: 100px; top: 100px;">
<div class="canvasChild" style="width: 240px; height: 160px;"><div class="interactive" style="width: 100%; height: 100%; min-width: 120px; min-height: 80px; background: rgb(243, 242, 241);" data-custom-widget-kind="example.counter" data-custom-widget-disabled="true">Widget "example.counter" is disabled</div></div>
</div>
<div class="anchor align center" data-whiteboard-type="CustomElement" style="left: 1000px; top: 1000px;">
<div class="canvasChild"><div class="interactive" style="width: 100%; height: 100%;" data-custom-widget-kind="contoso.poll" data-custom-widget-unregistered="true">Widget "contoso.poll" is not available</div></div>
</div>
<div class="anchor align topLeft" data-whiteboard-type="CustomElement" style="left: 0px; top: 0px;"><div class="canvasChild"></div></div>
</div></body></html>
""";

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"));
Expand Down
Loading