diff --git a/README.md b/README.md index 133cc37..222d53c 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,18 @@ The core also supports a shared map viewport with pan, center zoom, anchored zoom, and fitting to the visible map or a named layer. These navigation calls do not depend on a window. ActiveX exposes map/layer zoom operations and supports cursor-anchored mouse-wheel zoom and left-button drag-to-pan. +The ActiveX control also accepts supported vector datasets through file +drag-and-drop. Vector loads add layers rather than replace them. The first layer fits the initial view; later loads preserve it. Call `ZoomToMap` to include new layers in the visible extent, or `ClearLayers` to release loaded datasets before switching or refreshing them. +Dropped files append as layers and the control fits the combined visible +extent after processing the batch. Unsupported or invalid files are skipped; +valid files in the same drop are still loaded. + ## Build and test ### Prerequisites diff --git a/docs/PORTING.md b/docs/PORTING.md index a0aa150..0036d71 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -56,6 +56,13 @@ dataset, and reports whether a layer is loaded and its feature count. Loads add layers, including duplicate loads of one dataset. Call `ClearLayers` before switching or refreshing datasets. It releases all layers and resets the cached feature total while keeping the viewport. +The windowless control accepts Explorer file drops through its OLE drop-target +hook. Dropped supported vector datasets append as layers, then the control +fits the combined visible extent once. Unsupported, missing, or invalid paths +are skipped without discarding other files that loaded successfully. A partial +drop returns `S_FALSE` with a copy effect; a drop with no successful files +returns a failing HRESULT and no effect. Failed paths and HRESULTs are written +to the debugger output. New members belong on a new derived interface; do not change a published interface IID or silently add vtable members to it. @@ -69,6 +76,17 @@ in source coordinates without reprojection. OpenGL only handles convex single-ring polygons; GDI handles the supported polygon scene as the CPU fallback. +## Map and viewport behavior + +Core `Map` owns layers in insertion order. Each layer has a stable ID that is +independent of its display name and current list index. `MapView` owns the +viewport and the visible extent shared by every rendered layer. Map X increases +right and map Y increases up; screen X increases right and screen Y increases +down. Resizing keeps the view center and scale where possible. Loading through +`LoadVectorDataset` appends layers and preserves an existing view; a file-drop +batch appends layers and then fits the combined visible extent. Source +coordinates are not reprojected, so layers must use compatible coordinates. + Keep these gaps visible when extending the COM surface. Reuse Core behavior, add only the automation members required by a concrete use case, and retain a legacy identifier only after verifying that its full signature and behavior diff --git a/docs/TESTING.md b/docs/TESTING.md index e3cbb41..d7dc40b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -51,6 +51,13 @@ run exceeded 30 seconds); rerun it before treating a timeout as a hang. These integration checks require Windows and an interactive desktop session; they are not currently part of CI. +The native ActiveX integration tests also exercise the control's windowless +OLE drop target with `CF_HDROP` data. They verify that multiple supported +datasets append as layers and fit to their combined extent, and that an +unsupported file does not prevent other files in the same drop from loading. +Dragging files from Explorer onto the visible C# host remains a manual desktop +check. + The host executable accepts `--automated`, `--results `, and `--timeout-seconds <5-300>`. The PowerShell launcher supplies these options and starts the host without changing COM registration. diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index 97e2366..05d8a78 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp @@ -2,7 +2,7 @@ /** * @file MapWinGISControl.cpp - * @brief Implements vector loading, ActiveX state reporting, and host drawing. + * @brief Implements vector loading, file drop, ActiveX state, and host drawing. * * Redraws: every map or view change made through the control goes through * its MapView; the control calls FireViewChange when the MapView reports @@ -12,6 +12,7 @@ #include "MapWinGISControl.h" #include +#include #include #include @@ -90,6 +91,117 @@ namespace return L"Automatic"; } } + + [[nodiscard]] bool HasFileDropFormat(IDataObject* data) noexcept + { + if (data == nullptr) + { + return false; + } + + FORMATETC format{ + CF_HDROP, + nullptr, + DVASPECT_CONTENT, + -1, + TYMED_HGLOBAL}; + return SUCCEEDED(data->QueryGetData(&format)); + } + + class StgMediumGuard final + { + public: + explicit StgMediumGuard(STGMEDIUM& medium) noexcept + : _medium(medium) + { + } + + ~StgMediumGuard() + { + ::ReleaseStgMedium(&_medium); + } + + StgMediumGuard(const StgMediumGuard&) = delete; + StgMediumGuard& operator=(const StgMediumGuard&) = delete; + + private: + STGMEDIUM& _medium; + }; + + [[nodiscard]] HRESULT GetDroppedPaths( + IDataObject* data, + std::vector& paths) + { + if (data == nullptr) + { + return E_POINTER; + } + + FORMATETC format{ + CF_HDROP, + nullptr, + DVASPECT_CONTENT, + -1, + TYMED_HGLOBAL}; + STGMEDIUM medium{}; + const HRESULT result = data->GetData(&format, &medium); + if (FAILED(result)) + { + return result; + } + const StgMediumGuard mediumGuard(medium); + if (medium.tymed != TYMED_HGLOBAL || medium.hGlobal == nullptr) + { + return DV_E_TYMED; + } + + const HDROP drop = static_cast(medium.hGlobal); + const UINT count = ::DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + if (count == 0) + { + return DV_E_FORMATETC; + } + + paths.clear(); + paths.reserve(count); + for (UINT index = 0; index < count; ++index) + { + const UINT length = ::DragQueryFileW(drop, index, nullptr, 0); + if (length == 0 || length == std::numeric_limits::max()) + { + return DV_E_FORMATETC; + } + + std::wstring path(length + 1, L'\0'); + const UINT copied = ::DragQueryFileW( + drop, + index, + path.data(), + static_cast(path.size())); + if (copied != length) + { + return DV_E_FORMATETC; + } + path.resize(copied); + paths.emplace_back(std::move(path)); + } + return S_OK; + } + + void LogDropFailure( + const std::filesystem::path& path, + const HRESULT result) noexcept + { + wchar_t message[64]{}; + (void)::swprintf_s( + message, + 64, + L"MapWinGIS could not load dropped file (HRESULT 0x%08lX): ", + static_cast(result)); + ::OutputDebugStringW(message); + ::OutputDebugStringW(path.c_str()); + ::OutputDebugStringW(L"\n"); + } } CMapWinGISControl::CMapWinGISControl() @@ -685,6 +797,198 @@ STDMETHODIMP CMapWinGISControl::put_RenderingBackendPreference(BSTR preference) return S_OK; } +STDMETHODIMP CMapWinGISControl::GetDropTarget(IDropTarget** dropTarget) +{ + if (dropTarget == nullptr) + { + return E_POINTER; + } + + *dropTarget = static_cast(this); + AddRef(); + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::DragEnter( + IDataObject* data, + DWORD, + POINTL, + DWORD* effect) +{ + if (effect == nullptr) + { + return E_POINTER; + } + + const DWORD allowedEffects = *effect; + *effect = DROPEFFECT_NONE; + _dropAcceptsFiles = + (allowedEffects & DROPEFFECT_COPY) != 0 && HasFileDropFormat(data); + if (_dropAcceptsFiles) + { + *effect = DROPEFFECT_COPY; + } + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::DragOver(DWORD, POINTL, DWORD* effect) +{ + if (effect == nullptr) + { + return E_POINTER; + } + + if (_dropAcceptsFiles && (*effect & DROPEFFECT_COPY) != 0) + { + *effect = DROPEFFECT_COPY; + } + else + { + *effect = DROPEFFECT_NONE; + } + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::DragLeave() +{ + _dropAcceptsFiles = false; + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::Drop( + IDataObject* data, + DWORD, + POINTL, + DWORD* effect) +{ + if (effect == nullptr) + { + return E_POINTER; + } + + const DWORD allowedEffects = *effect; + *effect = DROPEFFECT_NONE; + _dropAcceptsFiles = false; + if ((allowedEffects & DROPEFFECT_COPY) == 0) + { + return S_OK; + } + + std::vector paths; + try + { + const HRESULT extractResult = GetDroppedPaths(data, paths); + if (FAILED(extractResult)) + { + ::OutputDebugStringA("MapWinGIS rejected dropped data without usable file paths.\n"); + return extractResult; + } + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + return E_FAIL; + } + + bool loadedAny = false; + HRESULT firstFailure = S_OK; + for (const std::filesystem::path& path : paths) + { + std::error_code fileError; + if (!std::filesystem::exists(path, fileError)) + { + const HRESULT pathResult = fileError + ? HRESULT_FROM_WIN32(static_cast(fileError.value())) + : HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND); + LogDropFailure(path, pathResult); + if (SUCCEEDED(firstFailure)) + { + firstFailure = pathResult; + } + continue; + } + + HRESULT loadResult = E_FAIL; + try + { + const std::wstring pathText = path.native(); + if (pathText.size() > std::numeric_limits::max()) + { + loadResult = E_INVALIDARG; + } + else + { + BSTR pathBstr = ::SysAllocStringLen( + pathText.data(), + static_cast(pathText.size())); + if (pathBstr == nullptr) + { + loadResult = E_OUTOFMEMORY; + } + else + { + loadResult = LoadVectorDataset(pathBstr); + ::SysFreeString(pathBstr); + } + } + } + catch (const std::bad_alloc&) + { + loadResult = E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + if (SUCCEEDED(firstFailure)) + { + firstFailure = E_FAIL; + } + } + + if (FAILED(loadResult)) + { + LogDropFailure(path, loadResult); + if (SUCCEEDED(firstFailure)) + { + firstFailure = loadResult; + } + if (loadResult == E_OUTOFMEMORY) + { + break; + } + continue; + } + loadedAny = true; + } + + if (loadedAny) + { + try + { + if (_view.FitToMap()) + { + FireViewChange(); + } + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + if (SUCCEEDED(firstFailure)) + { + firstFailure = E_FAIL; + } + } + *effect = DROPEFFECT_COPY; + return FAILED(firstFailure) ? S_FALSE : S_OK; + } + + return FAILED(firstFailure) ? firstFailure : E_FAIL; +} + bool CMapWinGISControl::ResizeView(const LONG width, const LONG height) noexcept { try diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.h b/src/MapWinGIS.ActiveX/MapWinGISControl.h index aafef27..268419a 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -4,8 +4,8 @@ * @file MapWinGISControl.h * @brief Declares the COM-creatable MapWinGIS ActiveX control. * - * Provides automation for vector loading and map navigation, GDAL operations, - * rendering preferences, and painting the map's polygon layers. The control + * Provides automation for vector loading and map navigation, file drop, GDAL + * operations, rendering preferences, and painting polygon layers. The control * owns one Core MapView, the only map and view state; it forwards host events * to it and draws its render scene through its viewport. */ @@ -34,6 +34,7 @@ class ATL_NO_VTABLE CMapWinGISControl : public ATL::IOleControlImpl, public ATL::IOleObjectImpl, public ATL::IOleInPlaceActiveObjectImpl, + public IDropTarget, public ATL::IViewObjectExImpl, public ATL::IOleInPlaceObjectWindowlessImpl, public ATL::IPersistStreamInitImpl, @@ -63,6 +64,7 @@ class ATL_NO_VTABLE CMapWinGISControl : COM_INTERFACE_ENTRY(IMapWinGISControl2) COM_INTERFACE_ENTRY(IMapWinGISControl) COM_INTERFACE_ENTRY(IMapWinGISControlGdal) + COM_INTERFACE_ENTRY(IDropTarget) COM_INTERFACE_ENTRY(IConnectionPointContainer) COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl4) COM_INTERFACE_ENTRY(IViewObjectEx) @@ -134,6 +136,19 @@ class ATL_NO_VTABLE CMapWinGISControl : BSTR* summary, BSTR* diagnostics, SAFEARRAY** warnings) override; + STDMETHOD(GetDropTarget)(IDropTarget** dropTarget) override; + STDMETHOD(DragEnter)( + IDataObject* data, + DWORD keyState, + POINTL point, + DWORD* effect) override; + STDMETHOD(DragOver)(DWORD keyState, POINTL point, DWORD* effect) override; + STDMETHOD(DragLeave)() override; + STDMETHOD(Drop)( + IDataObject* data, + DWORD keyState, + POINTL point, + DWORD* effect) override; STDMETHOD(Close)(DWORD saveOption) override; STDMETHOD(SetObjectRects)(LPCRECT position, LPCRECT clip) override; @@ -170,6 +185,7 @@ class ATL_NO_VTABLE CMapWinGISControl : mwg::activex::GdalEventSource _gdalEvents; mwg::ScreenPoint _lastPanPosition{}; bool _isPanning = false; + bool _dropAcceptsFiles = false; }; OBJECT_ENTRY_AUTO(CLSID_MapWinGISControl, CMapWinGISControl) diff --git a/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp index 4c1153b..99a15f0 100644 --- a/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp +++ b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp @@ -2,20 +2,25 @@ /** * @file ActiveXActivationTests.cpp - * @brief Tests COM activation, view rendering, and the hosted control lifecycle. + * @brief Tests COM activation, file drops, rendering, and the hosted control lifecycle. */ #include #include #include #include +#include +#include #include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -750,6 +755,188 @@ namespace HMODULE _handle; }; + class FileDropDataObject final : public IDataObject + { + public: + explicit FileDropDataObject( + const std::vector& paths) + { + for (const std::filesystem::path& path : paths) + { + const std::wstring text = path.native(); + _fileList.insert(_fileList.end(), text.begin(), text.end()); + _fileList.push_back(L'\0'); + } + _fileList.push_back(L'\0'); + } + + HRESULT STDMETHODCALLTYPE QueryInterface( + REFIID interfaceId, + void** result) override + { + if (result == nullptr) + { + return E_POINTER; + } + *result = nullptr; + if (IsEqualIID(interfaceId, IID_IUnknown) || + IsEqualIID(interfaceId, IID_IDataObject)) + { + *result = static_cast(this); + AddRef(); + return S_OK; + } + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override + { + return ++_references; + } + + ULONG STDMETHODCALLTYPE Release() override + { + const ULONG remaining = --_references; + if (remaining == 0) + { + delete this; + } + return remaining; + } + + HRESULT STDMETHODCALLTYPE GetData( + FORMATETC* format, + STGMEDIUM* medium) override + { + if (medium == nullptr) + { + return E_POINTER; + } + const HRESULT queryResult = QueryGetData(format); + if (FAILED(queryResult)) + { + return queryResult; + } + + const SIZE_T byteCount = sizeof(DROPFILES) + + _fileList.size() * sizeof(wchar_t); + HGLOBAL memory = ::GlobalAlloc(GMEM_MOVEABLE, byteCount); + if (memory == nullptr) + { + return E_OUTOFMEMORY; + } + + auto* dropFiles = static_cast(::GlobalLock(memory)); + if (dropFiles == nullptr) + { + ::GlobalFree(memory); + return E_OUTOFMEMORY; + } + *dropFiles = {}; + dropFiles->pFiles = sizeof(DROPFILES); + dropFiles->fWide = TRUE; + std::memcpy( + reinterpret_cast(dropFiles) + sizeof(DROPFILES), + _fileList.data(), + _fileList.size() * sizeof(wchar_t)); + ::GlobalUnlock(memory); + + medium->tymed = TYMED_HGLOBAL; + medium->hGlobal = memory; + medium->pUnkForRelease = nullptr; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE GetDataHere( + FORMATETC*, + STGMEDIUM*) override + { + return E_NOTIMPL; + } + + HRESULT STDMETHODCALLTYPE QueryGetData(FORMATETC* format) override + { + if (format == nullptr) + { + return E_POINTER; + } + if (format->cfFormat != CF_HDROP || + format->dwAspect != DVASPECT_CONTENT || + format->lindex != -1 || + (format->tymed & TYMED_HGLOBAL) == 0) + { + return DV_E_FORMATETC; + } + return S_OK; + } + + HRESULT STDMETHODCALLTYPE GetCanonicalFormatEtc( + FORMATETC*, + FORMATETC* output) override + { + if (output == nullptr) + { + return E_POINTER; + } + output->ptd = nullptr; + return E_NOTIMPL; + } + + HRESULT STDMETHODCALLTYPE SetData( + FORMATETC*, + STGMEDIUM*, + BOOL) override + { + return E_NOTIMPL; + } + + HRESULT STDMETHODCALLTYPE EnumFormatEtc( + DWORD, + IEnumFORMATETC** output) override + { + if (output == nullptr) + { + return E_POINTER; + } + *output = nullptr; + return E_NOTIMPL; + } + + HRESULT STDMETHODCALLTYPE DAdvise( + FORMATETC*, + DWORD, + IAdviseSink*, + DWORD* connection) override + { + if (connection == nullptr) + { + return E_POINTER; + } + *connection = 0; + return OLE_E_ADVISENOTSUPPORTED; + } + + HRESULT STDMETHODCALLTYPE DUnadvise(DWORD) override + { + return OLE_E_ADVISENOTSUPPORTED; + } + + HRESULT STDMETHODCALLTYPE EnumDAdvise( + IEnumSTATDATA** output) override + { + if (output == nullptr) + { + return E_POINTER; + } + *output = nullptr; + return OLE_E_ADVISENOTSUPPORTED; + } + + private: + std::atomic _references{1}; + std::vector _fileList; + }; + class ActiveXMapTest : public testing::Test { protected: @@ -795,6 +982,34 @@ namespace return _control->LoadVectorDataset(ATL::CComBSTR(path.c_str())); } + HRESULT DropFiles( + const std::vector& paths, + DWORD& effect) + { + ComPtr target; + HRESULT result = _window->GetDropTarget(&target); + if (FAILED(result)) + { + return result; + } + + ComPtr data; + data.Attach(new (std::nothrow) FileDropDataObject(paths)); + if (data == nullptr) + { + return E_OUTOFMEMORY; + } + + effect = DROPEFFECT_COPY; + const POINTL point{20, 20}; + result = target->DragEnter(data.Get(), 0, point, &effect); + if (FAILED(result) || (effect & DROPEFFECT_COPY) == 0) + { + return result; + } + return target->Drop(data.Get(), 0, point, &effect); + } + HRESULT Resize(const int width, const int height) { LRESULT result = 0; @@ -849,6 +1064,81 @@ TEST_F(ActiveXMapTest, LoadsStackAndClearReleasesTheDatasets) EXPECT_EQ(count, 1); } +TEST_F(ActiveXMapTest, FileDropAddsMultipleLayersAndFitsTheirCombinedExtent) +{ + ASSERT_EQ(Resize(200, 120), S_OK); + const std::array firstExtent{mwg::Extent{0, 0, 2, 2}}; + const std::array secondExtent{mwg::Extent{100, 0, 102, 2}}; + const auto first = mwg::test::MakeRectanglesDataset( + _directory.Path(), "dropped-first.gpkg", firstExtent); + const auto second = mwg::test::MakeRectanglesDataset( + _directory.Path(), "dropped-second.gpkg", secondExtent); + + DWORD effect = DROPEFFECT_NONE; + ASSERT_EQ(DropFiles({first, second}, effect), S_OK); + EXPECT_EQ(effect, static_cast(DROPEFFECT_COPY)); + + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + LONG layerCount = 0; + LONGLONG featureCount = 0; + ASSERT_EQ(automation->get_LayerCount(&layerCount), S_OK); + ASSERT_EQ(_control->get_FeatureCount(&featureCount), S_OK); + EXPECT_EQ(layerCount, 2); + EXPECT_EQ(featureCount, 2); + + BitmapSurface surface(200, 120); + ASSERT_TRUE(surface.IsValid()); + ASSERT_EQ(Draw(surface.Get(), 200, 120), S_OK); + mwg::Viewport expected(200, 120); + ASSERT_TRUE(expected.FitExtent({0, 0, 102, 2}, 12.0)); + for (const mwg::MapPoint point : {mwg::MapPoint{1, 1}, mwg::MapPoint{101, 1}}) + { + const auto screen = expected.MapToScreen(point); + ASSERT_TRUE(screen.has_value()); + const auto pixel = mwg::ToPixel(*screen); + ASSERT_TRUE(pixel.has_value()); + EXPECT_EQ(GetPixel(surface.Get(), pixel->x, pixel->y), RGB(61, 122, 169)); + } +} + +TEST_F(ActiveXMapTest, FileDropKeepsSuccessfulFilesWhenOthersAreUnsupported) +{ + const auto unsupported = _directory.Path() / "unsupported.txt"; + { + std::ofstream output(unsupported); + output << "not a vector dataset"; + ASSERT_TRUE(output.good()); + } + const auto valid = mwg::test::MakeRectanglesDataset( + _directory.Path(), + "dropped-valid.gpkg", + std::array{mwg::Extent{0, 0, 2, 2}}); + + DWORD effect = DROPEFFECT_NONE; + const auto missing = _directory.Path() / "missing.geojson"; + ASSERT_EQ(DropFiles({unsupported, missing, valid}, effect), S_FALSE); + EXPECT_EQ(effect, static_cast(DROPEFFECT_COPY)); + + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + LONG layerCount = 0; + LONGLONG featureCount = 0; + ASSERT_EQ(automation->get_LayerCount(&layerCount), S_OK); + ASSERT_EQ(_control->get_FeatureCount(&featureCount), S_OK); + EXPECT_EQ(layerCount, 1); + EXPECT_EQ(featureCount, 1); + + ASSERT_EQ(_control->ClearLayers(), S_OK); + effect = DROPEFFECT_COPY; + ASSERT_EQ(DropFiles({unsupported, missing}, effect), E_FAIL); + EXPECT_EQ(effect, static_cast(DROPEFFECT_NONE)); + ASSERT_EQ(automation->get_LayerCount(&layerCount), S_OK); + ASSERT_EQ(_control->get_FeatureCount(&featureCount), S_OK); + EXPECT_EQ(layerCount, 0); + EXPECT_EQ(featureCount, 0); +} + TEST_F(ActiveXMapTest, RejectedLoadsLeaveTheMapAndRenderingUnchanged) { EXPECT_EQ(LoadLine(), E_FAIL);