65.9K
CodeProject 正在变化。 阅读更多。
Home

C.B.R.

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (52投票s)

2011年12月3日

GPL3

29分钟阅读

viewsIcon

137832

downloadIcon

1769

漫画和电子出版物阅读器,具有图书馆管理、扩展文件转换和设备支持功能。

引言

鉴于第一个项目的“成功”和一些空闲时间,我在同一个CodePlex项目上启动了一个全新的版本。

我决定重写它,以便更深入地探索MVVM模式,并将其扩展到其他电子书格式。我也对7款手机的开发很感兴趣,所以我采用了我在iPhone应用程序上看到的动态书籍的概念。路线图包括:

  • 更好的用户界面和设计:Ribbon...
  • 多种格式支持和转换:图片、PDF、XPS、CBR/RAR、CBZ/ZIP、ePUB...
  • 动态书籍格式:CBZD,一种完整的zip格式,包含与页面关联的附加XML文件,这些文件包含框架描述。CBR包含一个设计器,用于“编辑”书籍,以便在不同的页面区域添加带有顺序和时间安排的框架。这使得桌面上的自动阅读成为可能,但主要用于手机应用程序。
  • 支持外部设备和Windows Phone 7应用程序。

相关出版物:CodePlex、"产品"网站博客

截图:新C.B.R.界面

项目内容

作为开发人员,除了这里没有描述的许多技巧之外,您可以在这个项目中找到有用的内容:

  • 新功能:OPDS feed、按代码本地化功能、MVVM设计中的多文档和工具箱、为动态书籍重新设计的MVVM框架编辑器、新的平板电脑和7款手机模拟器、‘任意CPU’并兼容32位和64位平台、用于PDF转换的图像合并器等...
  • 基于Mitsu Futura“书籍”的两页书籍视图
  • 本地化引擎,可与resx、xml、bin配合使用,并可根据您的需求进行扩展...!
  • 一个带有网格、简单和复杂缩略图视图的浏览器视图
  • 一个显示直接操作和“xml新闻”feed的起始主页
  • 相当复杂的MVVM模式
  • MVVM中简单的类似浏览器视图,带树形和文件夹视图
  • 使用WMI进行USB设备检测
  • PDF图像提取
  • SevenZipSharp:提取、压缩、内存使用
  • Office风格:Ribbon控件使用,类似最近文件列表的后台设计
  • 控件:放大镜、5星评级、分割器扩展器、WaitSpin、ZoomFlyer、扩展ListView
  • XPS读写,ePUB解析和转换
  • 单实例、参数处理和文件类型注册

章节

截图

您可以在产品网站上找到更多截图:http://guillaume.waser.free.fr

代码:架构和原则

由于项目变得比最初复杂一些,并且一直处于测试阶段,我将文章分为几个“高级”部分:核心类、MVVM模式类和编码技巧……直到架构稳定下来,才能给出更完整的描述。

核心模型类

下面说明的核心模型类包含一个表示库的Catalog类。它映射到ExplorerViewModel,并由相应的CatalogService管理。所有书籍都由一个Book实体表示,该实体(如果可能)扩展到PagesZone类专用于我的动态书籍新格式。当需要额外数据时,它存储在BookTag属性中(例如ePUB格式)。

图表:核心模型类

核心服务类

以下是主要服务类。FileServiceFileExtension管理我们需要将文件(基于扩展名)与对话框过滤器等数据关联起来的数据,以及关联的模型、服务和视图模型。这将被BookServiceFactory用来创建相应的ViewModel,该ViewModel也将使用反射创建相应的服务。BookInfoService是一个独立的类,仅管理Book二进制内部结构的加载/保存操作。CatalogService是管理库的类。

更新...!

FileserviceFileInfo类已被DocumentFactoryDocumentInfoDocumentType取代,这些类链接到核心模型。

注意:关于内部文件结构...C.B.R.不使用任何数据库。它是一个独立的二进制文件,您可以将其存储在您想要的任何位置,该文件集中了目录信息(指向书籍文件的路径)。在当前应用程序文件夹下,我将书籍信息(如评分或封面)存储在一个单独的bin文件中,以便它们可以在多个库之间共享。您可能会丢失您的库,但如果书籍二进制文件在那里,那么什么都不会丢失!

图表:核心服务类

核心工作区类

这些类管理工作环境中的所有数据。单例类WorkspaceService将设置存储在WorkspaceInfo类中,该类包含程序选项(用户可以在后台更改)、两个RecentFileInfo列表(用于书籍和库的最近文件列表)以及作为DeviceInfo集合的支持设备列表。设置类通过程序属性进行序列化。它在程序启动时加载,并在关闭时自动保存。

更新...!

ExtendedInfo包含扩展选项的数据。FeedInfoFeedItemInfo是后台视图FeedInfoView的数据。已添加ProxyInfo类来管理任何特殊的互联网设置。

图表:核心工作区类

核心转换类

我的愿望是不包含任何非纯WPF的“第三方”……所以我必须找到一种方法将不支持的格式(如PDF)转换为我选择支持的格式。

基于后台的转换面板,我们有一个ContractParameter类,它将所有选定选项分组。它被提供给BookFileConverter类,该类通过BackgroundWorker支持多线程(因为它在用户界面中使用报告进度)。然后转换过程通过基于输入和输出格式的两个接口进行:IReaderContractIWriterContract。请注意,转换过程不管理多种输入/输出格式。

BookFileConverter首先调用阅读器提取有用数据,然后通过写入器。该软件包包括ImageFileReaderRARImageReaderPDFImageReaderXPSImageReader用于读取文件夹、Rar/Zip、PDF或XPS文件中的图像(很快将扩展到ePUB)。我们可以通过ImageFileWriter、XPSImageWriter和ZIPWriter类将文件写入图像文件、XPS或CBZ/ZIP文件。

Click to enlarge image

图表:转换器核心类

从阅读器到写入器传输数据的标准方式是表示图像的字节数组,但我采取了一个捷径,例如RAR到图像的转换,其中阅读器直接从RAR中提取图像到文件夹,而不需要写入器。下面是用于转换的传输表模式

Reader 传输模式 写入器 目标
图像 x x x 图像
x 直接 压缩文件夹 CBZ/ZIP
提取到内存 内存 写入内存 XPS
PDF 提取到内存 内存 写入文件 图像
提取到内存 内存 写入文件
压缩文件夹
CBZ/ZIP
提取到内存 内存 写入内存 XPS
CBR/RAR 提取到文件夹 直接 x 图像
提取到文件夹 到临时文件夹 压缩文件夹 CBZ/ZIP
提取到内存 内存 写入内存 XPS
CBZ/ZIP 提取到文件夹 直接 x 图像
x x x CBZ/ZIP
提取到内存 内存 写入内存 XPS
XPS 提取到内存 内存 写入文件 图像
提取到内存 内存 写入文件
压缩文件夹
CBZ/ZIP
x x x XPS
ePUB x x x 图像
x x x CBZ/ZIP
提取到内存 内存 写入内存 XPS

MVVM 相关应用程序类

这是最复杂的部分……下面是参与该模式的应用程序中的类。我不会描述诸如ViewModelBaseMediatorMessenger之类的辅助类。它分为5个部分。视图:主用户界面、后台和模式之外的类。视图模型:主用户界面、后台和一些附加类。让我们逐一介绍它们。

图表:MVVM应用程序类

视图层

主用户界面由一个MainView(整个窗口)、一个ExplorerView(左侧的库浏览器)和一些Ribbon内容(不同的书籍格式和设备视图)组成,这些内容由一个TabConbtrol(不显示用户界面)托管,并绑定到ViewModel集合。

  • InfoViewOptionsViewRecentFileViewDeviceConfigView是显示在Ribbon后台的面板。
  • BookView(用于漫画)、XpsBookView(用于XPS文档)和ePUBBookView(用于类似网络的查看器)托管在主窗口部分的TabControl中
  • USBDeviceView和随后的PhoneDeviceView也托管在主窗口部分的TabControl中,但通过Ribbon组“设备”以上下文方式显示

ConvertViewSimulateView实际上脱离了该模式,因为BackgroundWorder/线程或处于beta阶段的代码。

ViewModel 层

视图模型层也完全相同,此外还有一个BookViewModelBase,它集中了所有常见的书籍功能;还有一个DeviceViewModelBase,它对设备做同样的事情。请注意,它们都继承自ViewModelBase(该类在其Data属性中托管一个模型),而ViewModelBase又继承自BindableObject,后者实现了INotifyPropertyChanged接口,用于绑定和MVVM支持。

右侧还有TreeviewItemViewModel类,它派生出SysElementViewModel(表示文件系统元素),并特化为SysDriveViewModelSysDirectoryViewModelSysFileViewModel。它们用于USBDeviceViewModel中,该ViewModel包含一个treeview和一个listview控件来显示设备内容。

更新...!

模型已通过FeedView/Model等新类完成,以显示OPDS feeds……

主要变化是由于AvalonDock集成,它引入了从PaneViewModel派生出的ToolViewModel(表示文档和工具箱视图模型)。

交换:如何在层之间通信

我稍后会尝试发布一个关于交换的图表。方式是基于MVVM的:Views调用ViewModel上的CommandsViewModel之间通过Mediator通信,ViewModel通过MessagesViews通信。我最大限度地避免了控件事件处理,但有时这是不可能或过于复杂的。

本地化引擎

这个新部分基于CodeProject的优秀文章《WPF Localization using resx》(所以我不再解释了……)。但是这个解决方案并不能让我满意,因为我不喜欢resx(当涉及到更新/复制/粘贴和重建新资源时,开发人员的工作量太大),而且我认为C.B.R.太小,不值得拥有这么多本地程序集……此外,还需要一个编辑器,以便任何用户都可以管理本地化或将资源存储打开到数据库中。

以下是支持XAML本地化的核心类。我只是对现有代码做了一些小改动,例如CultureManager上的单例模式,或者重命名后的LocalizationExtension,它现在有一个ResModul属性,可以标识resx文件或xml/bin文件。为了完善由CultureManager(单一公共类)表示的核心模型,我添加了一些管理功能,例如GetAvailableCulturesGetAvailableModulesGetModuleResourceSaveResourcesCreateCulture:这将满足编辑器的需求。请注意,最终,resx方法非常受限,无法覆盖所有这些重载……

我还添加了一个扩展CultureInfoCultureItem类。它将映射到一个视图类,用于填充Ribbon中的语言库。

图表:本地化核心类

为了扩展resx模型,我选择向CultureManager添加一个提供者模式。IResourceProviderResxProvider类(现有的不管理文化的类)和FileProviderBase直接实现,后者派生出BinProviderXmlProvider(我选择的解决方案,因为文件是人类可读的)。数据模型非常简单,将直接序列化或反序列化到资源文件。它由LocalizationFile组成,该文件按文化代码对所有LocalizationDictionnary进行分组,每个字典包含一个LocalizationItem集合,表示一个资源集。有关其实现和用法的更多详细信息,请参阅编程摘录。

图表:本地化提供者类

ePUB和OPDS模型

我对之前的模型进行了彻底的改造(之前的模型无法扩展到转换或写入功能)。ePubManager是解析类,下面是模型类。这个模型要好得多,即使ePUB有很多规范版本……它也必须适合几乎所有的阅读。请注意,查看器基于IE(需要注册一些键以获得更好的仿真模式——由安装程序完成——您将在解决方案中找到它们)。如果检测到错误编码,可以使用视图上下文菜单。

OPDS 是一种专用于电子出版物的特殊订阅源格式。CBR 包含一个订阅源管理视图和一个浏览器视图,允许浏览 RSS 并下载书籍。下面是适合我需求(非 OPDS 规范)的模型,而 OpdsManager 是解析订阅源以创建视图和视图模型对象的主类。

代码:编程摘录

在下一章中,我将指出我面临的问题和解决方案,以及一些值得分享的优秀代码片段。

转换:提取PDF图像

我深入研究了许多解决方案——阅读和解析PDF、图像提取工具——然后偶然在网上找到了一份代码文件。通过一些测试文件,我发现iTextSharp有一个监听器,你可以将其插入解析器,以便在解析过程中获取专用事件和数据。实现一个带有IRenderListener接口的类,RenderImage方法将在每个图像上被调用,这样你就可以取回字节。

通过PdFReaderPdfReaderContentParser类处理PDF页面时,将其传递给ProcessContent方法。请注意,您也可以获取文本事件。

try
{
    reader = new PdfReader(inputFileorFolder);
    PdfReaderContentParser parser = new PdfReaderContentParser(reader);

    listener = new PDFImageListener();

    for (int i = 1; i <= reader.NumberOfPages; i++)
    {
        parser.ProcessContent(i, listener);
    }
...

更新...!

RenderImage方法已通过此新代码得到改进。以前,/flat和/lzw未被处理,并且GetImageAsBytes对于ITextSharp可以读取的图像类型效果更好。

public void RenderImage(ImageRenderInfo renderInfo)
{
    PdfImageObject image = renderInfo.GetImage();
    PdfName filter = (PdfName)image.Get(PdfName.FILTER);

    if (PdfName.DCTDECODE.Equals(filter))
    {
    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_JPG));
    _ImageBytes.Add(image.GetImageAsBytes());
    }
    else if (PdfName.JPXDECODE.Equals(filter))
    {
    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_JP2));

    _ImageBytes.Add(image.GetImageAsBytes());
    }
    else if (PdfName.LZWDECODE.Equals(filter))
    {
    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_TIF));
    _ImageBytes.Add(image.GetImageAsBytes());
    }
    else if (PdfName.FLATEDECODE.Equals(filter))

    {
    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_PNG ));
    _ImageBytes.Add(image.GetImageAsBytes());
    }
...

新功能:按页合并图像(适用于PDF)

我过去的一些测试中注意到,PDF图像提取有时会导致数百张图像,因为每页都被切割成几部分。我不知道这是从哪里来的,但解决方案就在这里。我在转换面板中添加了一个复选框选项,以便在图像数量与页数不符时合并图像。

由于我的转换引擎基于一个阅读器(或提取器)、一个带有图像字节的管道,然后是一个写入器来完成所选的目标格式,因此我在中间放置了一个ImageJoiner类,它将原始字节组合成真实的页面。

我首先修改ImageListener以确保页面索引始终包含在图像文件名中。当我在PDFImageReader类的Read方法中检测到差异时,我将数组交给ImageJoiner类,并将其数组作为结果。

if (settings.CheckResult && reader.NumberOfPages != listener.ImageNames.Count)
{
    if (settings.JoinImages)
    {
        progress(string.Format("Extracting {0} : {1} images for {2} pages - Try to merge !", inputFileorFolder, listener.ImageNames.Count, reader.NumberOfPages));

        ImageJoiner cp = new ImageJoiner();

        cp.Merge(listener.ImageBytes, listener.ImageNames);

        progress(string.Format("Merge to {0} new images...", cp.NewImageNames.Count));

        imageBytes.AddRange(cp.NewImageBytes);
        imageNames.AddRange(cp.NewImageNames);

ImageJoiner 相当简单,技巧在于第二个 Merge 方法:它将要处理的索引作为参数。我首先创建一个相应 BitmapImage 的列表,并计算目标位图的大小。然后创建一个匹配目标的 RenderTargetBitmap。使用 DrawingVisual,我打开它的上下文并在其中绘制所有位图。然后我要求 RenderTargetBitmap 绘制视觉效果,之后将字节提取到新数组中……就是这样!

public void MergeGroup(List<byte[]> imageBytes, List<string> imageNames, int start, int end, int index)
{

    try
    {
        List<BitmapImage> bmps = new List<BitmapImage>();
        double maxWidth = 0, maxHeight = 0, position = 0;

        for (int i = start; i <= end; i++)
        {

            MemoryStream ms = new MemoryStream(imageBytes[i]);
            BitmapImage myImage = new BitmapImage();
            myImage.BeginInit();
            myImage.StreamSource = ms;
            myImage.CacheOption = BitmapCacheOption.None;
            myImage.EndInit();

            bmps.Add(myImage);
            maxWidth = Math.Max(myImage.Width, maxWidth);
            maxHeight += myImage.Height;

        }

        RenderTargetBitmap temp = new RenderTargetBitmap((int)maxWidth, (int)maxHeight, 96d, 96d, PixelFormats.Pbgra32);

        DrawingVisual dv = new DrawingVisual();
        using (DrawingContext ctx = dv.RenderOpen())
        {
            foreach (BitmapImage bi in bmps)
            {
                ctx.DrawImage(bi, new System.Windows.Rect(0, position, bi.Width, bi.Height));
                position += bi.Height;

            }
            ctx.Close();
        }

        temp.Render(dv);

        NewImageNames.Add(string.Format("{0:0000}.jpg", index));
        NewImageBytes.Add(StreamToImage.BufferFromImage(temp));

        bmps.Clear();
...

XPS:在固定页面上适配图像

如何在XPS文档页面上适配图像?这似乎是一个相当简单的问题,但在找到我的阿喀琉斯之踵之前,我不得不进行大量的测试……不要混淆像素图像大小和WPF单位!

请查看CBR.Core中的XPSHelper类。WriteDocument方法包含将图像数组写入固定文档的所有机制。它调用WritePageContent方法,用于写入XAML页面结构。在这里,我指定viewbox以将图像适配到A4格式的页面。

private void WritePageContent(System.Xml.XmlWriter xmlWriter, 
    XpsResource res, double wpfWidth, double wpfHeight)
{
    try
    {
        xmlWriter.WriteStartElement("FixedPage");
        xmlWriter.WriteAttributeString("xmlns", 
            @"http://schemas.microsoft.com/xps/2005/06");
        xmlWriter.WriteAttributeString("Width", "794");
        xmlWriter.WriteAttributeString("Height", "1123");
        xmlWriter.WriteAttributeString("xml:lang", "en-US");

        xmlWriter.WriteStartElement("Canvas");

        if (res is XpsImage)
        {
            xmlWriter.WriteStartElement("Path");
            xmlWriter.WriteAttributeString("Data", 
                "M 0,0 L 794,0 794,1123 0,1123 z");
            xmlWriter.WriteStartElement("Path.Fill");
            xmlWriter.WriteStartElement("ImageBrush");
            xmlWriter.WriteAttributeString("ImageSource", 
                        res.Uri.ToString());
            xmlWriter.WriteAttributeString
                ("Viewbox", string.Format("0,0,{0},{1}",
                System.Convert.ToInt32(wpfWidth), 
                System.Convert.ToInt32(wpfHeight)));

这就是为什么我创建了一个WPF BitmapImage 来获取WPF单位的图像大小......!

//this is just to get the real WPF image size as WPF display units and 
//not image pixel size !!
using (MemoryStream ms = new MemoryStream(images[i]))
{
    BitmapImage myImage = new BitmapImage();
    myImage.BeginInit();
    myImage.CacheOption = BitmapCacheOption.OnLoad;
    myImage.StreamSource = ms;
    myImage.EndInit();

    //write the page
    WritePageContent(xmlWriter, xpsImage, myImage.Width, myImage.Height);
}

动态属性

CBR 没有数据库来存储图书信息,所以我必须找到一种方法来动态地向我的对象添加属性,以便它们可以扩展。假设您按系列管理所有图书……这不是我的Book模型对象的现有属性。因此,您无法定义数据,也无法对它们进行分组或排序。另一个要求是它们需要是属性才能工作,并利用ObservableCollection中图书的GroupDescriptionsSortDescription。请参阅下面“上下文菜单”一章。

.NET 4 带来了一个很棒但不太为人所知的功能:dynamicExpando 对象。首先,根据它们为我们的模型添加一个新属性,如下所示。你添加给它的内容将被 WPF 和反射视为一个属性,但在代码中我们通常将其视为一个 Dictionary

private dynamic _dynamics = new ExpandoObject();
[Browsable(false)]
public dynamic Dynamics
{
    get { return _dynamics; }
    set { _dynamics = value; RaisePropertyChanged("Dynamics"); }
}

在定义了允许管理动态属性引用列表的后台面板之后,我能够使用它并通过同步方法刷新Book模型。我遍历了两个字典来更新book属性。

public void SynchronizeProperties(Book bk)
{
    try
    {
        IDictionary<string, object> dict = 
            (IDictionary<string, object>)bk.Dynamics;

        // add the properties from the settings if missing
        foreach( string k in WorkspaceService.Instance.Settings.Dynamics )
        {
            if (!dict.Keys.Contains(k))
                dict.Add(k, string.Empty);
        }

        // remove old properties that were removed from settings
        foreach (string k in dict.Keys)
        {
            if (!WorkspaceService.Instance.Settings.Dynamics.Contains(k))
                dict.Keys.Remove(k);
        }
    }...

填充后台文件信息面板很困难,因为WPF在绑定为PropertyName/PropertyValue列表时将其视为dictionary。因此,我不得不将其转换为KeyValueProperty视图模型类(派生自BindableObject)的集合,该类具有PropertyChanged事件,这样我就可以在动态属性中取回值。

我让您查看InfoViewModelKeyValueProperty中的代码,以及ExplorerViewModelPropertyViewModel中的代码,这些代码使用动态填充排序和分组下拉按钮菜单。

上下文菜单绑定

互联网上到处都指出,上下文菜单不共享相同的逻辑树视图……而且很容易获取父上下文……但在我的情况下,对于资源管理器中的上下文菜单,我还有额外的需求

  • 使用MainViewModel中的命令,因为Read/Bookmark或Delete等命令已经实现
  • 使用固定属性和dynamic属性填充排序和分组下拉菜单,所有这些都调用相同的命令

上下文菜单:排序和书籍命令

书籍上下文菜单

由于在逻辑树视图中向上获取父主窗口并绑定到这些命令并不容易,我选择在我的ExplorerViewModel中实现一个中间命令,该命令将其转发给MainViewModel。菜单项在XAML中定义如下

<ListBox.ContextMenu>
    <ContextMenu>
        <MenuItem Header="Read" Command="{Binding ForwardCommand}" 
        CommandParameter="BookReadCommand">

然后,在ExplorerViewModel中实现一个命令就很简单了,该命令使用Mediator通过CommandContext类通知其他视图发生了一个命令,该类包含作为stringCommandName和作为参数的Book项。

private ICommand forwardCommand;
public ICommand ForwardCommand
{
    get
    {
        if (forwardCommand == null)
            forwardCommand = new DelegateCommand<string>(
                delegate(string param)
                {
                    Mediator.Instance.NotifyColleagues
                    (ViewModelMessages.ExplorerContextCommand,
                        new CommandContext()
                        { CommandName = param, 
                        CommandParameter = 
                            this.Books.CurrentItem } );
                },
                delegate(string param)
                {
                    if (CatalogData != null && 
                        Books.CurrentItem != null)
                        return true;
                    return false;
            });
        return forwardCommand;
    }
}

MainViewModel上,我有一个方法,每当Mediator调用注册的委托时,它都会通过反射调用原始命令。

internal void ExecuteDistantCommand(CommandContext context)
{
    if (context != null)
    {
        new ReflectionHelper().ExecuteICommand
        ( this, context.CommandName, context.CommandParameter );
    }
}

排序和分组菜单

我遇到了排序和分组菜单的相同问题,而且我必须找到一种方法来用Book模型类属性填充它们,其中一些是动态属性。为了填充它,我在ExplorerViewModel中定义了两个属性,如下所示,它们给我菜单项列表。我还使用反射从书中获取属性……但我很快发现我需要一个菜单项的视图模型,因为我需要的不仅仅是一个属性名称。

public List<PropertyViewModel> SortProperties
{
    get { return GetSortProperties(); }
}

下面的PropertyViewModel包含完全标识属性所需的数据,以及一个基于与上一章相同模型的命令,该命令将通知ExplorerViewModel

public class PropertyViewModel : ViewModelBase
{
...
    #region ----------------PROPERTIES----------------

    private object CatalogData { get; set; }
    public string Prefix { get; set; }
    public string ToDisplay { get; set; }
    public string Name { get; set; }
...
    #region generic command
    private ICommand genericCommand;
    public ICommand GenericCommand
    {
        get
        {
            if (genericCommand == null)
                genericCommand = new DelegateCommand<string>
                (ExecCommand,

...
    void ExecCommand(string param)
    {
        if (param.Equals("group"))
            Mediator.Instance.NotifyColleagues
            (ViewModelMessages.ExplorerGroup, this.Name);
        else
        if (param.Equals("sort"))
            Mediator.Instance.NotifyColleagues(
                   ViewModelMessages.ExplorerSort, this.Name);
    }
...
}

请注意,在ExplorerViewModel中,我们订阅了这两个消息,之后,我们可以修改集合上的SortDescriptionGroupDescription。Group命令有点特殊,因为它需要将更改转发到View以在需要时删除GroupStyle,这就是我调用Messenger的原因。

Mediator.Instance.Register(
    (Object o) =>
    {
        Group( o as string );
    },
    ViewModelMessages.ExplorerGroup);
...

internal void Group(string propertyName)
{
    PropertyViewModel prop = GetGroupProperties().Find(p => p.Name == propertyName);

    IEnumerable<PropertyGroupDescription> result =
        Books.GroupDescriptions.Cast<PropertyGroupDescription>().
            Where(p => p.PropertyName == prop.Prefix + prop.Name);

    if (result != null && result.Count() == 1)
    {
        Books.GroupDescriptions.Remove(result.First());
    }
    else
    {
        Books.GroupDescriptions.Add(new PropertyGroupDescription
                (prop.Prefix + prop.Name));
    }

    Messenger.Default.Send<MessageBase>( new MessageBase(this) );

    RaisePropertyChanged("Books");
}

在XAML中,主要是要为菜单项定义正确的模板,包括显示和命令绑定。

<!-- style for menu items in sort/group dropdown buttons -->
<Style x:Key="PropertyViewModel">
    <Setter Property="MenuItem.Header" Value="{Binding ToDisplay}"/>
    <Setter Property="MenuItem.Command" Value="{Binding GenericCommand}" />

    <Setter Property="MenuItem.IsCheckable" Value="true" />
</Style>

<Style x:Key="GroupMenuItemStyle" BasedOn="{StaticResource PropertyViewModel}" >

    <Setter Property="MenuItem.CommandParameter" Value="group" />
</Style>

<Style x:Key="SortMenuItemStyle" BasedOn="{StaticResource PropertyViewModel}" >

    <Setter Property="MenuItem.CommandParameter" Value="sort" />
</Style>

更新...!

基于相同的原理,我实现了一个基于Attribute类的自动属性发现,允许我定义哪些属性可以排序、分组以及本地化代码……下面是示意图:PropertyHelper类直接为我提供了填充菜单所需的PropertyViewModel集合。

我将我的书籍模型扩展如下,以定义此属性不可分组,但可排序,并且本地化键是Core.Properties.FilePath。优点是如果我更改属性或语言,我的菜单现在会自动更新!

[UserPropertyAttribute(false, true, "Core.Properties.FilePath")]
public string FilePath

实现拖放

从外部应用程序

您可以将漫画文件或库拖放到应用程序以直接打开它。实现起来很简单,添加拖放事件处理程序,检查我们是否支持文件扩展名,并在MainViewModel中执行打开命令。不要忘记通过AllowDrop属性允许在您的主窗口上进行拖放。

private void RibbonWindow_Drop(object sender, DragEventArgs e)
{
    try
    {
        if (e.Data.GetDataPresent(DataFormats.FileDrop))
        {
            MainViewModel mvm = DataContext as MainViewModel;

            string[] droppedFilePaths = 
            e.Data.GetData(DataFormats.FileDrop, true) as string[];

            if (FileService.Instance.FindCatalogFilterByExt
            (System.IO.Path.GetExtension(droppedFilePaths[0])) != null)
                mvm.CatalogOpenFileCommand.Execute
                    (droppedFilePaths[0]);
            else
            if (FileService.Instance.FindBookFilterByExt
            (System.IO.Path.GetExtension(droppedFilePaths[0])) != null)
                mvm.BookOpenFileCommand.Execute(droppedFilePaths[0]);
        }
    }
    catch (Exception err)
    {
        ExceptionHelper.Manage("MainView:RibbonWindow_Drop", err);
    }
}

资源管理器和设备视图

当拖放操作来自内部控件时,我们必须管理MouseDownMouseMove事件,并在每个可能的源控件上启动拖放操作。为了简化操作,我开发了一个DragHelper,您可以将其插入到每个需要作为源的控件上——这样您就不再关心鼠标处理和项目预览了。拖放目标需要根据您的需求照常管理。DragHelper将其自身附加到构造函数中给定的控件。

 public DragHelper( Control attachedView )
{
    _Attached = attachedView;
    _Attached.PreviewMouseLeftButtonDown += 
        new MouseButtonEventHandler(PreviewMouseLeftButtonDown);
    _Attached.PreviewMouseMove += new MouseEventHandler(PreviewMouseMove);
}

void PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    _startPoint = e.GetPosition(null);
}

void PreviewMouseMove(object sender, MouseEventArgs e)
{
    if (e.LeftButton == MouseButtonState.Pressed && !IsDragging)
    {
        Point position = e.GetPosition(null);

        if (Math.Abs(position.X - _startPoint.X) > 
            SystemParameters.MinimumHorizontalDragDistance ||
            Math.Abs(position.Y - _startPoint.Y) > 
            SystemParameters.MinimumVerticalDragDistance)
        {
            Mouse.Capture(Application.Current.MainWindow);
            StartDrag(e);
        }
    }
}

如果我们满足拖动距离,我们会在应用程序中捕获鼠标,并向附加的控件发送一个StartDragEvent——这样它就可以准备数据并回调下面的DoDragDrop。简而言之,它允许拖放,将自己附加到主窗口的DragOver事件,创建一个包含源元素预览的装饰器,然后调用真实的DoDragDrop函数。释放时,我们清理所有内容。

 public void DoDragDrop( DataObject data, UIElement source )
{
    // Let's define our DragScope .. In this case it is every thing inside 
    // our main window ..
    DragScope = Application.Current.MainWindow.Content as FrameworkElement;

    // We enable Drag & Drop in our scope ... We are not implementing Drop, 
    // so it is OK, but this allows us to get DragOver
    bool previousDrop = DragScope.AllowDrop;
    DragScope.AllowDrop = true;

    // The DragOver event ...
    DragEventHandler draghandler = new DragEventHandler(ScopeDragOver);
    DragScope.PreviewDragOver += draghandler;

    //Here we create our adorner..
    _DragAdorner = new DragAdorner(DragScope, source, true, 0.5);
    AdornerLayer layer = AdornerLayer.GetAdornerLayer(DragScope as Visual);
    layer.Add(_DragAdorner);

    DragDropEffects de = DragDrop.DoDragDrop(_Attached, data, DragDropEffects.Move);

    // Clean up
    Mouse.Capture(null);
    DragScope.AllowDrop = previousDrop;
    AdornerLayer.GetAdornerLayer(DragScope).Remove(_DragAdorner);
    _DragAdorner = null;

    DragScope.PreviewDragOver -= draghandler;

    IsDragging = false;
}

如何使用它

使用它非常简单,只需在控件上实例化一个DragHelper,如下所示:

_drager = new DragHelper(CatalogListBox);
_drager.OnStartDrag += new StartDragEventHandler(drag_OnStartDrag);

然后,创建StartDragEventHandler,它将在拖放操作验证时由辅助程序调用。找到您的控件项并创建管理拖放所需的数据。调用DragHelper.DoDragDrop函数以验证并继续操作。

void drag_OnStartDrag(object sender, MouseEventArgs e)
{
    // Get the dragged ListViewItem
    ListBoxItem item = VisualHelper.FindAnchestor<ListBoxItem>
                ((DependencyObject)e.OriginalSource);

    if (item != null)
    {
        Book bk = (Book)CatalogListBox.ItemContainerGenerator.
                ItemFromContainer(item);

        // Find the data behind the item + 
        // Initialize the drag & drop operation
        DataObject dragData = new DataObject("CBR.Book.Path", bk.FilePath);

        _drager.DoDragDrop(dragData, item);
    }
}

在任何目标控件中,根据需要处理_DragOver_DragLeave_Drop处理程序。检查数据是否来自您,然后执行任何业务代码,如下所示,进行文件复制。

private void listViewContent_Drop(object sender, DragEventArgs e)
{
    if (e.Data.GetDataPresent("CBR.Book.Path"))
    {
        string path = e.Data.GetData("CBR.Book.Path") as string;

        string destFile = Path.Combine( 
        (this.FolderTree.SelectedItem as SysElementViewModel).FullPath,
             Path.GetFileName(path) );

        FileService.Instance.CopyToDevice(path, destFile, 
            this.cbDiskType.SelectedItem as DeviceInfo);
    }
}

实现USB和设备检测

WMI事件监视器

WMI作为Windows管理工具是一个不错(但复杂)的功能,是我在一位朋友说“为什么不直接把我的书拖到我的阅读器里呢?”时发现的……哈哈,就这么简单吗?那么电子书阅读器支持的格式呢?这促使我开发了两个新功能:后台的DeviceConfigView,它包含设备/制造商和支持的格式作为参考,以及新的USBDeviceView,用于显示可以与设备关联的USB驱动器。

这个想法很简单,就是检测大多数电子书阅读器(以及后来的7款手机)的USB外设。经过数小时的互联网搜索和测试,无法直接实现,因为WMI提供了大量信息,但都没有相同的结构。这是我通过编写一个辅助类实现的解决方案,该类首先在连接时搜索现有设备,然后监视USB事件。

WMI提供了有用的类并支持一种名为WQL的查询语言:ObjectQuery - 用于定义查询,ManagementObjectSearcher - 类似于Execute并返回ManagementBaseObject的集合 - 包含所请求信息的结果。

首先,我需要两个ManagementEventWatcher来获取针对Win32_USBControllerDevice结构的WMI事件,用于__InstanceCreationEvent__InstanceDeletionEvent。请注意,WITHIN子句类似于计时器,并允许轮询事件。

addedWatcher = new ManagementEventWatcher
    ("SELECT * FROM __InstanceCreationEvent WITHIN 5 WHERE TargetInstance 
    ISA \"Win32_USBControllerDevice\"");
addedWatcher.EventArrived += new EventArrivedEventHandler(HandleAddedEvent);
addedWatcher.Start();
...
// Stop listening for events
if (addedWatcher != null)
    addedWatcher.Stop();

处理创建事件有点复杂,因为插入设备时会创建很多东西。首先我们得到这个

Antecedent: \\FR-L25676\root\cimv2:Win32_USBController.DeviceID=
"PCI\\VEN_8086&DEV_293C&SUBSYS_00011179&REV_03\\3&21436425&0&D7"

Dependent: \\FR-L25676\root\cimv2:Win32_PnPEntity.DeviceID="USBSTOR\\
DISK&VEN_USB&PROD_FLASH_DISK&REV_1100\\AA04012700076941&0"

我从中提取了两条信息,允许我查询Win32_PnPEntity

  • PNP设备ID,即Win32_PnPEntity键=> USBSTOR\\DISK&VEN_USB&PROD_FLASH_DISK&REV_1100\\AA04012700076941&0
  • 设备 ID => AA04012700076941&0

结果是一个多行结果集。

我只在找到“USBSTOR”时创建USB驱动器。然后我解析服务为“disk”的行以获取更多信息,并调用GetDiskInformation,这与GetExistingDevices相同。

ManagementBaseObject targetInstance =
    e.NewEvent["TargetInstance"] as ManagementBaseObject;

DebugPrint(targetInstance);

// get the device name in the dependent property Dependent: extract the last part
string PNP_deviceID = Convert.ToString(targetInstance["Dependent"]).
Split('=').Last().Replace("\"", "").Replace("\\", "");
string device_name = Convert.ToString(targetInstance["Dependent"]).
Split('\\').Last().Replace("\"", "");

// query that device entity
ObjectQuery query = new ObjectQuery(string.Format("Select *
from Win32_PnPEntity Where DeviceID like \"%{0}%\"", device_name));

// check if match usb removable disk
using (ManagementObjectSearcher searcher = new ManagementObjectSearcher(query))
{
    ManagementObjectCollection entities = searcher.Get();

    //first loop to check USBSTOR
    foreach (var entity in entities)
    {
        string service = Convert.ToString(entity["Service"]);

        if (service == "USBSTOR")
        device = new USBDiskInfo();
    }

    if (device != null)
    {
        foreach (var entity in entities)
        {
            string service = Convert.ToString(entity["Service"]);
            if (service == "disk")
            {
                GetDiskInformation(device, device_name);

                Devices.Add(device);
                if (EventArrived != null)
                    EventArrived(this, new WMIEventArgs() 
                { Disk = device, EventType = WMIActions.Added });
....

为了处理设备移除,我实现了这个方法:获取事件触发的实例,提取PNP设备ID,如果我们将设备从我们的集合中移除,则触发一个针对应用程序的内部事件。

private void HandleRemovedEvent(object sender, EventArrivedEventArgs e)
{
    try
    {
        ManagementBaseObject targetInstance = 
            e.NewEvent["TargetInstance"] as ManagementBaseObject;

        DebugPrint(targetInstance);

        string PNP_deviceID = Convert.ToString(targetInstance
        ["Dependent"]).Split('=').Last().Replace
        ("\"", "").Replace("\\", "");

        USBDiskInfo device = Devices.Find(x => x.PNPDeviceID == PNP_deviceID);
        if( device != null )
        {
            Devices.Remove( device );
            if (EventArrived != null)
                EventArrived(this, new WMIEventArgs() 
                { Disk = device, EventType = WMIActions.Removed });
        }
    }
....

对于现有设备,我从Win32_DiskDrive经过Win32_DiskDriveToDiskPartition再经过Win32_LogicalDiskToPartition到达Win32_LogicalDisk。这形成了一个查询和循环的级联,如下所示

ObjectQuery diskQuery = new ObjectQuery
    ("Select * from Win32_DiskDrive where InterfaceType='USB'");

foreach (ManagementObject drive in new ManagementObjectSearcher(diskQuery).Get())
{
    ObjectQuery partQuery = new ObjectQuery(
    String.Format("associators of {{Win32_DiskDrive.DeviceID='{0}'}}
    where AssocClass = Win32_DiskDriveToDiskPartition", drive["DeviceID"])
    );

    DebugPrint(drive);

    foreach (ManagementObject partition in new ManagementObjectSearcher
            (partQuery).Get())
    {
        // associate partitions with logical disks (drive letter volumes)
        ObjectQuery logicalQuery = new ObjectQuery(
        String.Format("associators of {{Win32_DiskPartition.DeviceID='{0}'}}
        where AssocClass = Win32_LogicalDiskToPartition", partition["DeviceID"])
        );

        DebugPrint(partition);

        foreach (ManagementObject logical in new ManagementObjectSearcher
                (logicalQuery).Get())
        {
            DebugPrint(logical);

            USBDiskInfo disk = new USBDiskInfo();

            ParseDiskDriveInfo(disk, drive);
            ParseDiskLogicalInfo(disk, logical);

            devices.Add(disk);
        }
    }
}

在应用程序中使用它

首先,在MainWindowView中创建一个监听器实例,启动它并使用新的处理程序获取事件。不要忘记管理在应用程序启动前已连接的现有设备。

private WMIEventWatcher wmi = new WMIEventWatcher();

private void RibbonWindow_Loaded(object sender, RoutedEventArgs e)
{
    wmi.StartWatchUSB();
    wmi.EventArrived += new WMIEventArrived(wmi_EventArrived);

    MainViewModel mvm = DataContext as MainViewModel;

    if (mvm != null)
    {
        //add all existing disks
        foreach (USBDiskInfo disk in wmi.Devices)
            mvm.SysDeviceAddCommand.Execute(disk);
    }
}

在事件处理程序中,由于它在另一个线程上被调用,我们使用应用程序调度程序来调用添加或移除设备的命令。这将更新/创建设备视图。不要忘记在主窗口的OnClose事件中关闭监听器。

void wmi_EventArrived(object sender, WMIEventArgs e)
{
    Application.Current.Dispatcher.BeginInvoke
        (DispatcherPriority.DataBind, (ThreadStart)delegate {
        MainViewModel mvm = DataContext as MainViewModel;

        if (mvm != null)
        {
            if (e.EventType == WMIActions.Added)
                mvm.SysDeviceAddCommand.Execute(e.Disk);
            else
            if (e.EventType == WMIActions.Removed)
                mvm.SysDeviceRemoveCommand.Execute(e.Disk);
        }
    });
}

本地化引擎

它是如何工作的?

为了完成核心模型的解释,所有事情都发生在LocalizationExtensionGetValue方法中。我扩展了这个现有方法,以请求配置的提供程序。ConvertValue是相同的,GetObject已在所有提供程序中被覆盖,以搜索请求的键。GetDefaultValue中发生了更多更改,因为提供程序将创建文件、字典和资源项。

/// <summary>

/// Return the value associated with the key from the resource manager
/// </summary>
/// <returns>The value from the resources if possible otherwise the default value</returns>
protected override object GetValue()
{
    if (string.IsNullOrEmpty(Key))
        throw new ArgumentException("Key cannot be null");

    object result = null;
    IResourceProvider provider = null;

    try
    {
        object resource = null;

        //allow resource trapping by calling the handler
        if (GetResource != null)
            resource = GetResource(ResModul, Key, CultureManager.Instance.UICulture);

        if (resource == null)
        {
            //get the provider
            if (provider == null)
                provider = CultureManager.Instance.Provider;

            //get the localized resource
            if (provider != null)
                resource = provider.GetObject(this, CultureManager.Instance.UICulture);
        }

        //and then convert it to desired type
        result = provider.ConvertValue(this, resource);
    }
    catch (Exception err)
    {
    }
    try
    {
        // if it does not work, we ask the default value
        if (result == null)
            result = provider.GetDefaultValue(this, CultureManager.Instance.UICulture);
    }
    catch (Exception err)
    {
    }
    return result;
}

在应用程序中使用它

通过设置LocalizeProviderLocalizeFolder配置您想要的提供程序,然后用以下内容替换实际内容:ResModul是目标文件,Key是文件中的唯一资源标识符,DefaultValue是显示的文本。无需添加额外的命名空间,因为资源扩展与标准XAML命名空间关联。

Text="{LocalizationExtension ResModul=CBR.Backstage, Key=ConvertView.Title, DefaultValue=Convert}"

请注意,只要您在使用它,本地化版本将在设计器和程序中创建并显示。这有助于快速识别您遗漏的内容。在运行时,所有运行文化的资源将自动添加到文件中,无需创建!

实现语言选择和编辑器

我设计了一个下拉按钮,它与一个图库相关联,该图库显示由CultureManagerGetAvailableCulture方法提供的CultureInfoItem。它们映射到LanguageMenuItemViewModel,该模型派生自MenuItemViewModel,后者是MVVM模式中所有CBR菜单项的通用类。

下面相关的XAML定义了它

<Fluent:Gallery ItemsSource="{Binding Languages}"
    GroupBy="Tag" x:Name="languageGallery" MinItemsInRow="1" MaxItemsInRow="2"

    Orientation="Horizontal" SelectionChanged="languageGallery_SelectionChanged">
    <Fluent:Gallery.ItemTemplate>
        <DataTemplate>
            <StackPanel>
                <Image Source="{Binding Icon}" Width="16" Height="16" />

                <TextBlock Text="{Binding ToDisplay}" />
            </StackPanel>
        </DataTemplate>
    </Fluent:Gallery.ItemTemplate>
    <Fluent:Gallery.ItemContainerStyle>

        <Style TargetType="{x:Type Fluent:GalleryItem}" 
                  BasedOn="{StaticResource {x:Type Fluent:GalleryItem}}">
            <Setter Property="Fluent:GalleryItem.IsSelected" Value="{Binding IsChecked}"/>
        </Style>

    </Fluent:Gallery.ItemContainerStyle>
</Fluent:Gallery>

编辑器是一个非常简单的工具窗口。在源窗格中,您选择要使用的语言和模块。单击“选择”按钮在应用程序中使用该语言。请注意,该窗口是无模式的,因此您可以继续使用C.B.R.来检查您的工作。网格显示选定的资源:Key、Default是开发时的原始文本,Translated是您需要处理并显示的内容。

在“新建”窗格中,您可以选择一个实际不存在的区域性,添加一个图标文件名,然后单击“创建”按钮在“内存”中添加一个新语言。使用“保存”按钮将您的更改提交到文件。

更新 1...!

这个臃肿的引擎并没有考虑从代码中进行本地化!在多文档支持的选项卡之前,我没有XAML之外的资源。但当涉及到消息或像“主页”和“设备”这样的选项卡标题时……MarkupExtension会失败,因为它只适用于XAML!所以我找到了一个权宜之计。对于要在消息框中显示的消息,只需请求资源即可。选项卡需要在语言更改时通过ViewModel更新。

我在CultureManager中编写了一个新函数,用于基于相同原理请求本地化……GetLocalization(string modul, string key, string defaultValue)将通过提供程序查找是否存在现有LocalizationExtensionLocalizationItem。如果不存在,它将像原始引擎一样管理所有内容。

要将其放入ViewModel中,例如我的“Home”选项卡,我必须在文化更改时订阅CultureManager事件……并在Dispose方法中取消订阅。就是这样……花费了数小时才找到一个优雅的解决方案!

public HomeViewModel()
{
    CultureManager.Instance.UICultureChanged += new CultureEventArrived(Instance_UICultureChanged);
    DisplayName = CultureManager.Instance.GetLocalization("ByCode", "DocumentTitle.Home", "Home");

}

void Instance_UICultureChanged(object sender, CultureEventArgs e)
{
    DisplayName = CultureManager.Instance.GetLocalization("ByCode", "DocumentTitle.Home", "Home");
}

protected override void OnDispose()
{

    base.OnDispose();

    CultureManager.Instance.UICultureChanged -= new CultureEventArrived(Instance_UICultureChanged);
}

更新 2...!

现在,文化信息通过 .NET 类 CultureInfo 中的 IETF 代码进行标识。它结合了语言和国家代码,例如 fr-FR:法国法语。fr-CA 是加拿大法语。CultureItem 类已被移除,图标现在也根据 IETF 代码命名。

更新 3...!

本地化编辑器已得到改进,现在显示一个关于未使用资源的额外列……然后它已通过WPF拼写检查完成。请注意,您将需要安装.net语言包!顺便说一句,流畅的图库现在可以正确显示,底部带有管理按钮。

扩展引擎

如果您想用数据库提供程序等来扩展它,请编写您的模型和提供程序,并随时向我们提供反馈!我另一个项目需要它微笑 | <img src= " src="https://codeproject.org.cn/script/Forums/Images/smiley_smile.gif" />

请注意,CBR并未完全本地化测试版项目和工具提示...

3D和2页翻页阅读器

长期以来,我一直在关注Mistu Futura的“两页书”,但我不太确定绑定到50多张位图页面的ItemControl的性能……最近我经过短暂测试发现,绑定只发生在翻页时!所以我决定进一步研究,并根据我的需求调整这个控件。原始代码文档不完善,内容基于“全屏适配”和xaml用户控件,因此没有缩放问题……所以我必须找到一种解决方案,让位图始终尽可能最好地适配页面。我还要求它在滚动视图中,并响应我当前的图书命令。

它是如何工作的?

首先,将所有原始代码放入一个文件夹中,更改类名以使其更清晰、分组,并将用户控件转换为一个控件。这使得我们得到了LinearGradiantHelperPageParametersCornerOriginPageStatus(无更改)——以及TwoPageBook(新的主控件)和TripleSheet控件,后者代表了书本右侧或左侧的3个页面。当动画一个页面时,我们可以看到它在我们面前的一侧,后面的一侧(当它翻页时)以及前一页(或后一页)的正面。

我修改了控件以使其具有默认样式,并将其包装在一个滚动视图中,如下图所示。我删除了一些我不需要的东西,例如当前页面的缩放功能……

<Style x:Key="{x:Type local:TwoPageBook}" TargetType="{x:Type local:TwoPageBook}">
    <Setter Property="VerticalAlignment" Value="Stretch" />

    <Setter Property="HorizontalAlignment" Value="Stretch" />
    <Setter Property="Margin" Value="0" />

    <Setter Property="ClipToBounds" Value="False" />
    <Setter Property="Template">
    <Setter.Value>
        <ControlTemplate TargetType="{x:Type local:TwoPageBook}">

            <ScrollViewer Name="PART_ScrollViewer" Focusable="True"
            VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
                <Grid Name="PART_Content">

                    <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="50*" />
                    <ColumnDefinition Width="50*" />
                    </Grid.ColumnDefinitions>

                <local:TripleSheet Grid.Column="0" x:Name="sheet0" HorizontalAlignment="Right"
                    IsTopRightCornerEnabled="false" IsBottomRightCornerEnabled="false" />

                <local:TripleSheet Grid.Column="1" x:Name="sheet1" HorizontalAlignment="Left"
                    IsTopLeftCornerEnabled="false" IsBottomLeftCornerEnabled="false" />

                </Grid>
            </ScrollViewer> 
        </ControlTemplate>
    </Setter.Value> 
    </Setter>
</Style>

然后,我完善了OnApplyTemplate方法,以确保_Content将缩放,并且_ScrollContainer将稍后用于适应文档视图。我还添加了ScaleFitModeCurrentPageIndex,因为原始代码管理一个页码索引(索引除以2)。然后我更改了TripleSheet模板,使其内容适合,并用颜色填充位图无法正确缩小时的空白。请注意,Fit方法通过使用Page模型类,打破了控件的通用性……这是找到内容尺寸的唯一方法!——沮丧 | <img src= src="https://codeproject.org.cn/script/Forums/Images/smiley_frown.gif" /> /p>

public override void OnApplyTemplate()
{
    base.OnApplyTemplate();

    _ScrollContainer = (ScrollViewer)GetTemplateChild("PART_ScrollViewer");
    _Content = (FrameworkElement)GetTemplateChild("PART_Content");

    _scaleTransform.CenterX = 0.5;
    _scaleTransform.CenterY = 0.5;
    _Content.LayoutTransform = _scaleTransform;

[...]

private void Fit()
{
    if (FitMode == DisplayFitMode.Height)
    {
        Scale = (this._ScrollContainer.ViewportHeight - FIT_BORDER) / (Items[CurrentSheetIndex] as CBR.Core.Models.Page).Image.Height;
    }
    else if (FitMode == DisplayFitMode.Width)
    {
        Scale = (this._ScrollContainer.ViewportWidth - FIT_BORDER) / ((Items[CurrentSheetIndex] as CBR.Core.Models.Page).Image.Width * 2);
    }
}

出于性能考虑,我将我的书籍视图拆分为两个独立的类:单页视图和双页视图。天哪!我必须管理两种模式之间的切换和参数传递!由于书籍已加载,我只需要当前页码索引。书籍视图通过主视图进行通信,主视图接收到SwapTwoPageView消息,并将视图转换为所需的视图,如下图所示。

Mediator.Instance.RegisterHandler<BookViewModelBase>(ViewModelMessages.SwapTwoPageView,
    (BookViewModelBase o) =>
    {
        SwapTwoPageMode(o);
    });
[...]
internal void SwapTwoPageMode(BookViewModelBase o)
{
    ViewModels.Remove(o);

    BookViewModelBase newModel = null;
    BookViewModelBase oldModel = null;
    if (o is ComicViewModel)
    {
        ComicViewModel comic = o as ComicViewModel;
        newModel = new TwoPageViewModel(o.Data, comic.CurrentPage.Index, comic.FitMode, comic.PreviousScale);
    }
    else
    {
        TwoPageViewModel comic = o as TwoPageViewModel;
        newModel = new ComicViewModel(o.Data, comic.CurrentPageIndex, comic.FitMode, comic.PreviousScale);
    }

    oldModel = o;
    ViewModels.Add(newModel);
    SetActiveView(newModel);

    ViewModels.Remove(oldModel);
}

请注意,我发现的许多书籍并不真正符合此视图,因为页面比例从未相同或包含双重扫描。请不要惊讶,但由于我不处理图像并将其加载到内存中,所以我无法进行任何调整。

贡献者

非常感谢SevenZip(它允许我在内存中解压缩)、Fluent Ribbon这个出色的控件库,以及所有在这个项目中我阅读过的互联网贡献者。

您的反馈

由于上一个版本发布时间相当长,0.6版本下载量约为6万次,我想您可能对提供更精确的反馈感兴趣,我将所有计划中的功能都放在了项目的“问题跟踪器”页面。现在您可以投票或创建新项目,就您的需求优先级向我提供反馈。这将帮助我规划下一个版本的内容。

交付物 - 安装注意事项

使用0.7版和安装程序时,请务必删除以前的版本,否则有些文件将不会被替换,从而导致大问题。我将努力寻找更好的解决方案。

CodeProject上不再提供下载,因为有4个大尺寸的交付物:安装程序9MB,源代码16MB,直接二进制文件7MB。请访问此链接获取所需文件

结论

我希望您能像我一样喜欢它,并能在我的代码或软件中找到您所需。如果您对我遇到的问题有更好的解决方案,请不吝赐教……

历史记录(简化版...)

  • v0.7
    • Ribbon 选项卡和命令已重新组织
    • ePUB:彻底重构
    • 为 OPDS 流添加了一个新的专用订阅源查看器
    • 本地化引擎改进:添加了按代码管理资源的功能,现在基于ietf语言/国家代码,从xml中移除图像(基于代码),移除CultureItem类。更好的复制、管理视图和图库,拼写检查
    • 动态图书:框架设计器现在采用 MVVM 模式,并且效果更好。它基于 ItemControl,并使用 Canvas 进行模板化。还添加了 7Phone 和平板模拟器。
    • 发布模式现在为“Any CPU”,以与运行平台兼容。添加了32位和64位版本的7z.dll。x32和x64安装程序,用于epub/html视图的ie仿真模式注册表项。
    • 多文档:C.B.R.现在提供多文档显示选项,带有标签页 - 集成定制的AvalonDock 2库。
    • 改进了PDF图像提取功能,并新增了图像合并转换选项
    • 新的 BrowseForControl
    • 定制的XPS查看器,用于去除工具栏并将其绑定到CBR命令
    • 在主页添加快速入门手册和按钮,管理内部CBR订阅源语言
C.B.R. - CodeProject - 代码之家
© . All rights reserved.