编号书签 - Visual Studio 扩展 VSX 2010





5.00/5 (17投票s)
用于创建编号书签的 Visual Studio 2010 扩展。
目录
引言
书签功能是我一直不太喜欢的 Visual Studio 功能。我从来不想有成百上千的书签。我只想为最常用的行/部分提供一些快捷方式。我找不到类似的东西,微软也不想添加这个功能到 Visual Studio,所以我自己动手创建了这个 Visual Studio 扩展。
编号书签是一个 Visual Studio 2010 扩展,用于使用数字创建和检索书签。它允许用户创建 10 个编号书签(从 0 到 9)。用户可以使用相同的快捷键添加或导航到特定的书签。该工具会在 Visual Studio 编辑器中添加一个书签边距,位于滚动条旁边。每当创建书签时,书签边距上就会放置一个可视的图标。用户还可以通过“工具”菜单下的“编号书签”菜单来创建/导航/清除书签。
使用编号书签相对容易
- 创建书签:按组合键 Ctrl+Alt+数字 来创建书签,其中数字可以是 0 到 9 之间的任意数字。
- 导航到书签:按组合键 Ctrl+Alt+数字 来移动到书签位置。
- 删除书签:右键单击书签边距中的书签。
- 删除所有书签:按组合键 Ctrl+Alt+Backspace。
- 书签信息:鼠标悬停在书签上会显示有关书签的基本信息,包括书签编号、文件名、行号和列号。
- 如何使用信息:鼠标悬停在书签边距的绿色书签上会显示基本的使用说明。
先决条件
为了开发编号书签,我们需要预装以下应用程序
分而治之
解释一个功能齐全的 Visual Studio 扩展的任务可能非常繁重,这取决于扩展的复杂性。编号书签虽然是一个简单的扩展,但确实有点难以解释(尤其是对初学者而言)。让我们尝试将其分解成更小的任务,逐个完成,最终达到我们的目标。那么,我们还在等什么?让我们开始吧。
创建基本基础结构
我们可以使用 Visual Studio 包向导来生成基本的基础结构。所以,让我们继续创建基础;您需要遵循的步骤是
- 转到文件->新建,然后在新建项目对话框中,选择其他项目类型->可扩展性,然后在右侧窗格中选择Visual Studio 包。将包命名为 NumberedBookmarks,然后单击确定。
- 在Visual Studio 包向导的欢迎页面上单击下一步。选择Visual C#作为您的编程语言,并选择生成新密钥以签名程序集选项,然后单击下一步。
- 在下一步中,提供有关包的信息。修改图标、公司名称、包名称、包版本和包信息,然后单击下一步。
- 在下一步中选择菜单命令选项。它会在“工具”菜单中创建一个标题为“编号书签”的菜单项。单击下一步。
- 在下一步中,将命令名称更改为 Numbered Bookmarks,将命令 ID更改为
cmdIDNumberedBookmarks
,然后单击下一步。 - 在测试项目选项页面上,取消选择单元测试项目和集成测试项目,然后单击完成。现在,我们的基本基础结构已准备就绪。
添加菜单和子菜单
除了键盘快捷键外,用户还可以使用菜单选项来创建、移动到或删除所有书签。让我们向前面步骤创建的“编号书签”菜单添加子菜单。最终我们的菜单应该如下所示
首先,我们将把命令 ID 添加到PkgCmdIDList.cs,这些 ID 用于将事件处理程序连接到菜单项。此类中已经有一个用于“编号书签”菜单的条目。我们不使用此菜单,而是将子菜单添加到“工具”菜单中,因此删除条目并为所有菜单项创建命令 ID。最后,它看起来像
static class PkgCmdIDList
{
public const uint cmdBookmark0 = 0x0005;
public const uint cmdBookmark1 = 0x0015;
...
public const uint cmdBookmark9 = 0x0095;
public const uint cmdClearBookmarks = 0x0105;
};
现在,在进一步修改 VSCT(Visual Studio 命令表)文件以获得更多菜单选项之前,让我们创建一个位图菜单条,用于菜单图标(每个图标 16x16)。是的,我们必须创建一个位图条。要创建此位图条,请将所有图像并排放置在新图像中,并将最终图像保存为位图。下面是编号书签使用的位图条的示例。别忘了将其添加到资源中。
现在是时候动手实践了。打开NumberedBookmarks.vsct文件。滚动到Bitmaps
部分并按如下方式修改该部分
<Bitmaps>
<Bitmap guid="guidIcons"
href="Resources\AllIcons.bmp"
usedList="bmpZero, bmpOne, bmpTwo, bmpThree, bmpFour, bmpFive,
bmpSix, bmpSeven, bmpEight, bmpNine, bmpNumbers"/>
</Bitmaps>
在此部分,我们指定了位图条的位置以及所有图像的名称。现在,让我们为所有图像创建符号。
滚动到符号部分,您会找到一个用于guidImages
的GUIDSymbol
条目。Visual Studio 默认会在包中添加一个位图条。删除该部分并添加以下内容
<GuidSymbol name="guidIcons" value="{1097bc53-206b-4232-a166-1dfe7cdaedf4}" >
<IDSymbol name="bmpZero" value="1" />
<IDSymbol name="bmpOne" value="2" />
...
<IDSymbol name="bmpNine" value="10" />
<IDSymbol name="bmpNumbers" value="11" />
</GuidSymbol>
请注意,图像是从 1 开始编号的,而不是从 0 开始。value 部分包含图像在位图条中从左侧的索引。
每个菜单都包含在一个菜单组中。这样我们就有了两个菜单组(惊讶?)。让我尝试解释一下。我们在“工具”菜单下添加一个“编号书签”菜单,并在其下方添加一个子菜单。所以我们有两个菜单,“编号书签”菜单和子菜单。两者都应该有相应的菜单组。让我们分别将它们命名为MyMenuGroup
和SubMenuGroup
。最终所有菜单选项都添加到SubMenuGroup
。
我们将此步骤分为五个部分
- 首先,让我们为所有命令(前面创建的)、菜单和菜单组添加 GUID 符号。确保所有值都与之前创建的命令 ID 匹配,否则事件处理程序将不会被触发。
<GuidSymbol name="guidNumberedBookmarksCmdSet"
value="{c74fc9bd-32e1-4135-bddd-779021cc3630}">
<IDSymbol name="MyMenuGroup" value="0x1020" />
<IDSymbol name="SubMenuGroup" value="0x1150"/>
<IDSymbol name="SubMenu" value="0x1100"/>
<IDSymbol name="cmdBookmark0" value="0x0005"/>
...
<IDSymbol name="cmdBookmark9" value="0x0095"/>
<IDSymbol name="cmdClearBookmarks" value="0x0105"/>
</GuidSymbol>
Groups
部分,并添加两个组的条目(如前所述)。IDM_VS_MENU_TOOLS
是“工具”菜单的常量。<Groups>
<Group guid="guidNumberedBookmarksCmdSet" id="MyMenuGroup" priority="0x0600">
<Parent guid="guidSHLMainMenu" id="IDM_VS_MENU_TOOLS"/>
</Group>
<Group guid="guidNumberedBookmarksCmdSet" id="SubMenuGroup" priority="0x0000">
<Parent guid="guidNumberedBookmarksCmdSet" id="SubMenu"/>
</Group>
</Groups>
Menus
部分,并为“编号书签”添加一个菜单条目。添加菜单时,您可以提供命令名称和显示的标题。是的,您无法为子菜单提供图标。叹气!<Menus>
<Menu guid="guidNumberedBookmarksCmdSet" id="SubMenu"
priority="0x0100" type="Menu">
<Parent guid="guidNumberedBookmarksCmdSet" id="MyMenuGroup"/>
<Strings>
<ButtonText>Numbered Bookmarks</ButtonText>
<CommandName>Numbered Bookmarks</CommandName>
</Strings>
</Menu>
</Menus>
Buttons
标签内使用Button
标签。我们可以为每个菜单提供命令名称、标题和图标。其他条目可以类似示例如下添加<Button guid="guidNumberedBookmarksCmdSet" id="cmdBookmark1"
priority="0x0000" type="Button">
<Parent guid="guidNumberedBookmarksCmdSet" id="SubMenuGroup" />
<Icon guid="guidIcons" id="bmpOne" />
<Strings>
<CommandName>cmdBookmark1</CommandName>
<ButtonText>Bookmark 1</ButtonText>
</Strings>
</Button>
<KeyBindings>
<KeyBinding guid="guidNumberedBookmarksCmdSet" id="cmdBookmark0"
editor="guidVSStd97" key1="0" mod1="Control Alt" />
<KeyBinding guid="guidNumberedBookmarksCmdSet" id="cmdClearBookmarks"
editor="guidVSStd97" key1="VK_BACK" mod1="Control Alt" />
</KeyBindings>
最后,我们到达可以绑定命令到事件处理程序的地点。在包类(在此例中为NumberedBookmarksPackage
)的Initialize
函数中,我们可以将命令与事件处理程序绑定,并在同一类中为它创建一个事件处理程序。
CommandID menuCommandID = new CommandID(GuidList.guidNumberedBookmarksCmdSet,
(int)PkgCmdIDList.cmdIdNumberedBookmarks);
MenuCommand menuItem = new MenuCommand(MenuItemCallback, menuCommandID );
mcs.AddCommand( menuItem );
// event handler for the command
private void MenuItemCallback(object sender, EventArgs e) { }
对于编号书签,为所有 10 个条目(数字 0 到 9)添加书签的功能是相同的,在事件处理程序中,我们调用一个带有int
参数的函数AddBookmark
来指定要添加哪个书签。因此,我选择了匿名方法而不是事件处理程序。
// hoook up command for all bookmarks (0-9)
CommandID menuCommandBookmark0 = new CommandID(
GuidList.guidNumberedBookmarksCmdSet,
(int)PkgCmdIDList.cmdBookmark0);
MenuCommand subItemBookmark0 = new MenuCommand(
new EventHandler(
delegate(object sender, EventArgs args)
{ AddOrMoveToBookmark(0); }),
menuCommandBookmark0);
mcs.AddCommand(subItemBookmark0);
// hook up command for clearing bookmarks
CommandID menuCommandClearBookmark = new CommandID(
GuidList.guidNumberedBookmarksCmdSet,
(int)PkgCmdIDList.cmdClearBookmarks);
MenuCommand subItemClearBookmark = new MenuCommand(
new EventHandler(
delegate(object sender, EventArgs args)
{ ClearAllBookmarks(); }),
menuCommandClearBookmark);
mcs.AddCommand(subItemClearBookmark);
// add dummy methods
private void ClearAllBookmarks() { }
private void AddOrMoveToBookmark(int bookmarkNumber) { }
创建书签边距
创建边距或书签边距是一个双重过程。我们需要创建两个类,第一个类继承自IWpfTextViewMarginProvider
,第二个类继承自IWpfTextViewMargin
。我们需要将我们的工厂(提供程序)类导出到 MEF。根据 MEF 的哲学:“您导出,我们导入。我们导出,您导入。”这里我们导出提供程序类,MEF 导入它,然后这个类告诉 MEF 如何创建一个边距。第二个类实际上创建了边距。我们继承了Border
以提供一些基本的 WPF 功能,如背景颜色、宽度、高度等。我们也可以选择其他控件或自定义控件。
[Export(typeof(IWpfTextViewMarginProvider))]
[Name(BookmarkMargin.MarginName)]
[Order]
[MarginContainer(PredefinedMarginNames.Right)]
[ContentType("code")]
[TextViewRole(PredefinedTextViewRoles.Document)]
internal sealed class MarginFactory : IWpfTextViewMarginProvider
{
public IWpfTextViewMargin CreateMargin(IWpfTextViewHost textViewHost,
IWpfTextViewMargin containerMargin)
{
return new BookmarkMargin(textViewHost.TextView, bookmarkManager);
}
}
让我们尝试理解应用于MarginFactory
类的所有属性
Export
:指示此类提供何种类型的导出,在本例中为IWpfTextViewMarginProvider
。Name
:指示提供的导出的名称,在本例中为BookmarkMargin.MarginName
(一个常量)。Order
:指示 MEF 如何对扩展的多个实例进行排序/排列。MarginContainer
:此属性指定容器的名称(预定义常量),在本例中为PredefinedMarginNames.Right
。其他选项可以是Left
、Right
、Top
、Bottom
、ScrollBar
、ZoomControl
、LineNumber
、Spacer
、Selection
、Glyph
等。ContentType
:声明扩展与特定类型内容(在本例中为代码)的关联。TextViewRole
:指定扩展应与哪种类型的视图关联,在本例中为Document
。其他选项可以是Editable
、Debuggable
、Zoomable
等。
class BookmarkMargin : Border, IWpfTextViewMargin
{
...
}
IWpfTextViewMargin
表示附加到 Visual Studio 编辑器边缘(IWPFTextView
)的边距。
创建书签
书签是一个非常简单的 WPF 自定义控件,添加了一个椭圆。为了改善书签(称为图标)的整体外观和感觉,我们添加了一些资源进行了优化。我们还为工具提示创建了一个样式,当鼠标悬停在书签上时会显示该工具提示。
<UserControl ...>
<Canvas>
<Ellipse Stroke="OrangeRed" Height="16" Width="16"
x:Name="ellipse" Canvas.Left="0" Canvas.Top="0">
<Ellipse.Fill>
<RadialGradientBrush
GradientOrigin="0.25, 0.15">
<GradientStop Color="Orange" Offset="0.2"/>
<GradientStop Color="OrangeRed" Offset="0.9"/>
</RadialGradientBrush>
</Ellipse.Fill>
</Ellipse>
</Canvas>
</UserControl>
<Style x:Key="{x:Type ToolTip}" TargetType="ToolTip">
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="HasDropShadow" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Name="Border"
Background="{StaticResource LightBrush}"
BorderBrush="{StaticResource SolidBorderBrush}"
BorderThickness="1"
CornerRadius="5"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}">
<ContentPresenter
Margin="4"
HorizontalAlignment="Left"
VerticalAlignment="Top" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="HasDropShadow" Value="true">
<Setter TargetName="Border"
Property="CornerRadius" Value="5"/>
<Setter TargetName="Border"
Property="SnapsToDevicePixels" Value="true"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
收集文件、行和列信息
为了获取当前打开的文档、行号和列号,我们将使用DTE2
对象。DTE2
对象代表 Visual Studio .NET IDE,是自动化模型层次结构中的最高对象。让我们获取DTE2
的实例
private DTE2 GetDTE2()
{
DTE dte = (DTE)GetService(typeof(DTE));
DTE2 dte2 = dte as DTE2;
if (dte2 == null)
{
return null;
}
return dte2;
}
我们可以使用GetService
函数获取 Visual Studio 自动化模型 (DTE) 的实例(记住,它是Package
类的一部分,并且我们的包继承自Package
)。我们需要将 DTE 对象强制转换为DTE2
对象。由于一些历史原因(MSDN 如此解释),我们必须执行这两个操作:首先获取 DTE 对象,然后将其转换为DTE2
。我们可以使用DTE2
对象的ActiveDocument
属性来获取已打开文件的名称。
对于行号和列号,我们需要从 DTE2 对象的ActiveDocument.Selection.ActivePoint
属性中提取VirtualPoint
对象(当前光标位置)。行号和列号都从 1 开始(再次是历史原因,因为它来自 VB 脚本世界)。
string documentName = GetDTE2().ActiveDocument.Name;
VirtualPoint point = GetDTE2().ActiveDocument.Selection.ActivePoint;
int lineNumber = point.Line;
int columnNumber = point.DisplayColumn;
计算书签位置
计算书签位置相当简单,但书签位置究竟是什么意思?实际上,当用户选择在特定位置创建书签时,我们希望在边距中的相对位置创建书签。假设行号是 5,文档中的总行数是 20,那么我们希望书签放置在边距高度的大约四分之一(1/4th)处。为了计算位置,我们需要文档中的总行数和窗口的高度(即视口)。我们可以从IWpfTextView
中获取这两项。现在的问题是,如何获得IWpfTextView
实例。很简单,可以从IWpfTextViewHost
的TextView
属性中检索。现在的问题是,如何获得IWpfTextViewHost
。使用GetService
函数获取SVsTextManager
的实例;将其ActiveView
作为IVsTextView
提取。将IVsTextView
强制转换为IVsUserData
,并通过调用IVsUserData
的GetData
函数(使用预定义的DefGuidList.guidIWpfTextViewHost
常量)来获取IWpfTextViewHost
实例。这难道不简单吗?
private IWpfTextViewHost GetIWpfTextViewHost()
{
IVsTextManager txtMgr = (IVsTextManager)GetService(typeof(SVsTextManager));
IVsTextView vTextView = null;
int mustHaveFocus = 1;
txtMgr.GetActiveView(mustHaveFocus, null, out vTextView);
IVsUserData userData = vTextView as IVsUserData;
if (userData == null)
{
Trace.WriteLine("No text view is currently open");
return null;
}
IWpfTextViewHost viewHost;
object holder;
Guid guidViewHost = DefGuidList.guidIWpfTextViewHost;
userData.GetData(ref guidViewHost, out holder);
viewHost = (IWpfTextViewHost)holder;
return viewHost;
}
既然我们有了IWpfTextViewHost
,让我们来计算书签的 Y 坐标。从TextView
的TextSnapshot
中获取行数和视口高度,然后计算位置。
// assuming that textView is an ojbect of IWpfTextView
private double GetYCoordinateFromLineNumber(int lineNumber)
{
int totalLines = this.textView.TextSnapshot.LineCount;
double ratio = (double)lineNumber / (double)totalLines;
double yPos = ratio * textView.ViewportHeight;
return Math.Ceiling(yPos);
}
所以我们现在有了位置;让我们谈谈计算和定位书签在边距上的复杂性。如果
- 位置小于零或大于 ViewPortHeight:通过添加或减去几个像素来修复它,很简单。
private double AdjustYCoordinateForBoundaries(double position)
{
double currentPosition = position;
double viewPortHeight = Math.Ceiling(textView.ViewportHeight);
// here goes code to check boundary values and update
// y position accordingly
return currentPosition;
}
private double AdjustYCoordinateForExistingBookmarks(double position)
{
return FindNextAvailableYCoordinate(position, 1);
}
public double FindNextAvailableYCoordinate(double position, int multiplier)
{
double currentPosition = position;
foreach (UIElement item in marginCanvas.Children)
{
double topOfThisElement = Canvas.GetTop(item);
if (Math.Abs(currentPosition - topOfThisElement) < BookmarkManager.BookmarkGlyphSize)
{
// here goes our code to handle managing clashing of positions
// see source code for more details
}
}
return currentPosition;
}
管理所有书签
在讨论所有书签之前,让我们先简单谈谈书签。我们创建了一个Bookmark
类来表示书签。它是一个非常简单的类,具有公有属性Number
(书签编号)、LineNumber
、ColumnNumber
和FileName
,以及两个重载的构造函数来初始化变量。
为了管理所有书签,我们创建了一个BookmarkManager
类。该类维护一个字典(书签编号作为键,Bookmark
类的对象作为值)的所有书签,这些书签可通过公有属性Bookmarks
访问。它还声明了一个BookmarksUpdated
事件,该事件在字典发生任何更改时触发。为什么是这样?仅仅是为了在添加或删除另一个书签时更新边距。它还提供了转到特定书签编号(存储在其位置)的功能。我们只需使用DTE2
和书签中存储的FileName
找到ProjectItem
。打开文档并激活它,然后将光标移动到文档的开头,然后移动到LineNumber
和ColumnNumber
提供的偏移量。
public void GotoBookmark(int position)
{
Bookmark bookmark = Bookmarks[position];
EnvDTE.ProjectItem document = dte2.Solution.FindProjectItem(bookmark.FileName);
document.Open(BookmarkMargin.vsViewKindCode).Activate();
EnvDTE.TextSelection selection = dte2.ActiveDocument.Selection;
selection.StartOfDocument();
selection.MoveToLineAndOffset(bookmark.LineNumber, bookmark.ColumnNumber);
}
AddBookmark
和RemoveBookmark
函数只需向 Bookmarks 字典添加一个条目并触发事件。
BookmarkGlyph
(我们的 WPF 自定义控件)订阅MouseLeftButtonDown
和MouseRightButtonDown
事件,并调用 bookmark manager 的GoToBookmark
和RemoveBookmark
函数来提供功能。
添加书签并处理事件
BookmarkMargin
类通过向其中添加或删除书签来处理边距的更新。基本上,此类继承自IWpfTextViewMargin
和Border
类。我们将一个Canvas
添加到其子项中。添加书签时,我们将它们添加到Canvas
对象。BookmarkMargin
类持有一个BookmarkManager
的实例。在边距的构造函数中,我们调用UpdateBookmarks
函数,该函数依次为每个书签调用UpdateBookmark
。我们删除Canvas
中的所有子项,然后创建所有书签并将其添加到子项中。
private void UpdateBookmarks()
{
if (marginCanvas.Children.Count > 0)
{
marginCanvas.Children.Clear();
}
if (bookmarkManager != null)
{
foreach (Bookmark bookmark in bookmarkManager.Bookmarks.Values)
{
UpdateBookmark(bookmark);
}
}
}
private void UpdateBookmark(Bookmark bookmark)
{
double yPos = GetYCoordinateForBookmark(bookmark);
yPos = AdjustYCoordinateForBoundaries(yPos);
yPos = AdjustYCoordinateForExistingBookmarks(yPos);
BookmarkGlyph glyph;
if (bookmark.Number != BookmarkManager.HelpBookmarkNumber)
{
glyph = CreateBookmarkGlyph(bookmark, yPos);
}
else
{
glyph = CreateHelpGlyph(bookmark);
}
marginCanvas.Children.Add(glyph);
}
CreateBookmarkGlyph
和CreateHelpGlyph
使用特定属性创建BookmarkGlyph
类的实例并返回它。
添加帮助书签
帮助书签就像其他书签一样,具有一些默认/固定的值。其书签编号为 99(为什么?因为我喜欢它),并且始终放置在书签边距的中间。在创建BookmarkGlyph
对象时,我们识别书签并更改其属性,例如将文本更改为问号,将其填充颜色更改为绿色渐变,并提供与常规书签不同的工具提示(不提供代码,因为它非常直接)。
导出包和书签边距
这是创建包最重要的步骤之一。如果我们未能指定包的内容,它将无法按预期工作(书签边距将不会被创建)。这里需要注意的一个重要点是Description
字段。如果此字段超过 280 个字符,则无法将其上传到 Visual Studio Gallery。您还可以在清单中提供两个图像,Visual Studio Gallery 将使用它们来显示。
让我们回到内容部分。单击“添加内容”按钮,选择 MEF 组件作为内容类型,选择项目作为源,然后从下拉列表中选择 NumberedBookmarks。
确保将VS 包和MEF 组件都添加到内容列表中。
整合
让我们尝试从整体上理解这个系统。MarginFactory
创建BookmarkMargin
,并将BookmarkManager
与此BookmarkMargin
的特定实例关联起来。BookmarkMargin
创建BookmarkGlyph
并将书签图标添加到边距实例。BookmarkManager
则维护所有书签的列表,并在添加或删除书签时触发BookmarksUpdated
事件。BookmarkGlyph
则跟踪关联的BookmarkManager
,并调用其函数来处理鼠标按下事件。很简单!
试运行
是时候试试了。生成并启动解决方案(有或无调试),这将启动一个专门用于测试扩展的 Visual Studio 实例,称为实验实例。可以在此实例中调试扩展。
下载和安装
该扩展可以通过以下方式安装
- 生成并安装:生成附加的解决方案,然后双击Release/Debug文件夹中的NumberedBookmarks.vsix。这将开始安装扩展。这通常是一键式安装。
- 使用扩展管理器:转到工具->扩展管理器。在左侧导航面板中单击在线库,然后在搜索在线库文本框中键入 Numbered Bookmarks,然后按Enter。它将显示扩展,旁边有一个下载按钮。单击下载并按照步骤安装扩展。
- 从 VS Gallery 下载:从此 URL 下载扩展,然后双击下载的vsix文件(或者,您可以选择运行该应用程序)。
您可以在扩展管理器中卸载该扩展,方法是单击扩展旁边的卸载按钮。重新启动 Visual Studio 以使更改生效。
摘要
为 Visual Studio 创建扩展非常简单明了(并非总是如此),但有时也会很棘手。编号书签是我在这方面(特别是针对 VS 2010)的第一次尝试。请提供您的反馈和建议。不要忘记在 Visual Studio Gallery 上下载并评价我的扩展。
历史
- 2010 年 2 月 25 日:初稿。