beginnings of drag and drop #33
5 changed files with 104 additions and 92 deletions
commit
9d14e2f631
|
|
@ -31,26 +31,9 @@ struct _MwLLGDI {
|
||||||
int get_darktheme;
|
int get_darktheme;
|
||||||
|
|
||||||
/* drag and drop */
|
/* drag and drop */
|
||||||
void *shell32lib;
|
|
||||||
void *ole32lib;
|
|
||||||
void *urlmonlib;
|
|
||||||
UINT (*DragQueryFileW)(HDROP hDrop,UINT iFile,LPWSTR lpszFile,UINT cch);
|
|
||||||
void (*ReleaseStgMedium)(LPSTGMEDIUM unnamedParam1);
|
|
||||||
HRESULT (*OleInitialize)(LPVOID pvReserved);
|
|
||||||
HRESULT (*RegisterDragDrop)(HWND hwnd, LPDROPTARGET pDropTarget);
|
|
||||||
HRESULT (*FindMimeFromData)(
|
|
||||||
LPBC pBC,
|
|
||||||
LPCWSTR pwzUrl,
|
|
||||||
LPVOID pBuffer,
|
|
||||||
DWORD cbSize,
|
|
||||||
LPCWSTR pwzMimeProposed,
|
|
||||||
DWORD dwMimeFlags,
|
|
||||||
LPWSTR *ppwzMimeOut,
|
|
||||||
_Reserved_ DWORD dwReserved
|
|
||||||
);
|
|
||||||
struct _MwLLGDIDropTarget* dropTarget;
|
struct _MwLLGDIDropTarget* dropTarget;
|
||||||
MwBool listening_for_drag_and_drop;
|
struct _MwLLGDIDropSource* dropSource;
|
||||||
MwBool dnd_override;
|
MwBool drag_update;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct _MwLLGDIColor {
|
struct _MwLLGDIColor {
|
||||||
|
|
|
||||||
|
|
@ -101,5 +101,6 @@
|
||||||
#define MwNdrawHandler "Cdraw"
|
#define MwNdrawHandler "Cdraw"
|
||||||
#define MwNclipboardHandler "Cclipboard"
|
#define MwNclipboardHandler "Cclipboard"
|
||||||
#define MwNdarkThemeHandler "CdarkTheme"
|
#define MwNdarkThemeHandler "CdarkTheme"
|
||||||
|
#define MwNdragAndDropHandler "CdragAndDrop"
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ struct _MwClass {
|
||||||
MwHandlerChildrenProp children_prop_change;
|
MwHandlerChildrenProp children_prop_change;
|
||||||
MwHandlerClipboardReceived clipboard;
|
MwHandlerClipboardReceived clipboard;
|
||||||
MwHandlerProps props_change;
|
MwHandlerProps props_change;
|
||||||
void* reserved1;
|
MwHandler drag_and_drop;
|
||||||
void* reserved2;
|
void* reserved2;
|
||||||
void* reserved3;
|
void* reserved3;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,27 @@ typedef struct userdata {
|
||||||
|
|
||||||
static struct symtbl {
|
static struct symtbl {
|
||||||
void* lib_dwmapi;
|
void* lib_dwmapi;
|
||||||
|
void *shell32lib;
|
||||||
|
void *ole32lib;
|
||||||
|
void *urlmonlib;
|
||||||
|
|
||||||
|
MwBool has_drag_and_drop;
|
||||||
HRESULT(WINAPI* DwmSetWindowAttribute)(HWND hwnd, DWORD dwAttribute, LPCVOID pvAttribute, DWORD cbAttribute);
|
HRESULT(WINAPI* DwmSetWindowAttribute)(HWND hwnd, DWORD dwAttribute, LPCVOID pvAttribute, DWORD cbAttribute);
|
||||||
|
UINT (WINAPI *DragQueryFileW)(HDROP hDrop,UINT iFile,LPWSTR lpszFile,UINT cch);
|
||||||
|
UINT (WINAPI *DragQueryFileA)(HDROP hDrop,UINT iFile,LPSTR lpszFile,UINT cch);
|
||||||
|
void (WINAPI *ReleaseStgMedium)(LPSTGMEDIUM unnamedParam1);
|
||||||
|
HRESULT (WINAPI *OleInitialize)(LPVOID pvReserved);
|
||||||
|
HRESULT (WINAPI *RegisterDragDrop)(HWND hwnd, LPDROPTARGET pDropTarget);
|
||||||
|
HRESULT (WINAPI *FindMimeFromData)(
|
||||||
|
LPBC pBC,
|
||||||
|
LPCWSTR pwzUrl,
|
||||||
|
LPVOID pBuffer,
|
||||||
|
DWORD cbSize,
|
||||||
|
LPCWSTR pwzMimeProposed,
|
||||||
|
DWORD dwMimeFlags,
|
||||||
|
LPWSTR *ppwzMimeOut,
|
||||||
|
_Reserved_ DWORD dwReserved
|
||||||
|
);
|
||||||
} wsymtbl;
|
} wsymtbl;
|
||||||
|
|
||||||
static int is_plgblt_reliable = 0;
|
static int is_plgblt_reliable = 0;
|
||||||
|
|
@ -422,6 +441,9 @@ static MwLL MwLLCreateImpl(MwLL parent, int x, int y, int width, int height) {
|
||||||
|
|
||||||
SetWindowPos(r->gdi.hWnd, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
|
SetWindowPos(r->gdi.hWnd, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
|
||||||
|
|
||||||
|
r->gdi.dropTarget = NULL;
|
||||||
|
r->gdi.drag_update = MwFALSE;
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,6 +458,8 @@ static void MwLLDestroyImpl(MwLL handle) {
|
||||||
|
|
||||||
if(handle->gdi.cursor != NULL) DestroyCursor(handle->gdi.cursor);
|
if(handle->gdi.cursor != NULL) DestroyCursor(handle->gdi.cursor);
|
||||||
|
|
||||||
|
if(handle->gdi.dropTarget) free(handle->gdi.dropTarget);
|
||||||
|
|
||||||
free(handle);
|
free(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,8 +573,7 @@ static int MwLLPendingImpl(MwLL handle) {
|
||||||
|
|
||||||
(void)handle;
|
(void)handle;
|
||||||
|
|
||||||
|
if(handle->gdi.get_clipboard || handle->gdi.get_darktheme || handle->gdi.drag_update) return 1;
|
||||||
if(handle->gdi.get_clipboard || handle->gdi.get_darktheme) return 1;
|
|
||||||
return PeekMessage(&msg, handle->gdi.hWnd, 0, 0, PM_NOREMOVE) ? 1 : 0;
|
return PeekMessage(&msg, handle->gdi.hWnd, 0, 0, PM_NOREMOVE) ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -582,8 +605,7 @@ static void MwLLNextEventImpl(MwLL handle) {
|
||||||
|
|
||||||
handle->gdi.get_darktheme = 0;
|
handle->gdi.get_darktheme = 0;
|
||||||
}
|
}
|
||||||
while(PeekMessage(&msg, handle->gdi.hWnd, 0, 0, PM_NOREMOVE)) {
|
while(PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
|
||||||
GetMessage(&msg, handle->gdi.hWnd, 0, 0);
|
|
||||||
TranslateMessage(&msg);
|
TranslateMessage(&msg);
|
||||||
DispatchMessage(&msg);
|
DispatchMessage(&msg);
|
||||||
}
|
}
|
||||||
|
|
@ -1021,46 +1043,21 @@ static ULONG STDMETHODCALLTYPE GDIDT_Release(IDropTarget* this_) {
|
||||||
|
|
||||||
static HRESULT STDMETHODCALLTYPE GDIDT_DragEnter(IDropTarget* this_, IDataObject* pDataObj,
|
static HRESULT STDMETHODCALLTYPE GDIDT_DragEnter(IDropTarget* this_, IDataObject* pDataObj,
|
||||||
DWORD grfKeyState, POINTL pt, DWORD* pdwEffect) {
|
DWORD grfKeyState, POINTL pt, DWORD* pdwEffect) {
|
||||||
FORMATETC fmt = {CF_HDROP, NULL, DVASPECT_CONTENT, -1, TYMED_HGLOBAL};
|
*pdwEffect = DROPEFFECT_COPY;
|
||||||
STGMEDIUM stg;
|
|
||||||
MwLLGDIDropTarget* self = (MwLLGDIDropTarget*)this_;
|
|
||||||
|
|
||||||
if(pDataObj->lpVtbl->GetData(pDataObj, &fmt, &stg) == S_OK) {
|
|
||||||
HDROP hDrop = (HDROP)stg.hGlobal;
|
|
||||||
UINT count = self->handle->gdi.DragQueryFileW(hDrop, 0xFFFFFFFF, NULL, 0);
|
|
||||||
UINT i;
|
|
||||||
HRESULT hr;
|
|
||||||
|
|
||||||
for(i = 0; i < count; i++) {
|
|
||||||
wchar_t path[MAX_PATH];
|
|
||||||
char mime_type_normal[MAX_PATH];
|
|
||||||
wchar_t *mime_type = NULL;
|
|
||||||
HRESULT hr;
|
|
||||||
int i;
|
|
||||||
self->handle->gdi.DragQueryFileW(hDrop, i, path, MAX_PATH);
|
|
||||||
hr = self->handle->gdi.FindMimeFromData( NULL, path, NULL, 0,
|
|
||||||
NULL, FMFD_URLASFILENAME, &mime_type, 0x0 );
|
|
||||||
|
|
||||||
wprintf(L"Dragged: %s\n", mime_type);
|
|
||||||
/* TODO: Check if the file begin dragged matches a mime type registered with MwNacceptedMimeType, and only set the effect to DROPEFFECT_COPY if so */
|
|
||||||
*pdwEffect = DROPEFFECT_COPY;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
self->handle->gdi.ReleaseStgMedium(&stg);
|
|
||||||
} else {
|
|
||||||
*pdwEffect = DROPEFFECT_NONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return S_OK;
|
return S_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
static HRESULT STDMETHODCALLTYPE GDIDT_DragOver(IDropTarget* this_, DWORD grfKeyState,
|
static HRESULT STDMETHODCALLTYPE GDIDT_DragOver(IDropTarget* this_, DWORD grfKeyState,
|
||||||
POINTL pt, DWORD* pdwEffect) {
|
POINTL pt, DWORD* pdwEffect) {
|
||||||
|
MwLLGDIDropTarget* self = (MwLLGDIDropTarget*)this_;
|
||||||
|
self->handle->gdi.drag_update = MwTRUE;
|
||||||
|
|
||||||
return S_OK;
|
return S_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
static HRESULT STDMETHODCALLTYPE GDIDT_DragLeave(IDropTarget* this_) {
|
static HRESULT STDMETHODCALLTYPE GDIDT_DragLeave(IDropTarget* this_) {
|
||||||
|
MwLLGDIDropTarget* self = (MwLLGDIDropTarget*)this_;
|
||||||
|
self->handle->gdi.drag_update = MwFALSE;
|
||||||
return S_OK;
|
return S_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1070,27 +1067,50 @@ static HRESULT STDMETHODCALLTYPE GDIDT_Drop(IDropTarget* this_, IDataObject* pDa
|
||||||
STGMEDIUM stg;
|
STGMEDIUM stg;
|
||||||
MwLLGDIDropTarget* self = (MwLLGDIDropTarget*)this_;
|
MwLLGDIDropTarget* self = (MwLLGDIDropTarget*)this_;
|
||||||
|
|
||||||
|
self->handle->gdi.drag_update = MwTRUE;
|
||||||
|
|
||||||
if(pDataObj->lpVtbl->GetData(pDataObj, &fmt, &stg) == S_OK) {
|
if(pDataObj->lpVtbl->GetData(pDataObj, &fmt, &stg) == S_OK) {
|
||||||
HDROP hDrop = (HDROP)stg.hGlobal;
|
HDROP hDrop = (HDROP)stg.hGlobal;
|
||||||
UINT count = self->handle->gdi.DragQueryFileW(hDrop, 0xFFFFFFFF, NULL, 0);
|
UINT count = wsymtbl.DragQueryFileW(hDrop, 0xFFFFFFFF, NULL, 0);
|
||||||
UINT i;
|
UINT i, n;
|
||||||
HRESULT hr;
|
HRESULT hr;
|
||||||
|
|
||||||
for(i = 0; i < count; i++) {
|
for(i = 0; i < count; i++) {
|
||||||
wchar_t path[MAX_PATH];
|
wchar_t path[MAX_PATH];
|
||||||
char mime_type_normal[MAX_PATH];
|
char path_normal[MAX_PATH];
|
||||||
|
char *mime_type_normal;
|
||||||
wchar_t *mime_type = NULL;
|
wchar_t *mime_type = NULL;
|
||||||
|
int mime_type_size;
|
||||||
HRESULT hr;
|
HRESULT hr;
|
||||||
int i;
|
int i;
|
||||||
self->handle->gdi.DragQueryFileW(hDrop, i, path, MAX_PATH);
|
|
||||||
hr = self->handle->gdi.FindMimeFromData( NULL, path, NULL, 0,
|
|
||||||
NULL, FMFD_URLASFILENAME, &mime_type, 0x0 );
|
|
||||||
|
|
||||||
/* TODO: Check if the file begin dragged matches a mime type registered with MwNacceptedMimeType, and call a user callback */
|
wsymtbl.DragQueryFileW(hDrop, i, path, MAX_PATH);
|
||||||
wprintf(L"Dropped: %s\n", mime_type);
|
wsymtbl.DragQueryFileA(hDrop, i, path_normal, MAX_PATH);
|
||||||
|
hr = wsymtbl.FindMimeFromData( NULL, path, NULL, 0,
|
||||||
|
NULL, FMFD_DEFAULT , &mime_type, 0x0 );
|
||||||
|
|
||||||
|
mime_type_size = WideCharToMultiByte(CP_ACP, 0, mime_type, -1, NULL, 0, NULL, FALSE);
|
||||||
|
mime_type_normal = malloc(sizeof(mime_type_size));
|
||||||
|
WideCharToMultiByte(CP_ACP, 0, mime_type, -1, mime_type_normal, mime_type_size, NULL, FALSE);
|
||||||
|
|
||||||
|
if(mime_type_size == 0) {
|
||||||
|
printf("Error converting mime type to multi-byte: %ld\n",GetLastError());
|
||||||
|
} else {
|
||||||
|
if(self->handle->common.known_mime_types) {
|
||||||
|
for(n = 0; n < arrlen(self->handle->common.known_mime_types); n++) {
|
||||||
|
if(strcmp(mime_type_normal, self->handle->common.known_mime_types[n]) == 0) {
|
||||||
|
MwDispatchUserHandler(self->handle->common.user, MwNdragAndDropHandler, path_normal);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MwDispatchUserHandler(self->handle->common.user, MwNdragAndDropHandler, path_normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(mime_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
self->handle->gdi.ReleaseStgMedium(&stg);
|
wsymtbl.ReleaseStgMedium(&stg);
|
||||||
*pdwEffect = DROPEFFECT_COPY;
|
*pdwEffect = DROPEFFECT_COPY;
|
||||||
} else {
|
} else {
|
||||||
*pdwEffect = DROPEFFECT_NONE;
|
*pdwEffect = DROPEFFECT_NONE;
|
||||||
|
|
@ -1098,6 +1118,7 @@ static HRESULT STDMETHODCALLTYPE GDIDT_Drop(IDropTarget* this_, IDataObject* pDa
|
||||||
return S_OK;
|
return S_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static IDropTargetVtbl DropTarget_Vtbl = {
|
static IDropTargetVtbl DropTarget_Vtbl = {
|
||||||
GDIDT_QueryInterface,
|
GDIDT_QueryInterface,
|
||||||
GDIDT_AddRef,
|
GDIDT_AddRef,
|
||||||
|
|
@ -1108,20 +1129,25 @@ static IDropTargetVtbl DropTarget_Vtbl = {
|
||||||
GDIDT_Drop};
|
GDIDT_Drop};
|
||||||
|
|
||||||
static void MwLLSetupDragAndDropImpl(MwLL handle) {
|
static void MwLLSetupDragAndDropImpl(MwLL handle) {
|
||||||
HRESULT hr = 0;
|
HRESULT hr = 0;
|
||||||
|
|
||||||
if(!(handle->gdi.shell32lib = LoadLibrary("shell32.dll"))) return;
|
handle->gdi.dropTarget = (MwLLGDIDropTarget*)malloc(sizeof(MwLLGDIDropTarget));
|
||||||
if(!(handle->gdi.ole32lib = LoadLibrary("ole32.dll"))) return;
|
handle->gdi.dropTarget->lpVtbl = &DropTarget_Vtbl;
|
||||||
if(!(handle->gdi.urlmonlib = LoadLibrary("Urlmon.dll"))) return;
|
handle->gdi.dropTarget->refCount = 1;
|
||||||
|
handle->gdi.dropTarget->hwnd = handle->gdi.hWnd;
|
||||||
|
handle->gdi.dropTarget->handle = handle;
|
||||||
|
|
||||||
#define SHELL32_FUNC(x) if(!(handle->gdi.x = (void*)GetProcAddress(handle->gdi.shell32lib, #x))) {printf(#x "not found, drag and drop will not be supported"); return;}
|
hr = wsymtbl.RegisterDragDrop(handle->gdi.hWnd, (IDropTarget*)handle->gdi.dropTarget);
|
||||||
#define OLE32_FUNC(x) if(!(handle->gdi.x = (void*)GetProcAddress(handle->gdi.ole32lib, #x))) {printf(#x "not found, drag and drop will not be supported"); return;}
|
if(!SUCCEEDED(hr)) {
|
||||||
#define URLMON_FUNC(x) if(!(handle->gdi.x = (void*)GetProcAddress(handle->gdi.urlmonlib, #x))) {printf(#x "not found, drag and drop will not be supported"); return;}
|
printf("RegisterDragDrop failed; %08lx\n", hr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf("dnd setup\n");
|
||||||
|
}
|
||||||
|
|
||||||
SHELL32_FUNC(DragQueryFileW);
|
static int MwLLGDICallInitImpl(void) {
|
||||||
OLE32_FUNC(ReleaseStgMedium);
|
void* ntdll;
|
||||||
OLE32_FUNC(RegisterDragDrop);
|
HRESULT hr;
|
||||||
URLMON_FUNC(FindMimeFromData);
|
|
||||||
|
|
||||||
CoInitialize(NULL);
|
CoInitialize(NULL);
|
||||||
hr = OleInitialize(NULL);
|
hr = OleInitialize(NULL);
|
||||||
|
|
@ -1132,27 +1158,10 @@ static void MwLLSetupDragAndDropImpl(MwLL handle) {
|
||||||
CoInitialize(NULL);
|
CoInitialize(NULL);
|
||||||
} else {
|
} else {
|
||||||
printf("OleInitialize failed; %08lx\n", hr);
|
printf("OleInitialize failed; %08lx\n", hr);
|
||||||
return;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handle->gdi.dropTarget = (MwLLGDIDropTarget*)malloc(sizeof(MwLLGDIDropTarget));
|
|
||||||
handle->gdi.dropTarget->lpVtbl = &DropTarget_Vtbl;
|
|
||||||
handle->gdi.dropTarget->refCount = 1;
|
|
||||||
handle->gdi.dropTarget->hwnd = handle->gdi.hWnd;
|
|
||||||
handle->gdi.dropTarget->handle = handle;
|
|
||||||
|
|
||||||
hr = handle->gdi.RegisterDragDrop(handle->gdi.hWnd, (IDropTarget*)handle->gdi.dropTarget);
|
|
||||||
if(!SUCCEEDED(hr)) {
|
|
||||||
printf("RegisterDragDrop failed; %08lx\n", hr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
printf("dnd setup\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
static int MwLLGDICallInitImpl(void) {
|
|
||||||
void* ntdll;
|
|
||||||
|
|
||||||
memset(&wsymtbl, 0, sizeof(wsymtbl));
|
memset(&wsymtbl, 0, sizeof(wsymtbl));
|
||||||
|
|
||||||
wsymtbl.lib_dwmapi = MwDynamicOpen("dwmapi.dll");
|
wsymtbl.lib_dwmapi = MwDynamicOpen("dwmapi.dll");
|
||||||
|
|
@ -1165,6 +1174,24 @@ static int MwLLGDICallInitImpl(void) {
|
||||||
is_plgblt_reliable = 1;
|
is_plgblt_reliable = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wsymtbl.has_drag_and_drop = MwTRUE;
|
||||||
|
|
||||||
|
if(!(wsymtbl.shell32lib = LoadLibrary("shell32.dll"))) {goto dnd_error;};
|
||||||
|
if(!(wsymtbl.ole32lib = LoadLibrary("ole32.dll"))) {goto dnd_error;};
|
||||||
|
if(!(wsymtbl.urlmonlib = LoadLibrary("Urlmon.dll"))) {goto dnd_error;};
|
||||||
|
|
||||||
|
#define SHELL32_FUNC(x) if(!(wsymtbl.x = (void*)GetProcAddress(wsymtbl.shell32lib, #x))) {printf(#x "not found, drag and drop will not be supported"); goto dnd_error;}
|
||||||
|
#define OLE32_FUNC(x) if(!(wsymtbl.x = (void*)GetProcAddress(wsymtbl.ole32lib, #x))) {printf(#x "not found, drag and drop will not be supported"); goto dnd_error;}
|
||||||
|
#define URLMON_FUNC(x) if(!(wsymtbl.x = (void*)GetProcAddress(wsymtbl.urlmonlib, #x))) {printf(#x "not found, drag and drop will not be supported"); goto dnd_error;}
|
||||||
|
|
||||||
|
SHELL32_FUNC(DragQueryFileW);
|
||||||
|
SHELL32_FUNC(DragQueryFileA);
|
||||||
|
OLE32_FUNC(ReleaseStgMedium);
|
||||||
|
OLE32_FUNC(RegisterDragDrop);
|
||||||
|
URLMON_FUNC(FindMimeFromData);
|
||||||
|
|
||||||
|
wsymtbl.has_drag_and_drop = MwTRUE;
|
||||||
|
dnd_error:
|
||||||
/* TODO: check properly */
|
/* TODO: check properly */
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ void MwLLCreateCommon(MwLL handle) {
|
||||||
memset(handle->common.handler, 0, sizeof(*handle->common.handler));
|
memset(handle->common.handler, 0, sizeof(*handle->common.handler));
|
||||||
|
|
||||||
handle->common.supports_transparency = 0;
|
handle->common.supports_transparency = 0;
|
||||||
|
handle->common.known_mime_types = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MwLLDestroyCommon(MwLL handle) {
|
void MwLLDestroyCommon(MwLL handle) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue