网网腾科技feat: initial commit
d5a81bebAdd on 2 days agoHistory
"""Scroll synchronization between Markdown editor and Preview sheet."""

from typing import List, Optional, Any

try:
    import sublime
except ImportError:
    sublime = None


class ScrollSynchronizer:
    """Computes matching anchors and sync points between source Markdown and HTML preview."""

    @staticmethod
    def get_current_line(view: Any) -> int:
        """Get the current top visible line or cursor line in the view (1-indexed)."""
        if not view:
            return 1

        try:
            visible_region = view.visible_region()
            top_pt = visible_region.begin()
            row, _ = view.rowcol(top_pt)
            return row + 1
        except Exception:
            return 1

    @staticmethod
    def find_nearest_heading_anchor(lines: List[str], target_line: int) -> Optional[str]:
        """Find the nearest heading anchor at or above target_line."""
        nearest_anchor = None
        for i in range(min(target_line - 1, len(lines) - 1), -1, -1):
            line = lines[i].strip()
            if line.startswith("#"):
                clean_title = line.lstrip("#").strip()
                import re
                slug = re.sub(r"[^\w\s-]", "", clean_title.lower()).strip()
                slug = re.sub(r"[-\s]+", "-", slug)
                if slug:
                    return slug
        return nearest_anchor

    @staticmethod
    def calculate_scroll_ratio(view: Any) -> float:
        """Calculate the vertical scroll ratio (0.0 ~ 1.0) of an editor view."""
        if not view:
            return 0.0

        try:
            view_pos = view.viewport_position()
            view_y = view_pos[1] if isinstance(view_pos, (tuple, list)) else getattr(view_pos, "y", 0.0)

            view_extent = view.layout_extent()
            view_h = view_extent[1] if isinstance(view_extent, (tuple, list)) else getattr(view_extent, "y", 1.0)

            view_vp = view.viewport_extent()
            vp_h = view_vp[1] if isinstance(view_vp, (tuple, list)) else getattr(view_vp, "y", 1.0)

            max_scroll = max(1.0, float(view_h) - float(vp_h))
            return max(0.0, min(1.0, float(view_y) / max_scroll))
        except Exception:
            return 0.0

    @staticmethod
    def calculate_target_sheet_y(sheet: Any, ratio: float) -> float:
        """Calculate the target vertical layout offset in an HtmlSheet for a given ratio."""
        if not sheet or not hasattr(sheet, "layout_extent") or not hasattr(sheet, "viewport_extent"):
            return 0.0

        try:
            sheet_layout = sheet.layout_extent()
            sheet_h = getattr(sheet_layout, "y", None)
            if sheet_h is None:
                sheet_h = sheet_layout[1] if isinstance(sheet_layout, (tuple, list)) else 0.0

            sheet_vp = sheet.viewport_extent()
            vp_h = getattr(sheet_vp, "y", None)
            if vp_h is None:
                vp_h = sheet_vp[1] if isinstance(sheet_vp, (tuple, list)) else 0.0

            max_sheet_scroll = max(0.0, float(sheet_h) - float(vp_h))
            return float(ratio) * max_sheet_scroll
        except Exception:
            return 0.0

    @classmethod
    def sync_editor_to_preview(cls, view: Any, sheet: Any, animate: bool = False):
        """Synchronize preview sheet viewport to match the source editor's scroll position."""
        if not view or not sheet:
            return

        if not hasattr(sheet, "set_viewport_position"):
            return

        try:
            ratio = cls.calculate_scroll_ratio(view)
            target_y = cls.calculate_target_sheet_y(sheet, ratio)

            if sublime and hasattr(sublime, "Vector"):
                target_pos = sublime.Vector(0.0, target_y)
            else:
                target_pos = (0.0, target_y)

            sheet.set_viewport_position(target_pos, animate=animate)
        except Exception:
            pass