Solution!
I stripped my code down to the essentials, so I haven’t tested the exact code that I’m posting. It should convey the general idea though.
We’re going to let the htmlEditor worry about wrapping the text. (htmlEditor.Document.body.noWrap = false

And we’ll set its width to the measure at which we want text to wrap. For example, htmlEditor.Width = graphics.DpiX * 8; will make text wrap at 8 inches.
Now place the htmlEditor inside a panel control. Set the panel control's AutoScroll to 'true' (I also set the panel’s BorderStyle to ‘Fixed3D’). Subscribe to the panel's resize event so that we can make sure the htmlEditor.Height == panel.ClientSize.Height;.
Since we will see the panel’s scrollbars, we don’t want htmlEditor to show its scrollbars too, so in the HtmlEditor file HtmlSite.cs, find the GetHostInfo method, and change the DOCHOSTUIINFO structure flag from DOCHOSTUIINFOFLAG.FLAT_SCROLLBAR to DOCHOSTUIINFOFLAG.SCROLL_NO.
Now the htmlEditor will essentially serve as a document for us while the panel serves as a view on that document.
We need the panel to do the scrolling for us, but the .NET panel control does not supply us with a lot of information about its scrolling, so we will have to add that, and we’ll need the following latent functions:
[DllImport("user32")]
public static extern int GetScrollPos( IntPtr hwnd, uint nBar );
[DllImport("user32")]
public static extern int SetScrollPos( IntPtr hwnd, uint nBar, int nPos, bool bRedraw );
[DllImport ("user32.dll")]
private static extern int SendMessage( IntPtr hWnd, uint Msg,
uint wParam, IntPtr lParam );
Also, from now on:
IHTMLBodyElement body = htmlEditor.Document.body;
IHTMLWindow window = htmlEditor.Document.parentWindow;
IDisplayServices display = (IDisplayServices)htmlEditor.Document;
We need to override our new panel’s WndProc method so that when the user drags the scrollbars on the panel control, the htmlEditor control scrolls.
if( m.Msg == 0x0115 /*WM_VSCROLL*/)
{
int pos = GetScrollPos( this.Handle, 1 /*SB_VERT*/ );
body.scrollTop = pos;
}
if( m.Msg == 0x0114 /*WM_HSCROLL*/)
{
int pos = GetScrollPos( this.Handle, 0 /*SB_HORZ*/ );
body.scrollLeft = pos;
}
Now we also need to update the panel’s scrollbars when the user changes the position of the cursor in the htmlEditor. For vertical scrolling, we just listen to the scroll event of IHTMLWindow2:
window.scroll = this;
Whatever control ‘this’ is must have the following function:
[DispId(0)]
public void DispatchHandler()
{
if(
window.@event.type == “scroll” )
{
SetScrollPos( panel.Handle, 1 /*SB_VERT*/,
body.scrollTop, true );
}
}
At this point, if the panel is less wide than the htmlEditor (which is the goal of this exercise), the user can type text that won’t be displayed (until the htmlEditor wraps it at 8in). So, IHTMLWindow’s scroll event will not give us any information about the horizontal scroll position.
So we’ll subscribe to the htmlEditor’s UpdateUI event, get the current position of the caret, and (iff its position has changed), scroll so that the caret is in view.
int caretX = 0;
private void htmlEditor_UpdateUI(object sender,
onlyconnect.HtmlUpdateUIEventArgs e)
{
IHTMLCaret caret;
tagPOINT point;
display.GetCaret( out caret );
caret.GetLocation( out point, 1 );
if( point.x != caretX )
{
caretX = point.x;
int xoffset = caretX - (panelBorder.ClientSize.Width-10);
if( xoffset < 0 )
xoffset = 0;
SetScrollPos( panel.Handle, SB_HORZ, xoffset, true );
uint wParam = (SB_THUMBPOSITION & (uint)0x0000FFFF) +
(((uint)xoffset << 16) & (uint)0xFFFF0000);
SendMessage( panel.Handle, WM_HSCROLL,
wParam, IntPtr.Zero );
}
}
The low-order word of the WPARAM for the WM_HSCROLL message is set to SB_THUMBPOSITION to tell the message recipient that the scroll position has been changed to a certain position. And the high-order word of WPARAM is the position to scroll to.