Compare commits

..

3 Commits

Author SHA1 Message Date
df389177e6 fix(split): frame scratchpad on existing ink
Some checks failed
CI / Windows build (push) Has been cancelled
The pen-first scratchpad opened at identity transform, showing only the
empty top-left corner of the 4000x4000 world — so existing ink (drawn
elsewhere) was off-screen and the pane looked blank ("草稿纸根本没看到").

On first layout, fit the strokes' world bounding box into the pane (padded,
scale clamped 0.15-1.5) so saved ink is immediately visible; an empty
scratchpad falls back to a 1:1 view near the origin.

flutter analyze: 0 issues.
2026-06-23 16:52:35 +08:00
8908f42f76 feat(windows): disable pen tap / press-hold visual feedback
On pen-down the OS drew the "Windows Ink" tap ripple / press-and-hold ring
under the nib — ugly and laggy-looking while writing. Set the tablet input
service's MicrosoftTabletPenServiceProperty on both the top-level window and
the Flutter child (where WM_POINTER lands) with the disable flags
(PENTAPFEEDBACK, PRESSANDHOLD, PENBARRELFEEDBACK, TOUCHUIFORCEON/OFF, FLICKS)
so the pen draws instantly with no OS animation.

Native-only change (windows/runner/flutter_window.cpp); built by CI.
2026-06-23 16:52:25 +08:00
eae4493954 fix(zoom): stop re-baseline scale oscillation
Device log showed the applied scale oscillating ~1.4x every frame while
the raw pinch was smooth (cur 1.116->0.797->1.074, raw ~0.46). Root cause:
on a pointer-count re-baseline (Windows touch flickers 2<->1<->2 mid-pinch)
the code set _scaleStart = matrix.getMaxScaleOnAxis() — a read-back captured
at a glitchy instant — so the absolute map K = scaleStart/rawScaleAtBaseline
jumped frame to frame.

Fix: anchor the re-baseline to the CLEAN tracked _lastAppliedScale instead
of the live matrix read-back, so the displayed scale is continuous across
the re-baseline regardless of any matrix transient. The math is already
covered by the pinch_scale_solver "same-instant re-baseline" test; this just
feeds it the right value.

flutter analyze: 0. pinch_scale_solver + pen_zoom: pass.
2026-06-23 16:52:14 +08:00
3 changed files with 117 additions and 21 deletions

View File

@@ -194,13 +194,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
// The transitional frame itself is skipped.
if (details.pointerCount != _lastPointerCount) {
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
// Anchor the new baseline to the CLEAN tracked scale (_lastAppliedScale),
// NOT a fresh matrix read-back. Windows touch flickers the pointer count
// (2↔1↔2) mid-pinch, firing this re-baseline spuriously; reading
// getMaxScaleOnAxis() at that glitchy instant popped _scaleStart to a
// noisy value, so the absolute map K = scaleStart / rawScaleAtBaseline
// oscillated frame-to-frame (the reported "zoom jump"). Using
// _lastAppliedScale makes the displayed scale CONTINUOUS across the
// re-baseline: target == _lastAppliedScale at this instant, regardless of
// any transient in the live matrix.
_scaleStart = _lastAppliedScale;
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = details.scale;
_lastAppliedScale = _scaleStart!;
// Re-anchor the absolute mapping: from here, cumulative scale is measured
// relative to THIS frame's details.scale (so the next good frame starts
// from _scaleStart, not _scaleStart * a stale cumulative value).
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
_rawScaleAtBaseline = details.scale;
InputDiagnostics.instance.recordRebaseline();
return;

View File

@@ -60,6 +60,9 @@ class _SplitViewState extends State<SplitViewScreen> {
/// Pan/zoom transform for the scratchpad world (PenCanvas drives this).
final TransformationController _scratchTransform = TransformationController();
/// Set once the initial view has been framed onto existing ink.
bool _scratchCentered = false;
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
/// Maps the scratchpad toolbar's [PenTool] to the pen-canvas tool. Shapes and
@@ -471,24 +474,79 @@ class _SplitViewState extends State<SplitViewScreen> {
// Render the world through the performant PenCanvas: strokes normalized
// against the current world size; toolbar width is in world pixels, so the
// pen-canvas fraction is width / worldWidth.
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: PenCanvas(
pageSize: _worldSize,
strokes: penStrokesFromInk(_strokes, _worldSize),
transformationController: _scratchTransform,
tool: _canvasTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth / _canvasWidth,
// The world is huge, so allow zooming further out to survey it.
minScale: 0.1,
maxScale: 8.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Colors.white),
),
return LayoutBuilder(
builder: (context, constraints) {
// On first layout, frame the view so existing ink is actually visible
// (otherwise identity shows only the empty top-left corner of the huge
// world). Empty scratchpad falls back to a comfortable 1:1 near origin.
if (!_scratchCentered) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_frameScratchpad(
Size(constraints.maxWidth, constraints.maxHeight));
setState(() => _scratchCentered = true);
});
}
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: PenCanvas(
pageSize: _worldSize,
strokes: penStrokesFromInk(_strokes, _worldSize),
transformationController: _scratchTransform,
tool: _canvasTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth / _canvasWidth,
// The world is huge, so allow zooming further out to survey it.
minScale: 0.1,
maxScale: 8.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Colors.white),
),
);
},
);
}
/// Position the scratchpad so existing ink is on-screen. Fits the strokes'
/// world bounding box into [pane] (with padding, scale clamped); for an empty
/// scratchpad, shows the top-left working area at 1:1.
void _frameScratchpad(Size pane) {
if (pane.isEmpty) return;
if (_strokes.isEmpty) {
_scratchTransform.value = Matrix4.identity();
return;
}
double minX = double.infinity, minY = double.infinity;
double maxX = -double.infinity, maxY = -double.infinity;
for (final s in _strokes) {
for (final p in s.points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
}
if (minX > maxX) {
_scratchTransform.value = Matrix4.identity();
return;
}
const pad = 80.0;
final boxW = (maxX - minX) + pad * 2;
final boxH = (maxY - minY) + pad * 2;
final scale =
(pane.width / boxW < pane.height / boxH ? pane.width / boxW : pane.height / boxH)
.clamp(0.15, 1.5);
final cx = (minX + maxX) / 2;
final cy = (minY + maxY) / 2;
final tx = pane.width / 2 - scale * cx;
final ty = pane.height / 2 - scale * cy;
_scratchTransform.value = Matrix4.identity()
..setEntry(0, 0, scale)
..setEntry(1, 1, scale)
..setEntry(2, 2, scale)
..setTranslationRaw(tx, ty, 0);
}
}
/// A marker linking a scratchpad position to a specific PDF page.

View File

@@ -29,6 +29,32 @@ LRESULT CALLBACK FlutterChildSubclassProc(HWND hwnd, UINT message,
return DefSubclassProc(hwnd, message, wparam, lparam);
}
// Turn off the OS-drawn pen visual feedback (the "Windows Ink" tap ripple, the
// press-and-hold right-click ring, barrel feedback, and flicks) so the pen
// draws instantly with no ugly OS animation under the nib. The tablet input
// service reads these flags from a window property; set it on whichever window
// receives the pointer input. Flag values are from the Windows pen/tablet docs
// (not declared in the public SDK headers).
void DisablePenVisualFeedback(HWND hwnd) {
if (!hwnd) {
return;
}
constexpr ULONG_PTR kDisablePressAndHold = 0x00000001;
constexpr ULONG_PTR kDisablePenTapFeedback = 0x00000008;
constexpr ULONG_PTR kDisablePenBarrelFeedback = 0x00000010;
constexpr ULONG_PTR kDisableTouchUIForceOn = 0x00000100;
constexpr ULONG_PTR kDisableTouchUIForceOff = 0x00000200;
constexpr ULONG_PTR kDisableFlicks = 0x00010000;
const ULONG_PTR flags = kDisablePressAndHold | kDisablePenTapFeedback |
kDisablePenBarrelFeedback | kDisableTouchUIForceOn |
kDisableTouchUIForceOff | kDisableFlicks;
const ATOM atom = ::GlobalAddAtomW(L"MicrosoftTabletPenServiceProperty");
if (atom != 0) {
::SetPropW(hwnd, MAKEINTATOM(atom), reinterpret_cast<HANDLE>(flags));
::GlobalDeleteAtom(atom);
}
}
} // namespace
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
@@ -64,6 +90,11 @@ bool FlutterWindow::OnCreate() {
SetWindowSubclass(flutter_child, FlutterChildSubclassProc, kPenSubclassId, 0);
}
// Kill the OS pen tap/press-and-hold visual feedback on both the top-level
// window and the Flutter child (the one that actually gets the pointer input).
DisablePenVisualFeedback(GetHandle());
DisablePenVisualFeedback(flutter_child);
flutter_controller_->engine()->SetNextFrameCallback([&]() {
this->Show();
});