mirror of
https://github.com/ultimatepp/ultimatepp.git
synced 2026-05-15 14:16:07 -06:00
rainbow: Telpp, WebWord
git-svn-id: svn://ultimatepp.org/upp/trunk@6699 f0d560ea-af0d-0410-9eb7-867de7ffcac7
This commit is contained in:
parent
0c4a00cff7
commit
a7a8aaafcc
9 changed files with 435 additions and 38 deletions
|
|
@ -13,13 +13,17 @@ NAMESPACE_UPP
|
|||
|
||||
static Point MousePos;
|
||||
|
||||
int Width = 1000;
|
||||
int Height = 1000;
|
||||
|
||||
TcpSocket server;
|
||||
TcpSocket socket;
|
||||
|
||||
One<Ctrl> desktop_ctrl;
|
||||
Size DesktopSize = Size(1000, 1000);
|
||||
|
||||
StaticRect& Desktop()
|
||||
{
|
||||
static StaticRect x;
|
||||
return x;
|
||||
}
|
||||
|
||||
|
||||
void Ctrl::InitTelpp()
|
||||
{
|
||||
|
|
@ -36,10 +40,9 @@ void Ctrl::InitTelpp()
|
|||
#endif
|
||||
ChStdSkin();
|
||||
|
||||
static StaticRect x;
|
||||
x.Color(Cyan());
|
||||
x.SetRect(0, 0, Width, Height);
|
||||
SetDesktop(x);
|
||||
Desktop().Color(Cyan());
|
||||
Desktop().SetRect(0, 0, DesktopSize.cx, DesktopSize.cy);
|
||||
SetDesktop(Desktop());
|
||||
}
|
||||
|
||||
Point GetMousePos() {
|
||||
|
|
@ -119,10 +122,10 @@ void Ctrl::PaintScene(SystemDraw& draw)
|
|||
return;
|
||||
LLOG("@ DoPaint");
|
||||
LTIMING("DoPaint paint");
|
||||
draw.Init(Size(Width, Height));
|
||||
draw.Init(DesktopSize);
|
||||
draw.Begin();
|
||||
Vector<Rect> invalid;
|
||||
invalid.Add(Size(Width, Height));
|
||||
invalid.Add(DesktopSize); _TODO_
|
||||
for(int i = topctrl.GetCount() - 1; i >= 0; i--) {
|
||||
Rect r = topctrl[i]->GetRect();
|
||||
Rect ri = GetClipBound(invalid, r);
|
||||
|
|
@ -155,7 +158,6 @@ void Ctrl::PaintCaretCursor(SystemDraw& draw)
|
|||
h << "url('data:image/png;base64,"
|
||||
<< Base64Encode(PNGEncoder().SaveString(fbCursorImage))
|
||||
<< "') " << p.x << ' ' << p.y << ", default";
|
||||
DDUMP(h);
|
||||
draw.Put8(SystemDraw::SETCURSORIMAGE);
|
||||
draw.Put16(0); // _TODO_ Cursor cache
|
||||
draw.Put(h);
|
||||
|
|
@ -221,7 +223,7 @@ void Ctrl::ReadKeyMods(CParser& p)
|
|||
|
||||
bool Ctrl::DoKeyFB(dword key, int cnt)
|
||||
{
|
||||
DLOG("DoKeyFB [" << GetKeyDesc(key) << "] " << key << ", " << cnt);
|
||||
LLOG("DoKeyFB [" << GetKeyDesc(key) << "] " << key << ", " << cnt);
|
||||
|
||||
bool b = DispatchKey(key, cnt);
|
||||
SyncCaret();
|
||||
|
|
@ -252,7 +254,7 @@ void Ctrl::DoMouseFB(int event, Point p, int zdelta, CParser& cp)
|
|||
else
|
||||
if(a == DOWN && ignoreclick)
|
||||
return;
|
||||
DLOG("### Mouse event: " << event << " position " << p << " zdelta " << zdelta << ", capture " << Upp::Name(captureCtrl));
|
||||
LLOG("### Mouse event: " << event << " position " << p << " zdelta " << zdelta << ", capture " << Upp::Name(captureCtrl));
|
||||
if(captureCtrl)
|
||||
MouseEventFB(captureCtrl->GetTopCtrl(), event, p, zdelta);
|
||||
else
|
||||
|
|
@ -276,12 +278,18 @@ static int sDistMax(Point a, Point b)
|
|||
return IsNull(a) ? INT_MAX : max(abs(a.x - b.x), abs(a.y - b.y));
|
||||
}
|
||||
|
||||
Point ReadPoint(CParser& p)
|
||||
{
|
||||
Point pt;
|
||||
pt.x = p.ReadInt();
|
||||
pt.y = p.ReadInt();
|
||||
return pt;
|
||||
}
|
||||
|
||||
void Ctrl::DoMouseButton(int event, CParser& p)
|
||||
{
|
||||
int button = p.ReadInt();
|
||||
int x = p.ReadInt();
|
||||
int y = p.ReadInt();
|
||||
Point pt(x, y);
|
||||
Point pt = ReadPoint(p);
|
||||
int64 tm = p.ReadInt64();
|
||||
(button == 0 ? mouseLeft : button == 2 ? mouseRight : mouseMiddle) = event == DOWN;
|
||||
if(event == DOWN)
|
||||
|
|
@ -306,11 +314,21 @@ bool Ctrl::ProcessEventQueue(const String& event_queue)
|
|||
if(p.Id("I"))
|
||||
SystemDraw::ResetI();
|
||||
else
|
||||
if(p.Id("R")) {
|
||||
DesktopSize = ReadPoint(p);
|
||||
Desktop().SetRect(0, 0, DesktopSize.cx, DesktopSize.cy);
|
||||
}
|
||||
if(p.Id("M")) {
|
||||
int x = p.ReadInt();
|
||||
int y = p.ReadInt();
|
||||
Point pt = ReadPoint(p);
|
||||
int64 tm = p.ReadInt64();
|
||||
DoMouseFB(MOUSEMOVE, Point(x, y), 0, p);
|
||||
DoMouseFB(MOUSEMOVE, pt, 0, p);
|
||||
}
|
||||
else
|
||||
if(p.Id("W")) {
|
||||
double w = p.ReadDouble();
|
||||
Point pt = ReadPoint(p);
|
||||
int64 tm = p.ReadInt64();
|
||||
DoMouseFB(MOUSEWHEEL, pt, w < 0 ? 120 : -120, p);
|
||||
}
|
||||
else
|
||||
if(p.Id("O")) {
|
||||
|
|
@ -416,7 +434,7 @@ void Ctrl::EventLoop(Ctrl *ctrl)
|
|||
ASSERT(IsMainThread());
|
||||
ASSERT(LoopLevel == 0 || ctrl);
|
||||
LoopLevel++;
|
||||
DLOG("Entering event loop at level " << LoopLevel << LOG_BEGIN);
|
||||
LLOG("Entering event loop at level " << LoopLevel << LOG_BEGIN);
|
||||
Ptr<Ctrl> ploop;
|
||||
if(ctrl) {
|
||||
ploop = LoopCtrl;
|
||||
|
|
|
|||
|
|
@ -37,17 +37,23 @@ void SystemDraw::Put(const String& s)
|
|||
result.Cat(s);
|
||||
}
|
||||
|
||||
Index<int64> SystemDraw::img_index;
|
||||
Index<int64> SystemDraw::img_index[3];
|
||||
|
||||
int SystemDraw::GetImageI(SystemDraw& w, const Image& img)
|
||||
int SystemDraw::GetImageI(int from, Index<int64>& img_index, int maxcount, SystemDraw& w, const Image& img)
|
||||
{
|
||||
int64 id = img.GetSerialId();
|
||||
int q = img_index.Find(id);
|
||||
if(q < 0) { // TODO: Implement some sort of victim elimination
|
||||
q = img_index.GetCount();
|
||||
img_index.Add(id);
|
||||
if(q < 0) {
|
||||
if(img_index.GetCount() < maxcount) {
|
||||
q = img_index.GetCount();
|
||||
img_index.Add(id);
|
||||
}
|
||||
else {
|
||||
q = Random(maxcount);
|
||||
img_index.Set(q, id);
|
||||
}
|
||||
w.Put8(SETIMAGE);
|
||||
w.Put16(q);
|
||||
w.Put16(q + from);
|
||||
w.Put(img.GetSize());
|
||||
const RGBA *end = ~img + img.GetLength();
|
||||
for(const RGBA *s = ~img; s < end; s++) {
|
||||
|
|
@ -57,7 +63,15 @@ int SystemDraw::GetImageI(SystemDraw& w, const Image& img)
|
|||
w.Put8(s->a);
|
||||
}
|
||||
}
|
||||
return q;
|
||||
return q + from;
|
||||
}
|
||||
|
||||
int SystemDraw::GetImageI(SystemDraw& w, const Image& img)
|
||||
{
|
||||
int area = img.GetWidth() * img.GetHeight();
|
||||
return area <= 64*64 ? GetImageI(0, img_index[0], 2048, w, img) :
|
||||
area <= 512 * 512 ? GetImageI(2048, img_index[1], 64, w, img) :
|
||||
GetImageI(2048 + 64, img_index[2], 8, w, img);
|
||||
}
|
||||
|
||||
void SystemDraw::PutImage(Point p, const Image& img, const Rect& src)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#define GUI_TELPP
|
||||
|
||||
#define _TODO_ _DBG_
|
||||
#define _TODO_ // _DBG_
|
||||
|
||||
#include <Draw/Draw.h>
|
||||
|
||||
|
|
@ -30,10 +30,11 @@ public:
|
|||
CURSORIMAGE = 6,
|
||||
};
|
||||
|
||||
static Index<int64> img_index;
|
||||
static Index<int64> img_index[3];
|
||||
|
||||
static int GetImageI(int from, Index<int64>& img_index, int maxcount, SystemDraw& w, const Image& img);
|
||||
static int GetImageI(SystemDraw& w, const Image& img);
|
||||
static void ResetI() { img_index.Clear(); }
|
||||
static void ResetI() { for(int i = 0; i < 3; i++) img_index[i].Clear(); }
|
||||
|
||||
StringBuffer result;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ html, body {
|
|||
|
||||
<body>
|
||||
|
||||
<canvas id="myCanvas" width="1000" height="1000" style="border:0px" tabindex="1">
|
||||
<canvas id="myCanvas" width="1000" height="1000" style="border:0px" tabindex="1" oncontextmenu="return false;">
|
||||
Your browser does not support the HTML5 canvas tag.
|
||||
</canvas>
|
||||
|
||||
|
|
@ -214,6 +214,11 @@ canvas.onmouseup = function(event)
|
|||
event.preventDefault();
|
||||
}
|
||||
|
||||
canvas.onwheel = function(event)
|
||||
{
|
||||
event_queue += "W " + event.deltaY + mouse_event(event);
|
||||
}
|
||||
|
||||
document.onkeydown = function(event)
|
||||
{
|
||||
event_queue += "K " + event.keyCode + " " + event.which + key_flags(event);
|
||||
|
|
@ -238,6 +243,8 @@ function ResizeCanvas()
|
|||
{
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
event_queue += "R " + canvas.width + ' ' + canvas.height + "\n";
|
||||
Ping();
|
||||
}
|
||||
|
||||
window.onresize = ResizeCanvas;
|
||||
|
|
|
|||
|
|
@ -298,12 +298,14 @@ struct EventsWnd : TopWindow {
|
|||
|
||||
GUI_APP_MAIN
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
StdLogSetup(LOG_COUT|LOG_FILE);
|
||||
|
||||
#endif
|
||||
|
||||
SetLanguage(LNG_ENGLISH);
|
||||
SetDefaultCharset(CHARSET_UTF8);
|
||||
|
||||
#if 1
|
||||
#if 0
|
||||
EventsWnd().Run();
|
||||
return;
|
||||
#endif
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
330
rainbow/WebWord/WebWord.cpp
Normal file
330
rainbow/WebWord/WebWord.cpp
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
#include <RichEdit/RichEdit.h>
|
||||
#include <PdfDraw/PdfDraw.h>
|
||||
|
||||
using namespace Upp;
|
||||
|
||||
#define IMAGECLASS UWordImg
|
||||
#define IMAGEFILE <UWord/UWord.iml>
|
||||
#include <Draw/iml.h>
|
||||
|
||||
FileSel& UWordFs()
|
||||
{
|
||||
static FileSel fs;
|
||||
return fs;
|
||||
}
|
||||
|
||||
FileSel& PdfFs()
|
||||
{
|
||||
static FileSel fs;
|
||||
return fs;
|
||||
}
|
||||
|
||||
struct UWord : public TopWindow {
|
||||
public:
|
||||
virtual void DragAndDrop(Point, PasteClip& d);
|
||||
virtual void FrameDragAndDrop(Point, PasteClip& d);
|
||||
|
||||
virtual void ShutdownWindow();
|
||||
|
||||
RichEdit editor;
|
||||
MenuBar menubar;
|
||||
ToolBar toolbar;
|
||||
StatusBar statusbar;
|
||||
String filename;
|
||||
|
||||
static LRUList& lrufile() { static LRUList l; return l; }
|
||||
|
||||
void Load(const String& filename);
|
||||
void OpenFile(const String& fn);
|
||||
void New();
|
||||
void Open();
|
||||
void Save0();
|
||||
void Save();
|
||||
void SaveAs();
|
||||
void Print();
|
||||
void Pdf();
|
||||
void About();
|
||||
void Destroy(bool shutdown);
|
||||
void SetBar();
|
||||
void FileBar(Bar& bar);
|
||||
void AboutMenu(Bar& bar);
|
||||
void MainMenu(Bar& bar);
|
||||
void MainBar(Bar& bar);
|
||||
|
||||
public:
|
||||
typedef UWord CLASSNAME;
|
||||
|
||||
static void SerializeApp(Stream& s);
|
||||
|
||||
UWord();
|
||||
};
|
||||
|
||||
void UWord::FileBar(Bar& bar)
|
||||
{
|
||||
bar.Add("New", CtrlImg::new_doc(), THISBACK(New))
|
||||
.Key(K_CTRL_N)
|
||||
.Help("Open new window");
|
||||
bar.Add("Open..", CtrlImg::open(), THISBACK(Open))
|
||||
.Key(K_CTRL_O)
|
||||
.Help("Open existing document");
|
||||
bar.Add(editor.IsModified(), "Save", CtrlImg::save(), THISBACK(Save))
|
||||
.Key(K_CTRL_S)
|
||||
.Help("Save current document");
|
||||
bar.Add("SaveAs", CtrlImg::save_as(), THISBACK(SaveAs))
|
||||
.Help("Save current document with a new name");
|
||||
bar.ToolGap();
|
||||
bar.MenuSeparator();
|
||||
bar.Add("Print..", CtrlImg::print(), THISBACK(Print))
|
||||
.Key(K_CTRL_P)
|
||||
.Help("Print document");
|
||||
bar.Add("Export to PDF..", UWordImg::pdf(), THISBACK(Pdf))
|
||||
.Help("Export document to PDF file");
|
||||
if(bar.IsMenuBar()) {
|
||||
if(lrufile().GetCount())
|
||||
lrufile()(bar, THISBACK(OpenFile));
|
||||
bar.Separator();
|
||||
bar.Add("Exit", THISBACK1(Destroy, false));
|
||||
}
|
||||
}
|
||||
|
||||
void UWord::AboutMenu(Bar& bar)
|
||||
{
|
||||
bar.Add("About..", THISBACK(About));
|
||||
}
|
||||
|
||||
void UWord::MainMenu(Bar& bar)
|
||||
{
|
||||
bar.Add("File", THISBACK(FileBar));
|
||||
bar.Add("Window", callback(WindowsMenu));
|
||||
bar.Add("Help", THISBACK(AboutMenu));
|
||||
}
|
||||
|
||||
void UWord::New()
|
||||
{
|
||||
new UWord;
|
||||
}
|
||||
|
||||
void UWord::Load(const String& name)
|
||||
{
|
||||
lrufile().NewEntry(name);
|
||||
editor.SetQTF(LoadFile(name));
|
||||
filename = name;
|
||||
editor.ClearModify();
|
||||
Title(filename);
|
||||
}
|
||||
|
||||
void UWord::OpenFile(const String& fn)
|
||||
{
|
||||
if(filename.IsEmpty() && !editor.IsModified())
|
||||
Load(fn);
|
||||
else
|
||||
(new UWord)->Load(fn);
|
||||
}
|
||||
|
||||
void UWord::Open()
|
||||
{
|
||||
FileSel& fs = UWordFs();
|
||||
if(fs.ExecuteOpen())
|
||||
OpenFile(fs);
|
||||
else
|
||||
statusbar.Temporary("Loading aborted.");
|
||||
}
|
||||
|
||||
void UWord::DragAndDrop(Point, PasteClip& d)
|
||||
{
|
||||
if(AcceptFiles(d)) {
|
||||
Vector<String> fn = GetFiles(d);
|
||||
for(int i = 0; i < fn.GetCount(); i++)
|
||||
if(FileExists(fn[i]))
|
||||
OpenFile(fn[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void UWord::FrameDragAndDrop(Point p, PasteClip& d)
|
||||
{
|
||||
DragAndDrop(p, d);
|
||||
}
|
||||
|
||||
void UWord::Save0()
|
||||
{
|
||||
lrufile().NewEntry(filename);
|
||||
if(filename.IsEmpty())
|
||||
SaveAs();
|
||||
else
|
||||
if(SaveFile(filename, editor.GetQTF())) {
|
||||
statusbar.Temporary("File " + filename + " was saved.");
|
||||
ClearModify();
|
||||
}
|
||||
else
|
||||
Exclamation("Error saving the file [* " + DeQtf(filename) + "]!");
|
||||
}
|
||||
|
||||
void UWord::Save()
|
||||
{
|
||||
if(!editor.IsModified()) return;
|
||||
Save0();
|
||||
}
|
||||
|
||||
void UWord::SaveAs()
|
||||
{
|
||||
FileSel& fs = UWordFs();
|
||||
if(fs.ExecuteSaveAs()) {
|
||||
filename = fs;
|
||||
Title(filename);
|
||||
Save0();
|
||||
}
|
||||
}
|
||||
|
||||
void UWord::Print()
|
||||
{
|
||||
editor.Print();
|
||||
}
|
||||
|
||||
void UWord::Pdf()
|
||||
{
|
||||
FileSel& fs = PdfFs();
|
||||
if(!fs.ExecuteSaveAs("Output PDF file"))
|
||||
return;
|
||||
Size page = Size(3968, 6074);
|
||||
PdfDraw pdf;
|
||||
UPP::Print(pdf, editor.Get(), page);
|
||||
SaveFile(~fs, pdf.Finish());
|
||||
}
|
||||
|
||||
void UWord::About()
|
||||
{
|
||||
PromptOK("[A5 uWord]&Using [*^www://upp.sf.net^ Ultimate`+`+] technology.");
|
||||
}
|
||||
|
||||
void UWord::Destroy(bool shutdown)
|
||||
{
|
||||
if(editor.IsModified()) {
|
||||
switch((shutdown ? PromptYesNo : PromptYesNoCancel)("Do you want to save the changes to the document?")) {
|
||||
case 1:
|
||||
Save();
|
||||
break;
|
||||
case -1:
|
||||
return;
|
||||
}
|
||||
}
|
||||
delete this;
|
||||
}
|
||||
|
||||
void UWord::ShutdownWindow()
|
||||
{
|
||||
Destroy(true);
|
||||
}
|
||||
|
||||
void UWord::MainBar(Bar& bar)
|
||||
{
|
||||
FileBar(bar);
|
||||
bar.Separator();
|
||||
editor.DefaultBar(bar);
|
||||
}
|
||||
|
||||
void UWord::SetBar()
|
||||
{
|
||||
toolbar.Set(THISBACK(MainBar));
|
||||
}
|
||||
|
||||
UWord::UWord()
|
||||
{
|
||||
AddFrame(menubar);
|
||||
AddFrame(TopSeparatorFrame());
|
||||
AddFrame(toolbar);
|
||||
AddFrame(statusbar);
|
||||
Add(editor.SizePos());
|
||||
menubar.Set(THISBACK(MainMenu));
|
||||
Sizeable().Zoomable();
|
||||
WhenClose = THISBACK1(Destroy, false);
|
||||
menubar.WhenHelp = toolbar.WhenHelp = statusbar;
|
||||
static int doc;
|
||||
Title(Format("Document%d", ++doc));
|
||||
Icon(CtrlImg::File());
|
||||
editor.ClearModify();
|
||||
SetBar();
|
||||
editor.WhenRefreshBar = THISBACK(SetBar);
|
||||
OpenMain();
|
||||
ActiveFocus(editor);
|
||||
}
|
||||
|
||||
void UWord::SerializeApp(Stream& s)
|
||||
{
|
||||
int version = 1;
|
||||
s / version;
|
||||
s % UWordFs()
|
||||
% PdfFs();
|
||||
if(version >= 1)
|
||||
s % lrufile();
|
||||
}
|
||||
|
||||
struct EventsWnd : TopWindow {
|
||||
Label l;
|
||||
String k;
|
||||
|
||||
|
||||
Image CursorImage(Point p, dword keyflags)
|
||||
{
|
||||
return UWordImg::pdf();
|
||||
}
|
||||
|
||||
void Do() {
|
||||
static int ii;
|
||||
String x;
|
||||
if(GetCtrl())
|
||||
x << "Ctrl ";
|
||||
if(GetAlt())
|
||||
x << "Alt ";
|
||||
if(GetShift())
|
||||
x << "Shift ";
|
||||
x << k << ' ' << GetMousePos();
|
||||
l = x;
|
||||
}
|
||||
|
||||
bool Key(dword key, int count) {
|
||||
k = GetKeyDesc(key) + ' ' + FormatIntHex(key);
|
||||
if(key < 256)
|
||||
k << '\"' << (char)key << '\"';
|
||||
Do();
|
||||
}
|
||||
|
||||
typedef EventsWnd CLASSNAME;
|
||||
|
||||
EventsWnd() {
|
||||
Add(l.SizePos());
|
||||
SetTimeCallback(-100, THISBACK(Do));
|
||||
}
|
||||
};
|
||||
|
||||
GUI_APP_MAIN
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
StdLogSetup(LOG_COUT|LOG_FILE);
|
||||
#endif
|
||||
|
||||
SetLanguage(LNG_ENGLISH);
|
||||
SetDefaultCharset(CHARSET_UTF8);
|
||||
|
||||
#if 0
|
||||
EventsWnd().Run();
|
||||
return;
|
||||
#endif
|
||||
|
||||
#if 0
|
||||
String xxx;
|
||||
EditText(xxx, "Edit", "Edit");
|
||||
return;
|
||||
#endif
|
||||
|
||||
UWordFs().Type("QTF files", "*.qtf")
|
||||
.AllFilesType()
|
||||
.DefaultExt("qtf");
|
||||
PdfFs().Type("PDF files", "*.pdf")
|
||||
.AllFilesType()
|
||||
.DefaultExt("pdf");
|
||||
|
||||
LoadFromFile(callback(UWord::SerializeApp));
|
||||
(new UWord)->editor.SetQTF(LoadFile(GetDataFile("test.qtf")));
|
||||
Ctrl::EventLoop();
|
||||
StoreToFile(callback(UWord::SerializeApp));
|
||||
}
|
||||
12
rainbow/WebWord/WebWord.upp
Normal file
12
rainbow/WebWord/WebWord.upp
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
uses
|
||||
Core,
|
||||
Telpp,
|
||||
plugin/DroidFonts,
|
||||
RichEdit;
|
||||
|
||||
file
|
||||
WebWord.cpp;
|
||||
|
||||
mainconfig
|
||||
"" = "SSE2 TELPP RAINBOW";
|
||||
|
||||
7
rainbow/WebWord/init
Normal file
7
rainbow/WebWord/init
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#ifndef _WebWord_icpp_init_stub
|
||||
#define _WebWord_icpp_init_stub
|
||||
#include "Core/init"
|
||||
#include "Telpp/init"
|
||||
#include "plugin/DroidFonts/init"
|
||||
#include "RichEdit/init"
|
||||
#endif
|
||||
Loading…
Add table
Add a link
Reference in a new issue